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!(
694            helper_callers
695                .iter()
696                .any(|e| e.path == PathBuf::from("a.rs"))
697        );
698    }
699
700    #[test]
701    fn test_line_proximity() {
702        // One file with "process" defined at line 10 and line 50, and a call at line 12.
703        // Assert resolution picks the definition at line 10 (closest).
704        let analysis = make_analysis(
705            vec![("process", 10), ("process", 50)],
706            vec![("main", "process", 12)],
707        );
708
709        let graph =
710            CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
711                .expect("Failed to build graph");
712
713        // Check that main calls process
714        assert!(graph.callees.contains_key("main"));
715        let main_callees = &graph.callees["main"];
716        assert_eq!(main_callees.len(), 1);
717        assert_eq!(main_callees[0].neighbor_name, "process");
718
719        // Check that process has a caller from main at line 12
720        assert!(graph.callers.contains_key("process"));
721        let process_callers = &graph.callers["process"];
722        assert!(
723            process_callers
724                .iter()
725                .any(|e| e.line == 12 && e.neighbor_name == "main")
726        );
727    }
728
729    #[test]
730    fn test_scope_prefix_stripping() {
731        // One file defines "method" at line 10. Calls use "self.method", "Type::method".
732        // Assert these resolve to "method" in the graph.
733        let analysis = make_analysis(
734            vec![("method", 10)],
735            vec![
736                ("caller1", "self.method", 5),
737                ("caller2", "Type::method", 15),
738                ("caller3", "module::method", 25),
739            ],
740        );
741
742        let graph =
743            CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
744                .expect("Failed to build graph");
745
746        // Check that all three callers have "method" as their callee
747        assert_eq!(graph.callees["caller1"][0].neighbor_name, "method");
748        assert_eq!(graph.callees["caller2"][0].neighbor_name, "method");
749        assert_eq!(graph.callees["caller3"][0].neighbor_name, "method");
750
751        // Check that method has three callers
752        assert!(graph.callers.contains_key("method"));
753        let method_callers = &graph.callers["method"];
754        assert_eq!(method_callers.len(), 3);
755        assert!(method_callers.iter().any(|e| e.neighbor_name == "caller1"));
756        assert!(method_callers.iter().any(|e| e.neighbor_name == "caller2"));
757        assert!(method_callers.iter().any(|e| e.neighbor_name == "caller3"));
758    }
759
760    #[test]
761    fn test_no_same_file_fallback() {
762        // File a.rs calls "helper" but "helper" is only defined in b.rs.
763        // Assert the call still resolves (graph has the edge).
764        let analysis_a = make_analysis(vec![("main", 1)], vec![("main", "helper", 5)]);
765        let analysis_b = make_analysis(vec![("helper", 10)], vec![]);
766
767        let graph = CallGraph::build_from_results(
768            vec![
769                (PathBuf::from("a.rs"), analysis_a),
770                (PathBuf::from("b.rs"), analysis_b),
771            ],
772            &[],
773            false,
774        )
775        .expect("Failed to build graph");
776
777        // Check that main calls helper
778        assert!(graph.callees.contains_key("main"));
779        let main_callees = &graph.callees["main"];
780        assert_eq!(main_callees.len(), 1);
781        assert_eq!(main_callees[0].neighbor_name, "helper");
782
783        // Check that helper has a caller from a.rs
784        assert!(graph.callers.contains_key("helper"));
785        let helper_callers = &graph.callers["helper"];
786        assert!(
787            helper_callers
788                .iter()
789                .any(|e| e.path == PathBuf::from("a.rs") && e.neighbor_name == "main")
790        );
791    }
792
793    #[test]
794    fn test_type_disambiguation_by_params() {
795        // Two functions named 'process' in the same file with different parameter counts.
796        // process(x: i32) at line 10, process(x: i32, y: String) at line 12.
797        // Call from main at line 11 is equidistant from both (1 line away).
798        // Type matching should prefer the 2-param version since arg_count=2.
799        let analysis = make_typed_analysis(
800            vec![
801                ("process", 10, vec!["(x: i32)".to_string()], Some("i32")),
802                (
803                    "process",
804                    12,
805                    vec!["(x: i32, y: String)".to_string()],
806                    Some("String"),
807                ),
808                ("main", 1, vec![], None),
809            ],
810            vec![("main", "process", 11, Some(2))],
811        );
812
813        let graph =
814            CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
815                .expect("Failed to build graph");
816
817        // Check that main calls process
818        assert!(graph.callees.contains_key("main"));
819        let main_callees = &graph.callees["main"];
820        assert_eq!(main_callees.len(), 1);
821        assert_eq!(main_callees[0].neighbor_name, "process");
822
823        // Check that process has a caller from main at line 11
824        assert!(graph.callers.contains_key("process"));
825        let process_callers = &graph.callers["process"];
826        assert!(
827            process_callers
828                .iter()
829                .any(|e| e.line == 11 && e.neighbor_name == "main")
830        );
831    }
832
833    #[test]
834    fn test_type_disambiguation_fallback() {
835        // Two functions named 'process' with no type info (empty parameters, None return_type).
836        // Call from main at line 12 should resolve using line proximity (no regression).
837        // arg_count=None means type matching won't fire, fallback to line proximity.
838        let analysis = make_analysis(
839            vec![("process", 10), ("process", 50), ("main", 1)],
840            vec![("main", "process", 12)],
841        );
842
843        let graph =
844            CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
845                .expect("Failed to build graph");
846
847        // Check that main calls process
848        assert!(graph.callees.contains_key("main"));
849        let main_callees = &graph.callees["main"];
850        assert_eq!(main_callees.len(), 1);
851        assert_eq!(main_callees[0].neighbor_name, "process");
852
853        // Check that process has a caller from main
854        assert!(graph.callers.contains_key("process"));
855        let process_callers = &graph.callers["process"];
856        assert!(
857            process_callers
858                .iter()
859                .any(|e| e.line == 12 && e.neighbor_name == "main")
860        );
861    }
862
863    #[test]
864    fn test_impl_only_filters_to_impl_sites() {
865        // Arrange: WriterImpl implements Write; plain_fn calls write directly.
866        use crate::types::ImplTraitInfo;
867        let analysis = make_analysis(
868            vec![("write", 1), ("plain_fn", 20)],
869            vec![("plain_fn", "write", 22)],
870        );
871        let impl_traits = vec![ImplTraitInfo {
872            trait_name: "Write".to_string(),
873            impl_type: "WriterImpl".to_string(),
874            path: PathBuf::from("test.rs"),
875            line: 10,
876        }];
877
878        // Act: build with impl_only=true
879        let graph = CallGraph::build_from_results(
880            vec![(PathBuf::from("test.rs"), analysis)],
881            &impl_traits,
882            true,
883        )
884        .expect("Failed to build graph");
885
886        // Assert: trait "Write" has WriterImpl as an explicit impl-trait caller edge.
887        let callers = graph
888            .callers
889            .get("Write")
890            .expect("Write must have impl caller");
891        assert_eq!(callers.len(), 1, "only impl-trait caller retained");
892        assert_eq!(callers[0].neighbor_name, "WriterImpl");
893        assert!(
894            callers[0].is_impl_trait,
895            "edge must be tagged is_impl_trait"
896        );
897
898        // Assert: regular call-site callers of "write" are filtered out by impl_only.
899        let write_callers = graph.callers.get("write").map(|v| v.len()).unwrap_or(0);
900        assert_eq!(
901            write_callers, 0,
902            "regular callers filtered when impl_only=true"
903        );
904    }
905
906    #[test]
907    fn test_impl_only_false_is_backward_compatible() {
908        // Arrange: same setup, impl_only=false -- all callers returned.
909        use crate::types::ImplTraitInfo;
910        let analysis = make_analysis(
911            vec![("write", 1), ("WriterImpl", 10), ("plain_fn", 20)],
912            vec![("WriterImpl", "write", 12), ("plain_fn", "write", 22)],
913        );
914        let impl_traits = vec![ImplTraitInfo {
915            trait_name: "Write".to_string(),
916            impl_type: "WriterImpl".to_string(),
917            path: PathBuf::from("test.rs"),
918            line: 10,
919        }];
920
921        // Act: build with impl_only=false
922        let graph = CallGraph::build_from_results(
923            vec![(PathBuf::from("test.rs"), analysis)],
924            &impl_traits,
925            false,
926        )
927        .expect("Failed to build graph");
928
929        // Assert: both call-site callers preserved
930        let callers = graph.callers.get("write").expect("write must have callers");
931        assert_eq!(
932            callers.len(),
933            2,
934            "both call-site callers should be present when impl_only=false"
935        );
936
937        // Assert: impl-trait edge is always present regardless of impl_only
938        let write_impl_callers = graph
939            .callers
940            .get("Write")
941            .expect("Write must have impl caller");
942        assert_eq!(write_impl_callers.len(), 1);
943        assert!(write_impl_callers[0].is_impl_trait);
944    }
945
946    #[test]
947    fn test_impl_only_callees_unaffected() {
948        // Arrange: WriterImpl calls write; impl_only=true should not remove callees.
949        use crate::types::ImplTraitInfo;
950        let analysis = make_analysis(
951            vec![("write", 1), ("WriterImpl", 10)],
952            vec![("WriterImpl", "write", 12)],
953        );
954        let impl_traits = vec![ImplTraitInfo {
955            trait_name: "Write".to_string(),
956            impl_type: "WriterImpl".to_string(),
957            path: PathBuf::from("test.rs"),
958            line: 10,
959        }];
960
961        let graph = CallGraph::build_from_results(
962            vec![(PathBuf::from("test.rs"), analysis)],
963            &impl_traits,
964            true,
965        )
966        .expect("Failed to build graph");
967
968        // Assert: callees of WriterImpl are NOT filtered
969        let callees = graph
970            .callees
971            .get("WriterImpl")
972            .expect("WriterImpl must have callees");
973        assert_eq!(
974            callees.len(),
975            1,
976            "callees must not be filtered by impl_only"
977        );
978        assert_eq!(callees[0].neighbor_name, "write");
979    }
980
981    // ---- resolve_symbol tests ----
982
983    fn known(names: &[&str]) -> Vec<String> {
984        names.iter().map(|s| s.to_string()).collect()
985    }
986
987    #[test]
988    fn test_resolve_symbol_exact_match() {
989        let syms = known(&["parse_config", "ParseConfig", "PARSE_CONFIG"]);
990        let result = resolve_symbol(syms.iter(), "parse_config", &SymbolMatchMode::Exact);
991        assert_eq!(result.unwrap(), "parse_config");
992    }
993
994    #[test]
995    fn test_resolve_symbol_exact_no_match() {
996        let syms = known(&["ParseConfig"]);
997        let err = resolve_symbol(syms.iter(), "parse_config", &SymbolMatchMode::Exact).unwrap_err();
998        std::assert_matches!(err, GraphError::SymbolNotFound { .. });
999    }
1000
1001    #[test]
1002    fn test_resolve_symbol_insensitive_match() {
1003        let syms = known(&["ParseConfig", "other"]);
1004        let result = resolve_symbol(syms.iter(), "parseconfig", &SymbolMatchMode::Insensitive);
1005        assert_eq!(result.unwrap(), "ParseConfig");
1006    }
1007
1008    #[test]
1009    fn test_resolve_symbol_insensitive_no_match() {
1010        let syms = known(&["unrelated"]);
1011        let err =
1012            resolve_symbol(syms.iter(), "parseconfig", &SymbolMatchMode::Insensitive).unwrap_err();
1013        std::assert_matches!(err, GraphError::SymbolNotFound { .. });
1014    }
1015
1016    #[test]
1017    fn test_resolve_symbol_prefix_single() {
1018        let syms = known(&["parse_config", "parse_args", "build"]);
1019        let result = resolve_symbol(syms.iter(), "build", &SymbolMatchMode::Prefix);
1020        assert_eq!(result.unwrap(), "build");
1021    }
1022
1023    #[test]
1024    fn test_resolve_symbol_prefix_multiple_candidates() {
1025        let syms = known(&["parse_config", "parse_args", "build"]);
1026        let err = resolve_symbol(syms.iter(), "parse", &SymbolMatchMode::Prefix).unwrap_err();
1027        std::assert_matches!(&err, GraphError::MultipleCandidates { .. });
1028        if let GraphError::MultipleCandidates { candidates, .. } = err {
1029            assert_eq!(candidates.len(), 2);
1030        }
1031    }
1032
1033    #[test]
1034    fn test_resolve_symbol_contains_single() {
1035        let syms = known(&["parse_config", "build_artifact"]);
1036        let result = resolve_symbol(syms.iter(), "config", &SymbolMatchMode::Contains);
1037        assert_eq!(result.unwrap(), "parse_config");
1038    }
1039
1040    #[test]
1041    fn test_resolve_symbol_contains_no_match() {
1042        let syms = known(&["parse_config", "build_artifact"]);
1043        let err = resolve_symbol(syms.iter(), "deploy", &SymbolMatchMode::Contains).unwrap_err();
1044        std::assert_matches!(err, GraphError::SymbolNotFound { .. });
1045    }
1046
1047    #[test]
1048    fn test_incoming_chain_order_two_hops() {
1049        // Graph: A calls B calls C.  Focus = C, follow_depth = 2.
1050        //
1051        // Expected chains after reverse():
1052        //   depth-0 chain: [B, A, C]  -- immediate caller first, then outermost, then focus
1053        //   depth-1 chain: [A, B]     -- A calls B
1054        //
1055        // This test pins the ordering so that a missing reverse() or an off-by-one in the
1056        // inner-loop push would be caught: chain[1] must be "A" (outermost), not "B" again.
1057        let analysis = make_analysis(
1058            vec![("A", 1), ("B", 10), ("C", 20)],
1059            vec![("A", "B", 2), ("B", "C", 15)],
1060        );
1061        let graph =
1062            CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
1063                .expect("Failed to build graph");
1064
1065        let chains = graph
1066            .find_incoming_chains("C", 2)
1067            .expect("Failed to find incoming chains");
1068
1069        assert!(
1070            !chains.is_empty(),
1071            "Expected at least one incoming chain for C"
1072        );
1073
1074        // The 2-hop chain has 3 elements: [immediate_caller, outermost_caller, focus].
1075        let chain = chains
1076            .iter()
1077            .find(|c| c.chain.len() == 3)
1078            .expect("Expected a 3-element chain");
1079
1080        assert_eq!(
1081            chain.chain[0].0, "B",
1082            "chain[0] should be immediate caller B, got {}",
1083            chain.chain[0].0
1084        );
1085        assert_eq!(
1086            chain.chain[1].0, "A",
1087            "chain[1] should be outermost caller A, got {}",
1088            chain.chain[1].0
1089        );
1090        assert_eq!(
1091            chain.chain[2].0, "C",
1092            "chain[2] should be focus node C, got {}",
1093            chain.chain[2].0
1094        );
1095    }
1096
1097    // ---- resolve_symbol_indexed tests ----
1098
1099    #[test]
1100    fn test_insensitive_resolve_via_index() {
1101        // Arrange: build a CallGraph with known symbols
1102        let analysis = make_analysis(
1103            vec![("ParseConfig", 1), ("parse_args", 5)],
1104            vec![("ParseConfig", "parse_args", 10)],
1105        );
1106        let graph =
1107            CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
1108                .expect("Failed to build graph");
1109
1110        // Act: resolve using insensitive mode via the indexed method
1111        let result = graph
1112            .resolve_symbol_indexed("parseconfig", &SymbolMatchMode::Insensitive)
1113            .expect("Should resolve ParseConfig");
1114
1115        // Assert: O(1) lookup via lowercase_index returns the original symbol
1116        assert_eq!(result, "ParseConfig");
1117    }
1118
1119    #[test]
1120    fn test_prefix_resolve_via_index() {
1121        // Arrange: build a CallGraph with multiple symbols matching a prefix
1122        let analysis = make_analysis(
1123            vec![("parse_config", 1), ("parse_args", 5), ("build", 10)],
1124            vec![],
1125        );
1126        let graph =
1127            CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
1128                .expect("Failed to build graph");
1129
1130        // Act: resolve using prefix mode via the indexed method
1131        let err = graph
1132            .resolve_symbol_indexed("parse", &SymbolMatchMode::Prefix)
1133            .unwrap_err();
1134
1135        // Assert: multiple candidates found
1136        std::assert_matches!(&err, GraphError::MultipleCandidates { .. });
1137        if let GraphError::MultipleCandidates { candidates, .. } = err {
1138            assert_eq!(candidates.len(), 2);
1139        }
1140    }
1141
1142    #[test]
1143    fn test_insensitive_case_collision_returns_multiple_candidates() {
1144        // Arrange: two symbols that differ only by case map to the same lowercase key
1145        let analysis = make_analysis(vec![("Foo", 1), ("foo", 5)], vec![("Foo", "foo", 10)]);
1146        let graph =
1147            CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
1148                .expect("Failed to build graph");
1149
1150        // Act: insensitive lookup for "foo" hits both Foo and foo
1151        let err = graph
1152            .resolve_symbol_indexed("foo", &SymbolMatchMode::Insensitive)
1153            .unwrap_err();
1154
1155        // Assert: MultipleCandidates returned for case collision
1156        std::assert_matches!(&err, GraphError::MultipleCandidates { .. });
1157        if let GraphError::MultipleCandidates { candidates, .. } = err {
1158            assert_eq!(candidates.len(), 2);
1159        }
1160    }
1161
1162    #[test]
1163    fn test_contains_resolve_via_index() {
1164        // Arrange: symbols where two match the query substring; one does not
1165        let analysis = make_analysis(
1166            vec![("parse_config", 1), ("build_config", 5), ("run", 10)],
1167            vec![],
1168        );
1169        let graph =
1170            CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)], &[], false)
1171                .expect("Failed to build graph");
1172
1173        // Act: resolve using contains mode; "config" matches parse_config and build_config
1174        let err = graph
1175            .resolve_symbol_indexed("config", &SymbolMatchMode::Contains)
1176            .unwrap_err();
1177
1178        // Assert: both matching symbols returned as MultipleCandidates
1179        std::assert_matches!(&err, GraphError::MultipleCandidates { .. });
1180        if let GraphError::MultipleCandidates { candidates, .. } = err {
1181            let mut sorted = candidates.clone();
1182            sorted.sort();
1183            assert_eq!(sorted, vec!["build_config", "parse_config"]);
1184        }
1185    }
1186}