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/// Resolve a symbol using the `lowercase_index` for O(1) case-insensitive lookup.
102/// For other modes, iterates the `lowercase_index` keys to avoid per-symbol allocations.
103impl CallGraph {
104    pub fn resolve_symbol_indexed(
105        &self,
106        query: &str,
107        mode: &SymbolMatchMode,
108    ) -> Result<String, GraphError> {
109        // Fast path for exact, case-sensitive lookups: O(1) contains_key checks with no
110        // intermediate allocations.
111        if matches!(mode, SymbolMatchMode::Exact) {
112            if self.definitions.contains_key(query)
113                || self.callers.contains_key(query)
114                || self.callees.contains_key(query)
115            {
116                return Ok(query.to_string());
117            }
118            return Err(GraphError::SymbolNotFound {
119                symbol: query.to_string(),
120                hint: "Try match_mode=insensitive for a case-insensitive search.".to_string(),
121            });
122        }
123
124        let query_lower = query.to_lowercase();
125        let mut matches: Vec<String> = {
126            match mode {
127                SymbolMatchMode::Insensitive => {
128                    // O(1) lookup using lowercase_index
129                    if let Some(originals) = self.lowercase_index.get(&query_lower) {
130                        if originals.len() > 1 {
131                            // Multiple originals map to the same lowercase key; report all.
132                            return Err(GraphError::MultipleCandidates {
133                                query: query.to_string(),
134                                candidates: originals.clone(),
135                            });
136                        }
137                        // Exactly one original maps to this lowercase key; return it.
138                        vec![originals[0].clone()]
139                    } else {
140                        vec![]
141                    }
142                }
143                SymbolMatchMode::Prefix => {
144                    // Use .iter() to avoid redundant hash lookup.
145                    self.lowercase_index
146                        .iter()
147                        .filter(|(k, _)| k.starts_with(&query_lower))
148                        .flat_map(|(_, v)| v.iter().cloned())
149                        .collect()
150                }
151                SymbolMatchMode::Contains => {
152                    // Use .iter() to avoid redundant hash lookup.
153                    self.lowercase_index
154                        .iter()
155                        .filter(|(k, _)| k.contains(&query_lower))
156                        .flat_map(|(_, v)| v.iter().cloned())
157                        .collect()
158                }
159                SymbolMatchMode::Exact => unreachable!("handled above"),
160            }
161        };
162        matches.sort();
163        matches.dedup();
164
165        debug!(
166            query,
167            mode = ?mode,
168            candidate_count = matches.len(),
169            "resolve_symbol_indexed"
170        );
171
172        match matches.len() {
173            1 => Ok(matches.into_iter().next().expect("len==1")),
174            0 => Err(GraphError::SymbolNotFound {
175                symbol: query.to_string(),
176                hint: "No symbols matched; try a shorter query or match_mode=contains.".to_string(),
177            }),
178            _ => Err(GraphError::MultipleCandidates {
179                query: query.to_string(),
180                candidates: matches,
181            }),
182        }
183    }
184}
185
186/// Strip scope prefixes from a callee name.
187/// Handles patterns: `self.method` -> `method`, `Type::method` -> `method`, `module::function` -> `function`.
188/// If no prefix is found, returns the original name.
189fn strip_scope_prefix(name: &str) -> &str {
190    if let Some(pos) = name.rfind("::") {
191        &name[pos + 2..]
192    } else if let Some(pos) = name.rfind('.') {
193        &name[pos + 1..]
194    } else {
195        name
196    }
197}
198
199#[derive(Debug, Clone)]
200pub(crate) struct InternalCallChain {
201    pub chain: Vec<(String, PathBuf, usize)>,
202}
203
204/// Call graph storing callers, callees, and function definitions.
205#[derive(Debug, Clone)]
206pub struct CallGraph {
207    /// Callers map: `function_name` -> vec of `CallEdge` (one per call site).
208    pub callers: HashMap<String, Vec<CallEdge>>,
209    /// Callees map: `function_name` -> vec of `CallEdge` (one per call site).
210    pub callees: HashMap<String, Vec<CallEdge>>,
211    /// Definitions map: `function_name` -> vec of (`file_path`, `line_number`).
212    pub definitions: HashMap<String, Vec<(PathBuf, usize)>>,
213    /// Internal: maps function name to type info for type-aware disambiguation.
214    function_types: HashMap<String, Vec<FunctionTypeInfo>>,
215    /// Index for O(1) case-insensitive symbol lookup: lowercased -> vec of originals.
216    lowercase_index: HashMap<String, Vec<String>>,
217}
218
219impl CallGraph {
220    #[must_use]
221    pub fn new() -> Self {
222        Self {
223            callers: HashMap::new(),
224            callees: HashMap::new(),
225            definitions: HashMap::new(),
226            function_types: HashMap::new(),
227            lowercase_index: HashMap::new(),
228        }
229    }
230
231    /// Resolve a callee name using two strategies:
232    /// 1. Try the raw callee name first in definitions; return it if found.
233    /// 2. Strip any scope prefix (e.g. `Foo::bar` → `bar`) and look up the stripped name; return it if found.
234    ///
235    /// If neither strategy finds a definition, returns the original callee unchanged.
236    ///
237    /// Returns the resolved callee name (which may be the stripped version).
238    fn resolve_callee(
239        callee: &str,
240        _call_file: &Path,
241        _call_line: usize,
242        _arg_count: Option<usize>,
243        definitions: &HashMap<String, Vec<(PathBuf, usize)>>,
244        _function_types: &HashMap<String, Vec<FunctionTypeInfo>>,
245    ) -> String {
246        // Try raw callee name first
247        if let Some(_defs) = definitions.get(callee) {
248            return callee.to_string();
249        }
250
251        // Try stripped name
252        let stripped = strip_scope_prefix(callee);
253        if stripped != callee
254            && let Some(_defs) = definitions.get(stripped)
255        {
256            return stripped.to_string();
257        }
258
259        // No definition found; return the original callee
260        callee.to_string()
261    }
262
263    /// Build a call graph from semantic analysis results and trait implementation info.
264    #[instrument(skip_all)]
265    #[allow(clippy::too_many_lines)]
266    // exhaustive graph construction pass; splitting into subfunctions harms readability
267    // public API; callers expect owned semantics
268    #[allow(clippy::needless_pass_by_value)]
269    pub fn build_from_results(
270        results: Vec<(PathBuf, SemanticAnalysis)>,
271        impl_traits: &[ImplTraitInfo],
272        impl_only: bool,
273    ) -> Result<Self, GraphError> {
274        let mut graph = CallGraph::new();
275
276        // Build definitions and function_types maps first
277        for (path, analysis) in &results {
278            for func in &analysis.functions {
279                graph
280                    .definitions
281                    .entry(func.name.clone())
282                    .or_default()
283                    .push((path.clone(), func.line));
284                graph
285                    .function_types
286                    .entry(func.name.clone())
287                    .or_default()
288                    .push((
289                        path.clone(),
290                        func.line,
291                        func.parameters.clone(),
292                        func.return_type.clone(),
293                    ));
294            }
295            for class in &analysis.classes {
296                graph
297                    .definitions
298                    .entry(class.name.clone())
299                    .or_default()
300                    .push((path.clone(), class.line));
301                graph
302                    .function_types
303                    .entry(class.name.clone())
304                    .or_default()
305                    .push((path.clone(), class.line, vec![], None));
306            }
307        }
308
309        // Process calls with resolved callee names
310        for (path, analysis) in &results {
311            for call in &analysis.calls {
312                let resolved_callee = Self::resolve_callee(
313                    &call.callee,
314                    path,
315                    call.line,
316                    call.arg_count,
317                    &graph.definitions,
318                    &graph.function_types,
319                );
320
321                graph
322                    .callees
323                    .entry(call.caller.clone())
324                    .or_default()
325                    .push(CallEdge {
326                        path: path.clone(),
327                        line: call.line,
328                        neighbor_name: resolved_callee.clone(),
329                        is_impl_trait: false,
330                    });
331                graph
332                    .callers
333                    .entry(resolved_callee)
334                    .or_default()
335                    .push(CallEdge {
336                        path: path.clone(),
337                        line: call.line,
338                        neighbor_name: call.caller.clone(),
339                        is_impl_trait: false,
340                    });
341            }
342            for reference in &analysis.references {
343                graph
344                    .callers
345                    .entry(reference.symbol.clone())
346                    .or_default()
347                    .push(CallEdge {
348                        path: path.clone(),
349                        line: reference.line,
350                        neighbor_name: "<reference>".to_string(),
351                        is_impl_trait: false,
352                    });
353            }
354        }
355
356        // Add explicit caller edges for each impl Trait for Type block.
357        // These represent the implementing type as a caller of the trait, enabling
358        // impl_only filtering to surface trait implementors rather than call sites.
359        for it in impl_traits {
360            graph
361                .callers
362                .entry(it.trait_name.clone())
363                .or_default()
364                .push(CallEdge {
365                    path: it.path.clone(),
366                    line: it.line,
367                    neighbor_name: it.impl_type.clone(),
368                    is_impl_trait: true,
369                });
370        }
371
372        // If impl_only=true, retain only impl-trait caller edges across all nodes.
373        // Callees are never filtered. This ensures traversal and formatting are
374        // consistently restricted to impl-trait edges regardless of follow_depth.
375        if impl_only {
376            for edges in graph.callers.values_mut() {
377                edges.retain(|e| e.is_impl_trait);
378            }
379        }
380
381        // Build lowercase_index for O(1) case-insensitive lookup.
382        // Union of all keys from definitions, callers, and callees.
383        // Group all originals per lowercase key; sort so min() is stable.
384        for key in graph
385            .definitions
386            .keys()
387            .chain(graph.callers.keys())
388            .chain(graph.callees.keys())
389        {
390            graph
391                .lowercase_index
392                .entry(key.to_lowercase())
393                .or_default()
394                .push(key.clone());
395        }
396        for originals in graph.lowercase_index.values_mut() {
397            originals.sort();
398            originals.dedup();
399        }
400
401        let total_edges = graph.callees.values().map(Vec::len).sum::<usize>()
402            + graph.callers.values().map(Vec::len).sum::<usize>();
403        let file_count = results.len();
404
405        tracing::debug!(
406            definitions = graph.definitions.len(),
407            edges = total_edges,
408            files = file_count,
409            impl_only,
410            "graph built"
411        );
412
413        Ok(graph)
414    }
415
416    fn find_chains_bfs(
417        &self,
418        symbol: &str,
419        follow_depth: u32,
420        is_incoming: bool,
421    ) -> Result<Vec<InternalCallChain>, GraphError> {
422        let graph_map = if is_incoming {
423            &self.callers
424        } else {
425            &self.callees
426        };
427
428        if !self.definitions.contains_key(symbol) && !graph_map.contains_key(symbol) {
429            return Err(GraphError::SymbolNotFound {
430                symbol: symbol.to_string(),
431                hint: "Symbol resolved but not found in graph. The symbol may have no calls or definitions in the indexed files.".to_string(),
432            });
433        }
434
435        let mut chains = Vec::new();
436        let mut visited = HashSet::new();
437        let mut queue = VecDeque::new();
438        queue.push_back((symbol.to_string(), 0));
439        visited.insert(symbol.to_string());
440
441        while let Some((current, depth)) = queue.pop_front() {
442            if depth > follow_depth {
443                continue;
444            }
445
446            if let Some(neighbors) = graph_map.get(&current) {
447                for edge in neighbors {
448                    let path = &edge.path;
449                    let line = edge.line;
450                    let neighbor = &edge.neighbor_name;
451                    // Pre-allocate capacity: chain holds at most follow_depth + 2 entries
452                    // (the BFS node, up to follow_depth intermediate hops, and the neighbor).
453                    // For incoming chains we accumulate in reverse BFS order (focus first, then
454                    // deeper callers) then call reverse() at the end so that:
455                    //   chain[0]    = immediate caller of focus (closest)
456                    //   chain.last() = focus symbol (the BFS start node at depth 0) or the
457                    //                  current BFS node for deeper depth levels.
458                    // For outgoing chains the order is already focus-first.
459                    let mut chain = {
460                        let mut v = Vec::with_capacity(follow_depth as usize + 2);
461                        v.push((current.clone(), path.clone(), line));
462                        v
463                    };
464                    let mut chain_node = neighbor.clone();
465                    let mut chain_depth = depth;
466
467                    while chain_depth < follow_depth {
468                        if let Some(next_neighbors) = graph_map.get(&chain_node) {
469                            if let Some(next_edge) = next_neighbors.first() {
470                                // Advance to the next (deeper) caller before pushing, so that
471                                // for incoming chains the element pushed is the deeper ancestor
472                                // (not chain_node itself, which was already recorded or is the
473                                // immediate neighbor pushed after this loop).
474                                chain_node = next_edge.neighbor_name.clone();
475                                chain.push((
476                                    chain_node.clone(),
477                                    next_edge.path.clone(),
478                                    next_edge.line,
479                                ));
480                                chain_depth += 1;
481                            } else {
482                                break;
483                            }
484                        } else {
485                            break;
486                        }
487                    }
488
489                    if is_incoming {
490                        // Add the immediate neighbor (closest to focus) at the end,
491                        // then reverse so chain[0] = immediate neighbor.
492                        chain.push((neighbor.clone(), path.clone(), line));
493                        chain.reverse();
494                    } else {
495                        chain.push((neighbor.clone(), path.clone(), line));
496                    }
497
498                    debug_assert!(
499                        chain.len() <= follow_depth as usize + 2,
500                        "find_chains_bfs: chain length {} exceeds bound {}",
501                        chain.len(),
502                        follow_depth + 2
503                    );
504
505                    chains.push(InternalCallChain { chain });
506
507                    if !visited.contains(neighbor) && depth < follow_depth {
508                        visited.insert(neighbor.clone());
509                        queue.push_back((neighbor.clone(), depth + 1));
510                    }
511                }
512            }
513        }
514
515        Ok(chains)
516    }
517
518    #[instrument(skip(self))]
519    pub(crate) fn find_incoming_chains(
520        &self,
521        symbol: &str,
522        follow_depth: u32,
523    ) -> Result<Vec<InternalCallChain>, GraphError> {
524        self.find_chains_bfs(symbol, follow_depth, true)
525    }
526
527    #[instrument(skip(self))]
528    pub(crate) fn find_outgoing_chains(
529        &self,
530        symbol: &str,
531        follow_depth: u32,
532    ) -> Result<Vec<InternalCallChain>, GraphError> {
533        self.find_chains_bfs(symbol, follow_depth, false)
534    }
535}
536
537impl Default for CallGraph {
538    fn default() -> Self {
539        Self::new()
540    }
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546    use crate::types::{CallInfo, FunctionInfo};
547
548    fn make_analysis(
549        funcs: Vec<(&str, usize)>,
550        calls: Vec<(&str, &str, usize)>,
551    ) -> SemanticAnalysis {
552        SemanticAnalysis {
553            functions: funcs
554                .into_iter()
555                .map(|(n, l)| FunctionInfo {
556                    name: n.to_string(),
557                    line: l,
558                    end_line: l + 5,
559                    parameters: vec![],
560                    return_type: None,
561                })
562                .collect(),
563            classes: vec![],
564            imports: vec![],
565            references: vec![],
566            call_frequency: Default::default(),
567            calls: calls
568                .into_iter()
569                .map(|(c, e, l)| CallInfo {
570                    caller: c.to_string(),
571                    callee: e.to_string(),
572                    line: l,
573                    column: 0,
574                    arg_count: None,
575                })
576                .collect(),
577            impl_traits: vec![],
578        }
579    }
580
581    fn make_typed_analysis(
582        funcs: Vec<(&str, usize, Vec<String>, Option<&str>)>,
583        calls: Vec<(&str, &str, usize, Option<usize>)>,
584    ) -> SemanticAnalysis {
585        SemanticAnalysis {
586            functions: funcs
587                .into_iter()
588                .map(|(n, l, params, ret_type)| FunctionInfo {
589                    name: n.to_string(),
590                    line: l,
591                    end_line: l + 5,
592                    parameters: params,
593                    return_type: ret_type.map(|s| s.to_string()),
594                })
595                .collect(),
596            classes: vec![],
597            imports: vec![],
598            references: vec![],
599            call_frequency: Default::default(),
600            calls: calls
601                .into_iter()
602                .map(|(c, e, l, arg_count)| CallInfo {
603                    caller: c.to_string(),
604                    callee: e.to_string(),
605                    line: l,
606                    column: 0,
607                    arg_count,
608                })
609                .collect(),
610            impl_traits: vec![],
611        }
612    }
613
614    #[test]
615    fn test_graph_construction() {
616        let analysis = make_analysis(
617            vec![("main", 1), ("foo", 10), ("bar", 20)],
618            vec![("main", "foo", 2), ("foo", "bar", 15)],
619        );
620        let graph =
621            CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
622                .expect("Failed to build graph");
623        assert!(graph.definitions.contains_key("main"));
624        assert!(graph.definitions.contains_key("foo"));
625        assert_eq!(graph.callees["main"][0].neighbor_name, "foo");
626        assert_eq!(graph.callers["foo"][0].neighbor_name, "main");
627    }
628
629    #[test]
630    fn test_find_incoming_chains_depth_zero() {
631        let analysis = make_analysis(vec![("main", 1), ("foo", 10)], vec![("main", "foo", 2)]);
632        let graph =
633            CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
634                .expect("Failed to build graph");
635        assert!(
636            !graph
637                .find_incoming_chains("foo", 0)
638                .expect("Failed to find chains")
639                .is_empty()
640        );
641    }
642
643    #[test]
644    fn test_find_outgoing_chains_depth_zero() {
645        let analysis = make_analysis(vec![("main", 1), ("foo", 10)], vec![("main", "foo", 2)]);
646        let graph =
647            CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
648                .expect("Failed to build graph");
649        assert!(
650            !graph
651                .find_outgoing_chains("main", 0)
652                .expect("Failed to find chains")
653                .is_empty()
654        );
655    }
656
657    #[test]
658    fn test_symbol_not_found() {
659        assert!(
660            CallGraph::new()
661                .find_incoming_chains("nonexistent", 0)
662                .is_err()
663        );
664    }
665
666    #[test]
667    fn test_same_file_preference() {
668        // Two files each define "helper". File a.rs has a call from "main" to "helper".
669        // Assert that the graph's callees for "main" point to "helper" and the callers
670        // for "helper" include an entry from a.rs (not b.rs).
671        let analysis_a = make_analysis(
672            vec![("main", 1), ("helper", 10)],
673            vec![("main", "helper", 5)],
674        );
675        let analysis_b = make_analysis(vec![("helper", 20)], vec![]);
676
677        let graph = CallGraph::build_from_results(
678            vec![
679                (PathBuf::from("a.rs"), analysis_a),
680                (PathBuf::from("b.rs"), analysis_b),
681            ],
682            &[],
683            false,
684        )
685        .expect("Failed to build graph");
686
687        // Check that main calls helper
688        assert!(graph.callees.contains_key("main"));
689        let main_callees = &graph.callees["main"];
690        assert_eq!(main_callees.len(), 1);
691        assert_eq!(main_callees[0].neighbor_name, "helper");
692
693        // Check that the call is from a.rs (same file as main)
694        assert_eq!(main_callees[0].path, PathBuf::from("a.rs"));
695
696        // Check that helper has a caller from a.rs
697        assert!(graph.callers.contains_key("helper"));
698        let helper_callers = &graph.callers["helper"];
699        assert!(
700            helper_callers
701                .iter()
702                .any(|e| e.path == PathBuf::from("a.rs"))
703        );
704    }
705
706    #[test]
707    fn test_line_proximity() {
708        // One file with "process" defined at line 10 and line 50, and a call at line 12.
709        // Assert resolution picks the definition at line 10 (closest).
710        let analysis = make_analysis(
711            vec![("process", 10), ("process", 50)],
712            vec![("main", "process", 12)],
713        );
714
715        let graph =
716            CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
717                .expect("Failed to build graph");
718
719        // Check that main calls process
720        assert!(graph.callees.contains_key("main"));
721        let main_callees = &graph.callees["main"];
722        assert_eq!(main_callees.len(), 1);
723        assert_eq!(main_callees[0].neighbor_name, "process");
724
725        // Check that process has a caller from main at line 12
726        assert!(graph.callers.contains_key("process"));
727        let process_callers = &graph.callers["process"];
728        assert!(
729            process_callers
730                .iter()
731                .any(|e| e.line == 12 && e.neighbor_name == "main")
732        );
733    }
734
735    #[test]
736    fn test_scope_prefix_stripping() {
737        // One file defines "method" at line 10. Calls use "self.method", "Type::method".
738        // Assert these resolve to "method" in the graph.
739        let analysis = make_analysis(
740            vec![("method", 10)],
741            vec![
742                ("caller1", "self.method", 5),
743                ("caller2", "Type::method", 15),
744                ("caller3", "module::method", 25),
745            ],
746        );
747
748        let graph =
749            CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
750                .expect("Failed to build graph");
751
752        // Check that all three callers have "method" as their callee
753        assert_eq!(graph.callees["caller1"][0].neighbor_name, "method");
754        assert_eq!(graph.callees["caller2"][0].neighbor_name, "method");
755        assert_eq!(graph.callees["caller3"][0].neighbor_name, "method");
756
757        // Check that method has three callers
758        assert!(graph.callers.contains_key("method"));
759        let method_callers = &graph.callers["method"];
760        assert_eq!(method_callers.len(), 3);
761        assert!(method_callers.iter().any(|e| e.neighbor_name == "caller1"));
762        assert!(method_callers.iter().any(|e| e.neighbor_name == "caller2"));
763        assert!(method_callers.iter().any(|e| e.neighbor_name == "caller3"));
764    }
765
766    #[test]
767    fn test_no_same_file_fallback() {
768        // File a.rs calls "helper" but "helper" is only defined in b.rs.
769        // Assert the call still resolves (graph has the edge).
770        let analysis_a = make_analysis(vec![("main", 1)], vec![("main", "helper", 5)]);
771        let analysis_b = make_analysis(vec![("helper", 10)], vec![]);
772
773        let graph = CallGraph::build_from_results(
774            vec![
775                (PathBuf::from("a.rs"), analysis_a),
776                (PathBuf::from("b.rs"), analysis_b),
777            ],
778            &[],
779            false,
780        )
781        .expect("Failed to build graph");
782
783        // Check that main calls helper
784        assert!(graph.callees.contains_key("main"));
785        let main_callees = &graph.callees["main"];
786        assert_eq!(main_callees.len(), 1);
787        assert_eq!(main_callees[0].neighbor_name, "helper");
788
789        // Check that helper has a caller from a.rs
790        assert!(graph.callers.contains_key("helper"));
791        let helper_callers = &graph.callers["helper"];
792        assert!(
793            helper_callers
794                .iter()
795                .any(|e| e.path == PathBuf::from("a.rs") && e.neighbor_name == "main")
796        );
797    }
798
799    #[test]
800    fn test_type_disambiguation_by_params() {
801        // Two functions named 'process' in the same file with different parameter counts.
802        // process(x: i32) at line 10, process(x: i32, y: String) at line 12.
803        // Call from main at line 11 is equidistant from both (1 line away).
804        // Type matching should prefer the 2-param version since arg_count=2.
805        let analysis = make_typed_analysis(
806            vec![
807                ("process", 10, vec!["(x: i32)".to_string()], Some("i32")),
808                (
809                    "process",
810                    12,
811                    vec!["(x: i32, y: String)".to_string()],
812                    Some("String"),
813                ),
814                ("main", 1, vec![], None),
815            ],
816            vec![("main", "process", 11, Some(2))],
817        );
818
819        let graph =
820            CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
821                .expect("Failed to build graph");
822
823        // Check that main calls process
824        assert!(graph.callees.contains_key("main"));
825        let main_callees = &graph.callees["main"];
826        assert_eq!(main_callees.len(), 1);
827        assert_eq!(main_callees[0].neighbor_name, "process");
828
829        // Check that process has a caller from main at line 11
830        assert!(graph.callers.contains_key("process"));
831        let process_callers = &graph.callers["process"];
832        assert!(
833            process_callers
834                .iter()
835                .any(|e| e.line == 11 && e.neighbor_name == "main")
836        );
837    }
838
839    #[test]
840    fn test_type_disambiguation_fallback() {
841        // Two functions named 'process' with no type info (empty parameters, None return_type).
842        // Call from main at line 12 should resolve using line proximity (no regression).
843        // arg_count=None means type matching won't fire, fallback to line proximity.
844        let analysis = make_analysis(
845            vec![("process", 10), ("process", 50), ("main", 1)],
846            vec![("main", "process", 12)],
847        );
848
849        let graph =
850            CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
851                .expect("Failed to build graph");
852
853        // Check that main calls process
854        assert!(graph.callees.contains_key("main"));
855        let main_callees = &graph.callees["main"];
856        assert_eq!(main_callees.len(), 1);
857        assert_eq!(main_callees[0].neighbor_name, "process");
858
859        // Check that process has a caller from main
860        assert!(graph.callers.contains_key("process"));
861        let process_callers = &graph.callers["process"];
862        assert!(
863            process_callers
864                .iter()
865                .any(|e| e.line == 12 && e.neighbor_name == "main")
866        );
867    }
868
869    #[test]
870    fn test_impl_only_filters_to_impl_sites() {
871        // Arrange: WriterImpl implements Write; plain_fn calls write directly.
872        use crate::types::ImplTraitInfo;
873        let analysis = make_analysis(
874            vec![("write", 1), ("plain_fn", 20)],
875            vec![("plain_fn", "write", 22)],
876        );
877        let impl_traits = vec![ImplTraitInfo {
878            trait_name: "Write".to_string(),
879            impl_type: "WriterImpl".to_string(),
880            path: PathBuf::from("test.rs"),
881            line: 10,
882        }];
883
884        // Act: build with impl_only=true
885        let graph = CallGraph::build_from_results(
886            vec![(PathBuf::from("test.rs"), analysis)],
887            &impl_traits,
888            true,
889        )
890        .expect("Failed to build graph");
891
892        // Assert: trait "Write" has WriterImpl as an explicit impl-trait caller edge.
893        let callers = graph
894            .callers
895            .get("Write")
896            .expect("Write must have impl caller");
897        assert_eq!(callers.len(), 1, "only impl-trait caller retained");
898        assert_eq!(callers[0].neighbor_name, "WriterImpl");
899        assert!(
900            callers[0].is_impl_trait,
901            "edge must be tagged is_impl_trait"
902        );
903
904        // Assert: regular call-site callers of "write" are filtered out by impl_only.
905        let write_callers = graph.callers.get("write").map(|v| v.len()).unwrap_or(0);
906        assert_eq!(
907            write_callers, 0,
908            "regular callers filtered when impl_only=true"
909        );
910    }
911
912    #[test]
913    fn test_impl_only_false_is_backward_compatible() {
914        // Arrange: same setup, impl_only=false -- all callers returned.
915        use crate::types::ImplTraitInfo;
916        let analysis = make_analysis(
917            vec![("write", 1), ("WriterImpl", 10), ("plain_fn", 20)],
918            vec![("WriterImpl", "write", 12), ("plain_fn", "write", 22)],
919        );
920        let impl_traits = vec![ImplTraitInfo {
921            trait_name: "Write".to_string(),
922            impl_type: "WriterImpl".to_string(),
923            path: PathBuf::from("test.rs"),
924            line: 10,
925        }];
926
927        // Act: build with impl_only=false
928        let graph = CallGraph::build_from_results(
929            vec![(PathBuf::from("test.rs"), analysis)],
930            &impl_traits,
931            false,
932        )
933        .expect("Failed to build graph");
934
935        // Assert: both call-site callers preserved
936        let callers = graph.callers.get("write").expect("write must have callers");
937        assert_eq!(
938            callers.len(),
939            2,
940            "both call-site callers should be present when impl_only=false"
941        );
942
943        // Assert: impl-trait edge is always present regardless of impl_only
944        let write_impl_callers = graph
945            .callers
946            .get("Write")
947            .expect("Write must have impl caller");
948        assert_eq!(write_impl_callers.len(), 1);
949        assert!(write_impl_callers[0].is_impl_trait);
950    }
951
952    #[test]
953    fn test_impl_only_callees_unaffected() {
954        // Arrange: WriterImpl calls write; impl_only=true should not remove callees.
955        use crate::types::ImplTraitInfo;
956        let analysis = make_analysis(
957            vec![("write", 1), ("WriterImpl", 10)],
958            vec![("WriterImpl", "write", 12)],
959        );
960        let impl_traits = vec![ImplTraitInfo {
961            trait_name: "Write".to_string(),
962            impl_type: "WriterImpl".to_string(),
963            path: PathBuf::from("test.rs"),
964            line: 10,
965        }];
966
967        let graph = CallGraph::build_from_results(
968            vec![(PathBuf::from("test.rs"), analysis)],
969            &impl_traits,
970            true,
971        )
972        .expect("Failed to build graph");
973
974        // Assert: callees of WriterImpl are NOT filtered
975        let callees = graph
976            .callees
977            .get("WriterImpl")
978            .expect("WriterImpl must have callees");
979        assert_eq!(
980            callees.len(),
981            1,
982            "callees must not be filtered by impl_only"
983        );
984        assert_eq!(callees[0].neighbor_name, "write");
985    }
986
987    // ---- resolve_symbol tests ----
988
989    fn known(names: &[&str]) -> Vec<String> {
990        names.iter().map(|s| s.to_string()).collect()
991    }
992
993    #[test]
994    fn test_resolve_symbol_exact_match() {
995        let syms = known(&["parse_config", "ParseConfig", "PARSE_CONFIG"]);
996        let result = resolve_symbol(syms.iter(), "parse_config", &SymbolMatchMode::Exact);
997        assert_eq!(result.unwrap(), "parse_config");
998    }
999
1000    #[test]
1001    fn test_resolve_symbol_exact_no_match() {
1002        let syms = known(&["ParseConfig"]);
1003        let err = resolve_symbol(syms.iter(), "parse_config", &SymbolMatchMode::Exact).unwrap_err();
1004        assert!(matches!(err, GraphError::SymbolNotFound { .. }));
1005    }
1006
1007    #[test]
1008    fn test_resolve_symbol_insensitive_match() {
1009        let syms = known(&["ParseConfig", "other"]);
1010        let result = resolve_symbol(syms.iter(), "parseconfig", &SymbolMatchMode::Insensitive);
1011        assert_eq!(result.unwrap(), "ParseConfig");
1012    }
1013
1014    #[test]
1015    fn test_resolve_symbol_insensitive_no_match() {
1016        let syms = known(&["unrelated"]);
1017        let err =
1018            resolve_symbol(syms.iter(), "parseconfig", &SymbolMatchMode::Insensitive).unwrap_err();
1019        assert!(matches!(err, GraphError::SymbolNotFound { .. }));
1020    }
1021
1022    #[test]
1023    fn test_resolve_symbol_prefix_single() {
1024        let syms = known(&["parse_config", "parse_args", "build"]);
1025        let result = resolve_symbol(syms.iter(), "build", &SymbolMatchMode::Prefix);
1026        assert_eq!(result.unwrap(), "build");
1027    }
1028
1029    #[test]
1030    fn test_resolve_symbol_prefix_multiple_candidates() {
1031        let syms = known(&["parse_config", "parse_args", "build"]);
1032        let err = resolve_symbol(syms.iter(), "parse", &SymbolMatchMode::Prefix).unwrap_err();
1033        assert!(matches!(&err, GraphError::MultipleCandidates { .. }));
1034        if let GraphError::MultipleCandidates { candidates, .. } = err {
1035            assert_eq!(candidates.len(), 2);
1036        }
1037    }
1038
1039    #[test]
1040    fn test_resolve_symbol_contains_single() {
1041        let syms = known(&["parse_config", "build_artifact"]);
1042        let result = resolve_symbol(syms.iter(), "config", &SymbolMatchMode::Contains);
1043        assert_eq!(result.unwrap(), "parse_config");
1044    }
1045
1046    #[test]
1047    fn test_resolve_symbol_contains_no_match() {
1048        let syms = known(&["parse_config", "build_artifact"]);
1049        let err = resolve_symbol(syms.iter(), "deploy", &SymbolMatchMode::Contains).unwrap_err();
1050        assert!(matches!(err, GraphError::SymbolNotFound { .. }));
1051    }
1052
1053    #[test]
1054    fn test_incoming_chain_order_two_hops() {
1055        // Graph: A calls B calls C.  Focus = C, follow_depth = 2.
1056        //
1057        // Expected chains after reverse():
1058        //   depth-0 chain: [B, A, C]  -- immediate caller first, then outermost, then focus
1059        //   depth-1 chain: [A, B]     -- A calls B
1060        //
1061        // This test pins the ordering so that a missing reverse() or an off-by-one in the
1062        // inner-loop push would be caught: chain[1] must be "A" (outermost), not "B" again.
1063        let analysis = make_analysis(
1064            vec![("A", 1), ("B", 10), ("C", 20)],
1065            vec![("A", "B", 2), ("B", "C", 15)],
1066        );
1067        let graph =
1068            CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
1069                .expect("Failed to build graph");
1070
1071        let chains = graph
1072            .find_incoming_chains("C", 2)
1073            .expect("Failed to find incoming chains");
1074
1075        assert!(
1076            !chains.is_empty(),
1077            "Expected at least one incoming chain for C"
1078        );
1079
1080        // The 2-hop chain has 3 elements: [immediate_caller, outermost_caller, focus].
1081        let chain = chains
1082            .iter()
1083            .find(|c| c.chain.len() == 3)
1084            .expect("Expected a 3-element chain");
1085
1086        assert_eq!(
1087            chain.chain[0].0, "B",
1088            "chain[0] should be immediate caller B, got {}",
1089            chain.chain[0].0
1090        );
1091        assert_eq!(
1092            chain.chain[1].0, "A",
1093            "chain[1] should be outermost caller A, got {}",
1094            chain.chain[1].0
1095        );
1096        assert_eq!(
1097            chain.chain[2].0, "C",
1098            "chain[2] should be focus node C, got {}",
1099            chain.chain[2].0
1100        );
1101    }
1102
1103    // ---- resolve_symbol_indexed tests ----
1104
1105    #[test]
1106    fn test_insensitive_resolve_via_index() {
1107        // Arrange: build a CallGraph with known symbols
1108        let analysis = make_analysis(
1109            vec![("ParseConfig", 1), ("parse_args", 5)],
1110            vec![("ParseConfig", "parse_args", 10)],
1111        );
1112        let graph =
1113            CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
1114                .expect("Failed to build graph");
1115
1116        // Act: resolve using insensitive mode via the indexed method
1117        let result = graph
1118            .resolve_symbol_indexed("parseconfig", &SymbolMatchMode::Insensitive)
1119            .expect("Should resolve ParseConfig");
1120
1121        // Assert: O(1) lookup via lowercase_index returns the original symbol
1122        assert_eq!(result, "ParseConfig");
1123    }
1124
1125    #[test]
1126    fn test_prefix_resolve_via_index() {
1127        // Arrange: build a CallGraph with multiple symbols matching a prefix
1128        let analysis = make_analysis(
1129            vec![("parse_config", 1), ("parse_args", 5), ("build", 10)],
1130            vec![],
1131        );
1132        let graph =
1133            CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
1134                .expect("Failed to build graph");
1135
1136        // Act: resolve using prefix mode via the indexed method
1137        let err = graph
1138            .resolve_symbol_indexed("parse", &SymbolMatchMode::Prefix)
1139            .unwrap_err();
1140
1141        // Assert: multiple candidates found
1142        assert!(matches!(&err, GraphError::MultipleCandidates { .. }));
1143        if let GraphError::MultipleCandidates { candidates, .. } = err {
1144            assert_eq!(candidates.len(), 2);
1145        }
1146    }
1147
1148    #[test]
1149    fn test_insensitive_case_collision_returns_multiple_candidates() {
1150        // Arrange: two symbols that differ only by case map to the same lowercase key
1151        let analysis = make_analysis(vec![("Foo", 1), ("foo", 5)], vec![("Foo", "foo", 10)]);
1152        let graph =
1153            CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
1154                .expect("Failed to build graph");
1155
1156        // Act: insensitive lookup for "foo" hits both Foo and foo
1157        let err = graph
1158            .resolve_symbol_indexed("foo", &SymbolMatchMode::Insensitive)
1159            .unwrap_err();
1160
1161        // Assert: MultipleCandidates returned for case collision
1162        assert!(matches!(&err, GraphError::MultipleCandidates { .. }));
1163        if let GraphError::MultipleCandidates { candidates, .. } = err {
1164            assert_eq!(candidates.len(), 2);
1165        }
1166    }
1167
1168    #[test]
1169    fn test_contains_resolve_via_index() {
1170        // Arrange: symbols where two match the query substring; one does not
1171        let analysis = make_analysis(
1172            vec![("parse_config", 1), ("build_config", 5), ("run", 10)],
1173            vec![],
1174        );
1175        let graph =
1176            CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
1177                .expect("Failed to build graph");
1178
1179        // Act: resolve using contains mode; "config" matches parse_config and build_config
1180        let err = graph
1181            .resolve_symbol_indexed("config", &SymbolMatchMode::Contains)
1182            .unwrap_err();
1183
1184        // Assert: both matching symbols returned as MultipleCandidates
1185        assert!(matches!(&err, GraphError::MultipleCandidates { .. }));
1186        if let GraphError::MultipleCandidates { candidates, .. } = err {
1187            let mut sorted = candidates.clone();
1188            sorted.sort();
1189            assert_eq!(sorted, vec!["build_config", "parse_config"]);
1190        }
1191    }
1192}