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
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 struct CallChain {
116 pub chain: Vec<(String, PathBuf, usize)>,
117}
118
119#[derive(Debug, Clone)]
121pub struct CallGraph {
122 pub callers: HashMap<String, Vec<(PathBuf, usize, String)>>,
124 pub callees: HashMap<String, Vec<(PathBuf, usize, String)>>,
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 count_parameters(params_str: &str) -> usize {
145 if params_str.is_empty() || params_str == "()" {
146 return 0;
147 }
148 let inner = params_str
150 .trim_start_matches('(')
151 .trim_end_matches(')')
152 .trim();
153 if inner.is_empty() {
154 return 0;
155 }
156 inner.split(',').count()
158 }
159
160 fn match_by_type(
164 &self,
165 candidates: &[FunctionTypeInfo],
166 expected_param_count: Option<usize>,
167 expected_return_type: Option<&str>,
168 ) -> Option<usize> {
169 if candidates.is_empty() {
170 return None;
171 }
172
173 if expected_param_count.is_none() && expected_return_type.is_none() {
175 return None;
176 }
177
178 let mut best_idx = 0;
179 let mut best_score = 0;
180
181 for (idx, (_path, _line, params, ret_type)) in candidates.iter().enumerate() {
182 let mut score = 0;
183
184 if let Some(expected_count) = expected_param_count
186 && !params.is_empty()
187 {
188 let actual_count = Self::count_parameters(¶ms[0]);
189 if actual_count == expected_count {
190 score += 2;
191 }
192 }
193
194 if let Some(expected_ret) = expected_return_type
196 && let Some(actual_ret) = ret_type
197 && actual_ret == expected_ret
198 {
199 score += 1;
200 }
201
202 if !params.is_empty() {
204 score += 1;
205 }
206 if ret_type.is_some() {
207 score += 1;
208 }
209
210 if score > best_score {
211 best_score = score;
212 best_idx = idx;
213 }
214 }
215
216 (best_score > 0).then_some(best_idx)
218 }
219
220 fn resolve_callee(
229 &self,
230 callee: &str,
231 call_file: &Path,
232 call_line: usize,
233 arg_count: Option<usize>,
234 definitions: &HashMap<String, Vec<(PathBuf, usize)>>,
235 function_types: &HashMap<String, Vec<FunctionTypeInfo>>,
236 ) -> String {
237 if let Some(defs) = definitions.get(callee) {
239 return self.pick_best_definition(
240 defs,
241 call_file,
242 call_line,
243 arg_count,
244 callee,
245 function_types,
246 );
247 }
248
249 let stripped = strip_scope_prefix(callee);
251 if stripped != callee
252 && let Some(defs) = definitions.get(stripped)
253 {
254 return self.pick_best_definition(
255 defs,
256 call_file,
257 call_line,
258 arg_count,
259 stripped,
260 function_types,
261 );
262 }
263
264 callee.to_string()
266 }
267
268 fn pick_best_definition(
270 &self,
271 defs: &[(PathBuf, usize)],
272 call_file: &Path,
273 call_line: usize,
274 arg_count: Option<usize>,
275 resolved_name: &str,
276 function_types: &HashMap<String, Vec<FunctionTypeInfo>>,
277 ) -> String {
278 let same_file_defs: Vec<_> = defs.iter().filter(|(path, _)| path == call_file).collect();
280
281 if !same_file_defs.is_empty() {
282 if let Some(type_info) = function_types.get(resolved_name) {
284 let same_file_types: Vec<_> = type_info
285 .iter()
286 .filter(|(path, _, _, _)| path == call_file)
287 .cloned()
288 .collect();
289
290 if !same_file_types.is_empty() && same_file_types.len() > 1 {
291 let mut proximity_groups: Vec<Vec<usize>> = vec![];
293 for (idx, (_, def_line, _, _)) in same_file_types.iter().enumerate() {
294 let mut placed = false;
295 for group in &mut proximity_groups {
296 if let Some((_, first_line, _, _)) = same_file_types.get(group[0])
297 && first_line.abs_diff(*def_line) <= 5
298 {
299 group.push(idx);
300 placed = true;
301 break;
302 }
303 }
304 if !placed {
305 proximity_groups.push(vec![idx]);
306 }
307 }
308
309 let closest_group = proximity_groups.iter().min_by_key(|group| {
311 group
312 .iter()
313 .map(|idx| {
314 if let Some((_, def_line, _, _)) = same_file_types.get(*idx) {
315 def_line.abs_diff(call_line)
316 } else {
317 usize::MAX
318 }
319 })
320 .min()
321 .unwrap_or(usize::MAX)
322 });
323
324 if let Some(group) = closest_group {
325 if group.len() > 1 {
327 let candidates: Vec<_> = group
329 .iter()
330 .filter_map(|idx| same_file_types.get(*idx).cloned())
331 .collect();
332 if let Some(_best_idx) =
334 self.match_by_type(&candidates, arg_count, None)
335 {
336 return resolved_name.to_string();
337 }
338 }
339 }
340 }
341 }
342
343 let _best = same_file_defs
345 .iter()
346 .min_by_key(|(_, def_line)| (*def_line).abs_diff(call_line));
347 return resolved_name.to_string();
348 }
349
350 resolved_name.to_string()
352 }
353
354 #[instrument(skip_all)]
355 pub fn build_from_results(
356 results: Vec<(PathBuf, SemanticAnalysis)>,
357 ) -> Result<Self, GraphError> {
358 let mut graph = CallGraph::new();
359
360 for (path, analysis) in &results {
362 for func in &analysis.functions {
363 graph
364 .definitions
365 .entry(func.name.clone())
366 .or_default()
367 .push((path.clone(), func.line));
368 graph
369 .function_types
370 .entry(func.name.clone())
371 .or_default()
372 .push((
373 path.clone(),
374 func.line,
375 func.parameters.clone(),
376 func.return_type.clone(),
377 ));
378 }
379 for class in &analysis.classes {
380 graph
381 .definitions
382 .entry(class.name.clone())
383 .or_default()
384 .push((path.clone(), class.line));
385 graph
386 .function_types
387 .entry(class.name.clone())
388 .or_default()
389 .push((path.clone(), class.line, vec![], None));
390 }
391 }
392
393 for (path, analysis) in &results {
395 for call in &analysis.calls {
396 let resolved_callee = graph.resolve_callee(
397 &call.callee,
398 path,
399 call.line,
400 call.arg_count,
401 &graph.definitions,
402 &graph.function_types,
403 );
404
405 graph.callees.entry(call.caller.clone()).or_default().push((
406 path.clone(),
407 call.line,
408 resolved_callee.clone(),
409 ));
410 graph.callers.entry(resolved_callee).or_default().push((
411 path.clone(),
412 call.line,
413 call.caller.clone(),
414 ));
415 }
416 for reference in &analysis.references {
417 graph
418 .callers
419 .entry(reference.symbol.clone())
420 .or_default()
421 .push((path.clone(), reference.line, "<reference>".to_string()));
422 }
423 }
424
425 let total_edges = graph.callees.values().map(|v| v.len()).sum::<usize>()
426 + graph.callers.values().map(|v| v.len()).sum::<usize>();
427 let file_count = results.len();
428
429 tracing::debug!(
430 definitions = graph.definitions.len(),
431 edges = total_edges,
432 files = file_count,
433 "graph built"
434 );
435
436 Ok(graph)
437 }
438
439 fn find_chains_bfs(
440 &self,
441 symbol: &str,
442 follow_depth: u32,
443 is_incoming: bool,
444 ) -> Result<Vec<CallChain>, GraphError> {
445 let graph_map = if is_incoming {
446 &self.callers
447 } else {
448 &self.callees
449 };
450
451 if !self.definitions.contains_key(symbol) && !graph_map.contains_key(symbol) {
452 return Err(GraphError::SymbolNotFound {
453 symbol: symbol.to_string(),
454 hint: "Symbol resolved but not found in graph. The symbol may have no calls or definitions in the indexed files.".to_string(),
455 });
456 }
457
458 let mut chains = Vec::new();
459 let mut visited = HashSet::new();
460 let mut queue = VecDeque::new();
461 queue.push_back((symbol.to_string(), 0));
462 visited.insert(symbol.to_string());
463
464 while let Some((current, depth)) = queue.pop_front() {
465 if depth > follow_depth {
466 continue;
467 }
468
469 if let Some(neighbors) = graph_map.get(¤t) {
470 for (path, line, neighbor) in neighbors {
471 let mut chain = vec![(current.clone(), path.clone(), *line)];
472 let mut chain_node = neighbor.clone();
473 let mut chain_depth = depth;
474
475 while chain_depth < follow_depth {
476 if let Some(next_neighbors) = graph_map.get(&chain_node) {
477 if let Some((p, l, n)) = next_neighbors.first() {
478 if is_incoming {
479 chain.insert(0, (chain_node.clone(), p.clone(), *l));
480 } else {
481 chain.push((chain_node.clone(), p.clone(), *l));
482 }
483 chain_node = n.clone();
484 chain_depth += 1;
485 } else {
486 break;
487 }
488 } else {
489 break;
490 }
491 }
492
493 if is_incoming {
494 chain.insert(0, (neighbor.clone(), path.clone(), *line));
495 } else {
496 chain.push((neighbor.clone(), path.clone(), *line));
497 }
498 chains.push(CallChain { chain });
499
500 if !visited.contains(neighbor) && depth < follow_depth {
501 visited.insert(neighbor.clone());
502 queue.push_back((neighbor.clone(), depth + 1));
503 }
504 }
505 }
506 }
507
508 Ok(chains)
509 }
510
511 #[instrument(skip(self))]
512 pub fn find_incoming_chains(
513 &self,
514 symbol: &str,
515 follow_depth: u32,
516 ) -> Result<Vec<CallChain>, GraphError> {
517 self.find_chains_bfs(symbol, follow_depth, true)
518 }
519
520 #[instrument(skip(self))]
521 pub fn find_outgoing_chains(
522 &self,
523 symbol: &str,
524 follow_depth: u32,
525 ) -> Result<Vec<CallChain>, GraphError> {
526 self.find_chains_bfs(symbol, follow_depth, false)
527 }
528}
529
530impl Default for CallGraph {
531 fn default() -> Self {
532 Self::new()
533 }
534}
535
536#[cfg(test)]
537mod tests {
538 use super::*;
539 use crate::types::{CallInfo, FunctionInfo};
540
541 fn make_analysis(
542 funcs: Vec<(&str, usize)>,
543 calls: Vec<(&str, &str, usize)>,
544 ) -> SemanticAnalysis {
545 SemanticAnalysis {
546 functions: funcs
547 .into_iter()
548 .map(|(n, l)| FunctionInfo {
549 name: n.to_string(),
550 line: l,
551 end_line: l + 5,
552 parameters: vec![],
553 return_type: None,
554 })
555 .collect(),
556 classes: vec![],
557 imports: vec![],
558 references: vec![],
559 call_frequency: Default::default(),
560 calls: calls
561 .into_iter()
562 .map(|(c, e, l)| CallInfo {
563 caller: c.to_string(),
564 callee: e.to_string(),
565 line: l,
566 column: 0,
567 arg_count: None,
568 })
569 .collect(),
570 assignments: vec![],
571 field_accesses: vec![],
572 }
573 }
574
575 fn make_typed_analysis(
576 funcs: Vec<(&str, usize, Vec<String>, Option<&str>)>,
577 calls: Vec<(&str, &str, usize, Option<usize>)>,
578 ) -> SemanticAnalysis {
579 SemanticAnalysis {
580 functions: funcs
581 .into_iter()
582 .map(|(n, l, params, ret_type)| FunctionInfo {
583 name: n.to_string(),
584 line: l,
585 end_line: l + 5,
586 parameters: params,
587 return_type: ret_type.map(|s| s.to_string()),
588 })
589 .collect(),
590 classes: vec![],
591 imports: vec![],
592 references: vec![],
593 call_frequency: Default::default(),
594 calls: calls
595 .into_iter()
596 .map(|(c, e, l, arg_count)| CallInfo {
597 caller: c.to_string(),
598 callee: e.to_string(),
599 line: l,
600 column: 0,
601 arg_count,
602 })
603 .collect(),
604 assignments: vec![],
605 field_accesses: vec![],
606 }
607 }
608
609 #[test]
610 fn test_graph_construction() {
611 let analysis = make_analysis(
612 vec![("main", 1), ("foo", 10), ("bar", 20)],
613 vec![("main", "foo", 2), ("foo", "bar", 15)],
614 );
615 let graph = CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)])
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].2, "foo");
620 assert_eq!(graph.callers["foo"][0].2, "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 = CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)])
627 .expect("Failed to build graph");
628 assert!(
629 !graph
630 .find_incoming_chains("foo", 0)
631 .expect("Failed to find chains")
632 .is_empty()
633 );
634 }
635
636 #[test]
637 fn test_find_outgoing_chains_depth_zero() {
638 let analysis = make_analysis(vec![("main", 1), ("foo", 10)], vec![("main", "foo", 2)]);
639 let graph = CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)])
640 .expect("Failed to build graph");
641 assert!(
642 !graph
643 .find_outgoing_chains("main", 0)
644 .expect("Failed to find chains")
645 .is_empty()
646 );
647 }
648
649 #[test]
650 fn test_symbol_not_found() {
651 assert!(
652 CallGraph::new()
653 .find_incoming_chains("nonexistent", 0)
654 .is_err()
655 );
656 }
657
658 #[test]
659 fn test_same_file_preference() {
660 let analysis_a = make_analysis(
664 vec![("main", 1), ("helper", 10)],
665 vec![("main", "helper", 5)],
666 );
667 let analysis_b = make_analysis(vec![("helper", 20)], vec![]);
668
669 let graph = CallGraph::build_from_results(vec![
670 (PathBuf::from("a.rs"), analysis_a),
671 (PathBuf::from("b.rs"), analysis_b),
672 ])
673 .expect("Failed to build graph");
674
675 assert!(graph.callees.contains_key("main"));
677 let main_callees = &graph.callees["main"];
678 assert_eq!(main_callees.len(), 1);
679 assert_eq!(main_callees[0].2, "helper");
680
681 assert_eq!(main_callees[0].0, PathBuf::from("a.rs"));
683
684 assert!(graph.callers.contains_key("helper"));
686 let helper_callers = &graph.callers["helper"];
687 assert!(
688 helper_callers
689 .iter()
690 .any(|(path, _, _)| path == &PathBuf::from("a.rs"))
691 );
692 }
693
694 #[test]
695 fn test_line_proximity() {
696 let analysis = make_analysis(
699 vec![("process", 10), ("process", 50)],
700 vec![("main", "process", 12)],
701 );
702
703 let graph = CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)])
704 .expect("Failed to build graph");
705
706 assert!(graph.callees.contains_key("main"));
708 let main_callees = &graph.callees["main"];
709 assert_eq!(main_callees.len(), 1);
710 assert_eq!(main_callees[0].2, "process");
711
712 assert!(graph.callers.contains_key("process"));
714 let process_callers = &graph.callers["process"];
715 assert!(
716 process_callers
717 .iter()
718 .any(|(_, line, caller)| *line == 12 && caller == "main")
719 );
720 }
721
722 #[test]
723 fn test_scope_prefix_stripping() {
724 let analysis = make_analysis(
727 vec![("method", 10)],
728 vec![
729 ("caller1", "self.method", 5),
730 ("caller2", "Type::method", 15),
731 ("caller3", "module::method", 25),
732 ],
733 );
734
735 let graph = CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)])
736 .expect("Failed to build graph");
737
738 assert_eq!(graph.callees["caller1"][0].2, "method");
740 assert_eq!(graph.callees["caller2"][0].2, "method");
741 assert_eq!(graph.callees["caller3"][0].2, "method");
742
743 assert!(graph.callers.contains_key("method"));
745 let method_callers = &graph.callers["method"];
746 assert_eq!(method_callers.len(), 3);
747 assert!(
748 method_callers
749 .iter()
750 .any(|(_, _, caller)| caller == "caller1")
751 );
752 assert!(
753 method_callers
754 .iter()
755 .any(|(_, _, caller)| caller == "caller2")
756 );
757 assert!(
758 method_callers
759 .iter()
760 .any(|(_, _, caller)| caller == "caller3")
761 );
762 }
763
764 #[test]
765 fn test_no_same_file_fallback() {
766 let analysis_a = make_analysis(vec![("main", 1)], vec![("main", "helper", 5)]);
769 let analysis_b = make_analysis(vec![("helper", 10)], vec![]);
770
771 let graph = CallGraph::build_from_results(vec![
772 (PathBuf::from("a.rs"), analysis_a),
773 (PathBuf::from("b.rs"), analysis_b),
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].2, "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(|(path, _, caller)| { path == &PathBuf::from("a.rs") && caller == "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 = CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)])
814 .expect("Failed to build graph");
815
816 assert!(graph.callees.contains_key("main"));
818 let main_callees = &graph.callees["main"];
819 assert_eq!(main_callees.len(), 1);
820 assert_eq!(main_callees[0].2, "process");
821
822 assert!(graph.callers.contains_key("process"));
824 let process_callers = &graph.callers["process"];
825 assert!(
826 process_callers
827 .iter()
828 .any(|(_, line, caller)| *line == 11 && caller == "main")
829 );
830 }
831
832 #[test]
833 fn test_type_disambiguation_fallback() {
834 let analysis = make_analysis(
838 vec![("process", 10), ("process", 50), ("main", 1)],
839 vec![("main", "process", 12)],
840 );
841
842 let graph = CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)])
843 .expect("Failed to build graph");
844
845 assert!(graph.callees.contains_key("main"));
847 let main_callees = &graph.callees["main"];
848 assert_eq!(main_callees.len(), 1);
849 assert_eq!(main_callees[0].2, "process");
850
851 assert!(graph.callers.contains_key("process"));
853 let process_callers = &graph.callers["process"];
854 assert!(
855 process_callers
856 .iter()
857 .any(|(_, line, caller)| *line == 12 && caller == "main")
858 );
859 }
860
861 fn known(names: &[&str]) -> Vec<String> {
864 names.iter().map(|s| s.to_string()).collect()
865 }
866
867 #[test]
868 fn test_resolve_symbol_exact_match() {
869 let syms = known(&["parse_config", "ParseConfig", "PARSE_CONFIG"]);
870 let result = resolve_symbol(syms.iter(), "parse_config", &SymbolMatchMode::Exact);
871 assert_eq!(result.unwrap(), "parse_config");
872 }
873
874 #[test]
875 fn test_resolve_symbol_exact_no_match() {
876 let syms = known(&["ParseConfig"]);
877 let err = resolve_symbol(syms.iter(), "parse_config", &SymbolMatchMode::Exact).unwrap_err();
878 assert!(matches!(err, GraphError::SymbolNotFound { .. }));
879 }
880
881 #[test]
882 fn test_resolve_symbol_insensitive_match() {
883 let syms = known(&["ParseConfig", "other"]);
884 let result = resolve_symbol(syms.iter(), "parseconfig", &SymbolMatchMode::Insensitive);
885 assert_eq!(result.unwrap(), "ParseConfig");
886 }
887
888 #[test]
889 fn test_resolve_symbol_insensitive_no_match() {
890 let syms = known(&["unrelated"]);
891 let err =
892 resolve_symbol(syms.iter(), "parseconfig", &SymbolMatchMode::Insensitive).unwrap_err();
893 assert!(matches!(err, GraphError::SymbolNotFound { .. }));
894 }
895
896 #[test]
897 fn test_resolve_symbol_prefix_single() {
898 let syms = known(&["parse_config", "parse_args", "build"]);
899 let result = resolve_symbol(syms.iter(), "build", &SymbolMatchMode::Prefix);
900 assert_eq!(result.unwrap(), "build");
901 }
902
903 #[test]
904 fn test_resolve_symbol_prefix_multiple_candidates() {
905 let syms = known(&["parse_config", "parse_args", "build"]);
906 let err = resolve_symbol(syms.iter(), "parse", &SymbolMatchMode::Prefix).unwrap_err();
907 assert!(matches!(&err, GraphError::MultipleCandidates { .. }));
908 if let GraphError::MultipleCandidates { candidates, .. } = err {
909 assert_eq!(candidates.len(), 2);
910 }
911 }
912
913 #[test]
914 fn test_resolve_symbol_contains_single() {
915 let syms = known(&["parse_config", "build_artifact"]);
916 let result = resolve_symbol(syms.iter(), "config", &SymbolMatchMode::Contains);
917 assert_eq!(result.unwrap(), "parse_config");
918 }
919
920 #[test]
921 fn test_resolve_symbol_contains_no_match() {
922 let syms = known(&["parse_config", "build_artifact"]);
923 let err = resolve_symbol(syms.iter(), "deploy", &SymbolMatchMode::Contains).unwrap_err();
924 assert!(matches!(err, GraphError::SymbolNotFound { .. }));
925 }
926}