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