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)]
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}. Refine the symbol name or use a stricter match_mode.",
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.".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.".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(crate) struct InternalCallChain {
201 pub chain: Vec<(String, PathBuf, usize)>,
202}
203
204#[derive(Debug, Clone)]
206pub struct CallGraph {
207 pub callers: HashMap<String, Vec<CallEdge>>,
209 pub callees: HashMap<String, Vec<CallEdge>>,
211 pub definitions: HashMap<String, Vec<(PathBuf, usize)>>,
213 function_types: HashMap<String, Vec<FunctionTypeInfo>>,
215 lowercase_index: HashMap<String, Vec<String>>,
217}
218
219impl CallGraph {
220 #[must_use]
221 pub fn new() -> Self {
222 Self {
223 callers: HashMap::new(),
224 callees: HashMap::new(),
225 definitions: HashMap::new(),
226 function_types: HashMap::new(),
227 lowercase_index: HashMap::new(),
228 }
229 }
230
231 fn resolve_callee(
239 callee: &str,
240 _call_file: &Path,
241 _call_line: usize,
242 _arg_count: Option<usize>,
243 definitions: &HashMap<String, Vec<(PathBuf, usize)>>,
244 _function_types: &HashMap<String, Vec<FunctionTypeInfo>>,
245 ) -> String {
246 if let Some(_defs) = definitions.get(callee) {
248 return callee.to_string();
249 }
250
251 let stripped = strip_scope_prefix(callee);
253 if stripped != callee
254 && let Some(_defs) = definitions.get(stripped)
255 {
256 return stripped.to_string();
257 }
258
259 callee.to_string()
261 }
262
263 #[instrument(skip_all)]
265 #[allow(clippy::too_many_lines)]
266 #[allow(clippy::needless_pass_by_value)]
269 pub fn build_from_results(
270 results: Vec<(PathBuf, SemanticAnalysis)>,
271 impl_traits: &[ImplTraitInfo],
272 impl_only: bool,
273 ) -> Result<Self, GraphError> {
274 let mut graph = CallGraph::new();
275
276 for (path, analysis) in &results {
278 for func in &analysis.functions {
279 graph
280 .definitions
281 .entry(func.name.clone())
282 .or_default()
283 .push((path.clone(), func.line));
284 graph
285 .function_types
286 .entry(func.name.clone())
287 .or_default()
288 .push((
289 path.clone(),
290 func.line,
291 func.parameters.clone(),
292 func.return_type.clone(),
293 ));
294 }
295 for class in &analysis.classes {
296 graph
297 .definitions
298 .entry(class.name.clone())
299 .or_default()
300 .push((path.clone(), class.line));
301 graph
302 .function_types
303 .entry(class.name.clone())
304 .or_default()
305 .push((path.clone(), class.line, vec![], None));
306 }
307 }
308
309 for (path, analysis) in &results {
311 for call in &analysis.calls {
312 let resolved_callee = Self::resolve_callee(
313 &call.callee,
314 path,
315 call.line,
316 call.arg_count,
317 &graph.definitions,
318 &graph.function_types,
319 );
320
321 graph
322 .callees
323 .entry(call.caller.clone())
324 .or_default()
325 .push(CallEdge {
326 path: path.clone(),
327 line: call.line,
328 neighbor_name: resolved_callee.clone(),
329 is_impl_trait: false,
330 });
331 graph
332 .callers
333 .entry(resolved_callee)
334 .or_default()
335 .push(CallEdge {
336 path: path.clone(),
337 line: call.line,
338 neighbor_name: call.caller.clone(),
339 is_impl_trait: false,
340 });
341 }
342 for reference in &analysis.references {
343 graph
344 .callers
345 .entry(reference.symbol.clone())
346 .or_default()
347 .push(CallEdge {
348 path: path.clone(),
349 line: reference.line,
350 neighbor_name: "<reference>".to_string(),
351 is_impl_trait: false,
352 });
353 }
354 }
355
356 for it in impl_traits {
360 graph
361 .callers
362 .entry(it.trait_name.clone())
363 .or_default()
364 .push(CallEdge {
365 path: it.path.clone(),
366 line: it.line,
367 neighbor_name: it.impl_type.clone(),
368 is_impl_trait: true,
369 });
370 }
371
372 if impl_only {
376 for edges in graph.callers.values_mut() {
377 edges.retain(|e| e.is_impl_trait);
378 }
379 }
380
381 for key in graph
385 .definitions
386 .keys()
387 .chain(graph.callers.keys())
388 .chain(graph.callees.keys())
389 {
390 graph
391 .lowercase_index
392 .entry(key.to_lowercase())
393 .or_default()
394 .push(key.clone());
395 }
396 for originals in graph.lowercase_index.values_mut() {
397 originals.sort();
398 originals.dedup();
399 }
400
401 let total_edges = graph.callees.values().map(Vec::len).sum::<usize>()
402 + graph.callers.values().map(Vec::len).sum::<usize>();
403 let file_count = results.len();
404
405 tracing::debug!(
406 definitions = graph.definitions.len(),
407 edges = total_edges,
408 files = file_count,
409 impl_only,
410 "graph built"
411 );
412
413 Ok(graph)
414 }
415
416 fn find_chains_bfs(
417 &self,
418 symbol: &str,
419 follow_depth: u32,
420 is_incoming: bool,
421 ) -> Result<Vec<InternalCallChain>, GraphError> {
422 let graph_map = if is_incoming {
423 &self.callers
424 } else {
425 &self.callees
426 };
427
428 if !self.definitions.contains_key(symbol) && !graph_map.contains_key(symbol) {
429 return Err(GraphError::SymbolNotFound {
430 symbol: symbol.to_string(),
431 hint: "Symbol resolved but not found in graph. The symbol may have no calls or definitions in the indexed files.".to_string(),
432 });
433 }
434
435 let mut chains = Vec::new();
436 let mut visited = HashSet::new();
437 let mut queue = VecDeque::new();
438 queue.push_back((symbol.to_string(), 0));
439 visited.insert(symbol.to_string());
440
441 while let Some((current, depth)) = queue.pop_front() {
442 if depth > follow_depth {
443 continue;
444 }
445
446 if let Some(neighbors) = graph_map.get(¤t) {
447 for edge in neighbors {
448 let path = &edge.path;
449 let line = edge.line;
450 let neighbor = &edge.neighbor_name;
451 let mut chain = {
460 let mut v = Vec::with_capacity(follow_depth as usize + 2);
461 v.push((current.clone(), path.clone(), line));
462 v
463 };
464 let mut chain_node = neighbor.clone();
465 let mut chain_depth = depth;
466
467 while chain_depth < follow_depth {
468 if let Some(next_neighbors) = graph_map.get(&chain_node) {
469 if let Some(next_edge) = next_neighbors.first() {
470 chain_node = next_edge.neighbor_name.clone();
475 chain.push((
476 chain_node.clone(),
477 next_edge.path.clone(),
478 next_edge.line,
479 ));
480 chain_depth += 1;
481 } else {
482 break;
483 }
484 } else {
485 break;
486 }
487 }
488
489 if is_incoming {
490 chain.push((neighbor.clone(), path.clone(), line));
493 chain.reverse();
494 } else {
495 chain.push((neighbor.clone(), path.clone(), line));
496 }
497
498 debug_assert!(
499 chain.len() <= follow_depth as usize + 2,
500 "find_chains_bfs: chain length {} exceeds bound {}",
501 chain.len(),
502 follow_depth + 2
503 );
504
505 chains.push(InternalCallChain { chain });
506
507 if !visited.contains(neighbor) && depth < follow_depth {
508 visited.insert(neighbor.clone());
509 queue.push_back((neighbor.clone(), depth + 1));
510 }
511 }
512 }
513 }
514
515 Ok(chains)
516 }
517
518 #[instrument(skip(self))]
519 pub(crate) fn find_incoming_chains(
520 &self,
521 symbol: &str,
522 follow_depth: u32,
523 ) -> Result<Vec<InternalCallChain>, GraphError> {
524 self.find_chains_bfs(symbol, follow_depth, true)
525 }
526
527 #[instrument(skip(self))]
528 pub(crate) fn find_outgoing_chains(
529 &self,
530 symbol: &str,
531 follow_depth: u32,
532 ) -> Result<Vec<InternalCallChain>, GraphError> {
533 self.find_chains_bfs(symbol, follow_depth, false)
534 }
535}
536
537impl Default for CallGraph {
538 fn default() -> Self {
539 Self::new()
540 }
541}
542
543#[cfg(test)]
544mod tests {
545 use super::*;
546 use crate::types::{CallInfo, FunctionInfo};
547
548 fn make_analysis(
549 funcs: Vec<(&str, usize)>,
550 calls: Vec<(&str, &str, usize)>,
551 ) -> SemanticAnalysis {
552 SemanticAnalysis {
553 functions: funcs
554 .into_iter()
555 .map(|(n, l)| FunctionInfo {
556 name: n.to_string(),
557 line: l,
558 end_line: l + 5,
559 parameters: vec![],
560 return_type: None,
561 })
562 .collect(),
563 classes: vec![],
564 imports: vec![],
565 references: vec![],
566 call_frequency: Default::default(),
567 calls: calls
568 .into_iter()
569 .map(|(c, e, l)| CallInfo {
570 caller: c.to_string(),
571 callee: e.to_string(),
572 line: l,
573 column: 0,
574 arg_count: None,
575 })
576 .collect(),
577 impl_traits: vec![],
578 }
579 }
580
581 fn make_typed_analysis(
582 funcs: Vec<(&str, usize, Vec<String>, Option<&str>)>,
583 calls: Vec<(&str, &str, usize, Option<usize>)>,
584 ) -> SemanticAnalysis {
585 SemanticAnalysis {
586 functions: funcs
587 .into_iter()
588 .map(|(n, l, params, ret_type)| FunctionInfo {
589 name: n.to_string(),
590 line: l,
591 end_line: l + 5,
592 parameters: params,
593 return_type: ret_type.map(|s| s.to_string()),
594 })
595 .collect(),
596 classes: vec![],
597 imports: vec![],
598 references: vec![],
599 call_frequency: Default::default(),
600 calls: calls
601 .into_iter()
602 .map(|(c, e, l, arg_count)| CallInfo {
603 caller: c.to_string(),
604 callee: e.to_string(),
605 line: l,
606 column: 0,
607 arg_count,
608 })
609 .collect(),
610 impl_traits: vec![],
611 }
612 }
613
614 #[test]
615 fn test_graph_construction() {
616 let analysis = make_analysis(
617 vec![("main", 1), ("foo", 10), ("bar", 20)],
618 vec![("main", "foo", 2), ("foo", "bar", 15)],
619 );
620 let graph =
621 CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
622 .expect("Failed to build graph");
623 assert!(graph.definitions.contains_key("main"));
624 assert!(graph.definitions.contains_key("foo"));
625 assert_eq!(graph.callees["main"][0].neighbor_name, "foo");
626 assert_eq!(graph.callers["foo"][0].neighbor_name, "main");
627 }
628
629 #[test]
630 fn test_find_incoming_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_incoming_chains("foo", 0)
638 .expect("Failed to find chains")
639 .is_empty()
640 );
641 }
642
643 #[test]
644 fn test_find_outgoing_chains_depth_zero() {
645 let analysis = make_analysis(vec![("main", 1), ("foo", 10)], vec![("main", "foo", 2)]);
646 let graph =
647 CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
648 .expect("Failed to build graph");
649 assert!(
650 !graph
651 .find_outgoing_chains("main", 0)
652 .expect("Failed to find chains")
653 .is_empty()
654 );
655 }
656
657 #[test]
658 fn test_symbol_not_found() {
659 assert!(
660 CallGraph::new()
661 .find_incoming_chains("nonexistent", 0)
662 .is_err()
663 );
664 }
665
666 #[test]
667 fn test_same_file_preference() {
668 let analysis_a = make_analysis(
672 vec![("main", 1), ("helper", 10)],
673 vec![("main", "helper", 5)],
674 );
675 let analysis_b = make_analysis(vec![("helper", 20)], vec![]);
676
677 let graph = CallGraph::build_from_results(
678 vec![
679 (PathBuf::from("a.rs"), analysis_a),
680 (PathBuf::from("b.rs"), analysis_b),
681 ],
682 &[],
683 false,
684 )
685 .expect("Failed to build graph");
686
687 assert!(graph.callees.contains_key("main"));
689 let main_callees = &graph.callees["main"];
690 assert_eq!(main_callees.len(), 1);
691 assert_eq!(main_callees[0].neighbor_name, "helper");
692
693 assert_eq!(main_callees[0].path, PathBuf::from("a.rs"));
695
696 assert!(graph.callers.contains_key("helper"));
698 let helper_callers = &graph.callers["helper"];
699 assert!(
700 helper_callers
701 .iter()
702 .any(|e| e.path == PathBuf::from("a.rs"))
703 );
704 }
705
706 #[test]
707 fn test_line_proximity() {
708 let analysis = make_analysis(
711 vec![("process", 10), ("process", 50)],
712 vec![("main", "process", 12)],
713 );
714
715 let graph =
716 CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
717 .expect("Failed to build graph");
718
719 assert!(graph.callees.contains_key("main"));
721 let main_callees = &graph.callees["main"];
722 assert_eq!(main_callees.len(), 1);
723 assert_eq!(main_callees[0].neighbor_name, "process");
724
725 assert!(graph.callers.contains_key("process"));
727 let process_callers = &graph.callers["process"];
728 assert!(
729 process_callers
730 .iter()
731 .any(|e| e.line == 12 && e.neighbor_name == "main")
732 );
733 }
734
735 #[test]
736 fn test_scope_prefix_stripping() {
737 let analysis = make_analysis(
740 vec![("method", 10)],
741 vec![
742 ("caller1", "self.method", 5),
743 ("caller2", "Type::method", 15),
744 ("caller3", "module::method", 25),
745 ],
746 );
747
748 let graph =
749 CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
750 .expect("Failed to build graph");
751
752 assert_eq!(graph.callees["caller1"][0].neighbor_name, "method");
754 assert_eq!(graph.callees["caller2"][0].neighbor_name, "method");
755 assert_eq!(graph.callees["caller3"][0].neighbor_name, "method");
756
757 assert!(graph.callers.contains_key("method"));
759 let method_callers = &graph.callers["method"];
760 assert_eq!(method_callers.len(), 3);
761 assert!(method_callers.iter().any(|e| e.neighbor_name == "caller1"));
762 assert!(method_callers.iter().any(|e| e.neighbor_name == "caller2"));
763 assert!(method_callers.iter().any(|e| e.neighbor_name == "caller3"));
764 }
765
766 #[test]
767 fn test_no_same_file_fallback() {
768 let analysis_a = make_analysis(vec![("main", 1)], vec![("main", "helper", 5)]);
771 let analysis_b = make_analysis(vec![("helper", 10)], vec![]);
772
773 let graph = CallGraph::build_from_results(
774 vec![
775 (PathBuf::from("a.rs"), analysis_a),
776 (PathBuf::from("b.rs"), analysis_b),
777 ],
778 &[],
779 false,
780 )
781 .expect("Failed to build graph");
782
783 assert!(graph.callees.contains_key("main"));
785 let main_callees = &graph.callees["main"];
786 assert_eq!(main_callees.len(), 1);
787 assert_eq!(main_callees[0].neighbor_name, "helper");
788
789 assert!(graph.callers.contains_key("helper"));
791 let helper_callers = &graph.callers["helper"];
792 assert!(
793 helper_callers
794 .iter()
795 .any(|e| e.path == PathBuf::from("a.rs") && e.neighbor_name == "main")
796 );
797 }
798
799 #[test]
800 fn test_type_disambiguation_by_params() {
801 let analysis = make_typed_analysis(
806 vec![
807 ("process", 10, vec!["(x: i32)".to_string()], Some("i32")),
808 (
809 "process",
810 12,
811 vec!["(x: i32, y: String)".to_string()],
812 Some("String"),
813 ),
814 ("main", 1, vec![], None),
815 ],
816 vec![("main", "process", 11, Some(2))],
817 );
818
819 let graph =
820 CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
821 .expect("Failed to build graph");
822
823 assert!(graph.callees.contains_key("main"));
825 let main_callees = &graph.callees["main"];
826 assert_eq!(main_callees.len(), 1);
827 assert_eq!(main_callees[0].neighbor_name, "process");
828
829 assert!(graph.callers.contains_key("process"));
831 let process_callers = &graph.callers["process"];
832 assert!(
833 process_callers
834 .iter()
835 .any(|e| e.line == 11 && e.neighbor_name == "main")
836 );
837 }
838
839 #[test]
840 fn test_type_disambiguation_fallback() {
841 let analysis = make_analysis(
845 vec![("process", 10), ("process", 50), ("main", 1)],
846 vec![("main", "process", 12)],
847 );
848
849 let graph =
850 CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
851 .expect("Failed to build graph");
852
853 assert!(graph.callees.contains_key("main"));
855 let main_callees = &graph.callees["main"];
856 assert_eq!(main_callees.len(), 1);
857 assert_eq!(main_callees[0].neighbor_name, "process");
858
859 assert!(graph.callers.contains_key("process"));
861 let process_callers = &graph.callers["process"];
862 assert!(
863 process_callers
864 .iter()
865 .any(|e| e.line == 12 && e.neighbor_name == "main")
866 );
867 }
868
869 #[test]
870 fn test_impl_only_filters_to_impl_sites() {
871 use crate::types::ImplTraitInfo;
873 let analysis = make_analysis(
874 vec![("write", 1), ("plain_fn", 20)],
875 vec![("plain_fn", "write", 22)],
876 );
877 let impl_traits = vec![ImplTraitInfo {
878 trait_name: "Write".to_string(),
879 impl_type: "WriterImpl".to_string(),
880 path: PathBuf::from("test.rs"),
881 line: 10,
882 }];
883
884 let graph = CallGraph::build_from_results(
886 vec![(PathBuf::from("test.rs"), analysis)],
887 &impl_traits,
888 true,
889 )
890 .expect("Failed to build graph");
891
892 let callers = graph
894 .callers
895 .get("Write")
896 .expect("Write must have impl caller");
897 assert_eq!(callers.len(), 1, "only impl-trait caller retained");
898 assert_eq!(callers[0].neighbor_name, "WriterImpl");
899 assert!(
900 callers[0].is_impl_trait,
901 "edge must be tagged is_impl_trait"
902 );
903
904 let write_callers = graph.callers.get("write").map(|v| v.len()).unwrap_or(0);
906 assert_eq!(
907 write_callers, 0,
908 "regular callers filtered when impl_only=true"
909 );
910 }
911
912 #[test]
913 fn test_impl_only_false_is_backward_compatible() {
914 use crate::types::ImplTraitInfo;
916 let analysis = make_analysis(
917 vec![("write", 1), ("WriterImpl", 10), ("plain_fn", 20)],
918 vec![("WriterImpl", "write", 12), ("plain_fn", "write", 22)],
919 );
920 let impl_traits = vec![ImplTraitInfo {
921 trait_name: "Write".to_string(),
922 impl_type: "WriterImpl".to_string(),
923 path: PathBuf::from("test.rs"),
924 line: 10,
925 }];
926
927 let graph = CallGraph::build_from_results(
929 vec![(PathBuf::from("test.rs"), analysis)],
930 &impl_traits,
931 false,
932 )
933 .expect("Failed to build graph");
934
935 let callers = graph.callers.get("write").expect("write must have callers");
937 assert_eq!(
938 callers.len(),
939 2,
940 "both call-site callers should be present when impl_only=false"
941 );
942
943 let write_impl_callers = graph
945 .callers
946 .get("Write")
947 .expect("Write must have impl caller");
948 assert_eq!(write_impl_callers.len(), 1);
949 assert!(write_impl_callers[0].is_impl_trait);
950 }
951
952 #[test]
953 fn test_impl_only_callees_unaffected() {
954 use crate::types::ImplTraitInfo;
956 let analysis = make_analysis(
957 vec![("write", 1), ("WriterImpl", 10)],
958 vec![("WriterImpl", "write", 12)],
959 );
960 let impl_traits = vec![ImplTraitInfo {
961 trait_name: "Write".to_string(),
962 impl_type: "WriterImpl".to_string(),
963 path: PathBuf::from("test.rs"),
964 line: 10,
965 }];
966
967 let graph = CallGraph::build_from_results(
968 vec![(PathBuf::from("test.rs"), analysis)],
969 &impl_traits,
970 true,
971 )
972 .expect("Failed to build graph");
973
974 let callees = graph
976 .callees
977 .get("WriterImpl")
978 .expect("WriterImpl must have callees");
979 assert_eq!(
980 callees.len(),
981 1,
982 "callees must not be filtered by impl_only"
983 );
984 assert_eq!(callees[0].neighbor_name, "write");
985 }
986
987 fn known(names: &[&str]) -> Vec<String> {
990 names.iter().map(|s| s.to_string()).collect()
991 }
992
993 #[test]
994 fn test_resolve_symbol_exact_match() {
995 let syms = known(&["parse_config", "ParseConfig", "PARSE_CONFIG"]);
996 let result = resolve_symbol(syms.iter(), "parse_config", &SymbolMatchMode::Exact);
997 assert_eq!(result.unwrap(), "parse_config");
998 }
999
1000 #[test]
1001 fn test_resolve_symbol_exact_no_match() {
1002 let syms = known(&["ParseConfig"]);
1003 let err = resolve_symbol(syms.iter(), "parse_config", &SymbolMatchMode::Exact).unwrap_err();
1004 assert!(matches!(err, GraphError::SymbolNotFound { .. }));
1005 }
1006
1007 #[test]
1008 fn test_resolve_symbol_insensitive_match() {
1009 let syms = known(&["ParseConfig", "other"]);
1010 let result = resolve_symbol(syms.iter(), "parseconfig", &SymbolMatchMode::Insensitive);
1011 assert_eq!(result.unwrap(), "ParseConfig");
1012 }
1013
1014 #[test]
1015 fn test_resolve_symbol_insensitive_no_match() {
1016 let syms = known(&["unrelated"]);
1017 let err =
1018 resolve_symbol(syms.iter(), "parseconfig", &SymbolMatchMode::Insensitive).unwrap_err();
1019 assert!(matches!(err, GraphError::SymbolNotFound { .. }));
1020 }
1021
1022 #[test]
1023 fn test_resolve_symbol_prefix_single() {
1024 let syms = known(&["parse_config", "parse_args", "build"]);
1025 let result = resolve_symbol(syms.iter(), "build", &SymbolMatchMode::Prefix);
1026 assert_eq!(result.unwrap(), "build");
1027 }
1028
1029 #[test]
1030 fn test_resolve_symbol_prefix_multiple_candidates() {
1031 let syms = known(&["parse_config", "parse_args", "build"]);
1032 let err = resolve_symbol(syms.iter(), "parse", &SymbolMatchMode::Prefix).unwrap_err();
1033 assert!(matches!(&err, GraphError::MultipleCandidates { .. }));
1034 if let GraphError::MultipleCandidates { candidates, .. } = err {
1035 assert_eq!(candidates.len(), 2);
1036 }
1037 }
1038
1039 #[test]
1040 fn test_resolve_symbol_contains_single() {
1041 let syms = known(&["parse_config", "build_artifact"]);
1042 let result = resolve_symbol(syms.iter(), "config", &SymbolMatchMode::Contains);
1043 assert_eq!(result.unwrap(), "parse_config");
1044 }
1045
1046 #[test]
1047 fn test_resolve_symbol_contains_no_match() {
1048 let syms = known(&["parse_config", "build_artifact"]);
1049 let err = resolve_symbol(syms.iter(), "deploy", &SymbolMatchMode::Contains).unwrap_err();
1050 assert!(matches!(err, GraphError::SymbolNotFound { .. }));
1051 }
1052
1053 #[test]
1054 fn test_incoming_chain_order_two_hops() {
1055 let analysis = make_analysis(
1064 vec![("A", 1), ("B", 10), ("C", 20)],
1065 vec![("A", "B", 2), ("B", "C", 15)],
1066 );
1067 let graph =
1068 CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
1069 .expect("Failed to build graph");
1070
1071 let chains = graph
1072 .find_incoming_chains("C", 2)
1073 .expect("Failed to find incoming chains");
1074
1075 assert!(
1076 !chains.is_empty(),
1077 "Expected at least one incoming chain for C"
1078 );
1079
1080 let chain = chains
1082 .iter()
1083 .find(|c| c.chain.len() == 3)
1084 .expect("Expected a 3-element chain");
1085
1086 assert_eq!(
1087 chain.chain[0].0, "B",
1088 "chain[0] should be immediate caller B, got {}",
1089 chain.chain[0].0
1090 );
1091 assert_eq!(
1092 chain.chain[1].0, "A",
1093 "chain[1] should be outermost caller A, got {}",
1094 chain.chain[1].0
1095 );
1096 assert_eq!(
1097 chain.chain[2].0, "C",
1098 "chain[2] should be focus node C, got {}",
1099 chain.chain[2].0
1100 );
1101 }
1102
1103 #[test]
1106 fn test_insensitive_resolve_via_index() {
1107 let analysis = make_analysis(
1109 vec![("ParseConfig", 1), ("parse_args", 5)],
1110 vec![("ParseConfig", "parse_args", 10)],
1111 );
1112 let graph =
1113 CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
1114 .expect("Failed to build graph");
1115
1116 let result = graph
1118 .resolve_symbol_indexed("parseconfig", &SymbolMatchMode::Insensitive)
1119 .expect("Should resolve ParseConfig");
1120
1121 assert_eq!(result, "ParseConfig");
1123 }
1124
1125 #[test]
1126 fn test_prefix_resolve_via_index() {
1127 let analysis = make_analysis(
1129 vec![("parse_config", 1), ("parse_args", 5), ("build", 10)],
1130 vec![],
1131 );
1132 let graph =
1133 CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
1134 .expect("Failed to build graph");
1135
1136 let err = graph
1138 .resolve_symbol_indexed("parse", &SymbolMatchMode::Prefix)
1139 .unwrap_err();
1140
1141 assert!(matches!(&err, GraphError::MultipleCandidates { .. }));
1143 if let GraphError::MultipleCandidates { candidates, .. } = err {
1144 assert_eq!(candidates.len(), 2);
1145 }
1146 }
1147
1148 #[test]
1149 fn test_insensitive_case_collision_returns_multiple_candidates() {
1150 let analysis = make_analysis(vec![("Foo", 1), ("foo", 5)], vec![("Foo", "foo", 10)]);
1152 let graph =
1153 CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
1154 .expect("Failed to build graph");
1155
1156 let err = graph
1158 .resolve_symbol_indexed("foo", &SymbolMatchMode::Insensitive)
1159 .unwrap_err();
1160
1161 assert!(matches!(&err, GraphError::MultipleCandidates { .. }));
1163 if let GraphError::MultipleCandidates { candidates, .. } = err {
1164 assert_eq!(candidates.len(), 2);
1165 }
1166 }
1167
1168 #[test]
1169 fn test_contains_resolve_via_index() {
1170 let analysis = make_analysis(
1172 vec![("parse_config", 1), ("build_config", 5), ("run", 10)],
1173 vec![],
1174 );
1175 let graph =
1176 CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
1177 .expect("Failed to build graph");
1178
1179 let err = graph
1181 .resolve_symbol_indexed("config", &SymbolMatchMode::Contains)
1182 .unwrap_err();
1183
1184 assert!(matches!(&err, GraphError::MultipleCandidates { .. }));
1186 if let GraphError::MultipleCandidates { candidates, .. } = err {
1187 let mut sorted = candidates.clone();
1188 sorted.sort();
1189 assert_eq!(sorted, vec!["build_config", "parse_config"]);
1190 }
1191 }
1192}