1use crate::analyze::FileAnalysisOutput;
6use crate::graph::call_graph::CallGraph;
7use petgraph::Direction;
8use petgraph::graph::{DiGraph, NodeIndex};
9use petgraph::visit::EdgeRef;
10use serde::{Deserialize, Serialize};
11use std::collections::{BTreeMap, HashMap, HashSet};
12
13#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
14pub enum SymbolKind {
15 Function,
16 Class,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
20pub enum Node {
21 File {
22 path: String,
23 },
24 Symbol {
25 name: String,
26 kind: SymbolKind,
27 file_path: String,
28 line: usize,
29 },
30 Module {
31 path: String,
32 },
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
36pub enum Edge {
37 Contains,
38 Calls,
39 Imports,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct StructuralGraph {
44 pub graph: DiGraph<Node, Edge>,
45 #[serde(skip)]
46 symbol_index: HashMap<String, Vec<NodeIndex>>,
47}
48
49type BuildNodesResult = (
50 DiGraph<Node, Edge>,
51 HashSet<(NodeIndex, NodeIndex)>,
52 HashMap<String, Vec<NodeIndex>>,
53 HashMap<NodeIndex, usize>,
54);
55
56impl StructuralGraph {
57 fn build_symbol_index(graph: &DiGraph<Node, Edge>) -> HashMap<String, Vec<NodeIndex>> {
58 let mut index: HashMap<String, Vec<NodeIndex>> = HashMap::new();
59 for idx in graph.node_indices() {
60 if let Node::Symbol { name, .. } = &graph[idx] {
61 index.entry(name.clone()).or_default().push(idx);
62 }
63 }
64 index
65 }
66
67 pub fn from_graph(graph: DiGraph<Node, Edge>) -> Self {
68 let symbol_index = Self::build_symbol_index(&graph);
69 StructuralGraph {
70 graph,
71 symbol_index,
72 }
73 }
74
75 pub(crate) fn rebuild_symbol_index(&mut self) {
76 self.symbol_index = Self::build_symbol_index(&self.graph);
77 }
78
79 fn resolve_candidate(
86 candidates: &[NodeIndex],
87 graph: &DiGraph<Node, Edge>,
88 call_file: &str,
89 call_line: usize,
90 call_arg_count: Option<usize>,
91 param_counts: &HashMap<NodeIndex, usize>,
92 ) -> Option<NodeIndex> {
93 if candidates.is_empty() {
94 return None;
95 }
96 if candidates.len() == 1 {
97 return candidates.first().copied();
98 }
99
100 let same_file: Vec<NodeIndex> = candidates
102 .iter()
103 .filter(|idx| {
104 if let Node::Symbol { file_path, .. } = &graph[**idx] {
105 file_path == call_file
106 } else {
107 false
108 }
109 })
110 .copied()
111 .collect();
112
113 let mut pool: Vec<NodeIndex> = if same_file.is_empty() {
114 candidates.to_vec()
115 } else {
116 same_file
117 };
118
119 if pool.len() == 1 {
120 return pool.first().copied();
121 }
122
123 let min_line_distance = pool
125 .iter()
126 .filter_map(|idx| {
127 if let Node::Symbol { line, .. } = &graph[*idx] {
128 Some(line.abs_diff(call_line))
129 } else {
130 None
131 }
132 })
133 .min()?;
134
135 pool.retain(|idx| {
136 if let Node::Symbol { line, .. } = &graph[*idx] {
137 line.abs_diff(call_line) == min_line_distance
138 } else {
139 false
140 }
141 });
142
143 if pool.len() == 1 {
144 return pool.first().copied();
145 }
146
147 if let Some(arg_count) = call_arg_count
149 && let Some(matching) = pool
150 .iter()
151 .find(|idx| param_counts.get(idx) == Some(&arg_count))
152 {
153 return Some(*matching);
154 }
155
156 pool.first().copied()
158 }
159
160 fn build_nodes(entries: &[FileAnalysisOutput]) -> BuildNodesResult {
161 let mut graph = DiGraph::new();
162 let mut seen: HashSet<(NodeIndex, NodeIndex)> = HashSet::new();
163 let mut symbol_index: HashMap<String, Vec<NodeIndex>> = HashMap::new();
164 let mut param_counts: HashMap<NodeIndex, usize> = HashMap::new();
165
166 for entry in entries {
167 let fp = &entry.path;
168 let file = graph.add_node(Node::File {
169 path: fp.to_string(),
170 });
171
172 for f in &entry.semantic.functions {
173 let n = graph.add_node(Node::Symbol {
174 name: f.name.clone(),
175 kind: SymbolKind::Function,
176 file_path: fp.to_string(),
177 line: f.line,
178 });
179 if seen.insert((file, n)) {
180 graph.add_edge(file, n, Edge::Contains);
181 }
182 symbol_index.entry(f.name.clone()).or_default().push(n);
183 param_counts.insert(n, f.parameters.len());
184 }
185 for c in &entry.semantic.classes {
186 let n = graph.add_node(Node::Symbol {
187 name: c.name.clone(),
188 kind: SymbolKind::Class,
189 file_path: fp.to_string(),
190 line: c.line,
191 });
192 if seen.insert((file, n)) {
193 graph.add_edge(file, n, Edge::Contains);
194 }
195 symbol_index.entry(c.name.clone()).or_default().push(n);
196 }
197 for im in &entry.semantic.imports {
198 if !im.module.is_empty() {
199 let n = graph.add_node(Node::Module {
200 path: im.module.clone(),
201 });
202 if seen.insert((file, n)) {
203 graph.add_edge(file, n, Edge::Imports);
204 }
205 }
206 }
207 }
208
209 (graph, seen, symbol_index, param_counts)
210 }
211
212 pub fn build_from_analysis(entries: &[FileAnalysisOutput]) -> Self {
213 let (mut graph, mut seen, symbol_index, param_counts) = Self::build_nodes(entries);
214
215 for entry in entries {
217 for cl in &entry.semantic.calls {
218 let caller_candidates = symbol_index
219 .get(&cl.caller)
220 .map(|v| v.as_slice())
221 .unwrap_or(&[]);
222 let callee_candidates = symbol_index
223 .get(&cl.callee)
224 .map(|v| v.as_slice())
225 .unwrap_or(&[]);
226
227 let caller = Self::resolve_candidate(
228 caller_candidates,
229 &graph,
230 entry.path.as_str(),
231 cl.line,
232 None,
233 ¶m_counts,
234 );
235 let callee = Self::resolve_candidate(
236 callee_candidates,
237 &graph,
238 entry.path.as_str(),
239 cl.line,
240 cl.arg_count,
241 ¶m_counts,
242 );
243
244 if let (Some(c), Some(e)) = (caller, callee)
245 && seen.insert((c, e))
246 {
247 graph.add_edge(c, e, Edge::Calls);
248 }
249 }
250 }
251
252 StructuralGraph {
253 graph,
254 symbol_index,
255 }
256 }
257
258 pub fn from_call_graph(entries: &[FileAnalysisOutput], call_graph: &CallGraph) -> Self {
267 let (mut graph, mut seen, symbol_index, param_counts) = Self::build_nodes(entries);
268
269 for (caller_name, edges) in &call_graph.callees {
270 let caller_candidates = symbol_index
271 .get(caller_name)
272 .map(|v| v.as_slice())
273 .unwrap_or(&[]);
274
275 for edge in edges {
276 let callee_candidates = symbol_index
277 .get(&edge.neighbor_name)
278 .map(|v| v.as_slice())
279 .unwrap_or(&[]);
280 let call_file = edge.path.to_string_lossy();
281
282 let caller = Self::resolve_candidate(
283 caller_candidates,
284 &graph,
285 &call_file,
286 edge.line,
287 None,
288 ¶m_counts,
289 );
290 let callee = Self::resolve_candidate(
291 callee_candidates,
292 &graph,
293 &call_file,
294 edge.line,
295 None,
296 ¶m_counts,
297 );
298
299 if let (Some(c), Some(e)) = (caller, callee)
300 && seen.insert((c, e))
301 {
302 graph.add_edge(c, e, Edge::Calls);
303 }
304 }
305 }
306
307 StructuralGraph {
308 graph,
309 symbol_index,
310 }
311 }
312
313 fn bfs_frontier(&self, start: NodeIndex, depth: usize) -> (HashSet<NodeIndex>, Vec<NodeIndex>) {
319 let mut visited = HashSet::new();
320 let mut result = Vec::new();
321 let mut frontier = vec![start];
322 visited.insert(start);
323 for _ in 0..depth {
324 if frontier.is_empty() {
325 break;
326 }
327 let mut next = Vec::new();
328 for node in frontier {
329 for nb in self.graph.neighbors(node) {
330 if visited.insert(nb) {
331 result.push(nb);
332 next.push(nb);
333 }
334 }
335 }
336 frontier = next;
337 }
338 (visited, result)
339 }
340
341 pub fn bfs_blast_radius(&self, symbol: &str, depth: usize) -> Vec<NodeIndex> {
342 let Some(start) = self
343 .symbol_index
344 .get(symbol)
345 .and_then(|v| v.first())
346 .copied()
347 else {
348 return vec![];
349 };
350 self.bfs_frontier(start, depth).1
351 }
352
353 pub fn blast_radius_subgraph(
363 &self,
364 symbol: &str,
365 depth: usize,
366 ) -> (Vec<NodeIndex>, Vec<(NodeIndex, NodeIndex, Edge)>) {
367 let Some(start) = self
368 .symbol_index
369 .get(symbol)
370 .and_then(|v| v.first())
371 .copied()
372 else {
373 return (vec![], vec![]);
374 };
375
376 let (visited, tail) = self.bfs_frontier(start, depth);
377
378 let mut nodes = vec![start];
380 nodes.extend(tail);
381
382 let edges: Vec<(NodeIndex, NodeIndex, Edge)> = self
384 .graph
385 .edge_references()
386 .filter(|e| visited.contains(&e.source()) && visited.contains(&e.target()))
387 .map(|e| (e.source(), e.target(), e.weight().clone()))
388 .collect();
389
390 (nodes, edges)
391 }
392
393 pub fn render_subgraph_text(&self, nodes: &[NodeIndex]) -> String {
404 let node_set: HashSet<NodeIndex> = nodes.iter().copied().collect();
405
406 let mut calls_map: HashMap<NodeIndex, Vec<String>> = HashMap::new();
408 let mut callers_map: HashMap<NodeIndex, Vec<String>> = HashMap::new();
409
410 for &idx in &node_set {
411 if idx.index() >= self.graph.node_count() {
412 continue;
413 }
414
415 for edge in self.graph.edges_directed(idx, Direction::Outgoing) {
417 if *edge.weight() == Edge::Calls
418 && node_set.contains(&edge.target())
419 && let Node::Symbol { name, .. } = &self.graph[edge.target()]
420 {
421 calls_map.entry(idx).or_default().push(name.clone());
422 }
423 }
424
425 for edge in self.graph.edges_directed(idx, Direction::Incoming) {
427 if *edge.weight() == Edge::Calls
428 && node_set.contains(&edge.source())
429 && let Node::Symbol { name, .. } = &self.graph[edge.source()]
430 {
431 callers_map.entry(idx).or_default().push(name.clone());
432 }
433 }
434 }
435
436 let mut file_groups: BTreeMap<String, Vec<(String, NodeIndex)>> = BTreeMap::new();
438 for &idx in &node_set {
439 if idx.index() >= self.graph.node_count() {
440 continue;
441 }
442 if let Node::Symbol {
443 name,
444 kind: SymbolKind::Function,
445 file_path,
446 ..
447 } = &self.graph[idx]
448 {
449 file_groups
450 .entry(file_path.clone())
451 .or_default()
452 .push((name.clone(), idx));
453 }
454 }
455
456 let mut output = String::new();
457 for (file_path, mut funcs) in file_groups {
458 funcs.sort_by(|a, b| a.0.cmp(&b.0));
459 funcs.dedup_by(|a, b| a.0 == b.0 && a.1 == b.1);
460
461 if !output.is_empty() {
462 output.push('\n');
463 }
464 output.push_str(&format!("// {}\n", file_path));
465
466 for (name, idx) in funcs {
467 let mut line = format!("fn {}", name);
468
469 if let Some(mut callees) = calls_map.remove(&idx) {
470 callees.sort();
471 callees.dedup();
472 if !callees.is_empty() {
473 line.push_str(&format!(" [calls: {}]", callees.join(", ")));
474 }
475 }
476
477 if let Some(mut callers) = callers_map.remove(&idx) {
478 callers.sort();
479 callers.dedup();
480 if !callers.is_empty() {
481 line.push_str(&format!(" [callers: {}]", callers.join(", ")));
482 }
483 }
484
485 output.push_str(&line);
486 output.push('\n');
487 }
488 }
489
490 output
491 }
492
493 pub fn find_symbols(&self, names: &[&str]) -> Vec<NodeIndex> {
495 let mut indices = Vec::new();
496 for name in names {
497 if let Some(first) = self
498 .symbol_index
499 .get(*name)
500 .and_then(|v| v.first().copied())
501 {
502 indices.push(first);
503 }
504 }
505 indices
506 }
507
508 pub fn blast_radius_bidirectional(
515 &self,
516 seeds: &[NodeIndex],
517 max_nodes: usize,
518 max_depth: usize,
519 ) -> (Vec<NodeIndex>, Vec<(NodeIndex, NodeIndex, Edge)>) {
520 if seeds.is_empty() || max_nodes == 0 || max_depth == 0 {
521 return (vec![], vec![]);
522 }
523
524 let mut visited: HashSet<NodeIndex> = HashSet::new();
525 let mut result: Vec<NodeIndex> = Vec::new();
526 let mut frontier: Vec<NodeIndex> = Vec::new();
527
528 for &seed in seeds {
530 if seed.index() < self.graph.node_count() && visited.insert(seed) {
531 result.push(seed);
532 frontier.push(seed);
533 if result.len() >= max_nodes {
534 break;
535 }
536 }
537 }
538
539 let mut depth = 0;
540 while depth < max_depth && !frontier.is_empty() && result.len() < max_nodes {
541 depth += 1;
542
543 let mut candidates: Vec<NodeIndex> = Vec::new();
547 for &node in &frontier {
548 for edge in self.graph.edges_directed(node, Direction::Outgoing) {
549 if *edge.weight() == Edge::Calls && !visited.contains(&edge.target()) {
550 candidates.push(edge.target());
551 }
552 }
553 for edge in self.graph.edges_directed(node, Direction::Incoming) {
554 if *edge.weight() == Edge::Calls && !visited.contains(&edge.source()) {
555 candidates.push(edge.source());
556 }
557 }
558 }
559 candidates.sort_by_key(|n| n.index());
560 candidates.dedup();
561
562 let mut next = Vec::new();
563 for candidate in candidates {
564 if visited.insert(candidate) {
565 result.push(candidate);
566 next.push(candidate);
567 if result.len() >= max_nodes {
568 break;
569 }
570 }
571 }
572 frontier = next;
573 }
574
575 let edges: Vec<(NodeIndex, NodeIndex, Edge)> = self
577 .graph
578 .edge_references()
579 .filter(|e| visited.contains(&e.source()) && visited.contains(&e.target()))
580 .map(|e| (e.source(), e.target(), e.weight().clone()))
581 .collect();
582
583 (result, edges)
584 }
585}
586
587#[cfg(test)]
588mod tests {
589 use super::*;
590 use crate::types::{CallInfo, ClassInfo, FunctionInfo, ImportInfo, SemanticAnalysis};
591 use std::path::PathBuf;
592
593 fn make_output(
594 path: &str,
595 funcs: Vec<&str>,
596 classes: Vec<&str>,
597 imports: Vec<&str>,
598 calls: Vec<(&str, &str)>,
599 ) -> FileAnalysisOutput {
600 FileAnalysisOutput::new(
601 path.to_string(),
602 format!("{}:1:1:1", path),
603 SemanticAnalysis {
604 functions: funcs
605 .into_iter()
606 .map(|n| FunctionInfo {
607 name: n.to_string(),
608 line: 1,
609 end_line: 6,
610 parameters: vec![],
611 return_type: None,
612 })
613 .collect(),
614 classes: classes
615 .into_iter()
616 .map(|n| ClassInfo {
617 name: n.to_string(),
618 line: 1,
619 end_line: 10,
620 methods: vec![],
621 fields: vec![],
622 inherits: vec![],
623 })
624 .collect(),
625 imports: imports
626 .into_iter()
627 .map(|m| ImportInfo {
628 module: m.to_string(),
629 items: vec![],
630 line: 1,
631 })
632 .collect(),
633 references: vec![],
634 call_frequency: Default::default(),
635 calls: calls
636 .into_iter()
637 .map(|(c, e)| CallInfo {
638 caller: c.to_string(),
639 callee: e.to_string(),
640 line: 1,
641 column: 0,
642 arg_count: None,
643 })
644 .collect(),
645 impl_traits: vec![],
646 def_use_sites: vec![],
647 },
648 10,
649 None,
650 )
651 }
652
653 fn make_function(name: &str, line: usize, param_count: usize) -> FunctionInfo {
655 FunctionInfo {
656 name: name.to_string(),
657 line,
658 end_line: line + 5,
659 parameters: (0..param_count).map(|i| format!("p{}", i)).collect(),
660 return_type: None,
661 }
662 }
663
664 fn make_call(
666 caller: &str,
667 callee: &str,
668 call_line: usize,
669 arg_count: Option<usize>,
670 ) -> CallInfo {
671 CallInfo {
672 caller: caller.to_string(),
673 callee: callee.to_string(),
674 line: call_line,
675 column: 0,
676 arg_count,
677 }
678 }
679
680 fn make_output_custom(
682 path: &str,
683 functions: Vec<FunctionInfo>,
684 calls: Vec<CallInfo>,
685 ) -> FileAnalysisOutput {
686 FileAnalysisOutput::new(
687 path.to_string(),
688 format!("{}:1:1:1", path),
689 SemanticAnalysis {
690 functions,
691 classes: vec![],
692 imports: vec![],
693 references: vec![],
694 call_frequency: Default::default(),
695 calls,
696 impl_traits: vec![],
697 def_use_sites: vec![],
698 },
699 10,
700 None,
701 )
702 }
703
704 #[test]
705 fn test_build_happy_path() {
706 let e = make_output(
707 "src/main.rs",
708 vec!["main", "helper"],
709 vec!["Config"],
710 vec!["std::collections"],
711 vec![("main", "helper")],
712 );
713 let g = StructuralGraph::build_from_analysis(&[e]);
714 assert!(g.graph.node_count() >= 4, "nodes={}", g.graph.node_count());
715 assert!(g.graph.edge_count() >= 5, "edges={}", g.graph.edge_count());
716 assert!(g.graph.edge_indices().any(|i| g.graph[i] == Edge::Calls));
717 }
718
719 #[test]
720 fn test_build_empty_input() {
721 let e = make_output("src/e.rs", vec![], vec![], vec![], vec![]);
722 let g = StructuralGraph::build_from_analysis(&[e]);
723 assert_eq!(g.graph.node_count(), 1);
724 assert_eq!(g.graph.edge_count(), 0);
725 }
726
727 #[test]
728 fn test_build_no_cross_file_collision() {
732 let e1 = make_output(
733 "src/a.rs",
734 vec!["main", "helper"],
735 vec![],
736 vec![],
737 vec![("main", "helper")],
738 );
739 let e2 = make_output(
740 "src/b.rs",
741 vec!["main", "helper"],
742 vec![],
743 vec![],
744 vec![("main", "helper")],
745 );
746 let g = StructuralGraph::build_from_analysis(&[e1, e2]);
747 let calls_edges: Vec<_> = g
748 .graph
749 .edge_indices()
750 .filter(|i| g.graph[*i] == Edge::Calls)
751 .collect();
752 assert_eq!(
753 calls_edges.len(),
754 2,
755 "expected 2 Calls edges (same-file preference), got {}",
756 calls_edges.len()
757 );
758
759 for edge_idx in calls_edges {
761 let (source, target) = g.graph.edge_endpoints(edge_idx).unwrap();
762 let source_file = match &g.graph[source] {
763 Node::Symbol { file_path, .. } => file_path,
764 _ => panic!("source must be Symbol"),
765 };
766 let target_file = match &g.graph[target] {
767 Node::Symbol { file_path, .. } => file_path,
768 _ => panic!("target must be Symbol"),
769 };
770 assert_eq!(
771 source_file, target_file,
772 "call edge must not cross files: {} -> {}",
773 source_file, target_file
774 );
775 }
776 }
777
778 #[test]
779 fn test_bfs_diamond() {
780 let mut g = DiGraph::new();
781 let mut sym = |n: &str| {
782 g.add_node(Node::Symbol {
783 name: n.into(),
784 kind: SymbolKind::Function,
785 file_path: "t.rs".into(),
786 line: 1,
787 })
788 };
789 let a = sym("A");
790 let b = sym("B");
791 let c = sym("C");
792 let d = sym("D");
793 g.add_edge(a, b, Edge::Calls);
794 g.add_edge(a, c, Edge::Calls);
795 g.add_edge(b, d, Edge::Calls);
796 g.add_edge(c, d, Edge::Calls);
797 let graph = StructuralGraph::from_graph(g);
798 let r = graph.bfs_blast_radius("A", 2);
799 assert_eq!(r.len(), 3, "expected 3 nodes, got {:?}", r);
800 }
801
802 #[test]
803 fn test_bfs_symbol_not_found() {
804 let graph = StructuralGraph::from_graph(DiGraph::new());
805 assert!(graph.bfs_blast_radius("x", 3).is_empty());
806 }
807
808 #[test]
809 fn test_build_uses_explicit_path_field() {
810 let mut entry = make_output("correct.rs", vec!["foo"], vec![], vec![], vec![]);
813 entry.formatted = "WRONG_PATH\nsome details".to_string();
814
815 let graph = StructuralGraph::build_from_analysis(&[entry]);
816
817 let file_paths: Vec<&str> = graph
819 .graph
820 .node_weights()
821 .filter_map(|n| match n {
822 Node::File { path } => Some(path.as_str()),
823 _ => None,
824 })
825 .collect();
826 assert_eq!(file_paths, vec!["correct.rs"]);
827
828 let symbol_file_paths: Vec<&str> = graph
830 .graph
831 .node_weights()
832 .filter_map(|n| match n {
833 Node::Symbol { file_path, .. } => Some(file_path.as_str()),
834 _ => None,
835 })
836 .collect();
837 assert_eq!(symbol_file_paths, vec!["correct.rs"]);
838 }
839
840 #[test]
841 fn test_build_dedup_identical_call_within_one_file() {
844 let e = make_output(
845 "src/a.rs",
846 vec!["main", "helper"],
847 vec![],
848 vec![],
849 vec![("main", "helper"), ("main", "helper")], );
851 let g = StructuralGraph::build_from_analysis(&[e]);
852 let n = g
853 .graph
854 .edge_indices()
855 .filter(|i| g.graph[*i] == Edge::Calls)
856 .count();
857 assert_eq!(
858 n, 1,
859 "expected 1 Calls edge (dedup identical calls), got {}",
860 n
861 );
862 }
863
864 #[test]
865 fn test_resolve_same_file_preference() {
869 let e_a = make_output_custom(
873 "src/a.rs",
874 vec![make_function("main", 1, 0), make_function("helper", 50, 0)],
875 vec![make_call("main", "helper", 50, None)],
876 );
877 let e_b = make_output_custom("src/b.rs", vec![make_function("helper", 50, 0)], vec![]);
878
879 let g = StructuralGraph::build_from_analysis(&[e_a, e_b]);
880
881 let calls_edges: Vec<_> = g
883 .graph
884 .edge_indices()
885 .filter(|i| g.graph[*i] == Edge::Calls)
886 .collect();
887 assert_eq!(calls_edges.len(), 1);
888
889 let (_source, target) = g.graph.edge_endpoints(calls_edges[0]).unwrap();
891 let target_file = match &g.graph[target] {
892 Node::Symbol { file_path, .. } => file_path,
893 _ => panic!("target must be Symbol"),
894 };
895 assert_eq!(
896 target_file, "src/a.rs",
897 "call should resolve to helper in same file"
898 );
899 }
900
901 #[test]
902 fn test_resolve_line_proximity_fallback() {
906 let e_a = make_output_custom("src/a.rs", vec![make_function("helper", 45, 0)], vec![]);
910 let e_b = make_output_custom("src/b.rs", vec![make_function("helper", 30, 0)], vec![]);
911 let e_c = make_output_custom(
912 "src/c.rs",
913 vec![make_function("caller", 1, 0)],
914 vec![make_call("caller", "helper", 50, None)],
915 );
916
917 let g = StructuralGraph::build_from_analysis(&[e_a, e_b, e_c]);
918
919 let calls_edges: Vec<_> = g
921 .graph
922 .edge_indices()
923 .filter(|i| g.graph[*i] == Edge::Calls)
924 .collect();
925 assert_eq!(calls_edges.len(), 1);
926
927 let (_, target) = g.graph.edge_endpoints(calls_edges[0]).unwrap();
928 let target_line = match &g.graph[target] {
929 Node::Symbol { line, .. } => *line,
930 _ => panic!("target must be Symbol"),
931 };
932 assert_eq!(
933 target_line, 45,
934 "call should resolve to closest definition line"
935 );
936 }
937
938 #[test]
939 fn test_resolve_arg_count_fallback() {
943 let e_a = make_output_custom(
949 "src/a.rs",
950 vec![
951 make_function("main", 1, 0),
952 make_function("helper", 5, 1), make_function("helper", 15, 2), ],
955 vec![make_call("main", "helper", 10, Some(2))],
956 );
957
958 let g = StructuralGraph::build_from_analysis(&[e_a]);
959
960 let calls_edges: Vec<_> = g
961 .graph
962 .edge_indices()
963 .filter(|i| g.graph[*i] == Edge::Calls)
964 .collect();
965 assert_eq!(calls_edges.len(), 1);
966
967 let (_, target) = g.graph.edge_endpoints(calls_edges[0]).unwrap();
968 let target_line = match &g.graph[target] {
969 Node::Symbol { line, .. } => *line,
970 _ => panic!("target must be Symbol"),
971 };
972 assert_eq!(
973 target_line, 15,
974 "call should resolve to 2-param version (line 15) via arg-count match"
975 );
976 }
977
978 #[test]
979 fn test_resolve_true_ambiguity_first_definition_wins() {
983 let e_a = make_output_custom(
991 "src/a.rs",
992 vec![
993 make_function("main", 1, 0),
994 make_function("helper", 20, 0), make_function("helper", 20, 0), ],
997 vec![make_call("main", "helper", 20, None)],
998 );
999
1000 let g = StructuralGraph::build_from_analysis(&[e_a]);
1001
1002 let calls_edges: Vec<_> = g
1003 .graph
1004 .edge_indices()
1005 .filter(|i| g.graph[*i] == Edge::Calls)
1006 .collect();
1007 assert_eq!(calls_edges.len(), 1);
1008
1009 let (_, target) = g.graph.edge_endpoints(calls_edges[0]).unwrap();
1013 match &g.graph[target] {
1014 Node::Symbol { name, .. } => {
1015 assert_eq!(name, "helper", "call should resolve to a helper symbol");
1016 }
1017 _ => panic!("target must be Symbol"),
1018 }
1019 }
1020
1021 #[test]
1022 fn test_from_call_graph_diverges_from_build_from_analysis_on_arg_count_tie() {
1031 let entry = make_output_custom(
1034 "src/a.rs",
1035 vec![
1036 make_function("main", 1, 0),
1037 make_function("helper", 5, 1),
1038 make_function("helper", 15, 2),
1039 ],
1040 vec![make_call("main", "helper", 10, Some(2))],
1041 );
1042
1043 fn calls_target_line(g: &StructuralGraph) -> usize {
1044 let calls: Vec<_> = g
1045 .graph
1046 .edge_indices()
1047 .filter(|i| g.graph[*i] == Edge::Calls)
1048 .collect();
1049 assert_eq!(calls.len(), 1);
1050 let (_, target) = g.graph.edge_endpoints(calls[0]).unwrap();
1051 match &g.graph[target] {
1052 Node::Symbol { line, .. } => *line,
1053 _ => panic!("target must be Symbol"),
1054 }
1055 }
1056
1057 let full = StructuralGraph::build_from_analysis(std::slice::from_ref(&entry));
1058 assert_eq!(
1059 calls_target_line(&full),
1060 15,
1061 "build_from_analysis should use arg-count to pick the 2-param overload"
1062 );
1063
1064 let call_graph = CallGraph::build_from_results(
1065 vec![(PathBuf::from("src/a.rs"), entry.semantic.clone())],
1066 &[],
1067 false,
1068 )
1069 .expect("call graph build should succeed for this fixture");
1070
1071 let fast = StructuralGraph::from_call_graph(std::slice::from_ref(&entry), &call_graph);
1072 assert_eq!(
1073 calls_target_line(&fast),
1074 5,
1075 "from_call_graph lacks arg_count on CallEdge, so on a line-proximity tie it falls \
1076 back to first-definition-wins instead of matching the call's arg count"
1077 );
1078 }
1079
1080 #[test]
1081 fn test_render_subgraph_text_basic() {
1082 let f1 = make_output(
1084 "src/a.rs",
1085 vec!["caller_fn", "callee_fn"],
1086 vec![],
1087 vec![],
1088 vec![("caller_fn", "callee_fn")],
1089 );
1090 let f2 = make_output(
1091 "src/b.rs",
1092 vec!["other_fn"],
1093 vec![],
1094 vec![],
1095 vec![("other_fn", "caller_fn")],
1096 );
1097 let g = StructuralGraph::build_from_analysis(&[f1, f2]);
1098 let nodes = g.find_symbols(&["caller_fn", "callee_fn", "other_fn"]);
1099
1100 let rendered = g.render_subgraph_text(&nodes);
1102
1103 let expected = "// src/a.rs\nfn callee_fn [callers: caller_fn]\nfn caller_fn [calls: callee_fn] [callers: other_fn]\n\n// src/b.rs\nfn other_fn [calls: caller_fn]\n";
1105 assert_eq!(rendered, expected);
1106 }
1107
1108 #[test]
1109 fn test_render_subgraph_text_empty_and_non_function() {
1110 let f1 = make_output("src/a.rs", vec!["fn_a"], vec!["ClassA"], vec![], vec![]);
1112 let g = StructuralGraph::build_from_analysis(&[f1]);
1113
1114 assert_eq!(g.render_subgraph_text(&[]), "");
1116
1117 let class_nodes = g.find_symbols(&["ClassA"]);
1119 assert_eq!(g.render_subgraph_text(&class_nodes), "");
1120 }
1121
1122 #[test]
1123 fn test_blast_radius_bidirectional_includes_callers() {
1124 let f = make_output(
1126 "src/lib.rs",
1127 vec!["fn_a", "fn_b", "fn_c"],
1128 vec![],
1129 vec![],
1130 vec![("fn_a", "fn_b"), ("fn_b", "fn_c")],
1131 );
1132 let g = StructuralGraph::build_from_analysis(&[f]);
1133 let b_nodes = g.find_symbols(&["fn_b"]);
1134 assert_eq!(b_nodes.len(), 1);
1135
1136 let (nodes, edges) = g.blast_radius_bidirectional(&b_nodes, 10, 1);
1138
1139 assert_eq!(nodes.len(), 3);
1141 assert_eq!(nodes[0], b_nodes[0]); assert_eq!(edges.len(), 2);
1145 }
1146
1147 #[test]
1148 fn test_blast_radius_bidirectional_max_nodes_cap() {
1149 let f = make_output(
1151 "src/lib.rs",
1152 vec!["fn_a", "fn_b", "fn_c", "fn_d"],
1153 vec![],
1154 vec![],
1155 vec![("fn_a", "fn_b"), ("fn_b", "fn_c"), ("fn_c", "fn_d")],
1156 );
1157 let g = StructuralGraph::build_from_analysis(&[f]);
1158 let a_nodes = g.find_symbols(&["fn_a"]);
1159
1160 let (nodes, edges) = g.blast_radius_bidirectional(&a_nodes, 2, 5);
1162
1163 assert_eq!(nodes.len(), 2);
1165 assert_eq!(edges.len(), 1);
1166 }
1167
1168 #[test]
1169 fn test_blast_radius_bidirectional_multi_seed() {
1170 let f = make_output(
1172 "src/lib.rs",
1173 vec!["fn_a", "fn_b", "fn_c", "fn_d"],
1174 vec![],
1175 vec![],
1176 vec![("fn_a", "fn_b"), ("fn_c", "fn_d")],
1177 );
1178 let g = StructuralGraph::build_from_analysis(&[f]);
1179 let seeds = g.find_symbols(&["fn_a", "fn_c"]);
1180 assert_eq!(seeds.len(), 2);
1181
1182 let (nodes, edges) = g.blast_radius_bidirectional(&seeds, 10, 1);
1184
1185 assert_eq!(nodes.len(), 4);
1187 assert_eq!(edges.len(), 2);
1188 }
1189
1190 #[test]
1191 fn test_blast_radius_bidirectional_deterministic_order() {
1192 let f = make_output(
1194 "src/lib.rs",
1195 vec!["fn_root", "fn_z", "fn_y", "fn_x"],
1196 vec![],
1197 vec![],
1198 vec![
1199 ("fn_root", "fn_z"),
1200 ("fn_root", "fn_y"),
1201 ("fn_root", "fn_x"),
1202 ],
1203 );
1204 let g = StructuralGraph::build_from_analysis(&[f]);
1205 let seeds = g.find_symbols(&["fn_root"]);
1206
1207 let (nodes_a, _) = g.blast_radius_bidirectional(&seeds, 10, 1);
1209 let (nodes_b, _) = g.blast_radius_bidirectional(&seeds, 10, 1);
1210
1211 assert_eq!(nodes_a, nodes_b);
1213 assert_eq!(nodes_a[0], seeds[0]);
1214 let siblings = &nodes_a[1..];
1215 let mut sorted_siblings = siblings.to_vec();
1216 sorted_siblings.sort_by_key(|n| n.index());
1217 assert_eq!(siblings, sorted_siblings);
1218 }
1219
1220 #[test]
1221 fn test_blast_radius_bidirectional_empty_seeds_or_zero_limits() {
1222 let f = make_output("src/lib.rs", vec!["fn_a"], vec![], vec![], vec![]);
1224 let g = StructuralGraph::build_from_analysis(&[f]);
1225 let seeds = g.find_symbols(&["fn_a"]);
1226
1227 let (nodes, edges) = g.blast_radius_bidirectional(&[], 10, 2);
1229 assert!(nodes.is_empty());
1230 assert!(edges.is_empty());
1231
1232 let (nodes, edges) = g.blast_radius_bidirectional(&seeds, 0, 2);
1234 assert!(nodes.is_empty());
1235 assert!(edges.is_empty());
1236
1237 let (nodes, edges) = g.blast_radius_bidirectional(&seeds, 10, 0);
1239 assert!(nodes.is_empty());
1240 assert!(edges.is_empty());
1241 }
1242}