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