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
101fn strip_scope_prefix(name: &str) -> &str {
105 if let Some(pos) = name.rfind("::") {
106 &name[pos + 2..]
107 } else if let Some(pos) = name.rfind('.') {
108 &name[pos + 1..]
109 } else {
110 name
111 }
112}
113
114#[derive(Debug, Clone)]
115pub(crate) struct InternalCallChain {
116 pub chain: Vec<(String, PathBuf, usize)>,
117}
118
119#[derive(Debug, Clone)]
121pub struct CallGraph {
122 pub callers: HashMap<String, Vec<CallEdge>>,
124 pub callees: HashMap<String, Vec<CallEdge>>,
126 pub definitions: HashMap<String, Vec<(PathBuf, usize)>>,
128 function_types: HashMap<String, Vec<FunctionTypeInfo>>,
130}
131
132impl CallGraph {
133 pub fn new() -> Self {
134 Self {
135 callers: HashMap::new(),
136 callees: HashMap::new(),
137 definitions: HashMap::new(),
138 function_types: HashMap::new(),
139 }
140 }
141
142 fn resolve_callee(
153 &self,
154 callee: &str,
155 _call_file: &Path,
156 _call_line: usize,
157 _arg_count: Option<usize>,
158 definitions: &HashMap<String, Vec<(PathBuf, usize)>>,
159 _function_types: &HashMap<String, Vec<FunctionTypeInfo>>,
160 ) -> String {
161 if let Some(_defs) = definitions.get(callee) {
163 return callee.to_string();
164 }
165
166 let stripped = strip_scope_prefix(callee);
168 if stripped != callee
169 && let Some(_defs) = definitions.get(stripped)
170 {
171 return stripped.to_string();
172 }
173
174 callee.to_string()
176 }
177
178 #[instrument(skip_all)]
180 pub fn build_from_results(
181 results: Vec<(PathBuf, SemanticAnalysis)>,
182 impl_traits: &[ImplTraitInfo],
183 impl_only: bool,
184 ) -> Result<Self, GraphError> {
185 let mut graph = CallGraph::new();
186
187 for (path, analysis) in &results {
189 for func in &analysis.functions {
190 graph
191 .definitions
192 .entry(func.name.clone())
193 .or_default()
194 .push((path.clone(), func.line));
195 graph
196 .function_types
197 .entry(func.name.clone())
198 .or_default()
199 .push((
200 path.clone(),
201 func.line,
202 func.parameters.clone(),
203 func.return_type.clone(),
204 ));
205 }
206 for class in &analysis.classes {
207 graph
208 .definitions
209 .entry(class.name.clone())
210 .or_default()
211 .push((path.clone(), class.line));
212 graph
213 .function_types
214 .entry(class.name.clone())
215 .or_default()
216 .push((path.clone(), class.line, vec![], None));
217 }
218 }
219
220 for (path, analysis) in &results {
222 for call in &analysis.calls {
223 let resolved_callee = graph.resolve_callee(
224 &call.callee,
225 path,
226 call.line,
227 call.arg_count,
228 &graph.definitions,
229 &graph.function_types,
230 );
231
232 graph
233 .callees
234 .entry(call.caller.clone())
235 .or_default()
236 .push(CallEdge {
237 path: path.clone(),
238 line: call.line,
239 neighbor_name: resolved_callee.clone(),
240 is_impl_trait: false,
241 });
242 graph
243 .callers
244 .entry(resolved_callee)
245 .or_default()
246 .push(CallEdge {
247 path: path.clone(),
248 line: call.line,
249 neighbor_name: call.caller.clone(),
250 is_impl_trait: false,
251 });
252 }
253 for reference in &analysis.references {
254 graph
255 .callers
256 .entry(reference.symbol.clone())
257 .or_default()
258 .push(CallEdge {
259 path: path.clone(),
260 line: reference.line,
261 neighbor_name: "<reference>".to_string(),
262 is_impl_trait: false,
263 });
264 }
265 }
266
267 for it in impl_traits {
271 graph
272 .callers
273 .entry(it.trait_name.clone())
274 .or_default()
275 .push(CallEdge {
276 path: it.path.clone(),
277 line: it.line,
278 neighbor_name: it.impl_type.clone(),
279 is_impl_trait: true,
280 });
281 }
282
283 if impl_only {
287 for edges in graph.callers.values_mut() {
288 edges.retain(|e| e.is_impl_trait);
289 }
290 }
291
292 let total_edges = graph.callees.values().map(|v| v.len()).sum::<usize>()
293 + graph.callers.values().map(|v| v.len()).sum::<usize>();
294 let file_count = results.len();
295
296 tracing::debug!(
297 definitions = graph.definitions.len(),
298 edges = total_edges,
299 files = file_count,
300 impl_only,
301 "graph built"
302 );
303
304 Ok(graph)
305 }
306
307 fn find_chains_bfs(
308 &self,
309 symbol: &str,
310 follow_depth: u32,
311 is_incoming: bool,
312 ) -> Result<Vec<InternalCallChain>, GraphError> {
313 let graph_map = if is_incoming {
314 &self.callers
315 } else {
316 &self.callees
317 };
318
319 if !self.definitions.contains_key(symbol) && !graph_map.contains_key(symbol) {
320 return Err(GraphError::SymbolNotFound {
321 symbol: symbol.to_string(),
322 hint: "Symbol resolved but not found in graph. The symbol may have no calls or definitions in the indexed files.".to_string(),
323 });
324 }
325
326 let mut chains = Vec::new();
327 let mut visited = HashSet::new();
328 let mut queue = VecDeque::new();
329 queue.push_back((symbol.to_string(), 0));
330 visited.insert(symbol.to_string());
331
332 while let Some((current, depth)) = queue.pop_front() {
333 if depth > follow_depth {
334 continue;
335 }
336
337 if let Some(neighbors) = graph_map.get(¤t) {
338 for edge in neighbors {
339 let path = &edge.path;
340 let line = edge.line;
341 let neighbor = &edge.neighbor_name;
342 let mut chain = vec![(current.clone(), path.clone(), line)];
343 let mut chain_node = neighbor.clone();
344 let mut chain_depth = depth;
345
346 while chain_depth < follow_depth {
347 if let Some(next_neighbors) = graph_map.get(&chain_node) {
348 if let Some(next_edge) = next_neighbors.first() {
349 if is_incoming {
350 chain.insert(
351 0,
352 (
353 chain_node.clone(),
354 next_edge.path.clone(),
355 next_edge.line,
356 ),
357 );
358 } else {
359 chain.push((
360 chain_node.clone(),
361 next_edge.path.clone(),
362 next_edge.line,
363 ));
364 }
365 chain_node = next_edge.neighbor_name.clone();
366 chain_depth += 1;
367 } else {
368 break;
369 }
370 } else {
371 break;
372 }
373 }
374
375 if is_incoming {
376 chain.insert(0, (neighbor.clone(), path.clone(), line));
377 } else {
378 chain.push((neighbor.clone(), path.clone(), line));
379 }
380 chains.push(InternalCallChain { chain });
381
382 if !visited.contains(neighbor) && depth < follow_depth {
383 visited.insert(neighbor.clone());
384 queue.push_back((neighbor.clone(), depth + 1));
385 }
386 }
387 }
388 }
389
390 Ok(chains)
391 }
392
393 #[instrument(skip(self))]
394 pub(crate) fn find_incoming_chains(
395 &self,
396 symbol: &str,
397 follow_depth: u32,
398 ) -> Result<Vec<InternalCallChain>, GraphError> {
399 self.find_chains_bfs(symbol, follow_depth, true)
400 }
401
402 #[instrument(skip(self))]
403 pub(crate) fn find_outgoing_chains(
404 &self,
405 symbol: &str,
406 follow_depth: u32,
407 ) -> Result<Vec<InternalCallChain>, GraphError> {
408 self.find_chains_bfs(symbol, follow_depth, false)
409 }
410}
411
412impl Default for CallGraph {
413 fn default() -> Self {
414 Self::new()
415 }
416}
417
418#[cfg(test)]
419mod tests {
420 use super::*;
421 use crate::types::{CallInfo, FunctionInfo};
422
423 fn make_analysis(
424 funcs: Vec<(&str, usize)>,
425 calls: Vec<(&str, &str, usize)>,
426 ) -> SemanticAnalysis {
427 SemanticAnalysis {
428 functions: funcs
429 .into_iter()
430 .map(|(n, l)| FunctionInfo {
431 name: n.to_string(),
432 line: l,
433 end_line: l + 5,
434 parameters: vec![],
435 return_type: None,
436 })
437 .collect(),
438 classes: vec![],
439 imports: vec![],
440 references: vec![],
441 call_frequency: Default::default(),
442 calls: calls
443 .into_iter()
444 .map(|(c, e, l)| CallInfo {
445 caller: c.to_string(),
446 callee: e.to_string(),
447 line: l,
448 column: 0,
449 arg_count: None,
450 })
451 .collect(),
452 impl_traits: vec![],
453 }
454 }
455
456 fn make_typed_analysis(
457 funcs: Vec<(&str, usize, Vec<String>, Option<&str>)>,
458 calls: Vec<(&str, &str, usize, Option<usize>)>,
459 ) -> SemanticAnalysis {
460 SemanticAnalysis {
461 functions: funcs
462 .into_iter()
463 .map(|(n, l, params, ret_type)| FunctionInfo {
464 name: n.to_string(),
465 line: l,
466 end_line: l + 5,
467 parameters: params,
468 return_type: ret_type.map(|s| s.to_string()),
469 })
470 .collect(),
471 classes: vec![],
472 imports: vec![],
473 references: vec![],
474 call_frequency: Default::default(),
475 calls: calls
476 .into_iter()
477 .map(|(c, e, l, arg_count)| CallInfo {
478 caller: c.to_string(),
479 callee: e.to_string(),
480 line: l,
481 column: 0,
482 arg_count,
483 })
484 .collect(),
485 impl_traits: vec![],
486 }
487 }
488
489 #[test]
490 fn test_graph_construction() {
491 let analysis = make_analysis(
492 vec![("main", 1), ("foo", 10), ("bar", 20)],
493 vec![("main", "foo", 2), ("foo", "bar", 15)],
494 );
495 let graph =
496 CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
497 .expect("Failed to build graph");
498 assert!(graph.definitions.contains_key("main"));
499 assert!(graph.definitions.contains_key("foo"));
500 assert_eq!(graph.callees["main"][0].neighbor_name, "foo");
501 assert_eq!(graph.callers["foo"][0].neighbor_name, "main");
502 }
503
504 #[test]
505 fn test_find_incoming_chains_depth_zero() {
506 let analysis = make_analysis(vec![("main", 1), ("foo", 10)], vec![("main", "foo", 2)]);
507 let graph =
508 CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
509 .expect("Failed to build graph");
510 assert!(
511 !graph
512 .find_incoming_chains("foo", 0)
513 .expect("Failed to find chains")
514 .is_empty()
515 );
516 }
517
518 #[test]
519 fn test_find_outgoing_chains_depth_zero() {
520 let analysis = make_analysis(vec![("main", 1), ("foo", 10)], vec![("main", "foo", 2)]);
521 let graph =
522 CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
523 .expect("Failed to build graph");
524 assert!(
525 !graph
526 .find_outgoing_chains("main", 0)
527 .expect("Failed to find chains")
528 .is_empty()
529 );
530 }
531
532 #[test]
533 fn test_symbol_not_found() {
534 assert!(
535 CallGraph::new()
536 .find_incoming_chains("nonexistent", 0)
537 .is_err()
538 );
539 }
540
541 #[test]
542 fn test_same_file_preference() {
543 let analysis_a = make_analysis(
547 vec![("main", 1), ("helper", 10)],
548 vec![("main", "helper", 5)],
549 );
550 let analysis_b = make_analysis(vec![("helper", 20)], vec![]);
551
552 let graph = CallGraph::build_from_results(
553 vec![
554 (PathBuf::from("a.rs"), analysis_a),
555 (PathBuf::from("b.rs"), analysis_b),
556 ],
557 &[],
558 false,
559 )
560 .expect("Failed to build graph");
561
562 assert!(graph.callees.contains_key("main"));
564 let main_callees = &graph.callees["main"];
565 assert_eq!(main_callees.len(), 1);
566 assert_eq!(main_callees[0].neighbor_name, "helper");
567
568 assert_eq!(main_callees[0].path, PathBuf::from("a.rs"));
570
571 assert!(graph.callers.contains_key("helper"));
573 let helper_callers = &graph.callers["helper"];
574 assert!(
575 helper_callers
576 .iter()
577 .any(|e| e.path == PathBuf::from("a.rs"))
578 );
579 }
580
581 #[test]
582 fn test_line_proximity() {
583 let analysis = make_analysis(
586 vec![("process", 10), ("process", 50)],
587 vec![("main", "process", 12)],
588 );
589
590 let graph =
591 CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
592 .expect("Failed to build graph");
593
594 assert!(graph.callees.contains_key("main"));
596 let main_callees = &graph.callees["main"];
597 assert_eq!(main_callees.len(), 1);
598 assert_eq!(main_callees[0].neighbor_name, "process");
599
600 assert!(graph.callers.contains_key("process"));
602 let process_callers = &graph.callers["process"];
603 assert!(
604 process_callers
605 .iter()
606 .any(|e| e.line == 12 && e.neighbor_name == "main")
607 );
608 }
609
610 #[test]
611 fn test_scope_prefix_stripping() {
612 let analysis = make_analysis(
615 vec![("method", 10)],
616 vec![
617 ("caller1", "self.method", 5),
618 ("caller2", "Type::method", 15),
619 ("caller3", "module::method", 25),
620 ],
621 );
622
623 let graph =
624 CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
625 .expect("Failed to build graph");
626
627 assert_eq!(graph.callees["caller1"][0].neighbor_name, "method");
629 assert_eq!(graph.callees["caller2"][0].neighbor_name, "method");
630 assert_eq!(graph.callees["caller3"][0].neighbor_name, "method");
631
632 assert!(graph.callers.contains_key("method"));
634 let method_callers = &graph.callers["method"];
635 assert_eq!(method_callers.len(), 3);
636 assert!(method_callers.iter().any(|e| e.neighbor_name == "caller1"));
637 assert!(method_callers.iter().any(|e| e.neighbor_name == "caller2"));
638 assert!(method_callers.iter().any(|e| e.neighbor_name == "caller3"));
639 }
640
641 #[test]
642 fn test_no_same_file_fallback() {
643 let analysis_a = make_analysis(vec![("main", 1)], vec![("main", "helper", 5)]);
646 let analysis_b = make_analysis(vec![("helper", 10)], vec![]);
647
648 let graph = CallGraph::build_from_results(
649 vec![
650 (PathBuf::from("a.rs"), analysis_a),
651 (PathBuf::from("b.rs"), analysis_b),
652 ],
653 &[],
654 false,
655 )
656 .expect("Failed to build graph");
657
658 assert!(graph.callees.contains_key("main"));
660 let main_callees = &graph.callees["main"];
661 assert_eq!(main_callees.len(), 1);
662 assert_eq!(main_callees[0].neighbor_name, "helper");
663
664 assert!(graph.callers.contains_key("helper"));
666 let helper_callers = &graph.callers["helper"];
667 assert!(
668 helper_callers
669 .iter()
670 .any(|e| e.path == PathBuf::from("a.rs") && e.neighbor_name == "main")
671 );
672 }
673
674 #[test]
675 fn test_type_disambiguation_by_params() {
676 let analysis = make_typed_analysis(
681 vec![
682 ("process", 10, vec!["(x: i32)".to_string()], Some("i32")),
683 (
684 "process",
685 12,
686 vec!["(x: i32, y: String)".to_string()],
687 Some("String"),
688 ),
689 ("main", 1, vec![], None),
690 ],
691 vec![("main", "process", 11, Some(2))],
692 );
693
694 let graph =
695 CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
696 .expect("Failed to build graph");
697
698 assert!(graph.callees.contains_key("main"));
700 let main_callees = &graph.callees["main"];
701 assert_eq!(main_callees.len(), 1);
702 assert_eq!(main_callees[0].neighbor_name, "process");
703
704 assert!(graph.callers.contains_key("process"));
706 let process_callers = &graph.callers["process"];
707 assert!(
708 process_callers
709 .iter()
710 .any(|e| e.line == 11 && e.neighbor_name == "main")
711 );
712 }
713
714 #[test]
715 fn test_type_disambiguation_fallback() {
716 let analysis = make_analysis(
720 vec![("process", 10), ("process", 50), ("main", 1)],
721 vec![("main", "process", 12)],
722 );
723
724 let graph =
725 CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
726 .expect("Failed to build graph");
727
728 assert!(graph.callees.contains_key("main"));
730 let main_callees = &graph.callees["main"];
731 assert_eq!(main_callees.len(), 1);
732 assert_eq!(main_callees[0].neighbor_name, "process");
733
734 assert!(graph.callers.contains_key("process"));
736 let process_callers = &graph.callers["process"];
737 assert!(
738 process_callers
739 .iter()
740 .any(|e| e.line == 12 && e.neighbor_name == "main")
741 );
742 }
743
744 #[test]
745 fn test_impl_only_filters_to_impl_sites() {
746 use crate::types::ImplTraitInfo;
748 let analysis = make_analysis(
749 vec![("write", 1), ("plain_fn", 20)],
750 vec![("plain_fn", "write", 22)],
751 );
752 let impl_traits = vec![ImplTraitInfo {
753 trait_name: "Write".to_string(),
754 impl_type: "WriterImpl".to_string(),
755 path: PathBuf::from("test.rs"),
756 line: 10,
757 }];
758
759 let graph = CallGraph::build_from_results(
761 vec![(PathBuf::from("test.rs"), analysis)],
762 &impl_traits,
763 true,
764 )
765 .expect("Failed to build graph");
766
767 let callers = graph
769 .callers
770 .get("Write")
771 .expect("Write must have impl caller");
772 assert_eq!(callers.len(), 1, "only impl-trait caller retained");
773 assert_eq!(callers[0].neighbor_name, "WriterImpl");
774 assert!(
775 callers[0].is_impl_trait,
776 "edge must be tagged is_impl_trait"
777 );
778
779 let write_callers = graph.callers.get("write").map(|v| v.len()).unwrap_or(0);
781 assert_eq!(
782 write_callers, 0,
783 "regular callers filtered when impl_only=true"
784 );
785 }
786
787 #[test]
788 fn test_impl_only_false_is_backward_compatible() {
789 use crate::types::ImplTraitInfo;
791 let analysis = make_analysis(
792 vec![("write", 1), ("WriterImpl", 10), ("plain_fn", 20)],
793 vec![("WriterImpl", "write", 12), ("plain_fn", "write", 22)],
794 );
795 let impl_traits = vec![ImplTraitInfo {
796 trait_name: "Write".to_string(),
797 impl_type: "WriterImpl".to_string(),
798 path: PathBuf::from("test.rs"),
799 line: 10,
800 }];
801
802 let graph = CallGraph::build_from_results(
804 vec![(PathBuf::from("test.rs"), analysis)],
805 &impl_traits,
806 false,
807 )
808 .expect("Failed to build graph");
809
810 let callers = graph.callers.get("write").expect("write must have callers");
812 assert_eq!(
813 callers.len(),
814 2,
815 "both call-site callers should be present when impl_only=false"
816 );
817
818 let write_impl_callers = graph
820 .callers
821 .get("Write")
822 .expect("Write must have impl caller");
823 assert_eq!(write_impl_callers.len(), 1);
824 assert!(write_impl_callers[0].is_impl_trait);
825 }
826
827 #[test]
828 fn test_impl_only_callees_unaffected() {
829 use crate::types::ImplTraitInfo;
831 let analysis = make_analysis(
832 vec![("write", 1), ("WriterImpl", 10)],
833 vec![("WriterImpl", "write", 12)],
834 );
835 let impl_traits = vec![ImplTraitInfo {
836 trait_name: "Write".to_string(),
837 impl_type: "WriterImpl".to_string(),
838 path: PathBuf::from("test.rs"),
839 line: 10,
840 }];
841
842 let graph = CallGraph::build_from_results(
843 vec![(PathBuf::from("test.rs"), analysis)],
844 &impl_traits,
845 true,
846 )
847 .expect("Failed to build graph");
848
849 let callees = graph
851 .callees
852 .get("WriterImpl")
853 .expect("WriterImpl must have callees");
854 assert_eq!(
855 callees.len(),
856 1,
857 "callees must not be filtered by impl_only"
858 );
859 assert_eq!(callees[0].neighbor_name, "write");
860 }
861
862 fn known(names: &[&str]) -> Vec<String> {
865 names.iter().map(|s| s.to_string()).collect()
866 }
867
868 #[test]
869 fn test_resolve_symbol_exact_match() {
870 let syms = known(&["parse_config", "ParseConfig", "PARSE_CONFIG"]);
871 let result = resolve_symbol(syms.iter(), "parse_config", &SymbolMatchMode::Exact);
872 assert_eq!(result.unwrap(), "parse_config");
873 }
874
875 #[test]
876 fn test_resolve_symbol_exact_no_match() {
877 let syms = known(&["ParseConfig"]);
878 let err = resolve_symbol(syms.iter(), "parse_config", &SymbolMatchMode::Exact).unwrap_err();
879 assert!(matches!(err, GraphError::SymbolNotFound { .. }));
880 }
881
882 #[test]
883 fn test_resolve_symbol_insensitive_match() {
884 let syms = known(&["ParseConfig", "other"]);
885 let result = resolve_symbol(syms.iter(), "parseconfig", &SymbolMatchMode::Insensitive);
886 assert_eq!(result.unwrap(), "ParseConfig");
887 }
888
889 #[test]
890 fn test_resolve_symbol_insensitive_no_match() {
891 let syms = known(&["unrelated"]);
892 let err =
893 resolve_symbol(syms.iter(), "parseconfig", &SymbolMatchMode::Insensitive).unwrap_err();
894 assert!(matches!(err, GraphError::SymbolNotFound { .. }));
895 }
896
897 #[test]
898 fn test_resolve_symbol_prefix_single() {
899 let syms = known(&["parse_config", "parse_args", "build"]);
900 let result = resolve_symbol(syms.iter(), "build", &SymbolMatchMode::Prefix);
901 assert_eq!(result.unwrap(), "build");
902 }
903
904 #[test]
905 fn test_resolve_symbol_prefix_multiple_candidates() {
906 let syms = known(&["parse_config", "parse_args", "build"]);
907 let err = resolve_symbol(syms.iter(), "parse", &SymbolMatchMode::Prefix).unwrap_err();
908 assert!(matches!(&err, GraphError::MultipleCandidates { .. }));
909 if let GraphError::MultipleCandidates { candidates, .. } = err {
910 assert_eq!(candidates.len(), 2);
911 }
912 }
913
914 #[test]
915 fn test_resolve_symbol_contains_single() {
916 let syms = known(&["parse_config", "build_artifact"]);
917 let result = resolve_symbol(syms.iter(), "config", &SymbolMatchMode::Contains);
918 assert_eq!(result.unwrap(), "parse_config");
919 }
920
921 #[test]
922 fn test_resolve_symbol_contains_no_match() {
923 let syms = known(&["parse_config", "build_artifact"]);
924 let err = resolve_symbol(syms.iter(), "deploy", &SymbolMatchMode::Contains).unwrap_err();
925 assert!(matches!(err, GraphError::SymbolNotFound { .. }));
926 }
927}