Skip to main content

hydro_lang/viz/
debug.rs

1//! Debugging utilities for Hydro IR graph visualization.
2//!
3//! Similar to the DFIR debugging utilities, this module provides convenient
4//! methods for opening graphs in web browsers and VS Code.
5
6use std::fmt::Write;
7use std::io::{Result, Write as IoWrite};
8use std::time::{SystemTime, UNIX_EPOCH};
9
10use super::config::VisualizerConfig;
11use super::render::{
12    HydroWriteConfig, render_hydro_ir_dot, render_hydro_ir_json, render_hydro_ir_mermaid,
13};
14use crate::compile::ir::HydroRoot;
15
16/// Opens Hydro IR roots as a single mermaid diagram.
17pub fn open_mermaid(roots: &[HydroRoot], config: Option<HydroWriteConfig>) -> Result<()> {
18    let mermaid_src = render_with_config(roots, config, render_hydro_ir_mermaid);
19    open_mermaid_browser(&mermaid_src)
20}
21
22/// Opens Hydro IR roots as a single DOT diagram.
23pub fn open_dot(roots: &[HydroRoot], config: Option<HydroWriteConfig>) -> Result<()> {
24    let dot_src = render_with_config(roots, config, render_hydro_ir_dot);
25    open_dot_browser(&dot_src)
26}
27
28/// Saves Hydro IR roots as a Mermaid diagram file.
29/// If no filename is provided, saves to temporary directory.
30pub fn save_mermaid(
31    roots: &[HydroRoot],
32    filename: Option<&str>,
33    config: Option<HydroWriteConfig>,
34) -> Result<std::path::PathBuf> {
35    let content = render_with_config(roots, config, render_hydro_ir_mermaid);
36    save_to_file(content, filename, "hydro_graph.mermaid", "Mermaid diagram")
37}
38
39/// Saves Hydro IR roots as a DOT/Graphviz file.
40/// If no filename is provided, saves to temporary directory.
41pub fn save_dot(
42    roots: &[HydroRoot],
43    filename: Option<&str>,
44    config: Option<HydroWriteConfig>,
45) -> Result<std::path::PathBuf> {
46    let content = render_with_config(roots, config, render_hydro_ir_dot);
47    save_to_file(content, filename, "hydro_graph.dot", "DOT/Graphviz file")
48}
49
50fn open_mermaid_browser(mermaid_src: &str) -> Result<()> {
51    let state = serde_json::json!({
52        "code": mermaid_src,
53        "mermaid": serde_json::json!({
54            "theme": "default"
55        }),
56        "autoSync": true,
57        "updateDiagram": true
58    });
59    let state_json = serde_json::to_vec(&state)?;
60    let state_base64 = data_encoding::BASE64URL.encode(&state_json);
61    webbrowser::open(&format!(
62        "https://mermaid.live/edit#base64:{}",
63        state_base64
64    ))
65}
66
67fn open_dot_browser(dot_src: &str) -> Result<()> {
68    let mut url = "https://dreampuf.github.io/GraphvizOnline/#".to_owned();
69    for byte in dot_src.bytes() {
70        // Lazy percent encoding: https://en.wikipedia.org/wiki/Percent-encoding
71        write!(url, "%{:02x}", byte).unwrap();
72    }
73    webbrowser::open(&url)
74}
75
76/// Helper function to save content to a file with consistent path handling.
77/// If no filename is provided, saves to temporary directory with the default name.
78fn save_to_file(
79    content: String,
80    filename: Option<&str>,
81    default_name: &str,
82    content_type: &str,
83) -> Result<std::path::PathBuf> {
84    let file_path = if let Some(filename) = filename {
85        std::path::PathBuf::from(filename)
86    } else {
87        std::env::temp_dir().join(default_name)
88    };
89
90    std::fs::write(&file_path, content)?;
91    println!("Saved {} to {}", content_type, file_path.display());
92    Ok(file_path)
93}
94
95/// Helper function to handle config unwrapping and rendering.
96fn render_with_config<F>(
97    roots: &[HydroRoot],
98    config: Option<HydroWriteConfig>,
99    renderer: F,
100) -> String
101where
102    F: Fn(&[HydroRoot], HydroWriteConfig<'_>) -> String,
103{
104    let config = config.unwrap_or_default();
105    renderer(roots, config)
106}
107
108/// Compress JSON content using gzip compression.
109/// Returns the compressed bytes or an error if compression fails.
110fn compress_json(json_content: &str) -> Result<Vec<u8>> {
111    use flate2::Compression;
112    use flate2::write::GzEncoder;
113
114    let mut encoder = GzEncoder::new(Vec::new(), Compression::best());
115    encoder.write_all(json_content.as_bytes())?;
116    encoder.finish()
117}
118
119/// Encode data to base64 URL-safe format without padding.
120/// This format is safe for use in URLs and doesn't require additional escaping.
121fn encode_base64_url_safe(data: &[u8]) -> String {
122    data_encoding::BASE64URL_NOPAD.encode(data)
123}
124
125/// Try to compress and encode JSON content for URL embedding.
126/// Returns (encoded_data, is_compressed, compression_ratio).
127///
128/// Compression is skipped for small JSON (<min_compression_size bytes).
129/// If compression fails or doesn't reduce size, falls back to uncompressed encoding.
130fn try_compress_and_encode(json_content: &str, config: &VisualizerConfig) -> (String, bool, f64) {
131    let original_size = json_content.len();
132
133    // Skip compression for small JSON
134    if !config.enable_compression || original_size < config.min_compression_size {
135        let encoded = encode_base64_url_safe(json_content.as_bytes());
136        return (encoded, false, 1.0);
137    }
138
139    match compress_json(json_content) {
140        Ok(compressed) => {
141            let compressed_size = compressed.len();
142            let ratio = original_size as f64 / compressed_size as f64;
143
144            // Only use compression if it actually reduces size
145            if compressed_size < original_size {
146                let encoded = encode_base64_url_safe(&compressed);
147                (encoded, true, ratio)
148            } else {
149                // Compression didn't help, use uncompressed
150                let encoded = encode_base64_url_safe(json_content.as_bytes());
151                (encoded, false, 1.0)
152            }
153        }
154        Err(e) => {
155            // Compression failed, fall back to uncompressed
156            println!("āš ļø  Compression failed: {}, using uncompressed", e);
157            let encoded = encode_base64_url_safe(json_content.as_bytes());
158            (encoded, false, 1.0)
159        }
160    }
161}
162
163/// Calculate the total URL length for a given encoded data and parameter name.
164/// Returns the total length including base URL, parameter name, and encoded data.
165fn calculate_url_length(base_url: &str, param_name: &str, encoded_data: &str) -> usize {
166    // Format: base_url?param_name=encoded_data
167    base_url.len() + 1 + param_name.len() + 1 + encoded_data.len()
168}
169
170/// Generate a URL for the visualizer with the given JSON content.
171/// Automatically chooses between compressed and uncompressed encoding based on URL length.
172/// Returns (url, is_compressed) or None if the URL would be too long.
173fn generate_visualizer_url(
174    json_content: &str,
175    config: &VisualizerConfig,
176) -> Option<(String, bool)> {
177    let (encoded_data, is_compressed, _ratio) = try_compress_and_encode(json_content, config);
178
179    // Determine parameter name based on compression
180    let param_name = if is_compressed { "compressed" } else { "data" };
181
182    let url_length = calculate_url_length(&config.base_url, param_name, &encoded_data);
183
184    if url_length <= config.max_url_length {
185        let url = format!("{}?{}={}", config.base_url, param_name, encoded_data);
186        Some((url, is_compressed))
187    } else {
188        // message will be displayed by print_fallback_instructions
189        None
190    }
191}
192
193/// Generate a timestamped filename for temporary graph files.
194/// Format: hydro_graph_<timestamp>.json
195fn generate_timestamped_filename() -> String {
196    let timestamp = SystemTime::now()
197        .duration_since(UNIX_EPOCH)
198        .expect("System clock is before Unix epoch - clock may be corrupted")
199        .as_secs();
200    format!("hydro_graph_{}.json", timestamp)
201}
202
203/// Save JSON content to a temporary file with a timestamped filename.
204/// Returns the path to the created file.
205///
206/// Requirements: 9.1, 9.2, 9.3
207fn save_json_to_temp_file(json_content: &str) -> Result<std::path::PathBuf> {
208    let filename = generate_timestamped_filename();
209    let temp_file = std::env::temp_dir().join(filename);
210
211    std::fs::write(&temp_file, json_content)?;
212
213    println!("šŸ“ Saved graph to temporary file: {}", temp_file.display());
214
215    Ok(temp_file)
216}
217
218/// URL-encode a file path for safe transmission in query parameters.
219/// Uses percent encoding to ensure special characters are properly escaped.
220///
221/// Requirements: 9.4
222fn url_encode_file_path(file_path: &std::path::Path) -> String {
223    let path_str = file_path.to_string_lossy();
224    urlencoding::encode(&path_str).to_string()
225}
226
227/// Generate a visualizer URL with a file query parameter.
228/// Format: base_url?file=<encoded_path>
229///
230/// Requirements: 9.4, 9.5
231fn generate_file_based_url(file_path: &std::path::Path, config: &VisualizerConfig) -> String {
232    let encoded_path = url_encode_file_path(file_path);
233    format!("{}?file={}", config.base_url, encoded_path)
234}
235
236/// Print fallback instructions for manual loading of the graph file.
237/// Provides clear guidance if automatic browser opening fails.
238///
239/// Requirements: 9.6, 9.7
240fn print_fallback_instructions(file_path: &std::path::Path, url: &str) {
241    println!("\nšŸ“Š Graph Visualization Instructions");
242    println!("═══════════════════════════════════════════════════════════");
243    println!("The graph is too large to embed in a URL.");
244    println!("It has been saved to a temporary file:");
245    println!("  šŸ“ {}", file_path.display());
246    println!();
247    println!("Opening visualizer in browser...");
248    println!("  🌐 {}", url);
249    println!();
250    println!("If the browser doesn't open automatically, you can:");
251    println!("  1. Manually open: {}", url);
252    println!(
253        "  2. Or visit {} and drag-and-drop the file",
254        url.split('?').next().unwrap_or(url)
255    );
256    println!("═══════════════════════════════════════════════════════════\n");
257}
258
259/// Handle large graph visualization using file-based fallback.
260/// Saves the JSON to a temporary file and opens the visualizer with a file parameter.
261/// Uses the configured base URL from VisualizerConfig.
262fn handle_large_graph_fallback(json_content: &str, config: &VisualizerConfig) -> Result<()> {
263    let temp_file = save_json_to_temp_file(json_content)?;
264
265    // Generate URL with file parameter using configured base URL
266    let url = generate_file_based_url(&temp_file, config);
267
268    print_fallback_instructions(&temp_file, &url);
269
270    match webbrowser::open(&url) {
271        Ok(_) => {
272            println!("āœ“ Successfully opened visualizer in browser");
273        }
274        Err(e) => {
275            println!("āš ļø  Failed to open browser automatically: {}", e);
276            println!("Please manually open the URL above or drag-and-drop the file.");
277        }
278    }
279
280    Ok(())
281}
282
283/// Open JSON visualizer with automatic fallback to file-based approach for large graphs.
284/// First attempts to embed the JSON in the URL using compression.
285/// If the URL is too long, falls back to saving the file and using a file parameter.
286///
287/// This is the main entry point for opening JSON visualizations.
288///
289/// Requirements: 8.1-8.9, 9.1-9.9
290fn open_json_visualizer_with_fallback(json_content: &str, config: &VisualizerConfig) -> Result<()> {
291    // Try to generate a URL with embedded data
292    match generate_visualizer_url(json_content, config) {
293        Some((url, _is_compressed)) => {
294            // URL fits within length limit, open it directly
295            webbrowser::open(&url)?;
296            println!("āœ“ Successfully opened visualizer in browser");
297            Ok(())
298        }
299        None => {
300            // URL too long, use file-based fallback
301            println!("šŸ“¦ Graph too large for URL embedding, using file-based approach...");
302            handle_large_graph_fallback(json_content, config)
303        }
304    }
305}
306
307/// Opens Hydro IR roots as a JSON visualization in the browser.
308/// Automatically handles compression and file-based fallback for large graphs.
309///
310/// This function generates JSON from the Hydro IR and opens it in the configured
311/// visualizer (defaults to <https://hydro.run/hydroscope>, can be overridden
312/// with HYDRO_VISUALIZER_URL environment variable).
313pub fn open_json_visualizer(
314    roots: &[HydroRoot],
315    config: Option<HydroWriteConfig<'_>>,
316) -> Result<()> {
317    let json_content = render_with_config(roots, config, render_hydro_ir_json);
318    let viz_config = VisualizerConfig::default();
319    open_json_visualizer_with_fallback(&json_content, &viz_config)
320}
321
322/// Opens Hydro IR roots as a JSON visualization with custom visualizer configuration.
323/// Allows specifying a custom base URL and compression settings.
324pub fn open_json_visualizer_with_config(
325    roots: &[HydroRoot],
326    config: Option<HydroWriteConfig<'_>>,
327    viz_config: VisualizerConfig,
328) -> Result<()> {
329    let json_content = render_with_config(roots, config, render_hydro_ir_json);
330    open_json_visualizer_with_fallback(&json_content, &viz_config)
331}
332
333/// Saves Hydro IR roots as a JSON file.
334/// If no filename is provided, saves to temporary directory.
335pub fn save_json(
336    roots: &[HydroRoot],
337    filename: Option<&str>,
338    config: Option<HydroWriteConfig<'_>>,
339) -> Result<std::path::PathBuf> {
340    let content = render_with_config(roots, config, render_hydro_ir_json);
341    save_to_file(content, filename, "hydro_graph.json", "JSON file")
342}