Skip to main content

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