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