1use crate::types::{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
17#[derive(Debug, Clone)]
21pub struct ImportGraph {
22 pub incoming: HashMap<PathBuf, Vec<PathBuf>>,
24 pub outgoing: HashMap<PathBuf, Vec<PathBuf>>,
26}
27
28impl ImportGraph {
29 pub fn build_from_results(results: &[(PathBuf, SemanticAnalysis)]) -> Self {
32 let mut incoming: HashMap<PathBuf, Vec<PathBuf>> = HashMap::new();
33 let mut outgoing: HashMap<PathBuf, Vec<PathBuf>> = HashMap::new();
34
35 for (path, analysis) in results {
36 let mut out_files = Vec::new();
37 for import in &analysis.imports {
38 let module_path = PathBuf::from(&import.module);
40 out_files.push(module_path);
41 }
42 out_files.truncate(5);
43 if !out_files.is_empty() {
44 outgoing.insert(path.clone(), out_files);
45 }
46 }
47
48 for (from_file, to_files) in &outgoing {
50 for to_file in to_files {
51 incoming
52 .entry(to_file.clone())
53 .or_default()
54 .push(from_file.clone());
55 }
56 }
57
58 for inbound in incoming.values_mut() {
60 inbound.truncate(5);
61 }
62
63 ImportGraph { incoming, outgoing }
64 }
65}
66
67fn format_candidates(candidates: &[String]) -> String {
68 if candidates.len() <= MAX_CANDIDATES_IN_ERROR {
69 candidates.join(", ")
70 } else {
71 format!(
72 "{}, (and {} more)",
73 candidates[..MAX_CANDIDATES_IN_ERROR].join(", "),
74 candidates.len() - MAX_CANDIDATES_IN_ERROR
75 )
76 }
77}
78
79#[derive(Debug, Error)]
80pub enum GraphError {
81 #[error("Symbol not found: '{symbol}'. {hint}")]
82 SymbolNotFound { symbol: String, hint: String },
83 #[error(
84 "Multiple candidates matched '{query}': {candidates_display}. Refine the symbol name or use a stricter match_mode.",
85 candidates_display = format_candidates(.candidates)
86 )]
87 MultipleCandidates {
88 query: String,
89 candidates: Vec<String>,
90 },
91}
92
93pub fn resolve_symbol<'a>(
100 known_symbols: impl Iterator<Item = &'a String>,
101 query: &str,
102 mode: &SymbolMatchMode,
103) -> Result<String, GraphError> {
104 let mut matches: Vec<String> = if matches!(mode, SymbolMatchMode::Exact) {
105 known_symbols
106 .filter(|s| s.as_str() == query)
107 .cloned()
108 .collect()
109 } else {
110 let query_lower = query.to_lowercase();
111 known_symbols
112 .filter(|s| match mode {
113 SymbolMatchMode::Exact => unreachable!(),
114 SymbolMatchMode::Insensitive => s.to_lowercase() == query_lower,
115 SymbolMatchMode::Prefix => s.to_lowercase().starts_with(&query_lower),
116 SymbolMatchMode::Contains => s.to_lowercase().contains(&query_lower),
117 })
118 .cloned()
119 .collect()
120 };
121 matches.sort();
122
123 debug!(
124 query,
125 mode = ?mode,
126 candidate_count = matches.len(),
127 "resolve_symbol"
128 );
129
130 match matches.len() {
131 1 => Ok(matches.into_iter().next().expect("len==1")),
132 0 => {
133 let hint = match mode {
134 SymbolMatchMode::Exact => {
135 "Try match_mode=insensitive for a case-insensitive search.".to_string()
136 }
137 _ => "No symbols matched; try a shorter query or match_mode=contains.".to_string(),
138 };
139 Err(GraphError::SymbolNotFound {
140 symbol: query.to_string(),
141 hint,
142 })
143 }
144 _ => Err(GraphError::MultipleCandidates {
145 query: query.to_string(),
146 candidates: matches,
147 }),
148 }
149}
150
151fn strip_scope_prefix(name: &str) -> &str {
155 if let Some(pos) = name.rfind("::") {
156 &name[pos + 2..]
157 } else if let Some(pos) = name.rfind('.') {
158 &name[pos + 1..]
159 } else {
160 name
161 }
162}
163
164#[derive(Debug, Clone)]
165pub struct CallChain {
166 pub chain: Vec<(String, PathBuf, usize)>,
167}
168
169#[derive(Debug, Clone)]
171pub struct CallGraph {
172 pub callers: HashMap<String, Vec<(PathBuf, usize, String)>>,
174 pub callees: HashMap<String, Vec<(PathBuf, usize, String)>>,
176 pub definitions: HashMap<String, Vec<(PathBuf, usize)>>,
178 function_types: HashMap<String, Vec<FunctionTypeInfo>>,
180}
181
182impl CallGraph {
183 pub fn new() -> Self {
184 Self {
185 callers: HashMap::new(),
186 callees: HashMap::new(),
187 definitions: HashMap::new(),
188 function_types: HashMap::new(),
189 }
190 }
191
192 fn count_parameters(params_str: &str) -> usize {
195 if params_str.is_empty() || params_str == "()" {
196 return 0;
197 }
198 let inner = params_str
200 .trim_start_matches('(')
201 .trim_end_matches(')')
202 .trim();
203 if inner.is_empty() {
204 return 0;
205 }
206 inner.split(',').count()
208 }
209
210 fn match_by_type(
214 &self,
215 candidates: &[FunctionTypeInfo],
216 expected_param_count: Option<usize>,
217 expected_return_type: Option<&str>,
218 ) -> Option<usize> {
219 if candidates.is_empty() {
220 return None;
221 }
222
223 if expected_param_count.is_none() && expected_return_type.is_none() {
225 return None;
226 }
227
228 let mut best_idx = 0;
229 let mut best_score = 0;
230
231 for (idx, (_path, _line, params, ret_type)) in candidates.iter().enumerate() {
232 let mut score = 0;
233
234 if let Some(expected_count) = expected_param_count
236 && !params.is_empty()
237 {
238 let actual_count = Self::count_parameters(¶ms[0]);
239 if actual_count == expected_count {
240 score += 2;
241 }
242 }
243
244 if let Some(expected_ret) = expected_return_type
246 && let Some(actual_ret) = ret_type
247 && actual_ret == expected_ret
248 {
249 score += 1;
250 }
251
252 if !params.is_empty() {
254 score += 1;
255 }
256 if ret_type.is_some() {
257 score += 1;
258 }
259
260 if score > best_score {
261 best_score = score;
262 best_idx = idx;
263 }
264 }
265
266 (best_score > 0).then_some(best_idx)
268 }
269
270 fn resolve_callee(
279 &self,
280 callee: &str,
281 call_file: &Path,
282 call_line: usize,
283 arg_count: Option<usize>,
284 definitions: &HashMap<String, Vec<(PathBuf, usize)>>,
285 function_types: &HashMap<String, Vec<FunctionTypeInfo>>,
286 ) -> String {
287 if let Some(defs) = definitions.get(callee) {
289 return self.pick_best_definition(
290 defs,
291 call_file,
292 call_line,
293 arg_count,
294 callee,
295 function_types,
296 );
297 }
298
299 let stripped = strip_scope_prefix(callee);
301 if stripped != callee
302 && let Some(defs) = definitions.get(stripped)
303 {
304 return self.pick_best_definition(
305 defs,
306 call_file,
307 call_line,
308 arg_count,
309 stripped,
310 function_types,
311 );
312 }
313
314 callee.to_string()
316 }
317
318 fn pick_best_definition(
320 &self,
321 defs: &[(PathBuf, usize)],
322 call_file: &Path,
323 call_line: usize,
324 arg_count: Option<usize>,
325 resolved_name: &str,
326 function_types: &HashMap<String, Vec<FunctionTypeInfo>>,
327 ) -> String {
328 let same_file_defs: Vec<_> = defs.iter().filter(|(path, _)| path == call_file).collect();
330
331 if !same_file_defs.is_empty() {
332 if let Some(type_info) = function_types.get(resolved_name) {
334 let same_file_types: Vec<_> = type_info
335 .iter()
336 .filter(|(path, _, _, _)| path == call_file)
337 .cloned()
338 .collect();
339
340 if !same_file_types.is_empty() && same_file_types.len() > 1 {
341 let mut proximity_groups: Vec<Vec<usize>> = vec![];
343 for (idx, (_, def_line, _, _)) in same_file_types.iter().enumerate() {
344 let mut placed = false;
345 for group in &mut proximity_groups {
346 if let Some((_, first_line, _, _)) = same_file_types.get(group[0])
347 && first_line.abs_diff(*def_line) <= 5
348 {
349 group.push(idx);
350 placed = true;
351 break;
352 }
353 }
354 if !placed {
355 proximity_groups.push(vec![idx]);
356 }
357 }
358
359 let closest_group = proximity_groups.iter().min_by_key(|group| {
361 group
362 .iter()
363 .map(|idx| {
364 if let Some((_, def_line, _, _)) = same_file_types.get(*idx) {
365 def_line.abs_diff(call_line)
366 } else {
367 usize::MAX
368 }
369 })
370 .min()
371 .unwrap_or(usize::MAX)
372 });
373
374 if let Some(group) = closest_group {
375 if group.len() > 1 {
377 let candidates: Vec<_> = group
379 .iter()
380 .filter_map(|idx| same_file_types.get(*idx).cloned())
381 .collect();
382 if let Some(_best_idx) =
384 self.match_by_type(&candidates, arg_count, None)
385 {
386 return resolved_name.to_string();
387 }
388 }
389 }
390 }
391 }
392
393 let _best = same_file_defs
395 .iter()
396 .min_by_key(|(_, def_line)| (*def_line).abs_diff(call_line));
397 return resolved_name.to_string();
398 }
399
400 resolved_name.to_string()
402 }
403
404 #[instrument(skip_all)]
405 pub fn build_from_results(
406 results: Vec<(PathBuf, SemanticAnalysis)>,
407 ) -> Result<Self, GraphError> {
408 let mut graph = CallGraph::new();
409
410 for (path, analysis) in &results {
412 for func in &analysis.functions {
413 graph
414 .definitions
415 .entry(func.name.clone())
416 .or_default()
417 .push((path.clone(), func.line));
418 graph
419 .function_types
420 .entry(func.name.clone())
421 .or_default()
422 .push((
423 path.clone(),
424 func.line,
425 func.parameters.clone(),
426 func.return_type.clone(),
427 ));
428 }
429 for class in &analysis.classes {
430 graph
431 .definitions
432 .entry(class.name.clone())
433 .or_default()
434 .push((path.clone(), class.line));
435 graph
436 .function_types
437 .entry(class.name.clone())
438 .or_default()
439 .push((path.clone(), class.line, vec![], None));
440 }
441 }
442
443 for (path, analysis) in &results {
445 for call in &analysis.calls {
446 let resolved_callee = graph.resolve_callee(
447 &call.callee,
448 path,
449 call.line,
450 call.arg_count,
451 &graph.definitions,
452 &graph.function_types,
453 );
454
455 graph.callees.entry(call.caller.clone()).or_default().push((
456 path.clone(),
457 call.line,
458 resolved_callee.clone(),
459 ));
460 graph.callers.entry(resolved_callee).or_default().push((
461 path.clone(),
462 call.line,
463 call.caller.clone(),
464 ));
465 }
466 for reference in &analysis.references {
467 graph
468 .callers
469 .entry(reference.symbol.clone())
470 .or_default()
471 .push((path.clone(), reference.line, "<reference>".to_string()));
472 }
473 }
474
475 let total_edges = graph.callees.values().map(|v| v.len()).sum::<usize>()
476 + graph.callers.values().map(|v| v.len()).sum::<usize>();
477 let file_count = results.len();
478
479 tracing::debug!(
480 definitions = graph.definitions.len(),
481 edges = total_edges,
482 files = file_count,
483 "graph built"
484 );
485
486 Ok(graph)
487 }
488
489 fn find_chains_bfs(
490 &self,
491 symbol: &str,
492 follow_depth: u32,
493 is_incoming: bool,
494 ) -> Result<Vec<CallChain>, GraphError> {
495 let graph_map = if is_incoming {
496 &self.callers
497 } else {
498 &self.callees
499 };
500
501 if !self.definitions.contains_key(symbol) && !graph_map.contains_key(symbol) {
502 return Err(GraphError::SymbolNotFound {
503 symbol: symbol.to_string(),
504 hint: "Symbol resolved but not found in graph. The symbol may have no calls or definitions in the indexed files.".to_string(),
505 });
506 }
507
508 let mut chains = Vec::new();
509 let mut visited = HashSet::new();
510 let mut queue = VecDeque::new();
511 queue.push_back((symbol.to_string(), 0));
512 visited.insert(symbol.to_string());
513
514 while let Some((current, depth)) = queue.pop_front() {
515 if depth > follow_depth {
516 continue;
517 }
518
519 if let Some(neighbors) = graph_map.get(¤t) {
520 for (path, line, neighbor) in neighbors {
521 let mut chain = vec![(current.clone(), path.clone(), *line)];
522 let mut chain_node = neighbor.clone();
523 let mut chain_depth = depth;
524
525 while chain_depth < follow_depth {
526 if let Some(next_neighbors) = graph_map.get(&chain_node) {
527 if let Some((p, l, n)) = next_neighbors.first() {
528 if is_incoming {
529 chain.insert(0, (chain_node.clone(), p.clone(), *l));
530 } else {
531 chain.push((chain_node.clone(), p.clone(), *l));
532 }
533 chain_node = n.clone();
534 chain_depth += 1;
535 } else {
536 break;
537 }
538 } else {
539 break;
540 }
541 }
542
543 if is_incoming {
544 chain.insert(0, (neighbor.clone(), path.clone(), *line));
545 } else {
546 chain.push((neighbor.clone(), path.clone(), *line));
547 }
548 chains.push(CallChain { chain });
549
550 if !visited.contains(neighbor) && depth < follow_depth {
551 visited.insert(neighbor.clone());
552 queue.push_back((neighbor.clone(), depth + 1));
553 }
554 }
555 }
556 }
557
558 Ok(chains)
559 }
560
561 #[instrument(skip(self))]
562 pub fn find_incoming_chains(
563 &self,
564 symbol: &str,
565 follow_depth: u32,
566 ) -> Result<Vec<CallChain>, GraphError> {
567 self.find_chains_bfs(symbol, follow_depth, true)
568 }
569
570 #[instrument(skip(self))]
571 pub fn find_outgoing_chains(
572 &self,
573 symbol: &str,
574 follow_depth: u32,
575 ) -> Result<Vec<CallChain>, GraphError> {
576 self.find_chains_bfs(symbol, follow_depth, false)
577 }
578}
579
580impl Default for CallGraph {
581 fn default() -> Self {
582 Self::new()
583 }
584}
585
586#[cfg(test)]
587mod tests {
588 use super::*;
589 use crate::types::{CallInfo, FunctionInfo};
590
591 fn make_analysis(
592 funcs: Vec<(&str, usize)>,
593 calls: Vec<(&str, &str, usize)>,
594 ) -> SemanticAnalysis {
595 SemanticAnalysis {
596 functions: funcs
597 .into_iter()
598 .map(|(n, l)| FunctionInfo {
599 name: n.to_string(),
600 line: l,
601 end_line: l + 5,
602 parameters: vec![],
603 return_type: None,
604 })
605 .collect(),
606 classes: vec![],
607 imports: vec![],
608 references: vec![],
609 call_frequency: Default::default(),
610 calls: calls
611 .into_iter()
612 .map(|(c, e, l)| CallInfo {
613 caller: c.to_string(),
614 callee: e.to_string(),
615 line: l,
616 column: 0,
617 arg_count: None,
618 })
619 .collect(),
620 assignments: vec![],
621 field_accesses: vec![],
622 }
623 }
624
625 fn make_typed_analysis(
626 funcs: Vec<(&str, usize, Vec<String>, Option<&str>)>,
627 calls: Vec<(&str, &str, usize, Option<usize>)>,
628 ) -> SemanticAnalysis {
629 SemanticAnalysis {
630 functions: funcs
631 .into_iter()
632 .map(|(n, l, params, ret_type)| FunctionInfo {
633 name: n.to_string(),
634 line: l,
635 end_line: l + 5,
636 parameters: params,
637 return_type: ret_type.map(|s| s.to_string()),
638 })
639 .collect(),
640 classes: vec![],
641 imports: vec![],
642 references: vec![],
643 call_frequency: Default::default(),
644 calls: calls
645 .into_iter()
646 .map(|(c, e, l, arg_count)| CallInfo {
647 caller: c.to_string(),
648 callee: e.to_string(),
649 line: l,
650 column: 0,
651 arg_count,
652 })
653 .collect(),
654 assignments: vec![],
655 field_accesses: vec![],
656 }
657 }
658
659 #[test]
660 fn test_graph_construction() {
661 let analysis = make_analysis(
662 vec![("main", 1), ("foo", 10), ("bar", 20)],
663 vec![("main", "foo", 2), ("foo", "bar", 15)],
664 );
665 let graph = CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)])
666 .expect("Failed to build graph");
667 assert!(graph.definitions.contains_key("main"));
668 assert!(graph.definitions.contains_key("foo"));
669 assert_eq!(graph.callees["main"][0].2, "foo");
670 assert_eq!(graph.callers["foo"][0].2, "main");
671 }
672
673 #[test]
674 fn test_find_incoming_chains_depth_zero() {
675 let analysis = make_analysis(vec![("main", 1), ("foo", 10)], vec![("main", "foo", 2)]);
676 let graph = CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)])
677 .expect("Failed to build graph");
678 assert!(
679 !graph
680 .find_incoming_chains("foo", 0)
681 .expect("Failed to find chains")
682 .is_empty()
683 );
684 }
685
686 #[test]
687 fn test_find_outgoing_chains_depth_zero() {
688 let analysis = make_analysis(vec![("main", 1), ("foo", 10)], vec![("main", "foo", 2)]);
689 let graph = CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)])
690 .expect("Failed to build graph");
691 assert!(
692 !graph
693 .find_outgoing_chains("main", 0)
694 .expect("Failed to find chains")
695 .is_empty()
696 );
697 }
698
699 #[test]
700 fn test_symbol_not_found() {
701 assert!(
702 CallGraph::new()
703 .find_incoming_chains("nonexistent", 0)
704 .is_err()
705 );
706 }
707
708 #[test]
709 fn test_same_file_preference() {
710 let analysis_a = make_analysis(
714 vec![("main", 1), ("helper", 10)],
715 vec![("main", "helper", 5)],
716 );
717 let analysis_b = make_analysis(vec![("helper", 20)], vec![]);
718
719 let graph = CallGraph::build_from_results(vec![
720 (PathBuf::from("a.rs"), analysis_a),
721 (PathBuf::from("b.rs"), analysis_b),
722 ])
723 .expect("Failed to build graph");
724
725 assert!(graph.callees.contains_key("main"));
727 let main_callees = &graph.callees["main"];
728 assert_eq!(main_callees.len(), 1);
729 assert_eq!(main_callees[0].2, "helper");
730
731 assert_eq!(main_callees[0].0, PathBuf::from("a.rs"));
733
734 assert!(graph.callers.contains_key("helper"));
736 let helper_callers = &graph.callers["helper"];
737 assert!(
738 helper_callers
739 .iter()
740 .any(|(path, _, _)| path == &PathBuf::from("a.rs"))
741 );
742 }
743
744 #[test]
745 fn test_line_proximity() {
746 let analysis = make_analysis(
749 vec![("process", 10), ("process", 50)],
750 vec![("main", "process", 12)],
751 );
752
753 let graph = CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)])
754 .expect("Failed to build graph");
755
756 assert!(graph.callees.contains_key("main"));
758 let main_callees = &graph.callees["main"];
759 assert_eq!(main_callees.len(), 1);
760 assert_eq!(main_callees[0].2, "process");
761
762 assert!(graph.callers.contains_key("process"));
764 let process_callers = &graph.callers["process"];
765 assert!(
766 process_callers
767 .iter()
768 .any(|(_, line, caller)| *line == 12 && caller == "main")
769 );
770 }
771
772 #[test]
773 fn test_scope_prefix_stripping() {
774 let analysis = make_analysis(
777 vec![("method", 10)],
778 vec![
779 ("caller1", "self.method", 5),
780 ("caller2", "Type::method", 15),
781 ("caller3", "module::method", 25),
782 ],
783 );
784
785 let graph = CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)])
786 .expect("Failed to build graph");
787
788 assert_eq!(graph.callees["caller1"][0].2, "method");
790 assert_eq!(graph.callees["caller2"][0].2, "method");
791 assert_eq!(graph.callees["caller3"][0].2, "method");
792
793 assert!(graph.callers.contains_key("method"));
795 let method_callers = &graph.callers["method"];
796 assert_eq!(method_callers.len(), 3);
797 assert!(
798 method_callers
799 .iter()
800 .any(|(_, _, caller)| caller == "caller1")
801 );
802 assert!(
803 method_callers
804 .iter()
805 .any(|(_, _, caller)| caller == "caller2")
806 );
807 assert!(
808 method_callers
809 .iter()
810 .any(|(_, _, caller)| caller == "caller3")
811 );
812 }
813
814 #[test]
815 fn test_no_same_file_fallback() {
816 let analysis_a = make_analysis(vec![("main", 1)], vec![("main", "helper", 5)]);
819 let analysis_b = make_analysis(vec![("helper", 10)], vec![]);
820
821 let graph = CallGraph::build_from_results(vec![
822 (PathBuf::from("a.rs"), analysis_a),
823 (PathBuf::from("b.rs"), analysis_b),
824 ])
825 .expect("Failed to build graph");
826
827 assert!(graph.callees.contains_key("main"));
829 let main_callees = &graph.callees["main"];
830 assert_eq!(main_callees.len(), 1);
831 assert_eq!(main_callees[0].2, "helper");
832
833 assert!(graph.callers.contains_key("helper"));
835 let helper_callers = &graph.callers["helper"];
836 assert!(
837 helper_callers
838 .iter()
839 .any(|(path, _, caller)| { path == &PathBuf::from("a.rs") && caller == "main" })
840 );
841 }
842
843 #[test]
844 fn test_type_disambiguation_by_params() {
845 let analysis = make_typed_analysis(
850 vec![
851 ("process", 10, vec!["(x: i32)".to_string()], Some("i32")),
852 (
853 "process",
854 12,
855 vec!["(x: i32, y: String)".to_string()],
856 Some("String"),
857 ),
858 ("main", 1, vec![], None),
859 ],
860 vec![("main", "process", 11, Some(2))],
861 );
862
863 let graph = CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)])
864 .expect("Failed to build graph");
865
866 assert!(graph.callees.contains_key("main"));
868 let main_callees = &graph.callees["main"];
869 assert_eq!(main_callees.len(), 1);
870 assert_eq!(main_callees[0].2, "process");
871
872 assert!(graph.callers.contains_key("process"));
874 let process_callers = &graph.callers["process"];
875 assert!(
876 process_callers
877 .iter()
878 .any(|(_, line, caller)| *line == 11 && caller == "main")
879 );
880 }
881
882 #[test]
883 fn test_type_disambiguation_fallback() {
884 let analysis = make_analysis(
888 vec![("process", 10), ("process", 50), ("main", 1)],
889 vec![("main", "process", 12)],
890 );
891
892 let graph = CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)])
893 .expect("Failed to build graph");
894
895 assert!(graph.callees.contains_key("main"));
897 let main_callees = &graph.callees["main"];
898 assert_eq!(main_callees.len(), 1);
899 assert_eq!(main_callees[0].2, "process");
900
901 assert!(graph.callers.contains_key("process"));
903 let process_callers = &graph.callers["process"];
904 assert!(
905 process_callers
906 .iter()
907 .any(|(_, line, caller)| *line == 12 && caller == "main")
908 );
909 }
910
911 fn known(names: &[&str]) -> Vec<String> {
914 names.iter().map(|s| s.to_string()).collect()
915 }
916
917 #[test]
918 fn test_resolve_symbol_exact_match() {
919 let syms = known(&["parse_config", "ParseConfig", "PARSE_CONFIG"]);
920 let result = resolve_symbol(syms.iter(), "parse_config", &SymbolMatchMode::Exact);
921 assert_eq!(result.unwrap(), "parse_config");
922 }
923
924 #[test]
925 fn test_resolve_symbol_exact_no_match() {
926 let syms = known(&["ParseConfig"]);
927 let err = resolve_symbol(syms.iter(), "parse_config", &SymbolMatchMode::Exact).unwrap_err();
928 assert!(matches!(err, GraphError::SymbolNotFound { .. }));
929 }
930
931 #[test]
932 fn test_resolve_symbol_insensitive_match() {
933 let syms = known(&["ParseConfig", "other"]);
934 let result = resolve_symbol(syms.iter(), "parseconfig", &SymbolMatchMode::Insensitive);
935 assert_eq!(result.unwrap(), "ParseConfig");
936 }
937
938 #[test]
939 fn test_resolve_symbol_insensitive_no_match() {
940 let syms = known(&["unrelated"]);
941 let err =
942 resolve_symbol(syms.iter(), "parseconfig", &SymbolMatchMode::Insensitive).unwrap_err();
943 assert!(matches!(err, GraphError::SymbolNotFound { .. }));
944 }
945
946 #[test]
947 fn test_resolve_symbol_prefix_single() {
948 let syms = known(&["parse_config", "parse_args", "build"]);
949 let result = resolve_symbol(syms.iter(), "build", &SymbolMatchMode::Prefix);
950 assert_eq!(result.unwrap(), "build");
951 }
952
953 #[test]
954 fn test_resolve_symbol_prefix_multiple_candidates() {
955 let syms = known(&["parse_config", "parse_args", "build"]);
956 let err = resolve_symbol(syms.iter(), "parse", &SymbolMatchMode::Prefix).unwrap_err();
957 assert!(matches!(&err, GraphError::MultipleCandidates { .. }));
958 if let GraphError::MultipleCandidates { candidates, .. } = err {
959 assert_eq!(candidates.len(), 2);
960 }
961 }
962
963 #[test]
964 fn test_resolve_symbol_contains_single() {
965 let syms = known(&["parse_config", "build_artifact"]);
966 let result = resolve_symbol(syms.iter(), "config", &SymbolMatchMode::Contains);
967 assert_eq!(result.unwrap(), "parse_config");
968 }
969
970 #[test]
971 fn test_resolve_symbol_contains_no_match() {
972 let syms = known(&["parse_config", "build_artifact"]);
973 let err = resolve_symbol(syms.iter(), "deploy", &SymbolMatchMode::Contains).unwrap_err();
974 assert!(matches!(err, GraphError::SymbolNotFound { .. }));
975 }
976}