hydro_lang/viz/
json.rs

1use std::collections::{HashMap, HashSet};
2use std::fmt::Write;
3
4use serde::Serialize;
5
6use super::render::{HydroEdgeProp, HydroGraphWrite, HydroNodeType};
7use crate::compile::ir::HydroRoot;
8use crate::compile::ir::backtrace::Backtrace;
9
10/// A serializable backtrace frame for JSON output.
11/// Includes compatibility aliases to match potential viewer expectations.
12#[derive(Serialize)]
13struct BacktraceFrame {
14    /// Function name (truncated)
15    #[serde(rename = "fn")]
16    fn_name: String,
17    /// Function name alias for compatibility
18    function: String,
19    /// File path (truncated)
20    file: String,
21    /// File path alias for compatibility
22    filename: String,
23    /// Line number
24    line: Option<u32>,
25    /// Line number alias for compatibility
26    #[serde(rename = "lineNumber")]
27    line_number: Option<u32>,
28}
29
30/// Node data for JSON output.
31#[derive(Serialize)]
32struct NodeData {
33    #[serde(rename = "locationId")]
34    location_id: Option<usize>,
35    #[serde(rename = "locationType")]
36    location_type: Option<String>,
37    backtrace: serde_json::Value,
38}
39
40/// A serializable node for JSON output.
41#[derive(Serialize)]
42struct Node {
43    id: String,
44    #[serde(rename = "nodeType")]
45    node_type: String,
46    #[serde(rename = "fullLabel")]
47    full_label: String,
48    #[serde(rename = "shortLabel")]
49    short_label: String,
50    label: String,
51    data: NodeData,
52}
53
54/// A serializable edge for JSON output.
55#[derive(Serialize)]
56struct Edge {
57    id: String,
58    source: String,
59    target: String,
60    #[serde(rename = "semanticTags")]
61    semantic_tags: Vec<String>,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    label: Option<String>,
64}
65
66/// JSON graph writer for Hydro IR.
67/// Outputs JSON that can be used with interactive graph visualization tools.
68pub struct HydroJson<W> {
69    write: W,
70    nodes: Vec<serde_json::Value>,
71    edges: Vec<serde_json::Value>,
72    locations: HashMap<usize, (String, Vec<usize>)>, // location_id -> (label, node_ids)
73    node_locations: HashMap<usize, usize>,           // node_id -> location_id
74    edge_count: usize,
75    // Type name mappings
76    process_names: HashMap<usize, String>,
77    cluster_names: HashMap<usize, String>,
78    external_names: HashMap<usize, String>,
79    // Store backtraces for hierarchy generation
80    node_backtraces: HashMap<usize, Backtrace>,
81    // Config flags
82    use_short_labels: bool,
83}
84
85impl<W> HydroJson<W> {
86    pub fn new(write: W, config: &super::render::HydroWriteConfig) -> Self {
87        let process_names: HashMap<usize, String> =
88            config.process_id_name.iter().cloned().collect();
89        let cluster_names: HashMap<usize, String> =
90            config.cluster_id_name.iter().cloned().collect();
91        let external_names: HashMap<usize, String> =
92            config.external_id_name.iter().cloned().collect();
93
94        Self {
95            write,
96            nodes: Vec::new(),
97            edges: Vec::new(),
98            locations: HashMap::new(),
99            node_locations: HashMap::new(),
100            edge_count: 0,
101            process_names,
102            cluster_names,
103            external_names,
104            node_backtraces: HashMap::new(),
105            use_short_labels: config.use_short_labels,
106        }
107    }
108
109    /// Convert HydroNodeType to string representation
110    fn node_type_to_string(node_type: HydroNodeType) -> &'static str {
111        super::render::node_type_utils::to_string(node_type)
112    }
113
114    /// Convert HydroEdgeType to string representation for semantic tags
115    fn edge_type_to_string(edge_type: HydroEdgeProp) -> String {
116        match edge_type {
117            HydroEdgeProp::Bounded => "Bounded".to_string(),
118            HydroEdgeProp::Unbounded => "Unbounded".to_string(),
119            HydroEdgeProp::TotalOrder => "TotalOrder".to_string(),
120            HydroEdgeProp::NoOrder => "NoOrder".to_string(),
121            HydroEdgeProp::Keyed => "Keyed".to_string(),
122            HydroEdgeProp::Stream => "Stream".to_string(),
123            HydroEdgeProp::KeyedSingleton => "KeyedSingleton".to_string(),
124            HydroEdgeProp::KeyedStream => "KeyedStream".to_string(),
125            HydroEdgeProp::Singleton => "Singleton".to_string(),
126            HydroEdgeProp::Optional => "Optional".to_string(),
127            HydroEdgeProp::Network => "Network".to_string(),
128            HydroEdgeProp::Cycle => "Cycle".to_string(),
129        }
130    }
131
132    /// Get all node type definitions for JSON output
133    fn get_node_type_definitions() -> Vec<serde_json::Value> {
134        // Ensure deterministic ordering by sorting by type string
135        let mut types: Vec<(usize, &'static str)> =
136            super::render::node_type_utils::all_types_with_strings()
137                .into_iter()
138                .enumerate()
139                .map(|(idx, (_, type_str))| (idx, type_str))
140                .collect();
141        types.sort_by(|a, b| a.1.cmp(b.1));
142        types
143            .into_iter()
144            .enumerate()
145            .map(|(color_index, (_, type_str))| {
146                serde_json::json!({
147                    "id": type_str,
148                    "label": type_str,
149                    "colorIndex": color_index
150                })
151            })
152            .collect()
153    }
154
155    /// Get legend items for JSON output (simplified version of node type definitions)
156    fn get_legend_items() -> Vec<serde_json::Value> {
157        Self::get_node_type_definitions()
158            .into_iter()
159            .map(|def| {
160                serde_json::json!({
161                    "type": def["id"],
162                    "label": def["label"]
163                })
164            })
165            .collect()
166    }
167
168    /// Get edge style configuration with semantic→style mappings.
169    fn get_edge_style_config() -> serde_json::Value {
170        serde_json::json!({
171            "semanticPriorities": [
172                ["Unbounded", "Bounded"],
173                ["NoOrder", "TotalOrder"],
174                ["Keyed", "NotKeyed"],
175                ["Network", "Local"]
176            ],
177            "semanticMappings": {
178                // Network communication group - controls line pattern AND animation
179                "NetworkGroup": {
180                    "Local": {
181                        "line-pattern": "solid",
182                        "animation": "static"
183                    },
184                    "Network": {
185                        "line-pattern": "dashed",
186                        "animation": "animated"
187                    }
188                },
189
190                // Ordering group - controls waviness
191                "OrderingGroup": {
192                    "TotalOrder": {
193                        "waviness": "straight"
194                    },
195                    "NoOrder": {
196                        "waviness": "wavy"
197                    }
198                },
199
200                // Boundedness group - controls halo
201                "BoundednessGroup": {
202                    "Bounded": {
203                        "halo": "none"
204                    },
205                    "Unbounded": {
206                        "halo": "light-blue"
207                    }
208                },
209
210                // Keyedness group - controls vertical hash marks on the line
211                "KeyednessGroup": {
212                    "NotKeyed": {
213                        "line-style": "single"
214                    },
215                    "Keyed": {
216                        "line-style": "hash-marks"
217                    }
218                },
219
220                // Collection type group - controls color
221                "CollectionGroup": {
222                    "Stream": {
223                        "color": "#2563eb",
224                        "arrowhead": "triangle-filled"
225                    },
226                    "Singleton": {
227                        "color": "#000000",
228                        "arrowhead": "circle-filled"
229                    },
230                    "Optional": {
231                        "color": "#6b7280",
232                        "arrowhead": "diamond-open"
233                    }
234                },
235            },
236            "note": "Edge styles are now computed per-edge using the unified edge style system. This config is provided for reference and compatibility."
237        })
238    }
239
240    /// Optimize backtrace data for size efficiency
241    /// 1. Remove redundant/non-essential frames
242    /// 2. Truncate paths
243    /// 3. Remove memory addresses (not useful for visualization)
244    fn optimize_backtrace(&self, backtrace: &Backtrace) -> serde_json::Value {
245        #[cfg(feature = "build")]
246        {
247            let elements = backtrace.elements();
248
249            // filter out obviously internal frames
250            let relevant_frames: Vec<BacktraceFrame> = elements
251                .iter()
252                .map(|elem| {
253                    // Truncate paths and function names for size
254                    let short_filename = elem
255                        .filename
256                        .as_deref()
257                        .map(|f| Self::truncate_path(f))
258                        .unwrap_or_else(|| "unknown".to_string());
259
260                    let short_fn_name = Self::truncate_function_name(&elem.fn_name);
261
262                    BacktraceFrame {
263                        fn_name: short_fn_name.clone(),
264                        function: short_fn_name,
265                        file: short_filename.clone(),
266                        filename: short_filename,
267                        line: elem.lineno,
268                        line_number: elem.lineno,
269                    }
270                })
271                .collect();
272
273            serde_json::to_value(relevant_frames).unwrap_or_else(|_| serde_json::json!([]))
274        }
275        #[cfg(not(feature = "build"))]
276        {
277            serde_json::json!([])
278        }
279    }
280
281    /// Truncate file paths to keep only the relevant parts
282    fn truncate_path(path: &str) -> String {
283        let parts: Vec<&str> = path.split('/').collect();
284
285        // For paths like "/Users/foo/project/src/main.rs", keep "src/main.rs"
286        if let Some(src_idx) = parts.iter().rposition(|&p| p == "src") {
287            parts[src_idx..].join("/")
288        } else if parts.len() > 2 {
289            // Keep last 2 components
290            parts[parts.len().saturating_sub(2)..].join("/")
291        } else {
292            path.to_string()
293        }
294    }
295
296    /// Truncate function names to remove module paths
297    fn truncate_function_name(fn_name: &str) -> String {
298        // Remove everything before the last "::" to get just the function name
299        fn_name.split("::").last().unwrap_or(fn_name).to_string()
300    }
301}
302
303impl<W> HydroGraphWrite for HydroJson<W>
304where
305    W: Write,
306{
307    type Err = super::render::GraphWriteError;
308
309    fn write_prologue(&mut self) -> Result<(), Self::Err> {
310        // Clear any existing data
311        self.nodes.clear();
312        self.edges.clear();
313        self.locations.clear();
314        self.node_locations.clear();
315        self.edge_count = 0;
316        Ok(())
317    }
318
319    fn write_node_definition(
320        &mut self,
321        node_id: usize,
322        node_label: &super::render::NodeLabel,
323        node_type: HydroNodeType,
324        location_id: Option<usize>,
325        location_type: Option<&str>,
326        backtrace: Option<&Backtrace>,
327    ) -> Result<(), Self::Err> {
328        // Create the full label string using DebugExpr::Display for expressions
329        let full_label = match node_label {
330            super::render::NodeLabel::Static(s) => s.clone(),
331            super::render::NodeLabel::WithExprs { op_name, exprs } => {
332                if exprs.is_empty() {
333                    format!("{}()", op_name)
334                } else {
335                    // This is where DebugExpr::Display gets called with q! macro cleanup
336                    let expr_strs: Vec<String> = exprs.iter().map(|e| e.to_string()).collect();
337                    format!("{}({})", op_name, expr_strs.join(", "))
338                }
339            }
340        };
341
342        // Always extract short label for UI toggle functionality
343        let short_label = super::render::extract_short_label(&full_label);
344
345        // If short and full labels are the same or very similar, enhance the full label
346        // Use saturating comparison to avoid underflow when full_label is very short
347        let full_len = full_label.len();
348        let enhanced_full_label = if short_label.len() >= full_len.saturating_sub(2) {
349            // If they're nearly the same length, add more context to full label
350            match short_label.as_str() {
351                "inspect" => "inspect [debug output]".to_string(),
352                "persist" => "persist [state storage]".to_string(),
353                "tee" => "tee [branch dataflow]".to_string(),
354                "delta" => "delta [change detection]".to_string(),
355                "spin" => "spin [delay/buffer]".to_string(),
356                "send_bincode" => "send_bincode [send data to process/cluster]".to_string(),
357                "broadcast_bincode" => {
358                    "broadcast_bincode [send data to all cluster members]".to_string()
359                }
360                "source_iter" => "source_iter [iterate over collection]".to_string(),
361                "source_stream" => "source_stream [receive external data stream]".to_string(),
362                "network(recv)" => "network(recv) [receive from network]".to_string(),
363                "network(send)" => "network(send) [send to network]".to_string(),
364                "dest_sink" => "dest_sink [output destination]".to_string(),
365                _ => {
366                    if full_label.len() < 15 {
367                        format!("{} [{}]", node_label, "hydro operator")
368                    } else {
369                        node_label.to_string()
370                    }
371                }
372            }
373        } else {
374            node_label.to_string()
375        };
376
377        // Convert backtrace to JSON if available (optimized for size)
378        let backtrace_json = if let Some(bt) = backtrace {
379            // Store backtrace for hierarchy generation
380            self.node_backtraces.insert(node_id, bt.clone());
381            self.optimize_backtrace(bt)
382        } else {
383            serde_json::json!([])
384        };
385
386        // Node type string for styling/legend
387        let node_type_str = Self::node_type_to_string(node_type);
388
389        let node = Node {
390            id: node_id.to_string(),
391            node_type: node_type_str.to_string(),
392            full_label: enhanced_full_label,
393            short_label: short_label.clone(),
394            // Primary display label follows configuration (defaults to short)
395            label: if self.use_short_labels {
396                short_label
397            } else {
398                full_label
399            },
400            data: NodeData {
401                location_id,
402                location_type: location_type.map(|s| s.to_string()),
403                backtrace: backtrace_json,
404            },
405        };
406        self.nodes
407            .push(serde_json::to_value(node).expect("Node serialization should not fail"));
408
409        // Track node location for cross-location edge detection
410        if let Some(loc_id) = location_id {
411            self.node_locations.insert(node_id, loc_id);
412        }
413
414        Ok(())
415    }
416
417    fn write_edge(
418        &mut self,
419        src_id: usize,
420        dst_id: usize,
421        edge_properties: &HashSet<HydroEdgeProp>,
422        label: Option<&str>,
423    ) -> Result<(), Self::Err> {
424        let edge_id = format!("e{}", self.edge_count);
425        self.edge_count = self.edge_count.saturating_add(1);
426
427        // Convert edge properties to semantic tags (string array)
428        let mut semantic_tags: Vec<String> = edge_properties
429            .iter()
430            .map(|p| Self::edge_type_to_string(*p))
431            .collect();
432
433        // Get location information for styling
434        let src_loc = self.node_locations.get(&src_id).copied();
435        let dst_loc = self.node_locations.get(&dst_id).copied();
436
437        // Add Network tag if edge crosses locations; otherwise add Local for completeness
438        if let (Some(src), Some(dst)) = (src_loc, dst_loc)
439            && src != dst
440            && !semantic_tags.iter().any(|t| t == "Network")
441        {
442            semantic_tags.push("Network".to_string());
443        } else if semantic_tags.iter().all(|t| t != "Network") {
444            // Only add Local if Network not present (complement for styling)
445            semantic_tags.push("Local".to_string());
446        }
447
448        // Ensure deterministic ordering of semantic tags
449        semantic_tags.sort();
450
451        let edge = Edge {
452            id: edge_id,
453            source: src_id.to_string(),
454            target: dst_id.to_string(),
455            semantic_tags,
456            label: label.map(|s| s.to_string()),
457        };
458
459        self.edges
460            .push(serde_json::to_value(edge).expect("Edge serialization should not fail"));
461        Ok(())
462    }
463
464    fn write_location_start(
465        &mut self,
466        location_id: usize,
467        location_type: &str,
468    ) -> Result<(), Self::Err> {
469        let location_label = match location_type {
470            "Process" => {
471                if let Some(name) = self.process_names.get(&location_id) {
472                    // Use default name if the type name is just "()" (unit type)
473                    if name == "()" {
474                        format!("Process {}", location_id)
475                    } else {
476                        name.clone()
477                    }
478                } else {
479                    format!("Process {}", location_id)
480                }
481            }
482            "Cluster" => {
483                if let Some(name) = self.cluster_names.get(&location_id) {
484                    name.clone()
485                } else {
486                    format!("Cluster {}", location_id)
487                }
488            }
489            "External" => {
490                if let Some(name) = self.external_names.get(&location_id) {
491                    name.clone()
492                } else {
493                    format!("External {}", location_id)
494                }
495            }
496            _ => location_type.to_string(),
497        };
498
499        self.locations
500            .insert(location_id, (location_label, Vec::new()));
501        Ok(())
502    }
503
504    fn write_node(&mut self, node_id: usize) -> Result<(), Self::Err> {
505        // Find the current location being written and add this node to it
506        if let Some((_, node_ids)) = self.locations.values_mut().last() {
507            node_ids.push(node_id);
508        }
509        Ok(())
510    }
511
512    fn write_location_end(&mut self) -> Result<(), Self::Err> {
513        // Location grouping complete - nothing to do for JSON
514        Ok(())
515    }
516
517    fn write_epilogue(&mut self) -> Result<(), Self::Err> {
518        // Create multiple hierarchy options
519        let mut hierarchy_choices = Vec::new();
520        let mut node_assignments_choices = serde_json::Map::new();
521
522        // Always add location-based hierarchy
523        let (location_hierarchy, location_assignments) = self.create_location_hierarchy();
524        hierarchy_choices.push(serde_json::json!({
525            "id": "location",
526            "name": "Location",
527            "children": location_hierarchy
528        }));
529        node_assignments_choices.insert(
530            "location".to_string(),
531            serde_json::Value::Object(location_assignments),
532        );
533
534        // Add backtrace-based hierarchy if available
535        if self.has_backtrace_data() {
536            let (backtrace_hierarchy, backtrace_assignments) = self.create_backtrace_hierarchy();
537            hierarchy_choices.push(serde_json::json!({
538                "id": "backtrace",
539                "name": "Backtrace",
540                "children": backtrace_hierarchy
541            }));
542            node_assignments_choices.insert(
543                "backtrace".to_string(),
544                serde_json::Value::Object(backtrace_assignments),
545            );
546        }
547
548        // Before serialization, enforce deterministic ordering for nodes and edges
549        let mut nodes_sorted = self.nodes.clone();
550        nodes_sorted.sort_by(|a, b| a["id"].as_str().cmp(&b["id"].as_str()));
551        let mut edges_sorted = self.edges.clone();
552        edges_sorted.sort_by(|a, b| {
553            let a_src = a["source"].as_str();
554            let b_src = b["source"].as_str();
555            match a_src.cmp(&b_src) {
556                std::cmp::Ordering::Equal => {
557                    let a_dst = a["target"].as_str();
558                    let b_dst = b["target"].as_str();
559                    match a_dst.cmp(&b_dst) {
560                        std::cmp::Ordering::Equal => a["id"].as_str().cmp(&b["id"].as_str()),
561                        other => other,
562                    }
563                }
564                other => other,
565            }
566        });
567
568        // Create the final JSON structure in the format expected by the visualizer
569        let node_type_definitions = Self::get_node_type_definitions();
570        let legend_items = Self::get_legend_items();
571
572        let node_type_config = serde_json::json!({
573            "types": node_type_definitions,
574            "defaultType": "Transform"
575        });
576        let legend = serde_json::json!({
577            "title": "Node Types",
578            "items": legend_items
579        });
580
581        // Determine the selected hierarchy (first one is default)
582        let selected_hierarchy = if !hierarchy_choices.is_empty() {
583            hierarchy_choices[0]["id"].as_str().map(|s| s.to_string())
584        } else {
585            None
586        };
587
588        #[derive(serde::Serialize)]
589        struct GraphPayload {
590            nodes: Vec<serde_json::Value>,
591            edges: Vec<serde_json::Value>,
592            #[serde(rename = "hierarchyChoices")]
593            hierarchy_choices: Vec<serde_json::Value>,
594            #[serde(rename = "nodeAssignments")]
595            node_assignments: serde_json::Map<String, serde_json::Value>,
596            #[serde(rename = "selectedHierarchy", skip_serializing_if = "Option::is_none")]
597            selected_hierarchy: Option<String>,
598            #[serde(rename = "edgeStyleConfig")]
599            edge_style_config: serde_json::Value,
600            #[serde(rename = "nodeTypeConfig")]
601            node_type_config: serde_json::Value,
602            legend: serde_json::Value,
603        }
604
605        let payload = GraphPayload {
606            nodes: nodes_sorted,
607            edges: edges_sorted,
608            hierarchy_choices,
609            node_assignments: node_assignments_choices,
610            selected_hierarchy,
611            edge_style_config: Self::get_edge_style_config(),
612            node_type_config,
613            legend,
614        };
615
616        let final_json = serde_json::to_string_pretty(&payload).unwrap();
617
618        write!(self.write, "{}", final_json)
619    }
620}
621
622impl<W> HydroJson<W> {
623    /// Check if any nodes have meaningful backtrace data
624    fn has_backtrace_data(&self) -> bool {
625        self.nodes.iter().any(|node| {
626            if let Some(backtrace_array) = node["data"]["backtrace"].as_array() {
627                // Check if any frame has meaningful filename or fn_name data
628                backtrace_array.iter().any(|frame| {
629                    let filename = frame["file"].as_str().unwrap_or("");
630                    let fn_name = frame["fn"].as_str().unwrap_or("");
631                    !filename.is_empty() || !fn_name.is_empty()
632                })
633            } else {
634                false
635            }
636        })
637    }
638
639    /// Create location-based hierarchy (original behavior)
640    fn create_location_hierarchy(
641        &self,
642    ) -> (
643        Vec<serde_json::Value>,
644        serde_json::Map<String, serde_json::Value>,
645    ) {
646        // Create hierarchy structure (single level: locations as parents, nodes as children)
647        let mut locs: Vec<(&usize, &(String, Vec<usize>))> = self.locations.iter().collect();
648        locs.sort_by(|a, b| a.0.cmp(b.0));
649        let hierarchy: Vec<serde_json::Value> = locs
650            .into_iter()
651            .map(|(location_id, (label, _))| {
652                serde_json::json!({
653                    "id": format!("loc_{}", location_id),
654                    "name": label,
655                    "children": [] // Single level hierarchy - no nested children
656                })
657            })
658            .collect();
659
660        // Create node assignments by reading locationId from each node's data
661        // This is more reliable than using the write_node tracking which depends on HashMap iteration order
662        // Build and then sort assignments deterministically by node id key
663        let mut tmp: Vec<(String, String)> = Vec::new();
664        for node in &self.nodes {
665            if let (Some(node_id), Some(location_id)) =
666                (node["id"].as_str(), node["data"]["locationId"].as_u64())
667            {
668                let location_key = format!("loc_{}", location_id);
669                tmp.push((node_id.to_string(), location_key));
670            }
671        }
672        tmp.sort_by(|a, b| a.0.cmp(&b.0));
673        let mut node_assignments = serde_json::Map::new();
674        for (k, v) in tmp {
675            node_assignments.insert(k, serde_json::Value::String(v));
676        }
677
678        (hierarchy, node_assignments)
679    }
680
681    /// Create backtrace-based hierarchy using structured backtrace data
682    fn create_backtrace_hierarchy(
683        &self,
684    ) -> (
685        Vec<serde_json::Value>,
686        serde_json::Map<String, serde_json::Value>,
687    ) {
688        use std::collections::HashMap;
689
690        let mut hierarchy_map: HashMap<String, (String, usize, Option<String>)> = HashMap::new(); // path -> (name, depth, parent_path)
691        let mut path_to_node_assignments: HashMap<String, Vec<String>> = HashMap::new(); // path -> [node_ids]
692
693        // Process each node's backtrace using the stored backtraces
694        for node in &self.nodes {
695            if let Some(node_id_str) = node["id"].as_str()
696                && let Ok(node_id) = node_id_str.parse::<usize>()
697                && let Some(backtrace) = self.node_backtraces.get(&node_id)
698            {
699                let elements = backtrace.elements();
700                if elements.is_empty() {
701                    continue;
702                }
703
704                // Do not filter frames for now
705                let user_frames = elements;
706                if user_frames.is_empty() {
707                    continue;
708                }
709
710                // Build hierarchy path from backtrace frames (reverse order for call stack)
711                let mut hierarchy_path = Vec::new();
712                for (i, elem) in user_frames.iter().rev().enumerate() {
713                    let label = if i == 0 {
714                        if let Some(filename) = &elem.filename {
715                            Self::extract_file_path(filename)
716                        } else {
717                            format!("fn_{}", Self::truncate_function_name(&elem.fn_name))
718                        }
719                    } else {
720                        Self::truncate_function_name(&elem.fn_name)
721                    };
722                    hierarchy_path.push(label);
723                }
724
725                // Create hierarchy nodes for this path
726                let mut current_path = String::new();
727                let mut parent_path: Option<String> = None;
728                let mut deepest_path = String::new();
729                // Deduplicate consecutive identical labels for cleanliness
730                let mut deduped: Vec<String> = Vec::new();
731                for seg in hierarchy_path {
732                    if deduped.last().map(|s| s == &seg).unwrap_or(false) {
733                        continue;
734                    }
735                    deduped.push(seg);
736                }
737                for (depth, label) in deduped.iter().enumerate() {
738                    current_path = if current_path.is_empty() {
739                        label.clone()
740                    } else {
741                        format!("{}/{}", current_path, label)
742                    };
743                    if !hierarchy_map.contains_key(&current_path) {
744                        hierarchy_map.insert(
745                            current_path.clone(),
746                            (label.clone(), depth, parent_path.clone()),
747                        );
748                    }
749                    deepest_path = current_path.clone();
750                    parent_path = Some(current_path.clone());
751                }
752
753                if !deepest_path.is_empty() {
754                    path_to_node_assignments
755                        .entry(deepest_path)
756                        .or_default()
757                        .push(node_id_str.to_string());
758                }
759            }
760        }
761        // Build hierarchy tree and create proper ID mapping (deterministic)
762        let (mut hierarchy, mut path_to_id_map, id_remapping) =
763            self.build_hierarchy_tree_with_ids(&hierarchy_map);
764
765        // Create a root node for nodes without backtraces
766        let root_id = "bt_root".to_string();
767        let mut nodes_without_backtrace = Vec::new();
768
769        // Collect all node IDs
770        for node in &self.nodes {
771            if let Some(node_id_str) = node["id"].as_str() {
772                nodes_without_backtrace.push(node_id_str.to_string());
773            }
774        }
775
776        // Remove nodes that already have backtrace assignments
777        for node_ids in path_to_node_assignments.values() {
778            for node_id in node_ids {
779                nodes_without_backtrace.retain(|id| id != node_id);
780            }
781        }
782
783        // If there are nodes without backtraces, create a root container for them
784        if !nodes_without_backtrace.is_empty() {
785            hierarchy.push(serde_json::json!({
786                "id": root_id.clone(),
787                "name": "(no backtrace)",
788                "children": []
789            }));
790            path_to_id_map.insert("__root__".to_string(), root_id.clone());
791        }
792
793        // Create node assignments using the actual hierarchy IDs
794        let mut node_assignments = serde_json::Map::new();
795        let mut pairs: Vec<(String, Vec<String>)> = path_to_node_assignments.into_iter().collect();
796        pairs.sort_by(|a, b| a.0.cmp(&b.0));
797        for (path, mut node_ids) in pairs {
798            node_ids.sort();
799            if let Some(hierarchy_id) = path_to_id_map.get(&path) {
800                for node_id in node_ids {
801                    node_assignments
802                        .insert(node_id, serde_json::Value::String(hierarchy_id.clone()));
803                }
804            }
805        }
806
807        // Assign nodes without backtraces to the root
808        for node_id in nodes_without_backtrace {
809            node_assignments.insert(node_id, serde_json::Value::String(root_id.clone()));
810        }
811
812        // CRITICAL FIX: Apply ID remapping to node assignments
813        // When containers are collapsed, their IDs change, but nodeAssignments still reference old IDs
814        // We need to update all assignments to use the new (collapsed) container IDs
815        let mut remapped_assignments = serde_json::Map::new();
816        for (node_id, container_id_value) in node_assignments.iter() {
817            if let Some(container_id) = container_id_value.as_str() {
818                // Check if this container ID was remapped during collapsing
819                let final_container_id = id_remapping
820                    .get(container_id)
821                    .map(|s| s.as_str())
822                    .unwrap_or(container_id);
823                remapped_assignments.insert(
824                    node_id.clone(),
825                    serde_json::Value::String(final_container_id.to_string()),
826                );
827            }
828        }
829
830        (hierarchy, remapped_assignments)
831    }
832
833    /// Build a tree structure and return both the tree and path-to-ID mapping
834    fn build_hierarchy_tree_with_ids(
835        &self,
836        hierarchy_map: &HashMap<String, (String, usize, Option<String>)>,
837    ) -> (
838        Vec<serde_json::Value>,
839        HashMap<String, String>,
840        HashMap<String, String>,
841    ) {
842        // Assign IDs deterministically based on sorted path names
843        let mut keys: Vec<&String> = hierarchy_map.keys().collect();
844        keys.sort();
845        let mut path_to_id: HashMap<String, String> = HashMap::new();
846        for (i, path) in keys.iter().enumerate() {
847            path_to_id.insert((*path).clone(), format!("bt_{}", i.saturating_add(1)));
848        }
849
850        // Find root items (depth 0) and sort by name
851        let mut roots: Vec<(String, String)> = hierarchy_map
852            .iter()
853            .filter_map(|(path, (name, depth, _))| {
854                if *depth == 0 {
855                    Some((path.clone(), name.clone()))
856                } else {
857                    None
858                }
859            })
860            .collect();
861        roots.sort_by(|a, b| a.1.cmp(&b.1));
862        let mut root_nodes = Vec::new();
863        for (path, name) in roots {
864            let tree_node = Self::build_tree_node(&path, &name, hierarchy_map, &path_to_id);
865            root_nodes.push(tree_node);
866        }
867
868        // Apply top-down collapsing of single-child container chains
869        // and build a mapping of old IDs to new IDs
870        let mut id_remapping: HashMap<String, String> = HashMap::new();
871        root_nodes = root_nodes
872            .into_iter()
873            .map(|node| Self::collapse_single_child_containers(node, None, &mut id_remapping))
874            .collect();
875
876        // Update path_to_id with remappings
877        let mut updated_path_to_id = path_to_id.clone();
878        for (path, old_id) in path_to_id.iter() {
879            if let Some(new_id) = id_remapping.get(old_id) {
880                updated_path_to_id.insert(path.clone(), new_id.clone());
881            }
882        }
883
884        (root_nodes, updated_path_to_id, id_remapping)
885    }
886
887    /// Build a single tree node recursively
888    fn build_tree_node(
889        current_path: &str,
890        name: &str,
891        hierarchy_map: &HashMap<String, (String, usize, Option<String>)>,
892        path_to_id: &HashMap<String, String>,
893    ) -> serde_json::Value {
894        let current_id = path_to_id.get(current_path).unwrap().clone();
895
896        // Find children (paths that have this path as parent)
897        let mut child_specs: Vec<(&String, &String)> = hierarchy_map
898            .iter()
899            .filter_map(|(child_path, (child_name, _, parent_path))| {
900                if let Some(parent) = parent_path {
901                    if parent == current_path {
902                        Some((child_path, child_name))
903                    } else {
904                        None
905                    }
906                } else {
907                    None
908                }
909            })
910            .collect();
911        child_specs.sort_by(|a, b| a.1.cmp(b.1));
912        let mut children = Vec::new();
913        for (child_path, child_name) in child_specs {
914            let child_node =
915                Self::build_tree_node(child_path, child_name, hierarchy_map, path_to_id);
916            children.push(child_node);
917        }
918
919        if children.is_empty() {
920            serde_json::json!({
921                "id": current_id,
922                "name": name
923            })
924        } else {
925            serde_json::json!({
926                "id": current_id,
927                "name": name,
928                "children": children
929            })
930        }
931    }
932
933    /// Collapse single-child container chains (top-down)
934    /// When a container has exactly one child AND that child is also a container,
935    /// we collapse them by keeping the child's ID and combining names.
936    /// parent_name is used to accumulate names during recursion (None for roots)
937    /// id_remapping tracks which old IDs map to which new IDs after collapsing
938    fn collapse_single_child_containers(
939        node: serde_json::Value,
940        parent_name: Option<String>,
941        id_remapping: &mut HashMap<String, String>,
942    ) -> serde_json::Value {
943        let mut node_obj = match node {
944            serde_json::Value::Object(obj) => obj,
945            _ => return node,
946        };
947
948        let current_name = node_obj
949            .get("name")
950            .and_then(|v| v.as_str())
951            .unwrap_or("")
952            .to_string();
953
954        let current_id = node_obj
955            .get("id")
956            .and_then(|v| v.as_str())
957            .unwrap_or("")
958            .to_string();
959
960        // Determine the effective name (combined with parent if collapsing)
961        // Use → to show call chain (parent called child)
962        let effective_name = if let Some(parent) = parent_name.clone() {
963            format!("{} → {}", parent, current_name)
964        } else {
965            current_name.clone()
966        };
967
968        // Check if this node has children (is a container)
969        if let Some(serde_json::Value::Array(children)) = node_obj.get("children") {
970            // If exactly one child AND that child is also a container
971            if children.len() == 1
972                && let Some(child) = children.first()
973            {
974                let child_is_container = child
975                    .get("children")
976                    .and_then(|v| v.as_array())
977                    .map(|arr| !arr.is_empty())
978                    .unwrap_or(false);
979
980                if child_is_container {
981                    let child_id = child
982                        .get("id")
983                        .and_then(|v| v.as_str())
984                        .unwrap_or("")
985                        .to_string();
986
987                    // Record that this parent's ID should map to the child's ID
988                    if !current_id.is_empty() && !child_id.is_empty() {
989                        id_remapping.insert(current_id.clone(), child_id.clone());
990                    }
991
992                    // Collapse: recursively process the child with accumulated name
993                    return Self::collapse_single_child_containers(
994                        child.clone(),
995                        Some(effective_name),
996                        id_remapping,
997                    );
998                }
999            }
1000
1001            // Not collapsing: process children normally and update name if accumulated
1002            let processed_children: Vec<serde_json::Value> = children
1003                .iter()
1004                .map(|child| {
1005                    Self::collapse_single_child_containers(child.clone(), None, id_remapping)
1006                })
1007                .collect();
1008
1009            node_obj.insert(
1010                "name".to_string(),
1011                serde_json::Value::String(effective_name),
1012            );
1013            node_obj.insert(
1014                "children".to_string(),
1015                serde_json::Value::Array(processed_children),
1016            );
1017        } else {
1018            // Leaf node: just update name if accumulated
1019            node_obj.insert(
1020                "name".to_string(),
1021                serde_json::Value::String(effective_name),
1022            );
1023        }
1024
1025        serde_json::Value::Object(node_obj)
1026    }
1027
1028    /// Extract meaningful file path
1029    fn extract_file_path(filename: &str) -> String {
1030        if filename.is_empty() {
1031            return "unknown".to_string();
1032        }
1033
1034        // Extract the most relevant part of the file path
1035        let parts: Vec<&str> = filename.split('/').collect();
1036        let file_name = parts.last().unwrap_or(&"unknown");
1037
1038        // If it's a source file, include the parent directory for context
1039        if file_name.ends_with(".rs") && parts.len() > 1 {
1040            let parent_dir = parts[parts.len() - 2];
1041            format!("{}/{}", parent_dir, file_name)
1042        } else {
1043            file_name.to_string()
1044        }
1045    }
1046}
1047
1048/// Create JSON from Hydro IR with type names
1049pub fn hydro_ir_to_json(
1050    ir: &[HydroRoot],
1051    process_names: Vec<(usize, String)>,
1052    cluster_names: Vec<(usize, String)>,
1053    external_names: Vec<(usize, String)>,
1054) -> Result<String, Box<dyn std::error::Error>> {
1055    let mut output = String::new();
1056
1057    let config = super::render::HydroWriteConfig {
1058        show_metadata: false,
1059        show_location_groups: true,
1060        use_short_labels: true, // Default to short labels
1061        process_id_name: process_names,
1062        cluster_id_name: cluster_names,
1063        external_id_name: external_names,
1064    };
1065
1066    super::render::write_hydro_ir_json(&mut output, ir, &config)?;
1067
1068    Ok(output)
1069}
1070
1071/// Open JSON visualization in browser using the docs visualizer with URL-encoded data
1072pub fn open_json_browser(
1073    ir: &[HydroRoot],
1074    process_names: Vec<(usize, String)>,
1075    cluster_names: Vec<(usize, String)>,
1076    external_names: Vec<(usize, String)>,
1077) -> Result<(), Box<dyn std::error::Error>> {
1078    let config = super::render::HydroWriteConfig {
1079        process_id_name: process_names,
1080        cluster_id_name: cluster_names,
1081        external_id_name: external_names,
1082        ..Default::default()
1083    };
1084
1085    super::debug::open_json_visualizer(ir, Some(config))
1086        .map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
1087}
1088
1089/// Save JSON to file using the consolidated debug utilities
1090pub fn save_json(
1091    ir: &[HydroRoot],
1092    process_names: Vec<(usize, String)>,
1093    cluster_names: Vec<(usize, String)>,
1094    external_names: Vec<(usize, String)>,
1095    filename: &str,
1096) -> Result<std::path::PathBuf, Box<dyn std::error::Error>> {
1097    let config = super::render::HydroWriteConfig {
1098        process_id_name: process_names,
1099        cluster_id_name: cluster_names,
1100        external_id_name: external_names,
1101        ..Default::default()
1102    };
1103
1104    super::debug::save_json(ir, Some(filename), Some(config))
1105        .map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
1106}
1107
1108/// Open JSON visualization in browser for a BuiltFlow
1109#[cfg(feature = "build")]
1110pub fn open_browser(
1111    built_flow: &crate::compile::built::BuiltFlow,
1112) -> Result<(), Box<dyn std::error::Error>> {
1113    open_json_browser(
1114        built_flow.ir(),
1115        built_flow.process_id_name().clone(),
1116        built_flow.cluster_id_name().clone(),
1117        built_flow.external_id_name().clone(),
1118    )
1119}