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 find_symbols_all(&self, names: &[&str]) -> Vec<NodeIndex> {
510 let mut indices = Vec::new();
511 for name in names {
512 if let Some(bucket) = self.symbol_index.get(*name) {
513 indices.extend(bucket.iter().copied());
514 }
515 }
516 indices
517 }
518
519 pub fn blast_radius_bidirectional(
526 &self,
527 seeds: &[NodeIndex],
528 max_nodes: usize,
529 max_depth: usize,
530 ) -> (Vec<NodeIndex>, Vec<(NodeIndex, NodeIndex, Edge)>) {
531 if seeds.is_empty() || max_nodes == 0 || max_depth == 0 {
532 return (vec![], vec![]);
533 }
534
535 let mut visited: HashSet<NodeIndex> = HashSet::new();
536 let mut result: Vec<NodeIndex> = Vec::new();
537 let mut frontier: Vec<NodeIndex> = Vec::new();
538
539 for &seed in seeds {
541 if seed.index() < self.graph.node_count() && visited.insert(seed) {
542 result.push(seed);
543 frontier.push(seed);
544 if result.len() >= max_nodes {
545 break;
546 }
547 }
548 }
549
550 let mut depth = 0;
551 while depth < max_depth && !frontier.is_empty() && result.len() < max_nodes {
552 depth += 1;
553
554 let mut candidates: Vec<NodeIndex> = Vec::new();
558 for &node in &frontier {
559 for edge in self.graph.edges_directed(node, Direction::Outgoing) {
560 if *edge.weight() == Edge::Calls && !visited.contains(&edge.target()) {
561 candidates.push(edge.target());
562 }
563 }
564 for edge in self.graph.edges_directed(node, Direction::Incoming) {
565 if *edge.weight() == Edge::Calls && !visited.contains(&edge.source()) {
566 candidates.push(edge.source());
567 }
568 }
569 }
570 candidates.sort_by_key(|n| n.index());
571 candidates.dedup();
572
573 let mut next = Vec::new();
574 for candidate in candidates {
575 if visited.insert(candidate) {
576 result.push(candidate);
577 next.push(candidate);
578 if result.len() >= max_nodes {
579 break;
580 }
581 }
582 }
583 frontier = next;
584 }
585
586 let edges: Vec<(NodeIndex, NodeIndex, Edge)> = self
588 .graph
589 .edge_references()
590 .filter(|e| visited.contains(&e.source()) && visited.contains(&e.target()))
591 .map(|e| (e.source(), e.target(), e.weight().clone()))
592 .collect();
593
594 (result, edges)
595 }
596}
597
598#[cfg(test)]
599mod tests {
600 use super::*;
601 use crate::types::{CallInfo, ClassInfo, FunctionInfo, ImportInfo, SemanticAnalysis};
602 use std::path::PathBuf;
603
604 fn make_output(
605 path: &str,
606 funcs: Vec<&str>,
607 classes: Vec<&str>,
608 imports: Vec<&str>,
609 calls: Vec<(&str, &str)>,
610 ) -> FileAnalysisOutput {
611 FileAnalysisOutput::new(
612 path.to_string(),
613 format!("{}:1:1:1", path),
614 SemanticAnalysis {
615 functions: funcs
616 .into_iter()
617 .map(|n| FunctionInfo {
618 name: n.to_string(),
619 line: 1,
620 end_line: 6,
621 parameters: vec![],
622 return_type: None,
623 })
624 .collect(),
625 classes: classes
626 .into_iter()
627 .map(|n| ClassInfo {
628 name: n.to_string(),
629 line: 1,
630 end_line: 10,
631 methods: vec![],
632 fields: vec![],
633 inherits: vec![],
634 })
635 .collect(),
636 imports: imports
637 .into_iter()
638 .map(|m| ImportInfo {
639 module: m.to_string(),
640 items: vec![],
641 line: 1,
642 })
643 .collect(),
644 references: vec![],
645 call_frequency: Default::default(),
646 calls: calls
647 .into_iter()
648 .map(|(c, e)| CallInfo {
649 caller: c.to_string(),
650 callee: e.to_string(),
651 line: 1,
652 column: 0,
653 arg_count: None,
654 })
655 .collect(),
656 impl_traits: vec![],
657 def_use_sites: vec![],
658 },
659 10,
660 None,
661 )
662 }
663
664 fn make_function(name: &str, line: usize, param_count: usize) -> FunctionInfo {
666 FunctionInfo {
667 name: name.to_string(),
668 line,
669 end_line: line + 5,
670 parameters: (0..param_count).map(|i| format!("p{}", i)).collect(),
671 return_type: None,
672 }
673 }
674
675 fn make_call(
677 caller: &str,
678 callee: &str,
679 call_line: usize,
680 arg_count: Option<usize>,
681 ) -> CallInfo {
682 CallInfo {
683 caller: caller.to_string(),
684 callee: callee.to_string(),
685 line: call_line,
686 column: 0,
687 arg_count,
688 }
689 }
690
691 fn make_output_custom(
693 path: &str,
694 functions: Vec<FunctionInfo>,
695 calls: Vec<CallInfo>,
696 ) -> FileAnalysisOutput {
697 FileAnalysisOutput::new(
698 path.to_string(),
699 format!("{}:1:1:1", path),
700 SemanticAnalysis {
701 functions,
702 classes: vec![],
703 imports: vec![],
704 references: vec![],
705 call_frequency: Default::default(),
706 calls,
707 impl_traits: vec![],
708 def_use_sites: vec![],
709 },
710 10,
711 None,
712 )
713 }
714
715 #[test]
716 fn test_build_happy_path() {
717 let e = make_output(
718 "src/main.rs",
719 vec!["main", "helper"],
720 vec!["Config"],
721 vec!["std::collections"],
722 vec![("main", "helper")],
723 );
724 let g = StructuralGraph::build_from_analysis(&[e]);
725 assert!(g.graph.node_count() >= 4, "nodes={}", g.graph.node_count());
726 assert!(g.graph.edge_count() >= 5, "edges={}", g.graph.edge_count());
727 assert!(g.graph.edge_indices().any(|i| g.graph[i] == Edge::Calls));
728 }
729
730 #[test]
731 fn test_build_empty_input() {
732 let e = make_output("src/e.rs", vec![], vec![], vec![], vec![]);
733 let g = StructuralGraph::build_from_analysis(&[e]);
734 assert_eq!(g.graph.node_count(), 1);
735 assert_eq!(g.graph.edge_count(), 0);
736 }
737
738 #[test]
739 fn test_build_no_cross_file_collision() {
743 let e1 = make_output(
744 "src/a.rs",
745 vec!["main", "helper"],
746 vec![],
747 vec![],
748 vec![("main", "helper")],
749 );
750 let e2 = make_output(
751 "src/b.rs",
752 vec!["main", "helper"],
753 vec![],
754 vec![],
755 vec![("main", "helper")],
756 );
757 let g = StructuralGraph::build_from_analysis(&[e1, e2]);
758 let calls_edges: Vec<_> = g
759 .graph
760 .edge_indices()
761 .filter(|i| g.graph[*i] == Edge::Calls)
762 .collect();
763 assert_eq!(
764 calls_edges.len(),
765 2,
766 "expected 2 Calls edges (same-file preference), got {}",
767 calls_edges.len()
768 );
769
770 for edge_idx in calls_edges {
772 let (source, target) = g.graph.edge_endpoints(edge_idx).unwrap();
773 let source_file = match &g.graph[source] {
774 Node::Symbol { file_path, .. } => file_path,
775 _ => panic!("source must be Symbol"),
776 };
777 let target_file = match &g.graph[target] {
778 Node::Symbol { file_path, .. } => file_path,
779 _ => panic!("target must be Symbol"),
780 };
781 assert_eq!(
782 source_file, target_file,
783 "call edge must not cross files: {} -> {}",
784 source_file, target_file
785 );
786 }
787 }
788
789 #[test]
790 fn test_bfs_diamond() {
791 let mut g = DiGraph::new();
792 let mut sym = |n: &str| {
793 g.add_node(Node::Symbol {
794 name: n.into(),
795 kind: SymbolKind::Function,
796 file_path: "t.rs".into(),
797 line: 1,
798 })
799 };
800 let a = sym("A");
801 let b = sym("B");
802 let c = sym("C");
803 let d = sym("D");
804 g.add_edge(a, b, Edge::Calls);
805 g.add_edge(a, c, Edge::Calls);
806 g.add_edge(b, d, Edge::Calls);
807 g.add_edge(c, d, Edge::Calls);
808 let graph = StructuralGraph::from_graph(g);
809 let r = graph.bfs_blast_radius("A", 2);
810 assert_eq!(r.len(), 3, "expected 3 nodes, got {:?}", r);
811 }
812
813 #[test]
814 fn test_bfs_symbol_not_found() {
815 let graph = StructuralGraph::from_graph(DiGraph::new());
816 assert!(graph.bfs_blast_radius("x", 3).is_empty());
817 }
818
819 #[test]
820 fn test_build_uses_explicit_path_field() {
821 let mut entry = make_output("correct.rs", vec!["foo"], vec![], vec![], vec![]);
824 entry.formatted = "WRONG_PATH\nsome details".to_string();
825
826 let graph = StructuralGraph::build_from_analysis(&[entry]);
827
828 let file_paths: Vec<&str> = graph
830 .graph
831 .node_weights()
832 .filter_map(|n| match n {
833 Node::File { path } => Some(path.as_str()),
834 _ => None,
835 })
836 .collect();
837 assert_eq!(file_paths, vec!["correct.rs"]);
838
839 let symbol_file_paths: Vec<&str> = graph
841 .graph
842 .node_weights()
843 .filter_map(|n| match n {
844 Node::Symbol { file_path, .. } => Some(file_path.as_str()),
845 _ => None,
846 })
847 .collect();
848 assert_eq!(symbol_file_paths, vec!["correct.rs"]);
849 }
850
851 #[test]
852 fn test_build_dedup_identical_call_within_one_file() {
855 let e = make_output(
856 "src/a.rs",
857 vec!["main", "helper"],
858 vec![],
859 vec![],
860 vec![("main", "helper"), ("main", "helper")], );
862 let g = StructuralGraph::build_from_analysis(&[e]);
863 let n = g
864 .graph
865 .edge_indices()
866 .filter(|i| g.graph[*i] == Edge::Calls)
867 .count();
868 assert_eq!(
869 n, 1,
870 "expected 1 Calls edge (dedup identical calls), got {}",
871 n
872 );
873 }
874
875 #[test]
876 fn test_resolve_same_file_preference() {
880 let e_a = make_output_custom(
884 "src/a.rs",
885 vec![make_function("main", 1, 0), make_function("helper", 50, 0)],
886 vec![make_call("main", "helper", 50, None)],
887 );
888 let e_b = make_output_custom("src/b.rs", vec![make_function("helper", 50, 0)], vec![]);
889
890 let g = StructuralGraph::build_from_analysis(&[e_a, e_b]);
891
892 let calls_edges: Vec<_> = g
894 .graph
895 .edge_indices()
896 .filter(|i| g.graph[*i] == Edge::Calls)
897 .collect();
898 assert_eq!(calls_edges.len(), 1);
899
900 let (_source, target) = g.graph.edge_endpoints(calls_edges[0]).unwrap();
902 let target_file = match &g.graph[target] {
903 Node::Symbol { file_path, .. } => file_path,
904 _ => panic!("target must be Symbol"),
905 };
906 assert_eq!(
907 target_file, "src/a.rs",
908 "call should resolve to helper in same file"
909 );
910 }
911
912 #[test]
913 fn test_resolve_line_proximity_fallback() {
917 let e_a = make_output_custom("src/a.rs", vec![make_function("helper", 45, 0)], vec![]);
921 let e_b = make_output_custom("src/b.rs", vec![make_function("helper", 30, 0)], vec![]);
922 let e_c = make_output_custom(
923 "src/c.rs",
924 vec![make_function("caller", 1, 0)],
925 vec![make_call("caller", "helper", 50, None)],
926 );
927
928 let g = StructuralGraph::build_from_analysis(&[e_a, e_b, e_c]);
929
930 let calls_edges: Vec<_> = g
932 .graph
933 .edge_indices()
934 .filter(|i| g.graph[*i] == Edge::Calls)
935 .collect();
936 assert_eq!(calls_edges.len(), 1);
937
938 let (_, target) = g.graph.edge_endpoints(calls_edges[0]).unwrap();
939 let target_line = match &g.graph[target] {
940 Node::Symbol { line, .. } => *line,
941 _ => panic!("target must be Symbol"),
942 };
943 assert_eq!(
944 target_line, 45,
945 "call should resolve to closest definition line"
946 );
947 }
948
949 #[test]
950 fn test_resolve_arg_count_fallback() {
954 let e_a = make_output_custom(
960 "src/a.rs",
961 vec![
962 make_function("main", 1, 0),
963 make_function("helper", 5, 1), make_function("helper", 15, 2), ],
966 vec![make_call("main", "helper", 10, Some(2))],
967 );
968
969 let g = StructuralGraph::build_from_analysis(&[e_a]);
970
971 let calls_edges: Vec<_> = g
972 .graph
973 .edge_indices()
974 .filter(|i| g.graph[*i] == Edge::Calls)
975 .collect();
976 assert_eq!(calls_edges.len(), 1);
977
978 let (_, target) = g.graph.edge_endpoints(calls_edges[0]).unwrap();
979 let target_line = match &g.graph[target] {
980 Node::Symbol { line, .. } => *line,
981 _ => panic!("target must be Symbol"),
982 };
983 assert_eq!(
984 target_line, 15,
985 "call should resolve to 2-param version (line 15) via arg-count match"
986 );
987 }
988
989 #[test]
990 fn test_resolve_true_ambiguity_first_definition_wins() {
994 let e_a = make_output_custom(
1002 "src/a.rs",
1003 vec![
1004 make_function("main", 1, 0),
1005 make_function("helper", 20, 0), make_function("helper", 20, 0), ],
1008 vec![make_call("main", "helper", 20, None)],
1009 );
1010
1011 let g = StructuralGraph::build_from_analysis(&[e_a]);
1012
1013 let calls_edges: Vec<_> = g
1014 .graph
1015 .edge_indices()
1016 .filter(|i| g.graph[*i] == Edge::Calls)
1017 .collect();
1018 assert_eq!(calls_edges.len(), 1);
1019
1020 let (_, target) = g.graph.edge_endpoints(calls_edges[0]).unwrap();
1024 match &g.graph[target] {
1025 Node::Symbol { name, .. } => {
1026 assert_eq!(name, "helper", "call should resolve to a helper symbol");
1027 }
1028 _ => panic!("target must be Symbol"),
1029 }
1030 }
1031
1032 #[test]
1033 fn test_from_call_graph_diverges_from_build_from_analysis_on_arg_count_tie() {
1042 let entry = make_output_custom(
1045 "src/a.rs",
1046 vec![
1047 make_function("main", 1, 0),
1048 make_function("helper", 5, 1),
1049 make_function("helper", 15, 2),
1050 ],
1051 vec![make_call("main", "helper", 10, Some(2))],
1052 );
1053
1054 fn calls_target_line(g: &StructuralGraph) -> usize {
1055 let calls: Vec<_> = g
1056 .graph
1057 .edge_indices()
1058 .filter(|i| g.graph[*i] == Edge::Calls)
1059 .collect();
1060 assert_eq!(calls.len(), 1);
1061 let (_, target) = g.graph.edge_endpoints(calls[0]).unwrap();
1062 match &g.graph[target] {
1063 Node::Symbol { line, .. } => *line,
1064 _ => panic!("target must be Symbol"),
1065 }
1066 }
1067
1068 let full = StructuralGraph::build_from_analysis(std::slice::from_ref(&entry));
1069 assert_eq!(
1070 calls_target_line(&full),
1071 15,
1072 "build_from_analysis should use arg-count to pick the 2-param overload"
1073 );
1074
1075 let call_graph = CallGraph::build_from_results(
1076 vec![(PathBuf::from("src/a.rs"), entry.semantic.clone())],
1077 &[],
1078 false,
1079 )
1080 .expect("call graph build should succeed for this fixture");
1081
1082 let fast = StructuralGraph::from_call_graph(std::slice::from_ref(&entry), &call_graph);
1083 assert_eq!(
1084 calls_target_line(&fast),
1085 5,
1086 "from_call_graph lacks arg_count on CallEdge, so on a line-proximity tie it falls \
1087 back to first-definition-wins instead of matching the call's arg count"
1088 );
1089 }
1090
1091 #[test]
1092 fn test_render_subgraph_text_basic() {
1093 let f1 = make_output(
1095 "src/a.rs",
1096 vec!["caller_fn", "callee_fn"],
1097 vec![],
1098 vec![],
1099 vec![("caller_fn", "callee_fn")],
1100 );
1101 let f2 = make_output(
1102 "src/b.rs",
1103 vec!["other_fn"],
1104 vec![],
1105 vec![],
1106 vec![("other_fn", "caller_fn")],
1107 );
1108 let g = StructuralGraph::build_from_analysis(&[f1, f2]);
1109 let nodes = g.find_symbols(&["caller_fn", "callee_fn", "other_fn"]);
1110
1111 let rendered = g.render_subgraph_text(&nodes);
1113
1114 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";
1116 assert_eq!(rendered, expected);
1117 }
1118
1119 #[test]
1120 fn test_render_subgraph_text_empty_and_non_function() {
1121 let f1 = make_output("src/a.rs", vec!["fn_a"], vec!["ClassA"], vec![], vec![]);
1123 let g = StructuralGraph::build_from_analysis(&[f1]);
1124
1125 assert_eq!(g.render_subgraph_text(&[]), "");
1127
1128 let class_nodes = g.find_symbols(&["ClassA"]);
1130 assert_eq!(g.render_subgraph_text(&class_nodes), "");
1131 }
1132
1133 #[test]
1134 fn test_blast_radius_bidirectional_includes_callers() {
1135 let f = make_output(
1137 "src/lib.rs",
1138 vec!["fn_a", "fn_b", "fn_c"],
1139 vec![],
1140 vec![],
1141 vec![("fn_a", "fn_b"), ("fn_b", "fn_c")],
1142 );
1143 let g = StructuralGraph::build_from_analysis(&[f]);
1144 let b_nodes = g.find_symbols(&["fn_b"]);
1145 assert_eq!(b_nodes.len(), 1);
1146
1147 let (nodes, edges) = g.blast_radius_bidirectional(&b_nodes, 10, 1);
1149
1150 assert_eq!(nodes.len(), 3);
1152 assert_eq!(nodes[0], b_nodes[0]); assert_eq!(edges.len(), 2);
1156 }
1157
1158 #[test]
1159 fn test_blast_radius_bidirectional_max_nodes_cap() {
1160 let f = make_output(
1162 "src/lib.rs",
1163 vec!["fn_a", "fn_b", "fn_c", "fn_d"],
1164 vec![],
1165 vec![],
1166 vec![("fn_a", "fn_b"), ("fn_b", "fn_c"), ("fn_c", "fn_d")],
1167 );
1168 let g = StructuralGraph::build_from_analysis(&[f]);
1169 let a_nodes = g.find_symbols(&["fn_a"]);
1170
1171 let (nodes, edges) = g.blast_radius_bidirectional(&a_nodes, 2, 5);
1173
1174 assert_eq!(nodes.len(), 2);
1176 assert_eq!(edges.len(), 1);
1177 }
1178
1179 #[test]
1180 fn test_blast_radius_bidirectional_multi_seed() {
1181 let f = make_output(
1183 "src/lib.rs",
1184 vec!["fn_a", "fn_b", "fn_c", "fn_d"],
1185 vec![],
1186 vec![],
1187 vec![("fn_a", "fn_b"), ("fn_c", "fn_d")],
1188 );
1189 let g = StructuralGraph::build_from_analysis(&[f]);
1190 let seeds = g.find_symbols(&["fn_a", "fn_c"]);
1191 assert_eq!(seeds.len(), 2);
1192
1193 let (nodes, edges) = g.blast_radius_bidirectional(&seeds, 10, 1);
1195
1196 assert_eq!(nodes.len(), 4);
1198 assert_eq!(edges.len(), 2);
1199 }
1200
1201 #[test]
1202 fn test_blast_radius_bidirectional_deterministic_order() {
1203 let f = make_output(
1205 "src/lib.rs",
1206 vec!["fn_root", "fn_z", "fn_y", "fn_x"],
1207 vec![],
1208 vec![],
1209 vec![
1210 ("fn_root", "fn_z"),
1211 ("fn_root", "fn_y"),
1212 ("fn_root", "fn_x"),
1213 ],
1214 );
1215 let g = StructuralGraph::build_from_analysis(&[f]);
1216 let seeds = g.find_symbols(&["fn_root"]);
1217
1218 let (nodes_a, _) = g.blast_radius_bidirectional(&seeds, 10, 1);
1220 let (nodes_b, _) = g.blast_radius_bidirectional(&seeds, 10, 1);
1221
1222 assert_eq!(nodes_a, nodes_b);
1224 assert_eq!(nodes_a[0], seeds[0]);
1225 let siblings = &nodes_a[1..];
1226 let mut sorted_siblings = siblings.to_vec();
1227 sorted_siblings.sort_by_key(|n| n.index());
1228 assert_eq!(siblings, sorted_siblings);
1229 }
1230
1231 #[test]
1232 fn test_blast_radius_bidirectional_empty_seeds_or_zero_limits() {
1233 let f = make_output("src/lib.rs", vec!["fn_a"], vec![], vec![], vec![]);
1235 let g = StructuralGraph::build_from_analysis(&[f]);
1236 let seeds = g.find_symbols(&["fn_a"]);
1237
1238 let (nodes, edges) = g.blast_radius_bidirectional(&[], 10, 2);
1240 assert!(nodes.is_empty());
1241 assert!(edges.is_empty());
1242
1243 let (nodes, edges) = g.blast_radius_bidirectional(&seeds, 0, 2);
1245 assert!(nodes.is_empty());
1246 assert!(edges.is_empty());
1247
1248 let (nodes, edges) = g.blast_radius_bidirectional(&seeds, 10, 0);
1250 assert!(nodes.is_empty());
1251 assert!(edges.is_empty());
1252 }
1253
1254 #[test]
1255 fn test_find_symbols_all_multiple_matches() {
1256 let f1 = make_output(
1258 "src/a.rs",
1259 vec!["shared", "helper_a"],
1260 vec![],
1261 vec![],
1262 vec![],
1263 );
1264 let f2 = make_output(
1265 "src/b.rs",
1266 vec!["shared", "helper_b"],
1267 vec![],
1268 vec![],
1269 vec![],
1270 );
1271 let g = StructuralGraph::build_from_analysis(&[f1, f2]);
1272
1273 let all_shared = g.find_symbols_all(&["shared"]);
1275 let first_shared = g.find_symbols(&["shared"]);
1277
1278 assert_eq!(
1280 all_shared.len(),
1281 2,
1282 "find_symbols_all should return 2 'shared' symbols from 2 files"
1283 );
1284
1285 assert_eq!(
1287 first_shared.len(),
1288 1,
1289 "find_symbols should return 1 'shared' symbol (first only)"
1290 );
1291
1292 assert!(
1294 all_shared.contains(&first_shared[0]),
1295 "find_symbols result should be a subset of find_symbols_all"
1296 );
1297 }
1298}