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