Skip to main content

aptu_coder_core/
graph.rs

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