Skip to main content

code_analyze_mcp/
graph.rs

1//! Call graph construction and analysis.
2//!
3//! Builds caller and callee relationships from semantic analysis results.
4//! Implements type-aware function matching to disambiguate overloads and name collisions.
5
6use 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
12/// Type info for a function: (path, line, parameters, return_type)
13type 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
43/// Resolve a symbol name against the set of known symbols using the requested match mode.
44///
45/// Returns:
46/// - `Ok(name)` when exactly one symbol matches.
47/// - `Err(GraphError::SymbolNotFound)` when no symbol matches.
48/// - `Err(GraphError::MultipleCandidates)` when more than one symbol matches.
49pub 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
101/// Strip scope prefixes from a callee name.
102/// Handles patterns: 'self.method' -> 'method', 'Type::method' -> 'method', 'module::function' -> 'function'.
103/// If no prefix is found, returns the original name.
104fn 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/// Call graph storing callers, callees, and function definitions.
120#[derive(Debug, Clone)]
121pub struct CallGraph {
122    /// Callers map: function_name -> vec of CallEdge (one per call site).
123    pub callers: HashMap<String, Vec<CallEdge>>,
124    /// Callees map: function_name -> vec of CallEdge (one per call site).
125    pub callees: HashMap<String, Vec<CallEdge>>,
126    /// Definitions map: function_name -> vec of (file_path, line_number).
127    pub definitions: HashMap<String, Vec<(PathBuf, usize)>>,
128    /// Internal: maps function name to type info for type-aware disambiguation.
129    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    /// Count parameters in a parameter string.
143    /// Handles: "(x: i32, y: String)" -> 2, "(&self, x: i32)" -> 2, "()" -> 0, "(&self)" -> 1
144    /// Resolve a callee name using four strategies:
145    /// 1. Try the raw callee name first in definitions
146    /// 2. If not found, try the stripped name (via strip_scope_prefix)
147    /// 3. If multiple definitions exist, prefer same-file candidates
148    /// 4. Among same-file candidates, use type info as tiebreaker, then line proximity
149    /// 5. If no same-file candidates, use any definition (first one)
150    ///
151    /// Returns the resolved callee name (which may be the stripped version).
152    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        // Try raw callee name first
162        if let Some(_defs) = definitions.get(callee) {
163            return callee.to_string();
164        }
165
166        // Try stripped name
167        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        // No definition found; return the original callee
175        callee.to_string()
176    }
177
178    /// Build a call graph from semantic analysis results and trait implementation info.
179    #[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        // Build definitions and function_types maps first
188        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        // Process calls with resolved callee names
221        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        // Add explicit caller edges for each impl Trait for Type block.
268        // These represent the implementing type as a caller of the trait, enabling
269        // impl_only filtering to surface trait implementors rather than call sites.
270        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=true, retain only impl-trait caller edges across all nodes.
284        // Callees are never filtered. This ensures traversal and formatting are
285        // consistently restricted to impl-trait edges regardless of follow_depth.
286        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(&current) {
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        // Two files each define "helper". File a.rs has a call from "main" to "helper".
544        // Assert that the graph's callees for "main" point to "helper" and the callers
545        // for "helper" include an entry from a.rs (not b.rs).
546        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        // Check that main calls helper
563        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        // Check that the call is from a.rs (same file as main)
569        assert_eq!(main_callees[0].path, PathBuf::from("a.rs"));
570
571        // Check that helper has a caller from a.rs
572        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        // One file with "process" defined at line 10 and line 50, and a call at line 12.
584        // Assert resolution picks the definition at line 10 (closest).
585        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        // Check that main calls process
595        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        // Check that process has a caller from main at line 12
601        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        // One file defines "method" at line 10. Calls use "self.method", "Type::method".
613        // Assert these resolve to "method" in the graph.
614        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        // Check that all three callers have "method" as their callee
628        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        // Check that method has three callers
633        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        // File a.rs calls "helper" but "helper" is only defined in b.rs.
644        // Assert the call still resolves (graph has the edge).
645        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        // Check that main calls helper
659        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        // Check that helper has a caller from a.rs
665        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        // Two functions named 'process' in the same file with different parameter counts.
677        // process(x: i32) at line 10, process(x: i32, y: String) at line 12.
678        // Call from main at line 11 is equidistant from both (1 line away).
679        // Type matching should prefer the 2-param version since arg_count=2.
680        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        // Check that main calls process
699        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        // Check that process has a caller from main at line 11
705        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        // Two functions named 'process' with no type info (empty parameters, None return_type).
717        // Call from main at line 12 should resolve using line proximity (no regression).
718        // arg_count=None means type matching won't fire, fallback to line proximity.
719        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        // Check that main calls process
729        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        // Check that process has a caller from main
735        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        // Arrange: WriterImpl implements Write; plain_fn calls write directly.
747        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        // Act: build with impl_only=true
760        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        // Assert: trait "Write" has WriterImpl as an explicit impl-trait caller edge.
768        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        // Assert: regular call-site callers of "write" are filtered out by impl_only.
780        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        // Arrange: same setup, impl_only=false -- all callers returned.
790        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        // Act: build with impl_only=false
803        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        // Assert: both call-site callers preserved
811        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        // Assert: impl-trait edge is always present regardless of impl_only
819        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        // Arrange: WriterImpl calls write; impl_only=true should not remove callees.
830        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        // Assert: callees of WriterImpl are NOT filtered
850        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    // ---- resolve_symbol tests ----
863
864    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}