1use std::collections::{HashMap, HashSet};
2use std::fmt::Write;
3
4use serde::Serialize;
5use slotmap::{SecondaryMap, SparseSecondaryMap};
6
7use super::render::{
8 GraphWriteError, HydroEdgeProp, HydroGraphWrite, HydroNodeType, HydroWriteConfig,
9 write_hydro_ir_json,
10};
11use crate::compile::ir::HydroRoot;
12use crate::compile::ir::backtrace::Backtrace;
13use crate::location::{LocationKey, LocationType};
14use crate::viz::render::VizNodeKey;
15
16#[derive(Serialize)]
19struct BacktraceFrame {
20 #[serde(rename = "fn")]
22 fn_name: String,
23 function: String,
25 file: String,
27 filename: String,
29 line: Option<u32>,
31 #[serde(rename = "lineNumber")]
33 line_number: Option<u32>,
34}
35
36#[derive(Serialize)]
38struct NodeData {
39 #[serde(rename = "locationKey")]
40 location_key: Option<LocationKey>,
41 #[serde(rename = "locationType")]
42 location_type: Option<LocationType>,
43 backtrace: serde_json::Value,
44}
45
46#[derive(Serialize)]
48struct Node {
49 id: String,
50 #[serde(rename = "nodeType")]
51 node_type: String,
52 #[serde(rename = "fullLabel")]
53 full_label: String,
54 #[serde(rename = "shortLabel")]
55 short_label: String,
56 label: String,
57 data: NodeData,
58}
59
60#[derive(Serialize)]
62struct Edge {
63 id: String,
64 source: String,
65 target: String,
66 #[serde(rename = "semanticTags")]
67 semantic_tags: Vec<String>,
68 #[serde(skip_serializing_if = "Option::is_none")]
69 label: Option<String>,
70}
71
72pub struct HydroJson<'a, W> {
75 write: W,
76 nodes: Vec<serde_json::Value>,
77 edges: Vec<serde_json::Value>,
78 locations: SecondaryMap<LocationKey, (String, Vec<VizNodeKey>)>,
80 node_locations: SecondaryMap<VizNodeKey, LocationKey>,
82 edge_count: usize,
83 location_names: &'a SecondaryMap<LocationKey, String>,
85 node_backtraces: SparseSecondaryMap<VizNodeKey, Backtrace>,
87 use_short_labels: bool,
89}
90
91impl<'a, W> HydroJson<'a, W> {
92 pub fn new(write: W, config: HydroWriteConfig<'a>) -> Self {
93 Self {
94 write,
95 nodes: Vec::new(),
96 edges: Vec::new(),
97 locations: SecondaryMap::new(),
98 node_locations: SecondaryMap::new(),
99 edge_count: 0,
100 location_names: config.location_names,
101 node_backtraces: SparseSecondaryMap::new(),
102 use_short_labels: config.use_short_labels,
103 }
104 }
105
106 fn node_type_to_string(node_type: HydroNodeType) -> &'static str {
108 super::render::node_type_utils::to_string(node_type)
109 }
110
111 fn edge_type_to_string(edge_type: HydroEdgeProp) -> String {
113 match edge_type {
114 HydroEdgeProp::Bounded => "Bounded".to_owned(),
115 HydroEdgeProp::Unbounded => "Unbounded".to_owned(),
116 HydroEdgeProp::TotalOrder => "TotalOrder".to_owned(),
117 HydroEdgeProp::NoOrder => "NoOrder".to_owned(),
118 HydroEdgeProp::Keyed => "Keyed".to_owned(),
119 HydroEdgeProp::Stream => "Stream".to_owned(),
120 HydroEdgeProp::KeyedSingleton => "KeyedSingleton".to_owned(),
121 HydroEdgeProp::KeyedStream => "KeyedStream".to_owned(),
122 HydroEdgeProp::Singleton => "Singleton".to_owned(),
123 HydroEdgeProp::Optional => "Optional".to_owned(),
124 HydroEdgeProp::Network => "Network".to_owned(),
125 HydroEdgeProp::Cycle => "Cycle".to_owned(),
126 }
127 }
128
129 fn get_node_type_definitions() -> Vec<serde_json::Value> {
131 let mut types: Vec<(usize, &'static str)> =
133 super::render::node_type_utils::all_types_with_strings()
134 .into_iter()
135 .enumerate()
136 .map(|(idx, (_, type_str))| (idx, type_str))
137 .collect();
138 types.sort_by(|a, b| a.1.cmp(b.1));
139 types
140 .into_iter()
141 .enumerate()
142 .map(|(color_index, (_, type_str))| {
143 serde_json::json!({
144 "id": type_str,
145 "label": type_str,
146 "colorIndex": color_index
147 })
148 })
149 .collect()
150 }
151
152 fn get_legend_items() -> Vec<serde_json::Value> {
154 Self::get_node_type_definitions()
155 .into_iter()
156 .map(|def| {
157 serde_json::json!({
158 "type": def["id"],
159 "label": def["label"]
160 })
161 })
162 .collect()
163 }
164
165 fn get_edge_style_config() -> serde_json::Value {
167 serde_json::json!({
168 "semanticPriorities": [
169 ["Unbounded", "Bounded"],
170 ["NoOrder", "TotalOrder"],
171 ["Keyed", "NotKeyed"],
172 ["Network", "Local"]
173 ],
174 "semanticMappings": {
175 "NetworkGroup": {
177 "Local": {
178 "line-pattern": "solid",
179 "animation": "static"
180 },
181 "Network": {
182 "line-pattern": "dashed",
183 "animation": "animated"
184 }
185 },
186
187 "OrderingGroup": {
189 "TotalOrder": {
190 "waviness": "straight"
191 },
192 "NoOrder": {
193 "waviness": "wavy"
194 }
195 },
196
197 "BoundednessGroup": {
199 "Bounded": {
200 "halo": "none"
201 },
202 "Unbounded": {
203 "halo": "light-blue"
204 }
205 },
206
207 "KeyednessGroup": {
209 "NotKeyed": {
210 "line-style": "single"
211 },
212 "Keyed": {
213 "line-style": "hash-marks"
214 }
215 },
216
217 "CollectionGroup": {
219 "Stream": {
220 "color": "#2563eb",
221 "arrowhead": "triangle-filled"
222 },
223 "Singleton": {
224 "color": "#000000",
225 "arrowhead": "circle-filled"
226 },
227 "Optional": {
228 "color": "#6b7280",
229 "arrowhead": "diamond-open"
230 }
231 },
232 },
233 "note": "Edge styles are now computed per-edge using the unified edge style system. This config is provided for reference and compatibility."
234 })
235 }
236
237 fn optimize_backtrace(&self, backtrace: &Backtrace) -> serde_json::Value {
242 #[cfg(feature = "build")]
243 {
244 let elements = backtrace.elements();
245
246 let relevant_frames: Vec<BacktraceFrame> = elements
248 .map(|elem| {
249 let short_filename = elem
251 .filename
252 .as_deref()
253 .map(|f| Self::truncate_path(f))
254 .unwrap_or_else(|| "unknown".to_owned());
255
256 let short_fn_name = Self::truncate_function_name(&elem.fn_name).to_owned();
257
258 BacktraceFrame {
259 fn_name: short_fn_name.to_owned(),
260 function: short_fn_name,
261 file: short_filename.clone(),
262 filename: short_filename,
263 line: elem.lineno,
264 line_number: elem.lineno,
265 }
266 })
267 .collect();
268
269 serde_json::to_value(relevant_frames).unwrap_or_else(|_| serde_json::json!([]))
270 }
271 #[cfg(not(feature = "build"))]
272 {
273 serde_json::json!([])
274 }
275 }
276
277 fn truncate_path(path: &str) -> String {
279 let parts: Vec<&str> = path.split('/').collect();
280
281 if let Some(src_idx) = parts.iter().rposition(|&p| p == "src") {
283 parts[src_idx..].join("/")
284 } else if parts.len() > 2 {
285 parts[parts.len().saturating_sub(2)..].join("/")
287 } else {
288 path.to_owned()
289 }
290 }
291
292 fn truncate_function_name(fn_name: &str) -> &str {
294 fn_name.split("::").last().unwrap_or(fn_name)
296 }
297}
298
299impl<W> HydroGraphWrite for HydroJson<'_, W>
300where
301 W: Write,
302{
303 type Err = GraphWriteError;
304
305 fn write_prologue(&mut self) -> Result<(), Self::Err> {
306 self.nodes.clear();
308 self.edges.clear();
309 self.locations.clear();
310 self.node_locations.clear();
311 self.edge_count = 0;
312 Ok(())
313 }
314
315 fn write_node_definition(
316 &mut self,
317 node_id: VizNodeKey,
318 node_label: &super::render::NodeLabel,
319 node_type: HydroNodeType,
320 location_key: Option<LocationKey>,
321 location_type: Option<LocationType>,
322 backtrace: Option<&Backtrace>,
323 ) -> Result<(), Self::Err> {
324 let full_label = match node_label {
326 super::render::NodeLabel::Static(s) => s.clone(),
327 super::render::NodeLabel::WithExprs { op_name, exprs } => {
328 if exprs.is_empty() {
329 format!("{}()", op_name)
330 } else {
331 let expr_strs: Vec<String> = exprs.iter().map(|e| e.to_string()).collect();
333 format!("{}({})", op_name, expr_strs.join(", "))
334 }
335 }
336 };
337
338 let short_label = super::render::extract_short_label(&full_label);
340
341 let full_len = full_label.len();
344 let enhanced_full_label = if short_label.len() >= full_len.saturating_sub(2) {
345 match short_label.as_str() {
347 "inspect" => "inspect [debug output]".to_owned(),
348 "persist" => "persist [state storage]".to_owned(),
349 "tee" => "tee [branch dataflow]".to_owned(),
350 "delta" => "delta [change detection]".to_owned(),
351 "spin" => "spin [delay/buffer]".to_owned(),
352 "send_bincode" => "send_bincode [send data to process/cluster]".to_owned(),
353 "broadcast_bincode" => {
354 "broadcast_bincode [send data to all cluster members]".to_owned()
355 }
356 "source_iter" => "source_iter [iterate over collection]".to_owned(),
357 "source_stream" => "source_stream [receive external data stream]".to_owned(),
358 "network(recv)" => "network(recv) [receive from network]".to_owned(),
359 "network(send)" => "network(send) [send to network]".to_owned(),
360 "dest_sink" => "dest_sink [output destination]".to_owned(),
361 _ => {
362 if full_label.len() < 15 {
363 format!("{} [{}]", node_label, "hydro operator")
364 } else {
365 node_label.to_string()
366 }
367 }
368 }
369 } else {
370 node_label.to_string()
371 };
372
373 let backtrace_json = if let Some(bt) = backtrace {
375 self.node_backtraces.insert(node_id, bt.clone());
377 self.optimize_backtrace(bt)
378 } else {
379 serde_json::json!([])
380 };
381
382 let node_type_str = Self::node_type_to_string(node_type);
384
385 let node = Node {
386 id: node_id.to_string(),
387 node_type: node_type_str.to_owned(),
388 full_label: enhanced_full_label,
389 short_label: short_label.clone(),
390 label: if self.use_short_labels {
392 short_label
393 } else {
394 full_label
395 },
396 data: NodeData {
397 location_key,
398 location_type,
399 backtrace: backtrace_json,
400 },
401 };
402 self.nodes
403 .push(serde_json::to_value(node).expect("Node serialization should not fail"));
404
405 if let Some(loc_key) = location_key {
407 self.node_locations.insert(node_id, loc_key);
408 }
409
410 Ok(())
411 }
412
413 fn write_edge(
414 &mut self,
415 src_id: VizNodeKey,
416 dst_id: VizNodeKey,
417 edge_properties: &HashSet<HydroEdgeProp>,
418 label: Option<&str>,
419 ) -> Result<(), Self::Err> {
420 let edge_id = format!("e{}", self.edge_count);
421 self.edge_count = self.edge_count.saturating_add(1);
422
423 #[expect(
425 clippy::disallowed_methods,
426 reason = "nondeterministic iteration order, TODO(mingwei)"
427 )]
428 let mut semantic_tags: Vec<String> = edge_properties
429 .iter()
430 .map(|p| Self::edge_type_to_string(*p))
431 .collect();
432
433 let src_loc = self.node_locations.get(src_id).copied();
435 let dst_loc = self.node_locations.get(dst_id).copied();
436
437 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_owned());
443 } else if semantic_tags.iter().all(|t| t != "Network") {
444 semantic_tags.push("Local".to_owned());
446 }
447
448 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_owned()),
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_key: LocationKey,
467 location_type: LocationType,
468 ) -> Result<(), Self::Err> {
469 let location_label = if let Some(location_name) = self.location_names.get(location_key)
470 && "()" != location_name
471 {
473 format!("{:?} {}", location_type, location_name)
474 } else {
475 format!("{:?} {:?}", location_type, location_key)
476 };
477 self.locations
478 .insert(location_key, (location_label, Vec::new()));
479 Ok(())
480 }
481
482 fn write_node(&mut self, node_id: VizNodeKey) -> Result<(), Self::Err> {
483 if let Some((_, node_ids)) = self.locations.values_mut().last() {
485 node_ids.push(node_id);
486 }
487 Ok(())
488 }
489
490 fn write_location_end(&mut self) -> Result<(), Self::Err> {
491 Ok(())
493 }
494
495 fn write_epilogue(&mut self) -> Result<(), Self::Err> {
496 let mut hierarchy_choices = Vec::new();
498 let mut node_assignments_choices = serde_json::Map::new();
499
500 if self.has_backtrace_data() {
502 let (backtrace_hierarchy, backtrace_assignments) = self.create_backtrace_hierarchy();
503 hierarchy_choices.push(serde_json::json!({
504 "id": "backtrace",
505 "name": "Backtrace",
506 "children": backtrace_hierarchy
507 }));
508 node_assignments_choices.insert(
509 "backtrace".to_owned(),
510 serde_json::Value::Object(backtrace_assignments),
511 );
512 }
513
514 let (location_hierarchy, location_assignments) = self.create_location_hierarchy();
516 hierarchy_choices.push(serde_json::json!({
517 "id": "location",
518 "name": "Location",
519 "children": location_hierarchy
520 }));
521 node_assignments_choices.insert(
522 "location".to_owned(),
523 serde_json::Value::Object(location_assignments),
524 );
525
526 let mut nodes_sorted = self.nodes.clone();
528 nodes_sorted.sort_by(|a, b| a["id"].as_str().cmp(&b["id"].as_str()));
529 let mut edges_sorted = self.edges.clone();
530 edges_sorted.sort_by(|a, b| {
531 let a_src = a["source"].as_str();
532 let b_src = b["source"].as_str();
533 match a_src.cmp(&b_src) {
534 std::cmp::Ordering::Equal => {
535 let a_dst = a["target"].as_str();
536 let b_dst = b["target"].as_str();
537 match a_dst.cmp(&b_dst) {
538 std::cmp::Ordering::Equal => a["id"].as_str().cmp(&b["id"].as_str()),
539 other => other,
540 }
541 }
542 other => other,
543 }
544 });
545
546 let node_type_definitions = Self::get_node_type_definitions();
548 let legend_items = Self::get_legend_items();
549
550 let node_type_config = serde_json::json!({
551 "types": node_type_definitions,
552 "defaultType": "Transform"
553 });
554 let legend = serde_json::json!({
555 "title": "Node Types",
556 "items": legend_items
557 });
558
559 let selected_hierarchy = if !hierarchy_choices.is_empty() {
561 hierarchy_choices[0]["id"].as_str()
562 } else {
563 None
564 };
565
566 #[derive(serde::Serialize)]
567 struct GraphPayload<'a> {
568 nodes: Vec<serde_json::Value>,
569 edges: Vec<serde_json::Value>,
570 #[serde(rename = "hierarchyChoices")]
571 hierarchy_choices: &'a [serde_json::Value],
572 #[serde(rename = "nodeAssignments")]
573 node_assignments: serde_json::Map<String, serde_json::Value>,
574 #[serde(rename = "selectedHierarchy", skip_serializing_if = "Option::is_none")]
575 selected_hierarchy: Option<&'a str>,
576 #[serde(rename = "edgeStyleConfig")]
577 edge_style_config: serde_json::Value,
578 #[serde(rename = "nodeTypeConfig")]
579 node_type_config: serde_json::Value,
580 legend: serde_json::Value,
581 }
582
583 let payload = GraphPayload {
584 nodes: nodes_sorted,
585 edges: edges_sorted,
586 hierarchy_choices: &hierarchy_choices,
587 node_assignments: node_assignments_choices,
588 selected_hierarchy,
589 edge_style_config: Self::get_edge_style_config(),
590 node_type_config,
591 legend,
592 };
593
594 let final_json = serde_json::to_string_pretty(&payload).unwrap();
595
596 write!(self.write, "{}", final_json)
597 }
598}
599
600impl<W> HydroJson<'_, W> {
601 fn has_backtrace_data(&self) -> bool {
603 self.nodes.iter().any(|node| {
604 if let Some(backtrace_array) = node["data"]["backtrace"].as_array() {
605 backtrace_array.iter().any(|frame| {
607 let filename = frame["file"].as_str().unwrap_or_default();
608 let fn_name = frame["fn"].as_str().unwrap_or_default();
609 !filename.is_empty() || !fn_name.is_empty()
610 })
611 } else {
612 false
613 }
614 })
615 }
616
617 fn create_location_hierarchy(
619 &self,
620 ) -> (
621 Vec<serde_json::Value>,
622 serde_json::Map<String, serde_json::Value>,
623 ) {
624 let mut locs: Vec<(LocationKey, &(String, Vec<VizNodeKey>))> =
626 self.locations.iter().collect();
627 locs.sort_by(|a, b| a.0.cmp(&b.0));
628 let hierarchy: Vec<serde_json::Value> = locs
629 .into_iter()
630 .map(|(location_key, (label, _))| {
631 serde_json::json!({
632 "key": location_key.to_string(),
633 "name": label,
634 "children": [] })
636 })
637 .collect();
638
639 let mut tmp: Vec<(String, serde_json::Value)> = Vec::new();
643 for node in self.nodes.iter() {
644 if let (Some(node_id), location_key) =
645 (node["id"].as_str(), &node["data"]["locationKey"])
646 {
647 tmp.push((node_id.to_owned(), location_key.clone()));
648 }
649 }
650 tmp.sort_by(|a, b| a.0.cmp(&b.0));
651 let mut node_assignments = serde_json::Map::new();
652 for (k, v) in tmp {
653 node_assignments.insert(k, v);
654 }
655
656 (hierarchy, node_assignments)
657 }
658
659 fn create_backtrace_hierarchy(
661 &self,
662 ) -> (
663 Vec<serde_json::Value>,
664 serde_json::Map<String, serde_json::Value>,
665 ) {
666 use std::collections::HashMap;
667
668 let mut hierarchy_map: HashMap<String, (String, usize, Option<String>)> = HashMap::new(); let mut path_to_node_assignments: HashMap<String, Vec<String>> = HashMap::new(); for node in self.nodes.iter() {
673 if let Some(node_id_str) = node["id"].as_str()
674 && let Ok(node_id) = node_id_str.parse::<VizNodeKey>()
675 && let Some(backtrace) = self.node_backtraces.get(node_id)
676 {
677 let elements = backtrace.elements().collect::<Vec<_>>();
678 if elements.is_empty() {
679 continue;
680 }
681
682 let user_frames: Vec<_> = elements
684 .into_iter()
685 .filter(|elem| {
686 let fn_name = &elem.fn_name;
687 let file = elem.filename.as_deref().unwrap_or("");
688 !(fn_name.starts_with("alloc")
690 || fn_name.contains("call_once")
691 || fn_name.contains("{async_block")
692 || fn_name == "main"
693 || file.contains("/runtime/")
694 || file.contains("/future/")
695 || file.contains("/task/"))
696 })
697 .collect();
698 if user_frames.is_empty() {
699 continue;
700 }
701
702 let mut hierarchy_path = Vec::new();
704 let mut prev_fn = String::new();
705 for elem in user_frames.iter().rev() {
706 let fn_short = Self::truncate_function_name(&elem.fn_name);
707 let label = if let Some(filename) = &elem.filename {
708 let file_short = Self::truncate_path(filename);
709 if fn_short != prev_fn {
711 if let Some(line) = elem.lineno {
712 format!("{} — {}:{}", fn_short, file_short, line)
713 } else {
714 format!("{} — {}", fn_short, file_short)
715 }
716 } else if let Some(line) = elem.lineno {
717 format!("{}:{}", file_short, line)
718 } else {
719 file_short
720 }
721 } else {
722 fn_short.to_owned()
723 };
724 prev_fn = fn_short.to_owned();
725 hierarchy_path.push(label);
726 }
727
728 let mut current_path = String::new();
730 let mut parent_path: Option<String> = None;
731 let mut deepest_path = String::new();
732 let mut deduped: Vec<String> = Vec::new();
734 for seg in hierarchy_path {
735 if deduped.last().map(|s| s == &seg).unwrap_or(false) {
736 continue;
737 }
738 deduped.push(seg);
739 }
740 for (depth, label) in deduped.iter().enumerate() {
741 current_path = if current_path.is_empty() {
742 label.clone()
743 } else {
744 format!("{}/{}", current_path, label)
745 };
746 if !hierarchy_map.contains_key(¤t_path) {
747 hierarchy_map.insert(
748 current_path.clone(),
749 (label.clone(), depth, parent_path.clone()),
750 );
751 }
752 deepest_path = current_path.clone();
753 parent_path = Some(current_path.clone());
754 }
755
756 if !deepest_path.is_empty() {
757 path_to_node_assignments
758 .entry(deepest_path)
759 .or_default()
760 .push(node_id_str.to_owned());
761 }
762 }
763 }
764 let (mut hierarchy, mut path_to_id_map, id_remapping) =
766 self.build_hierarchy_tree_with_ids(&hierarchy_map);
767
768 let root_id = "bt_root";
770 let mut nodes_without_backtrace = Vec::new();
771
772 for node in self.nodes.iter() {
774 if let Some(node_id_str) = node["id"].as_str() {
775 nodes_without_backtrace.push(node_id_str.to_owned());
776 }
777 }
778
779 #[expect(
781 clippy::disallowed_methods,
782 reason = "nondeterministic iteration order, TODO(mingwei)"
783 )]
784 for node_ids in path_to_node_assignments.values() {
785 for node_id in node_ids {
786 nodes_without_backtrace.retain(|id| id != node_id);
787 }
788 }
789
790 if !nodes_without_backtrace.is_empty() {
792 hierarchy.push(serde_json::json!({
793 "id": root_id,
794 "name": "(no backtrace)",
795 "children": []
796 }));
797 path_to_id_map.insert("__root__".to_owned(), root_id.to_owned());
798 }
799
800 let mut node_assignments = serde_json::Map::new();
802 let mut pairs: Vec<(String, Vec<String>)> = path_to_node_assignments.into_iter().collect();
803 pairs.sort_by(|a, b| a.0.cmp(&b.0));
804 for (path, mut node_ids) in pairs {
805 node_ids.sort();
806 if let Some(hierarchy_id) = path_to_id_map.get(&path) {
807 for node_id in node_ids {
808 node_assignments
809 .insert(node_id, serde_json::Value::String(hierarchy_id.clone()));
810 }
811 }
812 }
813
814 for node_id in nodes_without_backtrace {
816 node_assignments.insert(node_id, serde_json::Value::String(root_id.to_owned()));
817 }
818
819 let mut remapped_assignments = serde_json::Map::new();
823 for (node_id, container_id_value) in node_assignments.iter() {
824 if let Some(container_id) = container_id_value.as_str() {
825 let final_container_id = id_remapping
827 .get(container_id)
828 .map(|s| &**s)
829 .unwrap_or(container_id);
830 remapped_assignments.insert(
831 node_id.clone(),
832 serde_json::Value::String(final_container_id.to_owned()),
833 );
834 }
835 }
836
837 (hierarchy, remapped_assignments)
838 }
839
840 fn build_hierarchy_tree_with_ids(
842 &self,
843 hierarchy_map: &HashMap<String, (String, usize, Option<String>)>,
844 ) -> (
845 Vec<serde_json::Value>,
846 HashMap<String, String>,
847 HashMap<String, String>,
848 ) {
849 #[expect(
851 clippy::disallowed_methods,
852 reason = "nondeterministic iteration order, TODO(mingwei)"
853 )]
854 let mut keys: Vec<&String> = hierarchy_map.keys().collect();
855 keys.sort();
856 let mut path_to_id: HashMap<String, String> = HashMap::new();
857 for (i, path) in keys.iter().enumerate() {
858 path_to_id.insert((*path).clone(), format!("bt_{}", i.saturating_add(1)));
859 }
860
861 #[expect(
863 clippy::disallowed_methods,
864 reason = "nondeterministic iteration order, TODO(mingwei)"
865 )]
866 let mut roots: Vec<(String, String)> = hierarchy_map
867 .iter()
868 .filter_map(|(path, (name, depth, _))| {
869 if *depth == 0 {
870 Some((path.clone(), name.clone()))
871 } else {
872 None
873 }
874 })
875 .collect();
876 roots.sort_by(|a, b| a.1.cmp(&b.1));
877 let mut root_nodes = Vec::new();
878 for (path, name) in roots {
879 let tree_node = Self::build_tree_node(&path, &name, hierarchy_map, &path_to_id);
880 root_nodes.push(tree_node);
881 }
882
883 let mut id_remapping: HashMap<String, String> = HashMap::new();
886 root_nodes = root_nodes
887 .into_iter()
888 .map(|node| Self::collapse_single_child_containers(node, None, &mut id_remapping))
889 .collect();
890
891 let mut updated_path_to_id = path_to_id.clone();
893 #[expect(
894 clippy::disallowed_methods,
895 reason = "nondeterministic iteration order, TODO(mingwei)"
896 )]
897 for (path, old_id) in path_to_id.iter() {
898 if let Some(new_id) = id_remapping.get(old_id) {
899 updated_path_to_id.insert(path.clone(), new_id.clone());
900 }
901 }
902
903 (root_nodes, updated_path_to_id, id_remapping)
904 }
905
906 fn build_tree_node(
908 current_path: &str,
909 name: &str,
910 hierarchy_map: &HashMap<String, (String, usize, Option<String>)>,
911 path_to_id: &HashMap<String, String>,
912 ) -> serde_json::Value {
913 let current_id = path_to_id.get(current_path).unwrap().clone();
914
915 #[expect(
917 clippy::disallowed_methods,
918 reason = "nondeterministic iteration order, TODO(mingwei)"
919 )]
920 let mut child_specs: Vec<(&String, &String)> = hierarchy_map
921 .iter()
922 .filter_map(|(child_path, (child_name, _, parent_path))| {
923 if let Some(parent) = parent_path {
924 if parent == current_path {
925 Some((child_path, child_name))
926 } else {
927 None
928 }
929 } else {
930 None
931 }
932 })
933 .collect();
934 child_specs.sort_by(|a, b| a.1.cmp(b.1));
935 let mut children = Vec::new();
936 for (child_path, child_name) in child_specs {
937 let child_node =
938 Self::build_tree_node(child_path, child_name, hierarchy_map, path_to_id);
939 children.push(child_node);
940 }
941
942 if children.is_empty() {
943 serde_json::json!({
944 "id": current_id,
945 "name": name
946 })
947 } else {
948 serde_json::json!({
949 "id": current_id,
950 "name": name,
951 "children": children
952 })
953 }
954 }
955
956 fn collapse_single_child_containers(
962 node: serde_json::Value,
963 parent_name: Option<&str>,
964 id_remapping: &mut HashMap<String, String>,
965 ) -> serde_json::Value {
966 let serde_json::Value::Object(mut node_obj) = node else {
967 return node;
968 };
969
970 let current_name = node_obj
971 .get("name")
972 .and_then(|v| v.as_str())
973 .unwrap_or_default();
974
975 let current_id = node_obj
976 .get("id")
977 .and_then(|v| v.as_str())
978 .unwrap_or_default();
979
980 let effective_name = if let Some(parent) = parent_name {
983 format!("{} → {}", parent, current_name)
984 } else {
985 current_name.to_owned()
986 };
987
988 if let Some(serde_json::Value::Array(children)) = node_obj.get("children") {
990 if children.len() == 1
992 && let Some(child) = children.first()
993 {
994 let child_is_container = child
995 .get("children")
996 .and_then(|v| v.as_array())
997 .is_some_and(|arr| !arr.is_empty());
998
999 if child_is_container {
1000 let child_id = child.get("id").and_then(|v| v.as_str()).unwrap_or_default();
1001
1002 if !current_id.is_empty() && !child_id.is_empty() {
1004 id_remapping.insert(current_id.to_owned(), child_id.to_owned());
1005 }
1006
1007 return Self::collapse_single_child_containers(
1009 child.clone(),
1010 Some(&effective_name),
1011 id_remapping,
1012 );
1013 }
1014 }
1015
1016 let processed_children: Vec<serde_json::Value> = children
1018 .iter()
1019 .map(|child| {
1020 Self::collapse_single_child_containers(child.clone(), None, id_remapping)
1021 })
1022 .collect();
1023
1024 node_obj.insert("name".to_owned(), serde_json::Value::String(effective_name));
1025 node_obj.insert(
1026 "children".to_owned(),
1027 serde_json::Value::Array(processed_children),
1028 );
1029 } else {
1030 node_obj.insert("name".to_owned(), serde_json::Value::String(effective_name));
1032 }
1033
1034 serde_json::Value::Object(node_obj)
1035 }
1036}
1037
1038pub fn hydro_ir_to_json(
1040 ir: &[HydroRoot],
1041 location_names: &SecondaryMap<LocationKey, String>,
1042) -> Result<String, Box<dyn std::error::Error>> {
1043 let mut output = String::new();
1044
1045 let config = HydroWriteConfig {
1046 show_metadata: false,
1047 show_location_groups: true,
1048 use_short_labels: true, location_names,
1050 };
1051
1052 write_hydro_ir_json(&mut output, ir, config)?;
1053
1054 Ok(output)
1055}
1056
1057pub fn save_json(
1059 ir: &[HydroRoot],
1060 location_names: &SecondaryMap<LocationKey, String>,
1061 filename: &str,
1062) -> Result<std::path::PathBuf, Box<dyn std::error::Error>> {
1063 let config = HydroWriteConfig {
1064 location_names,
1065 ..Default::default()
1066 };
1067
1068 super::debug::save_json(ir, Some(filename), Some(config))
1069 .map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
1070}