1use crate::types::{CallEdge, ImplTraitInfo, SemanticAnalysis, SymbolMatchMode};
9use std::collections::{HashMap, HashSet, VecDeque};
10use std::path::{Path, PathBuf};
11use thiserror::Error;
12use tracing::{debug, instrument};
13
14const MAX_CANDIDATES_IN_ERROR: usize = 20;
15
16fn format_candidates(candidates: &[String]) -> String {
17 if candidates.len() <= MAX_CANDIDATES_IN_ERROR {
18 candidates.join(", ")
19 } else {
20 format!(
21 "{}, (and {} more)",
22 candidates[..MAX_CANDIDATES_IN_ERROR].join(", "),
23 candidates.len() - MAX_CANDIDATES_IN_ERROR
24 )
25 }
26}
27
28#[derive(Debug, Error)]
29#[non_exhaustive]
30pub enum GraphError {
31 #[error("Symbol not found: '{symbol}'. {hint}")]
32 SymbolNotFound { symbol: String, hint: String },
33 #[error(
34 "Multiple candidates matched '{query}': {candidates_display}. Use match_mode=exact to target one of the candidates listed above, or refine the symbol name.",
35 candidates_display = format_candidates(.candidates)
36 )]
37 MultipleCandidates {
38 query: String,
39 candidates: Vec<String>,
40 },
41}
42
43pub fn resolve_symbol<'a>(
50 known_symbols: impl Iterator<Item = &'a String>,
51 query: &str,
52 mode: &SymbolMatchMode,
53) -> Result<String, GraphError> {
54 let mut matches: Vec<String> = if matches!(mode, SymbolMatchMode::Exact) {
55 known_symbols
56 .filter(|s| s.as_str() == query)
57 .cloned()
58 .collect()
59 } else {
60 let query_lower = query.to_lowercase();
61 known_symbols
62 .filter(|s| match mode {
63 SymbolMatchMode::Exact => unreachable!(),
64 SymbolMatchMode::Insensitive => s.to_lowercase() == query_lower,
65 SymbolMatchMode::Prefix => s.to_lowercase().starts_with(&query_lower),
66 SymbolMatchMode::Contains => s.to_lowercase().contains(&query_lower),
67 })
68 .cloned()
69 .collect()
70 };
71 matches.sort();
72
73 debug!(
74 query,
75 mode = ?mode,
76 candidate_count = matches.len(),
77 "resolve_symbol"
78 );
79
80 match matches.len() {
81 1 => Ok(matches.into_iter().next().expect("len==1")),
82 0 => {
83 let hint = match mode {
84 SymbolMatchMode::Exact => {
85 "Try match_mode=insensitive for a case-insensitive search, or match_mode=prefix to list symbols starting with this name.".to_string()
86 }
87 _ => "No symbols matched; try a shorter query or match_mode=contains.".to_string(),
88 };
89 Err(GraphError::SymbolNotFound {
90 symbol: query.to_string(),
91 hint,
92 })
93 }
94 _ => Err(GraphError::MultipleCandidates {
95 query: query.to_string(),
96 candidates: matches,
97 }),
98 }
99}
100
101impl CallGraph {
104 pub fn resolve_symbol_indexed(
105 &self,
106 query: &str,
107 mode: &SymbolMatchMode,
108 ) -> Result<String, GraphError> {
109 if matches!(mode, SymbolMatchMode::Exact) {
112 if self.definitions.contains_key(query)
113 || self.callers.contains_key(query)
114 || self.callees.contains_key(query)
115 {
116 return Ok(query.to_string());
117 }
118 return Err(GraphError::SymbolNotFound {
119 symbol: query.to_string(),
120 hint: "Try match_mode=insensitive for a case-insensitive search, or match_mode=prefix to list symbols starting with this name.".to_string(),
121 });
122 }
123
124 let query_lower = query.to_lowercase();
125 let mut matches: Vec<String> = {
126 match mode {
127 SymbolMatchMode::Insensitive => {
128 if let Some(originals) = self.lowercase_index.get(&query_lower) {
130 if originals.len() > 1 {
131 return Err(GraphError::MultipleCandidates {
133 query: query.to_string(),
134 candidates: originals.clone(),
135 });
136 }
137 vec![originals[0].clone()]
139 } else {
140 vec![]
141 }
142 }
143 SymbolMatchMode::Prefix => {
144 self.lowercase_index
146 .iter()
147 .filter(|(k, _)| k.starts_with(&query_lower))
148 .flat_map(|(_, v)| v.iter().cloned())
149 .collect()
150 }
151 SymbolMatchMode::Contains => {
152 self.lowercase_index
154 .iter()
155 .filter(|(k, _)| k.contains(&query_lower))
156 .flat_map(|(_, v)| v.iter().cloned())
157 .collect()
158 }
159 SymbolMatchMode::Exact => unreachable!("handled above"),
160 }
161 };
162 matches.sort();
163 matches.dedup();
164
165 debug!(
166 query,
167 mode = ?mode,
168 candidate_count = matches.len(),
169 "resolve_symbol_indexed"
170 );
171
172 match matches.len() {
173 1 => Ok(matches.into_iter().next().expect("len==1")),
174 0 => Err(GraphError::SymbolNotFound {
175 symbol: query.to_string(),
176 hint: "No symbols matched; try a shorter query or match_mode=contains.".to_string(),
177 }),
178 _ => Err(GraphError::MultipleCandidates {
179 query: query.to_string(),
180 candidates: matches,
181 }),
182 }
183 }
184}
185
186fn strip_scope_prefix(name: &str) -> &str {
190 if let Some(pos) = name.rfind("::") {
191 &name[pos + 2..]
192 } else if let Some(pos) = name.rfind('.') {
193 &name[pos + 1..]
194 } else {
195 name
196 }
197}
198
199#[derive(Debug, Clone)]
200pub struct InternalCallChain {
201 pub chain: Vec<(String, PathBuf, usize)>,
202}
203
204#[derive(Debug, Clone)]
206#[non_exhaustive]
207pub struct CallGraph {
208 pub callers: HashMap<String, Vec<CallEdge>>,
210 pub callees: HashMap<String, Vec<CallEdge>>,
212 pub definitions: HashMap<String, Vec<(PathBuf, usize)>>,
214 lowercase_index: HashMap<String, Vec<String>>,
216}
217
218impl CallGraph {
219 #[must_use]
220 pub fn new() -> Self {
221 Self {
222 callers: HashMap::new(),
223 callees: HashMap::new(),
224 definitions: HashMap::new(),
225 lowercase_index: HashMap::new(),
226 }
227 }
228
229 fn resolve_callee(
237 callee: &str,
238 _call_file: &Path,
239 _call_line: usize,
240 _arg_count: Option<usize>,
241 definitions: &HashMap<String, Vec<(PathBuf, usize)>>,
242 ) -> String {
243 if let Some(_defs) = definitions.get(callee) {
245 return callee.to_string();
246 }
247
248 let stripped = strip_scope_prefix(callee);
250 if stripped != callee
251 && let Some(_defs) = definitions.get(stripped)
252 {
253 return stripped.to_string();
254 }
255
256 callee.to_string()
258 }
259
260 #[instrument(skip_all)]
262 #[allow(clippy::too_many_lines)]
263 #[allow(clippy::needless_pass_by_value)]
266 pub fn build_from_results(
267 results: Vec<(PathBuf, SemanticAnalysis)>,
268 impl_traits: &[ImplTraitInfo],
269 impl_only: bool,
270 ) -> Result<Self, GraphError> {
271 let mut graph = CallGraph::new();
272
273 for (path, analysis) in &results {
275 for func in &analysis.functions {
276 graph
277 .definitions
278 .entry(func.name.clone())
279 .or_default()
280 .push((path.clone(), func.line));
281 }
282 for class in &analysis.classes {
283 graph
284 .definitions
285 .entry(class.name.clone())
286 .or_default()
287 .push((path.clone(), class.line));
288 }
289 }
290
291 for (path, analysis) in &results {
293 for call in &analysis.calls {
294 let resolved_callee = Self::resolve_callee(
295 &call.callee,
296 path,
297 call.line,
298 call.arg_count,
299 &graph.definitions,
300 );
301
302 graph
303 .callees
304 .entry(call.caller.clone())
305 .or_default()
306 .push(CallEdge {
307 path: path.clone(),
308 line: call.line,
309 neighbor_name: resolved_callee.clone(),
310 is_impl_trait: false,
311 });
312 graph
313 .callers
314 .entry(resolved_callee)
315 .or_default()
316 .push(CallEdge {
317 path: path.clone(),
318 line: call.line,
319 neighbor_name: call.caller.clone(),
320 is_impl_trait: false,
321 });
322 }
323 for reference in &analysis.references {
324 graph
325 .callers
326 .entry(reference.symbol.clone())
327 .or_default()
328 .push(CallEdge {
329 path: path.clone(),
330 line: reference.line,
331 neighbor_name: "<reference>".to_string(),
332 is_impl_trait: false,
333 });
334 }
335 }
336
337 for it in impl_traits {
341 graph
342 .callers
343 .entry(it.trait_name.clone())
344 .or_default()
345 .push(CallEdge {
346 path: it.path.clone(),
347 line: it.line,
348 neighbor_name: it.impl_type.clone(),
349 is_impl_trait: true,
350 });
351 }
352
353 if impl_only {
357 for edges in graph.callers.values_mut() {
358 edges.retain(|e| e.is_impl_trait);
359 }
360 }
361
362 for key in graph
366 .definitions
367 .keys()
368 .chain(graph.callers.keys())
369 .chain(graph.callees.keys())
370 {
371 graph
372 .lowercase_index
373 .entry(key.to_lowercase())
374 .or_default()
375 .push(key.clone());
376 }
377 for originals in graph.lowercase_index.values_mut() {
378 originals.sort();
379 originals.dedup();
380 }
381
382 let total_edges = graph.callees.values().map(Vec::len).sum::<usize>()
383 + graph.callers.values().map(Vec::len).sum::<usize>();
384 let file_count = results.len();
385
386 tracing::debug!(
387 definitions = graph.definitions.len(),
388 edges = total_edges,
389 files = file_count,
390 impl_only,
391 "graph built"
392 );
393
394 Ok(graph)
395 }
396
397 fn find_chains_bfs(
398 &self,
399 symbol: &str,
400 follow_depth: u32,
401 is_incoming: bool,
402 ) -> Result<Vec<InternalCallChain>, GraphError> {
403 let graph_map = if is_incoming {
404 &self.callers
405 } else {
406 &self.callees
407 };
408
409 if !self.definitions.contains_key(symbol) && !graph_map.contains_key(symbol) {
410 return Err(GraphError::SymbolNotFound {
411 symbol: symbol.to_string(),
412 hint: "Symbol resolved but not found in graph. The symbol may have no calls or definitions in the indexed files.".to_string(),
413 });
414 }
415
416 let mut chains = Vec::new();
417 let mut visited = HashSet::new();
418 let mut queue = VecDeque::new();
419 queue.push_back((symbol.to_string(), 0));
420 visited.insert(symbol.to_string());
421
422 while let Some((current, depth)) = queue.pop_front() {
423 if depth > follow_depth {
424 continue;
425 }
426
427 let _traverse_span = tracing::info_span!("graph.traverse", depth = depth).entered();
429
430 if let Some(neighbors) = graph_map.get(¤t) {
431 for edge in neighbors {
432 let path = &edge.path;
433 let line = edge.line;
434 let neighbor = &edge.neighbor_name;
435 let mut chain = {
444 let mut v = Vec::with_capacity(follow_depth as usize + 2);
445 v.push((current.clone(), path.clone(), line));
446 v
447 };
448 let mut chain_node = neighbor.clone();
449 let mut chain_depth = depth;
450
451 while chain_depth < follow_depth {
452 if let Some(next_neighbors) = graph_map.get(&chain_node) {
453 if let Some(next_edge) = next_neighbors.first() {
454 chain_node = next_edge.neighbor_name.clone();
459 chain.push((
460 chain_node.clone(),
461 next_edge.path.clone(),
462 next_edge.line,
463 ));
464 chain_depth += 1;
465 } else {
466 break;
467 }
468 } else {
469 break;
470 }
471 }
472
473 if is_incoming {
474 chain.push((neighbor.clone(), path.clone(), line));
477 chain.reverse();
478 } else {
479 chain.push((neighbor.clone(), path.clone(), line));
480 }
481
482 debug_assert!(
483 chain.len() <= follow_depth as usize + 2,
484 "find_chains_bfs: chain length {} exceeds bound {}",
485 chain.len(),
486 follow_depth + 2
487 );
488
489 chains.push(InternalCallChain { chain });
490
491 if !visited.contains(neighbor) && depth < follow_depth {
492 visited.insert(neighbor.clone());
493 queue.push_back((neighbor.clone(), depth + 1));
494 }
495 }
496 }
497 }
498
499 Ok(chains)
500 }
501
502 #[instrument(skip(self))]
503 pub(crate) fn find_incoming_chains(
504 &self,
505 symbol: &str,
506 follow_depth: u32,
507 ) -> Result<Vec<InternalCallChain>, GraphError> {
508 self.find_chains_bfs(symbol, follow_depth, true)
509 }
510
511 #[instrument(skip(self))]
512 pub(crate) fn find_outgoing_chains(
513 &self,
514 symbol: &str,
515 follow_depth: u32,
516 ) -> Result<Vec<InternalCallChain>, GraphError> {
517 self.find_chains_bfs(symbol, follow_depth, false)
518 }
519}
520
521impl Default for CallGraph {
522 fn default() -> Self {
523 Self::new()
524 }
525}
526
527#[cfg(test)]
528mod tests {
529 use super::*;
530 use crate::types::{CallInfo, FunctionInfo};
531
532 fn make_analysis(
533 funcs: Vec<(&str, usize)>,
534 calls: Vec<(&str, &str, usize)>,
535 ) -> SemanticAnalysis {
536 SemanticAnalysis {
537 functions: funcs
538 .into_iter()
539 .map(|(n, l)| FunctionInfo {
540 name: n.to_string(),
541 line: l,
542 end_line: l + 5,
543 parameters: vec![],
544 return_type: None,
545 })
546 .collect(),
547 classes: vec![],
548 imports: vec![],
549 references: vec![],
550 call_frequency: Default::default(),
551 calls: calls
552 .into_iter()
553 .map(|(c, e, l)| CallInfo {
554 caller: c.to_string(),
555 callee: e.to_string(),
556 line: l,
557 column: 0,
558 arg_count: None,
559 })
560 .collect(),
561 impl_traits: vec![],
562 def_use_sites: vec![],
563 }
564 }
565
566 fn make_typed_analysis(
567 funcs: Vec<(&str, usize, Vec<String>, Option<&str>)>,
568 calls: Vec<(&str, &str, usize, Option<usize>)>,
569 ) -> SemanticAnalysis {
570 SemanticAnalysis {
571 functions: funcs
572 .into_iter()
573 .map(|(n, l, params, ret_type)| FunctionInfo {
574 name: n.to_string(),
575 line: l,
576 end_line: l + 5,
577 parameters: params,
578 return_type: ret_type.map(|s| s.to_string()),
579 })
580 .collect(),
581 classes: vec![],
582 imports: vec![],
583 references: vec![],
584 call_frequency: Default::default(),
585 calls: calls
586 .into_iter()
587 .map(|(c, e, l, arg_count)| CallInfo {
588 caller: c.to_string(),
589 callee: e.to_string(),
590 line: l,
591 column: 0,
592 arg_count,
593 })
594 .collect(),
595 impl_traits: vec![],
596 def_use_sites: vec![],
597 }
598 }
599
600 #[test]
601 fn test_graph_construction() {
602 let analysis = make_analysis(
603 vec![("main", 1), ("foo", 10), ("bar", 20)],
604 vec![("main", "foo", 2), ("foo", "bar", 15)],
605 );
606 let graph =
607 CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
608 .expect("Failed to build graph");
609 assert!(graph.definitions.contains_key("main"));
610 assert!(graph.definitions.contains_key("foo"));
611 assert_eq!(graph.callees["main"][0].neighbor_name, "foo");
612 assert_eq!(graph.callers["foo"][0].neighbor_name, "main");
613 }
614
615 #[test]
616 fn test_find_incoming_chains_depth_zero() {
617 let analysis = make_analysis(vec![("main", 1), ("foo", 10)], vec![("main", "foo", 2)]);
618 let graph =
619 CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
620 .expect("Failed to build graph");
621 assert!(
622 !graph
623 .find_incoming_chains("foo", 0)
624 .expect("Failed to find chains")
625 .is_empty()
626 );
627 }
628
629 #[test]
630 fn test_find_outgoing_chains_depth_zero() {
631 let analysis = make_analysis(vec![("main", 1), ("foo", 10)], vec![("main", "foo", 2)]);
632 let graph =
633 CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
634 .expect("Failed to build graph");
635 assert!(
636 !graph
637 .find_outgoing_chains("main", 0)
638 .expect("Failed to find chains")
639 .is_empty()
640 );
641 }
642
643 #[test]
644 fn test_symbol_not_found() {
645 assert!(
646 CallGraph::new()
647 .find_incoming_chains("nonexistent", 0)
648 .is_err()
649 );
650 }
651
652 #[test]
653 fn test_same_file_preference() {
654 let analysis_a = make_analysis(
658 vec![("main", 1), ("helper", 10)],
659 vec![("main", "helper", 5)],
660 );
661 let analysis_b = make_analysis(vec![("helper", 20)], vec![]);
662
663 let graph = CallGraph::build_from_results(
664 vec![
665 (PathBuf::from("a.rs"), analysis_a),
666 (PathBuf::from("b.rs"), analysis_b),
667 ],
668 &[],
669 false,
670 )
671 .expect("Failed to build graph");
672
673 assert!(graph.callees.contains_key("main"));
675 let main_callees = &graph.callees["main"];
676 assert_eq!(main_callees.len(), 1);
677 assert_eq!(main_callees[0].neighbor_name, "helper");
678
679 assert_eq!(main_callees[0].path, PathBuf::from("a.rs"));
681
682 assert!(graph.callers.contains_key("helper"));
684 let helper_callers = &graph.callers["helper"];
685 assert!(
686 helper_callers
687 .iter()
688 .any(|e| e.path == PathBuf::from("a.rs"))
689 );
690 }
691
692 #[test]
693 fn test_line_proximity() {
694 let analysis = make_analysis(
697 vec![("process", 10), ("process", 50)],
698 vec![("main", "process", 12)],
699 );
700
701 let graph =
702 CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
703 .expect("Failed to build graph");
704
705 assert!(graph.callees.contains_key("main"));
707 let main_callees = &graph.callees["main"];
708 assert_eq!(main_callees.len(), 1);
709 assert_eq!(main_callees[0].neighbor_name, "process");
710
711 assert!(graph.callers.contains_key("process"));
713 let process_callers = &graph.callers["process"];
714 assert!(
715 process_callers
716 .iter()
717 .any(|e| e.line == 12 && e.neighbor_name == "main")
718 );
719 }
720
721 #[test]
722 fn test_scope_prefix_stripping() {
723 let analysis = make_analysis(
726 vec![("method", 10)],
727 vec![
728 ("caller1", "self.method", 5),
729 ("caller2", "Type::method", 15),
730 ("caller3", "module::method", 25),
731 ],
732 );
733
734 let graph =
735 CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
736 .expect("Failed to build graph");
737
738 assert_eq!(graph.callees["caller1"][0].neighbor_name, "method");
740 assert_eq!(graph.callees["caller2"][0].neighbor_name, "method");
741 assert_eq!(graph.callees["caller3"][0].neighbor_name, "method");
742
743 assert!(graph.callers.contains_key("method"));
745 let method_callers = &graph.callers["method"];
746 assert_eq!(method_callers.len(), 3);
747 assert!(method_callers.iter().any(|e| e.neighbor_name == "caller1"));
748 assert!(method_callers.iter().any(|e| e.neighbor_name == "caller2"));
749 assert!(method_callers.iter().any(|e| e.neighbor_name == "caller3"));
750 }
751
752 #[test]
753 fn test_no_same_file_fallback() {
754 let analysis_a = make_analysis(vec![("main", 1)], vec![("main", "helper", 5)]);
757 let analysis_b = make_analysis(vec![("helper", 10)], vec![]);
758
759 let graph = CallGraph::build_from_results(
760 vec![
761 (PathBuf::from("a.rs"), analysis_a),
762 (PathBuf::from("b.rs"), analysis_b),
763 ],
764 &[],
765 false,
766 )
767 .expect("Failed to build graph");
768
769 assert!(graph.callees.contains_key("main"));
771 let main_callees = &graph.callees["main"];
772 assert_eq!(main_callees.len(), 1);
773 assert_eq!(main_callees[0].neighbor_name, "helper");
774
775 assert!(graph.callers.contains_key("helper"));
777 let helper_callers = &graph.callers["helper"];
778 assert!(
779 helper_callers
780 .iter()
781 .any(|e| e.path == PathBuf::from("a.rs") && e.neighbor_name == "main")
782 );
783 }
784
785 #[test]
786 fn test_type_disambiguation_by_params() {
787 let analysis = make_typed_analysis(
792 vec![
793 ("process", 10, vec!["(x: i32)".to_string()], Some("i32")),
794 (
795 "process",
796 12,
797 vec!["(x: i32, y: String)".to_string()],
798 Some("String"),
799 ),
800 ("main", 1, vec![], None),
801 ],
802 vec![("main", "process", 11, Some(2))],
803 );
804
805 let graph =
806 CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
807 .expect("Failed to build graph");
808
809 assert!(graph.callees.contains_key("main"));
811 let main_callees = &graph.callees["main"];
812 assert_eq!(main_callees.len(), 1);
813 assert_eq!(main_callees[0].neighbor_name, "process");
814
815 assert!(graph.callers.contains_key("process"));
817 let process_callers = &graph.callers["process"];
818 assert!(
819 process_callers
820 .iter()
821 .any(|e| e.line == 11 && e.neighbor_name == "main")
822 );
823 }
824
825 #[test]
826 fn test_type_disambiguation_fallback() {
827 let analysis = make_analysis(
831 vec![("process", 10), ("process", 50), ("main", 1)],
832 vec![("main", "process", 12)],
833 );
834
835 let graph =
836 CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
837 .expect("Failed to build graph");
838
839 assert!(graph.callees.contains_key("main"));
841 let main_callees = &graph.callees["main"];
842 assert_eq!(main_callees.len(), 1);
843 assert_eq!(main_callees[0].neighbor_name, "process");
844
845 assert!(graph.callers.contains_key("process"));
847 let process_callers = &graph.callers["process"];
848 assert!(
849 process_callers
850 .iter()
851 .any(|e| e.line == 12 && e.neighbor_name == "main")
852 );
853 }
854
855 #[test]
856 fn test_impl_only_filters_to_impl_sites() {
857 use crate::types::ImplTraitInfo;
859 let analysis = make_analysis(
860 vec![("write", 1), ("plain_fn", 20)],
861 vec![("plain_fn", "write", 22)],
862 );
863 let impl_traits = vec![ImplTraitInfo {
864 trait_name: "Write".to_string(),
865 impl_type: "WriterImpl".to_string(),
866 path: PathBuf::from("test.rs"),
867 line: 10,
868 }];
869
870 let graph = CallGraph::build_from_results(
872 vec![(PathBuf::from("test.rs"), analysis)],
873 &impl_traits,
874 true,
875 )
876 .expect("Failed to build graph");
877
878 let callers = graph
880 .callers
881 .get("Write")
882 .expect("Write must have impl caller");
883 assert_eq!(callers.len(), 1, "only impl-trait caller retained");
884 assert_eq!(callers[0].neighbor_name, "WriterImpl");
885 assert!(
886 callers[0].is_impl_trait,
887 "edge must be tagged is_impl_trait"
888 );
889
890 let write_callers = graph.callers.get("write").map(|v| v.len()).unwrap_or(0);
892 assert_eq!(
893 write_callers, 0,
894 "regular callers filtered when impl_only=true"
895 );
896 }
897
898 #[test]
899 fn test_impl_only_false_is_backward_compatible() {
900 use crate::types::ImplTraitInfo;
902 let analysis = make_analysis(
903 vec![("write", 1), ("WriterImpl", 10), ("plain_fn", 20)],
904 vec![("WriterImpl", "write", 12), ("plain_fn", "write", 22)],
905 );
906 let impl_traits = vec![ImplTraitInfo {
907 trait_name: "Write".to_string(),
908 impl_type: "WriterImpl".to_string(),
909 path: PathBuf::from("test.rs"),
910 line: 10,
911 }];
912
913 let graph = CallGraph::build_from_results(
915 vec![(PathBuf::from("test.rs"), analysis)],
916 &impl_traits,
917 false,
918 )
919 .expect("Failed to build graph");
920
921 let callers = graph.callers.get("write").expect("write must have callers");
923 assert_eq!(
924 callers.len(),
925 2,
926 "both call-site callers should be present when impl_only=false"
927 );
928
929 let write_impl_callers = graph
931 .callers
932 .get("Write")
933 .expect("Write must have impl caller");
934 assert_eq!(write_impl_callers.len(), 1);
935 assert!(write_impl_callers[0].is_impl_trait);
936 }
937
938 #[test]
939 fn test_impl_only_callees_unaffected() {
940 use crate::types::ImplTraitInfo;
942 let analysis = make_analysis(
943 vec![("write", 1), ("WriterImpl", 10)],
944 vec![("WriterImpl", "write", 12)],
945 );
946 let impl_traits = vec![ImplTraitInfo {
947 trait_name: "Write".to_string(),
948 impl_type: "WriterImpl".to_string(),
949 path: PathBuf::from("test.rs"),
950 line: 10,
951 }];
952
953 let graph = CallGraph::build_from_results(
954 vec![(PathBuf::from("test.rs"), analysis)],
955 &impl_traits,
956 true,
957 )
958 .expect("Failed to build graph");
959
960 let callees = graph
962 .callees
963 .get("WriterImpl")
964 .expect("WriterImpl must have callees");
965 assert_eq!(
966 callees.len(),
967 1,
968 "callees must not be filtered by impl_only"
969 );
970 assert_eq!(callees[0].neighbor_name, "write");
971 }
972
973 fn known(names: &[&str]) -> Vec<String> {
976 names.iter().map(|s| s.to_string()).collect()
977 }
978
979 #[test]
980 fn test_resolve_symbol_exact_match() {
981 let syms = known(&["parse_config", "ParseConfig", "PARSE_CONFIG"]);
982 let result = resolve_symbol(syms.iter(), "parse_config", &SymbolMatchMode::Exact);
983 assert_eq!(result.unwrap(), "parse_config");
984 }
985
986 #[test]
987 fn test_resolve_symbol_exact_no_match() {
988 let syms = known(&["ParseConfig"]);
989 let err = resolve_symbol(syms.iter(), "parse_config", &SymbolMatchMode::Exact).unwrap_err();
990 assert!(matches!(err, GraphError::SymbolNotFound { .. }));
991 }
992
993 #[test]
994 fn test_resolve_symbol_insensitive_match() {
995 let syms = known(&["ParseConfig", "other"]);
996 let result = resolve_symbol(syms.iter(), "parseconfig", &SymbolMatchMode::Insensitive);
997 assert_eq!(result.unwrap(), "ParseConfig");
998 }
999
1000 #[test]
1001 fn test_resolve_symbol_insensitive_no_match() {
1002 let syms = known(&["unrelated"]);
1003 let err =
1004 resolve_symbol(syms.iter(), "parseconfig", &SymbolMatchMode::Insensitive).unwrap_err();
1005 assert!(matches!(err, GraphError::SymbolNotFound { .. }));
1006 }
1007
1008 #[test]
1009 fn test_resolve_symbol_prefix_single() {
1010 let syms = known(&["parse_config", "parse_args", "build"]);
1011 let result = resolve_symbol(syms.iter(), "build", &SymbolMatchMode::Prefix);
1012 assert_eq!(result.unwrap(), "build");
1013 }
1014
1015 #[test]
1016 fn test_resolve_symbol_prefix_multiple_candidates() {
1017 let syms = known(&["parse_config", "parse_args", "build"]);
1018 let err = resolve_symbol(syms.iter(), "parse", &SymbolMatchMode::Prefix).unwrap_err();
1019 assert!(matches!(&err, GraphError::MultipleCandidates { .. }));
1020 if let GraphError::MultipleCandidates { candidates, .. } = err {
1021 assert_eq!(candidates.len(), 2);
1022 }
1023 }
1024
1025 #[test]
1026 fn test_resolve_symbol_contains_single() {
1027 let syms = known(&["parse_config", "build_artifact"]);
1028 let result = resolve_symbol(syms.iter(), "config", &SymbolMatchMode::Contains);
1029 assert_eq!(result.unwrap(), "parse_config");
1030 }
1031
1032 #[test]
1033 fn test_resolve_symbol_contains_no_match() {
1034 let syms = known(&["parse_config", "build_artifact"]);
1035 let err = resolve_symbol(syms.iter(), "deploy", &SymbolMatchMode::Contains).unwrap_err();
1036 assert!(matches!(err, GraphError::SymbolNotFound { .. }));
1037 }
1038
1039 #[test]
1040 fn test_incoming_chain_order_two_hops() {
1041 let analysis = make_analysis(
1050 vec![("A", 1), ("B", 10), ("C", 20)],
1051 vec![("A", "B", 2), ("B", "C", 15)],
1052 );
1053 let graph =
1054 CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
1055 .expect("Failed to build graph");
1056
1057 let chains = graph
1058 .find_incoming_chains("C", 2)
1059 .expect("Failed to find incoming chains");
1060
1061 assert!(
1062 !chains.is_empty(),
1063 "Expected at least one incoming chain for C"
1064 );
1065
1066 let chain = chains
1068 .iter()
1069 .find(|c| c.chain.len() == 3)
1070 .expect("Expected a 3-element chain");
1071
1072 assert_eq!(
1073 chain.chain[0].0, "B",
1074 "chain[0] should be immediate caller B, got {}",
1075 chain.chain[0].0
1076 );
1077 assert_eq!(
1078 chain.chain[1].0, "A",
1079 "chain[1] should be outermost caller A, got {}",
1080 chain.chain[1].0
1081 );
1082 assert_eq!(
1083 chain.chain[2].0, "C",
1084 "chain[2] should be focus node C, got {}",
1085 chain.chain[2].0
1086 );
1087 }
1088
1089 #[test]
1092 fn test_insensitive_resolve_via_index() {
1093 let analysis = make_analysis(
1095 vec![("ParseConfig", 1), ("parse_args", 5)],
1096 vec![("ParseConfig", "parse_args", 10)],
1097 );
1098 let graph =
1099 CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
1100 .expect("Failed to build graph");
1101
1102 let result = graph
1104 .resolve_symbol_indexed("parseconfig", &SymbolMatchMode::Insensitive)
1105 .expect("Should resolve ParseConfig");
1106
1107 assert_eq!(result, "ParseConfig");
1109 }
1110
1111 #[test]
1112 fn test_prefix_resolve_via_index() {
1113 let analysis = make_analysis(
1115 vec![("parse_config", 1), ("parse_args", 5), ("build", 10)],
1116 vec![],
1117 );
1118 let graph =
1119 CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
1120 .expect("Failed to build graph");
1121
1122 let err = graph
1124 .resolve_symbol_indexed("parse", &SymbolMatchMode::Prefix)
1125 .unwrap_err();
1126
1127 assert!(matches!(&err, GraphError::MultipleCandidates { .. }));
1129 if let GraphError::MultipleCandidates { candidates, .. } = err {
1130 assert_eq!(candidates.len(), 2);
1131 }
1132 }
1133
1134 #[test]
1135 fn test_insensitive_case_collision_returns_multiple_candidates() {
1136 let analysis = make_analysis(vec![("Foo", 1), ("foo", 5)], vec![("Foo", "foo", 10)]);
1138 let graph =
1139 CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
1140 .expect("Failed to build graph");
1141
1142 let err = graph
1144 .resolve_symbol_indexed("foo", &SymbolMatchMode::Insensitive)
1145 .unwrap_err();
1146
1147 assert!(matches!(&err, GraphError::MultipleCandidates { .. }));
1149 if let GraphError::MultipleCandidates { candidates, .. } = err {
1150 assert_eq!(candidates.len(), 2);
1151 }
1152 }
1153
1154 #[test]
1155 fn test_contains_resolve_via_index() {
1156 let analysis = make_analysis(
1158 vec![("parse_config", 1), ("build_config", 5), ("run", 10)],
1159 vec![],
1160 );
1161 let graph =
1162 CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
1163 .expect("Failed to build graph");
1164
1165 let err = graph
1167 .resolve_symbol_indexed("config", &SymbolMatchMode::Contains)
1168 .unwrap_err();
1169
1170 assert!(matches!(&err, GraphError::MultipleCandidates { .. }));
1172 if let GraphError::MultipleCandidates { candidates, .. } = err {
1173 let mut sorted = candidates.clone();
1174 sorted.sort();
1175 assert_eq!(sorted, vec!["build_config", "parse_config"]);
1176 }
1177 }
1178}