1use crate::core::{Graph, Result};
4use std::collections::{HashMap, HashSet};
5
6pub struct Inspector;
8
9impl Inspector {
10 pub fn analyze(graph: &Graph) -> GraphAnalysis {
12 let node_count = graph.node_count();
13 let edge_count = graph.edge_count();
14
15 let (depth, width) = Self::calculate_dimensions(graph);
17
18 let sources = Self::find_source_nodes(graph);
20 let sinks = Self::find_sink_nodes(graph);
21
22 let avg_connections = if node_count > 0 {
24 edge_count as f64 / node_count as f64
25 } else {
26 0.0
27 };
28
29 GraphAnalysis {
30 node_count,
31 edge_count,
32 depth,
33 width,
34 source_nodes: sources,
35 sink_nodes: sinks,
36 avg_connections_per_node: avg_connections,
37 has_cycles: graph.validate().is_err(),
38 }
39 }
40
41 fn find_source_nodes(graph: &Graph) -> Vec<String> {
43 graph
44 .nodes()
45 .iter()
46 .filter(|node| {
47 graph
48 .incoming_edges(&node.config.id)
49 .map(|edges| edges.is_empty())
50 .unwrap_or(false)
51 })
52 .map(|node| node.config.id.clone())
53 .collect()
54 }
55
56 fn find_sink_nodes(graph: &Graph) -> Vec<String> {
58 graph
59 .nodes()
60 .iter()
61 .filter(|node| {
62 graph
63 .outgoing_edges(&node.config.id)
64 .map(|edges| edges.is_empty())
65 .unwrap_or(false)
66 })
67 .map(|node| node.config.id.clone())
68 .collect()
69 }
70
71 fn calculate_dimensions(graph: &Graph) -> (usize, usize) {
73 let order = match graph.topological_order() {
75 Ok(o) => o,
76 Err(_) => return (0, 0),
77 };
78
79 if order.is_empty() {
80 return (0, 0);
81 }
82
83 let mut levels: HashMap<String, usize> = HashMap::new();
85
86 for node_id in &order {
87 let incoming = graph.incoming_edges(node_id).unwrap_or_default();
89 let max_pred_level = incoming
90 .iter()
91 .filter_map(|edge| levels.get(&edge.from_node))
92 .max()
93 .copied()
94 .unwrap_or(0);
95
96 let level = if incoming.is_empty() {
97 0
98 } else {
99 max_pred_level + 1
100 };
101 levels.insert(node_id.clone(), level);
102 }
103
104 let depth = levels.values().max().copied().unwrap_or(0) + 1;
105
106 let mut level_counts: HashMap<usize, usize> = HashMap::new();
108 for level in levels.values() {
109 *level_counts.entry(*level).or_insert(0) += 1;
110 }
111 let width = level_counts.values().max().copied().unwrap_or(0);
112
113 (depth, width)
114 }
115
116 pub fn suggest_optimizations(graph: &Graph) -> Vec<Optimization> {
118 let mut suggestions = Vec::new();
119
120 for node in graph.nodes() {
122 let incoming = graph.incoming_edges(&node.config.id).unwrap_or_default();
123 let outgoing = graph.outgoing_edges(&node.config.id).unwrap_or_default();
124
125 if incoming.is_empty() && outgoing.is_empty() && graph.node_count() > 1 {
126 suggestions.push(Optimization {
127 optimization_type: OptimizationType::RemoveIsolatedNode,
128 description: format!("Node '{}' is isolated (no connections)", node.config.id),
129 node_ids: vec![node.config.id.clone()],
130 });
131 }
132 }
133
134 let mut connections: HashSet<(String, String)> = HashSet::new();
136 for edge in graph.edges() {
137 let pair = (edge.from_node.clone(), edge.to_node.clone());
138 if connections.contains(&pair) {
139 suggestions.push(Optimization {
140 optimization_type: OptimizationType::RemoveRedundantEdge,
141 description: format!(
142 "Multiple edges between '{}' and '{}'",
143 edge.from_node, edge.to_node
144 ),
145 node_ids: vec![edge.from_node.clone(), edge.to_node.clone()],
146 });
147 }
148 connections.insert(pair);
149 }
150
151 suggestions
152 }
153
154 pub fn visualize(graph: &Graph) -> Result<String> {
156 let order = graph.topological_order()?;
157 let mut output = String::new();
158
159 output.push_str("Graph Structure:\n");
160 output.push_str("================\n\n");
161
162 for node_id in order {
163 let node = graph.get_node(&node_id)?;
164 output.push_str(&format!(
165 "Node: {} ({})\n",
166 node.config.name, node.config.id
167 ));
168
169 if !node.config.input_ports.is_empty() {
171 output.push_str(" Inputs:\n");
172 for port in &node.config.input_ports {
173 let required = if port.required { "*" } else { "" };
174 output.push_str(&format!(
175 " - {}{} ({})\n",
176 port.display_name, required, port.broadcast_name
177 ));
178 }
179 }
180
181 if !node.config.output_ports.is_empty() {
183 output.push_str(" Outputs:\n");
184 for port in &node.config.output_ports {
185 output.push_str(&format!(
186 " - {} ({})\n",
187 port.display_name, port.broadcast_name
188 ));
189 }
190 }
191
192 let outgoing = graph.outgoing_edges(&node_id)?;
194 if !outgoing.is_empty() {
195 output.push_str(" Connections:\n");
196 for edge in outgoing {
197 output.push_str(&format!(
198 " - {} -> {}:{}\n",
199 edge.from_port, edge.to_node, edge.to_port
200 ));
201 }
202 }
203
204 output.push('\n');
205 }
206
207 Ok(output)
208 }
209
210 pub fn to_mermaid(graph: &Graph) -> Result<String> {
212 let mut output = String::new();
213
214 output.push_str("```mermaid\n");
215 output.push_str("graph TD\n");
216
217 let parallel_groups = Self::detect_parallel_groups(graph);
219
220 let branch_names = graph.branch_names();
222 let has_branches = !branch_names.is_empty();
223
224 for node in graph.nodes() {
226 let node_id = &node.config.id;
227 let node_name = &node.config.name;
228
229 let safe_id = node_id.replace(['-', ' '], "_");
231
232 let formatted_name = node_name.replace("\\n", "<br/>");
234
235 let incoming = graph.incoming_edges(node_id).unwrap_or_default();
237 let outgoing = graph.outgoing_edges(node_id).unwrap_or_default();
238
239 if incoming.is_empty() && !outgoing.is_empty() {
240 output.push_str(&format!(" {}[\"{}\"]\n", safe_id, formatted_name));
242 output.push_str(&format!(
243 " style {} fill:#e1f5ff,stroke:#01579b,stroke-width:2px\n",
244 safe_id
245 ));
246 } else if outgoing.is_empty() && !incoming.is_empty() {
247 output.push_str(&format!(" {}[\"{}\"]\n", safe_id, formatted_name));
249 output.push_str(&format!(
250 " style {} fill:#f3e5f5,stroke:#4a148c,stroke-width:2px\n",
251 safe_id
252 ));
253 } else {
254 output.push_str(&format!(" {}[\"{}\"]\n", safe_id, formatted_name));
256 output.push_str(&format!(
257 " style {} fill:#fff3e0,stroke:#e65100,stroke-width:2px\n",
258 safe_id
259 ));
260 }
261 }
262
263 if !parallel_groups.is_empty() {
265 output.push('\n');
266 output.push_str(" %% Parallel Execution Groups Detected\n");
267 for (i, group) in parallel_groups.iter().enumerate() {
268 output.push_str(&format!(
269 " %% Group {}: {} nodes executing in parallel\n",
270 i + 1,
271 group.parallel_nodes.len()
272 ));
273 }
274 }
275
276 for (i, group) in parallel_groups.iter().enumerate() {
278 output.push('\n');
279 output.push_str(&format!(
280 " subgraph parallel_group_{}[\"⚡ Parallel Execution Group {}\"]\n",
281 i + 1,
282 i + 1
283 ));
284 output.push_str(" direction LR\n");
285 for node_id in &group.parallel_nodes {
286 let safe_id = node_id.replace(['-', ' '], "_");
287 output.push_str(&format!(" {}\n", safe_id));
288 }
289 output.push_str(" end\n");
290 output.push_str(&format!(
291 " style parallel_group_{} fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px,stroke-dasharray: 5 5\n",
292 i + 1
293 ));
294 }
295
296 if has_branches {
298 output.push('\n');
299 output.push_str(" %% Variant Branches\n");
300
301 let mut variant_groups: HashMap<String, Vec<String>> = HashMap::new();
303 for branch_name in &branch_names {
304 if let Some(underscore_pos) = branch_name.rfind('_') {
306 if underscore_pos + 1 < branch_name.len() {
308 let prefix = &branch_name[..underscore_pos];
309 if branch_name[underscore_pos + 1..].parse::<usize>().is_ok() {
311 variant_groups
312 .entry(prefix.to_string())
313 .or_default()
314 .push(branch_name.clone());
315 continue;
316 }
317 }
318 }
319 variant_groups
321 .entry(branch_name.clone())
322 .or_default()
323 .push(branch_name.clone());
324 }
325
326 for (prefix, branches) in &variant_groups {
328 let safe_prefix = prefix.replace(['-', ' '], "_");
329 if branches.len() > 1 {
330 output.push_str(&format!(
332 " {}{{{{\"{}\\n{} variants\"}}}}\n",
333 safe_prefix,
334 prefix.replace('_', " "),
335 branches.len()
336 ));
337 output.push_str(&format!(
338 " style {} fill:#e8f5e9,stroke:#2e7d32,stroke-width:3px,stroke-dasharray: 5 5\n",
339 safe_prefix
340 ));
341 } else {
342 output.push_str(&format!(
344 " {}[\"Branch: {}\"]\n",
345 safe_prefix,
346 prefix.replace('_', " ")
347 ));
348 output.push_str(&format!(
349 " style {} fill:#fff9c4,stroke:#f57f17,stroke-width:2px\n",
350 safe_prefix
351 ));
352 }
353 }
354 }
355
356 output.push('\n');
357
358 for edge in graph.edges() {
360 let from_safe = edge.from_node.replace(['-', ' '], "_");
361 let to_safe = edge.to_node.replace(['-', ' '], "_");
362 let label = format!("{}→{}", edge.from_port, edge.to_port);
363
364 output.push_str(&format!(
365 " {} -->|\"{}\"| {}\n",
366 from_safe, label, to_safe
367 ));
368 }
369
370 output.push_str("```\n");
371
372 Ok(output)
373 }
374
375 fn detect_parallel_groups(graph: &Graph) -> Vec<ParallelGroup> {
377 let mut groups = Vec::new();
378
379 for node in graph.nodes() {
381 let outgoing = graph.outgoing_edges(&node.config.id).unwrap_or_default();
382
383 if outgoing.len() > 1 {
385 let target_nodes: Vec<String> =
387 outgoing.iter().map(|e| e.to_node.clone()).collect();
388
389 let mut common_targets: HashMap<String, usize> = HashMap::new();
391 for target_id in &target_nodes {
392 if let Ok(target_outgoing) = graph.outgoing_edges(target_id) {
393 for edge in target_outgoing {
394 *common_targets.entry(edge.to_node.clone()).or_insert(0) += 1;
395 }
396 }
397 }
398
399 let merge_nodes: Vec<String> = common_targets
401 .iter()
402 .filter(|(_, &count)| count > 1)
403 .map(|(node, _)| node.clone())
404 .collect();
405
406 if !merge_nodes.is_empty() {
407 groups.push(ParallelGroup {
408 source_node: node.config.id.clone(),
409 parallel_nodes: target_nodes,
410 merge_nodes,
411 });
412 }
413 }
414 }
415
416 groups
417 }
418}
419
420#[derive(Debug, Clone)]
422pub struct GraphAnalysis {
423 pub node_count: usize,
425 pub edge_count: usize,
427 pub depth: usize,
429 pub width: usize,
431 pub source_nodes: Vec<String>,
433 pub sink_nodes: Vec<String>,
435 pub avg_connections_per_node: f64,
437 pub has_cycles: bool,
439}
440
441impl GraphAnalysis {
442 pub fn summary(&self) -> String {
444 format!(
445 "Nodes: {}, Edges: {}, Depth: {}, Width: {}, Sources: {}, Sinks: {}, Avg Connections: {:.2}, Cycles: {}",
446 self.node_count,
447 self.edge_count,
448 self.depth,
449 self.width,
450 self.source_nodes.len(),
451 self.sink_nodes.len(),
452 self.avg_connections_per_node,
453 if self.has_cycles { "Yes" } else { "No" }
454 )
455 }
456}
457
458#[derive(Debug, Clone)]
460pub struct Optimization {
461 pub optimization_type: OptimizationType,
463 pub description: String,
465 pub node_ids: Vec<String>,
467}
468
469#[derive(Debug, Clone, PartialEq)]
471pub enum OptimizationType {
472 RemoveIsolatedNode,
474 RemoveRedundantEdge,
476 MergeNodes,
478 ParallelizeBranches,
480}
481
482#[derive(Debug, Clone)]
484struct ParallelGroup {
485 #[allow(dead_code)]
487 source_node: String,
488 parallel_nodes: Vec<String>,
490 #[allow(dead_code)]
492 merge_nodes: Vec<String>,
493}
494
495#[cfg(test)]
496mod tests {
497 use super::*;
498 use crate::core::{Edge, Node, NodeConfig, Port};
499 use std::collections::HashMap;
500 use std::sync::Arc;
501
502 fn dummy_function(
503 _inputs: &HashMap<String, crate::core::PortData>,
504 ) -> Result<HashMap<String, crate::core::PortData>> {
505 Ok(HashMap::new())
506 }
507
508 #[test]
509 fn test_analyze_empty_graph() {
510 let graph = Graph::new();
511 let analysis = Inspector::analyze(&graph);
512
513 assert_eq!(analysis.node_count, 0);
514 assert_eq!(analysis.edge_count, 0);
515 assert_eq!(analysis.depth, 0);
516 assert_eq!(analysis.width, 0);
517 }
518
519 #[test]
520 fn test_find_source_and_sink_nodes() {
521 let mut graph = Graph::new();
522
523 let config1 = NodeConfig::new(
525 "source",
526 "Source",
527 vec![],
528 vec![Port::new("out", "Output")],
529 Arc::new(dummy_function),
530 );
531
532 let config2 = NodeConfig::new(
533 "middle",
534 "Middle",
535 vec![Port::new("in", "Input")],
536 vec![Port::new("out", "Output")],
537 Arc::new(dummy_function),
538 );
539
540 let config3 = NodeConfig::new(
541 "sink",
542 "Sink",
543 vec![Port::new("in", "Input")],
544 vec![],
545 Arc::new(dummy_function),
546 );
547
548 graph.add(Node::new(config1)).unwrap();
549 graph.add(Node::new(config2)).unwrap();
550 graph.add(Node::new(config3)).unwrap();
551
552 graph
553 .add_edge(Edge::new("source", "out", "middle", "in"))
554 .unwrap();
555 graph
556 .add_edge(Edge::new("middle", "out", "sink", "in"))
557 .unwrap();
558
559 let analysis = Inspector::analyze(&graph);
560
561 assert_eq!(analysis.source_nodes.len(), 1);
562 assert_eq!(analysis.source_nodes[0], "source");
563 assert_eq!(analysis.sink_nodes.len(), 1);
564 assert_eq!(analysis.sink_nodes[0], "sink");
565 assert_eq!(analysis.depth, 3);
566 assert_eq!(analysis.width, 1);
567 }
568
569 #[test]
570 fn test_suggest_optimizations_isolated_node() {
571 let mut graph = Graph::new();
572
573 let config1 = NodeConfig::new("node1", "Node 1", vec![], vec![], Arc::new(dummy_function));
574
575 let config2 = NodeConfig::new(
576 "node2",
577 "Node 2",
578 vec![],
579 vec![Port::new("out", "Output")],
580 Arc::new(dummy_function),
581 );
582
583 graph.add(Node::new(config1)).unwrap();
584 graph.add(Node::new(config2)).unwrap();
585
586 let optimizations = Inspector::suggest_optimizations(&graph);
587
588 assert!(!optimizations.is_empty());
589 assert!(optimizations
590 .iter()
591 .any(|o| o.optimization_type == OptimizationType::RemoveIsolatedNode));
592 }
593
594 #[test]
595 fn test_mermaid_newline_replacement() {
596 let mut graph = Graph::new();
597
598 let config = NodeConfig::new(
600 "test_node",
601 "Line 1\\nLine 2\\nLine 3",
602 vec![],
603 vec![Port::new("out", "Output")],
604 Arc::new(dummy_function),
605 );
606
607 graph.add(Node::new(config)).unwrap();
608
609 let mermaid = Inspector::to_mermaid(&graph).unwrap();
610
611 assert!(mermaid.contains("<br/>"));
613 assert!(!mermaid.contains("\\n"));
614 }
615
616 #[test]
617 fn test_mermaid_parallel_group_detection() {
618 let mut graph = Graph::new();
619
620 let source = NodeConfig::new(
622 "source",
623 "Source",
624 vec![],
625 vec![Port::new("out", "Output")],
626 Arc::new(dummy_function),
627 );
628
629 let branch1 = NodeConfig::new(
630 "branch1",
631 "Branch 1",
632 vec![Port::new("in", "Input")],
633 vec![Port::new("out", "Output")],
634 Arc::new(dummy_function),
635 );
636
637 let branch2 = NodeConfig::new(
638 "branch2",
639 "Branch 2",
640 vec![Port::new("in", "Input")],
641 vec![Port::new("out", "Output")],
642 Arc::new(dummy_function),
643 );
644
645 let merger = NodeConfig::new(
646 "merger",
647 "Merger",
648 vec![Port::new("in1", "Input 1"), Port::new("in2", "Input 2")],
649 vec![],
650 Arc::new(dummy_function),
651 );
652
653 graph.add(Node::new(source)).unwrap();
654 graph.add(Node::new(branch1)).unwrap();
655 graph.add(Node::new(branch2)).unwrap();
656 graph.add(Node::new(merger)).unwrap();
657
658 graph
660 .add_edge(Edge::new("source", "out", "branch1", "in"))
661 .unwrap();
662 graph
663 .add_edge(Edge::new("source", "out", "branch2", "in"))
664 .unwrap();
665
666 graph
668 .add_edge(Edge::new("branch1", "out", "merger", "in1"))
669 .unwrap();
670 graph
671 .add_edge(Edge::new("branch2", "out", "merger", "in2"))
672 .unwrap();
673
674 let mermaid = Inspector::to_mermaid(&graph).unwrap();
675
676 assert!(mermaid.contains("Parallel Execution Groups Detected"));
678 assert!(mermaid.contains("subgraph parallel_group_"));
679 assert!(mermaid.contains("⚡ Parallel Execution Group"));
680 }
681
682 #[test]
683 fn test_mermaid_node_styling() {
684 let mut graph = Graph::new();
685
686 let source = NodeConfig::new(
688 "source",
689 "Source",
690 vec![],
691 vec![Port::new("out", "Output")],
692 Arc::new(dummy_function),
693 );
694
695 let processor = NodeConfig::new(
697 "processor",
698 "Processor",
699 vec![Port::new("in", "Input")],
700 vec![Port::new("out", "Output")],
701 Arc::new(dummy_function),
702 );
703
704 let sink = NodeConfig::new(
706 "sink",
707 "Sink",
708 vec![Port::new("in", "Input")],
709 vec![],
710 Arc::new(dummy_function),
711 );
712
713 graph.add(Node::new(source)).unwrap();
714 graph.add(Node::new(processor)).unwrap();
715 graph.add(Node::new(sink)).unwrap();
716
717 graph
718 .add_edge(Edge::new("source", "out", "processor", "in"))
719 .unwrap();
720 graph
721 .add_edge(Edge::new("processor", "out", "sink", "in"))
722 .unwrap();
723
724 let mermaid = Inspector::to_mermaid(&graph).unwrap();
725
726 assert!(mermaid.contains("#e1f5ff")); assert!(mermaid.contains("#fff3e0")); assert!(mermaid.contains("#f3e5f5")); }
731
732 #[test]
733 fn test_mermaid_edge_labels() {
734 let mut graph = Graph::new();
735
736 let source = NodeConfig::new(
737 "source",
738 "Source",
739 vec![],
740 vec![Port::new("output_port", "Output")],
741 Arc::new(dummy_function),
742 );
743
744 let sink = NodeConfig::new(
745 "sink",
746 "Sink",
747 vec![Port::new("input_port", "Input")],
748 vec![],
749 Arc::new(dummy_function),
750 );
751
752 graph.add(Node::new(source)).unwrap();
753 graph.add(Node::new(sink)).unwrap();
754
755 graph
756 .add_edge(Edge::new("source", "output_port", "sink", "input_port"))
757 .unwrap();
758
759 let mermaid = Inspector::to_mermaid(&graph).unwrap();
760
761 assert!(mermaid.contains("output_port→input_port"));
763 }
764}