Skip to main content

code_analyze_mcp/
graph.rs

1//! Call graph construction and analysis.
2//!
3//! Builds caller and callee relationships from semantic analysis results.
4//! Implements type-aware function matching to disambiguate overloads and name collisions.
5
6use crate::types::{SemanticAnalysis, SymbolMatchMode};
7use std::collections::{HashMap, HashSet, VecDeque};
8use std::path::{Path, PathBuf};
9use thiserror::Error;
10use tracing::{debug, instrument};
11
12/// Type info for a function: (path, line, parameters, return_type)
13type FunctionTypeInfo = (PathBuf, usize, Vec<String>, Option<String>);
14
15const MAX_CANDIDATES_IN_ERROR: usize = 20;
16
17/// Maps files to their incoming and outgoing import relationships.
18/// Incoming: files that import this file.
19/// Outgoing: files that this file imports.
20#[derive(Debug, Clone)]
21pub struct ImportGraph {
22    /// File -> list of files importing this file
23    pub incoming: HashMap<PathBuf, Vec<PathBuf>>,
24    /// File -> list of files this file imports
25    pub outgoing: HashMap<PathBuf, Vec<PathBuf>>,
26}
27
28impl ImportGraph {
29    /// Build an ImportGraph from semantic analysis results.
30    /// Caps each direction at 5 entries per file to limit output size.
31    pub fn build_from_results(results: &[(PathBuf, SemanticAnalysis)]) -> Self {
32        let mut incoming: HashMap<PathBuf, Vec<PathBuf>> = HashMap::new();
33        let mut outgoing: HashMap<PathBuf, Vec<PathBuf>> = HashMap::new();
34
35        for (path, analysis) in results {
36            let mut out_files = Vec::new();
37            for import in &analysis.imports {
38                // Store raw module path; proper module-to-file resolution deferred to follow-up
39                let module_path = PathBuf::from(&import.module);
40                out_files.push(module_path);
41            }
42            out_files.truncate(5);
43            if !out_files.is_empty() {
44                outgoing.insert(path.clone(), out_files);
45            }
46        }
47
48        // Build incoming map by reversing outgoing relationships
49        for (from_file, to_files) in &outgoing {
50            for to_file in to_files {
51                incoming
52                    .entry(to_file.clone())
53                    .or_default()
54                    .push(from_file.clone());
55            }
56        }
57
58        // Cap incoming at 5 per file
59        for inbound in incoming.values_mut() {
60            inbound.truncate(5);
61        }
62
63        ImportGraph { incoming, outgoing }
64    }
65}
66
67fn format_candidates(candidates: &[String]) -> String {
68    if candidates.len() <= MAX_CANDIDATES_IN_ERROR {
69        candidates.join(", ")
70    } else {
71        format!(
72            "{}, (and {} more)",
73            candidates[..MAX_CANDIDATES_IN_ERROR].join(", "),
74            candidates.len() - MAX_CANDIDATES_IN_ERROR
75        )
76    }
77}
78
79#[derive(Debug, Error)]
80pub enum GraphError {
81    #[error("Symbol not found: '{symbol}'. {hint}")]
82    SymbolNotFound { symbol: String, hint: String },
83    #[error(
84        "Multiple candidates matched '{query}': {candidates_display}. Refine the symbol name or use a stricter match_mode.",
85        candidates_display = format_candidates(.candidates)
86    )]
87    MultipleCandidates {
88        query: String,
89        candidates: Vec<String>,
90    },
91}
92
93/// Resolve a symbol name against the set of known symbols using the requested match mode.
94///
95/// Returns:
96/// - `Ok(name)` when exactly one symbol matches.
97/// - `Err(GraphError::SymbolNotFound)` when no symbol matches.
98/// - `Err(GraphError::MultipleCandidates)` when more than one symbol matches.
99pub fn resolve_symbol<'a>(
100    known_symbols: impl Iterator<Item = &'a String>,
101    query: &str,
102    mode: &SymbolMatchMode,
103) -> Result<String, GraphError> {
104    let mut matches: Vec<String> = if matches!(mode, SymbolMatchMode::Exact) {
105        known_symbols
106            .filter(|s| s.as_str() == query)
107            .cloned()
108            .collect()
109    } else {
110        let query_lower = query.to_lowercase();
111        known_symbols
112            .filter(|s| match mode {
113                SymbolMatchMode::Exact => unreachable!(),
114                SymbolMatchMode::Insensitive => s.to_lowercase() == query_lower,
115                SymbolMatchMode::Prefix => s.to_lowercase().starts_with(&query_lower),
116                SymbolMatchMode::Contains => s.to_lowercase().contains(&query_lower),
117            })
118            .cloned()
119            .collect()
120    };
121    matches.sort();
122
123    debug!(
124        query,
125        mode = ?mode,
126        candidate_count = matches.len(),
127        "resolve_symbol"
128    );
129
130    match matches.len() {
131        1 => Ok(matches.into_iter().next().expect("len==1")),
132        0 => {
133            let hint = match mode {
134                SymbolMatchMode::Exact => {
135                    "Try match_mode=insensitive for a case-insensitive search.".to_string()
136                }
137                _ => "No symbols matched; try a shorter query or match_mode=contains.".to_string(),
138            };
139            Err(GraphError::SymbolNotFound {
140                symbol: query.to_string(),
141                hint,
142            })
143        }
144        _ => Err(GraphError::MultipleCandidates {
145            query: query.to_string(),
146            candidates: matches,
147        }),
148    }
149}
150
151/// Strip scope prefixes from a callee name.
152/// Handles patterns: 'self.method' -> 'method', 'Type::method' -> 'method', 'module::function' -> 'function'.
153/// If no prefix is found, returns the original name.
154fn strip_scope_prefix(name: &str) -> &str {
155    if let Some(pos) = name.rfind("::") {
156        &name[pos + 2..]
157    } else if let Some(pos) = name.rfind('.') {
158        &name[pos + 1..]
159    } else {
160        name
161    }
162}
163
164#[derive(Debug, Clone)]
165pub struct CallChain {
166    pub chain: Vec<(String, PathBuf, usize)>,
167}
168
169/// Call graph storing callers, callees, and function definitions.
170#[derive(Debug, Clone)]
171pub struct CallGraph {
172    /// Callers map: function_name -> vec of (file_path, line_number, caller_name).
173    pub callers: HashMap<String, Vec<(PathBuf, usize, String)>>,
174    /// Callees map: function_name -> vec of (file_path, line_number, callee_name).
175    pub callees: HashMap<String, Vec<(PathBuf, usize, String)>>,
176    /// Definitions map: function_name -> vec of (file_path, line_number).
177    pub definitions: HashMap<String, Vec<(PathBuf, usize)>>,
178    /// Internal: maps function name to type info for type-aware disambiguation.
179    function_types: HashMap<String, Vec<FunctionTypeInfo>>,
180}
181
182impl CallGraph {
183    pub fn new() -> Self {
184        Self {
185            callers: HashMap::new(),
186            callees: HashMap::new(),
187            definitions: HashMap::new(),
188            function_types: HashMap::new(),
189        }
190    }
191
192    /// Count parameters in a parameter string.
193    /// Handles: "(x: i32, y: String)" -> 2, "(&self, x: i32)" -> 2, "()" -> 0, "(&self)" -> 1
194    fn count_parameters(params_str: &str) -> usize {
195        if params_str.is_empty() || params_str == "()" {
196            return 0;
197        }
198        // Remove outer parens and trim
199        let inner = params_str
200            .trim_start_matches('(')
201            .trim_end_matches(')')
202            .trim();
203        if inner.is_empty() {
204            return 0;
205        }
206        // Count commas + 1 to get parameter count
207        inner.split(',').count()
208    }
209
210    /// Match a callee by parameter count and return type.
211    /// Returns the index of the best match in the candidates list, or None if no good match.
212    /// Strategy: prefer candidates with matching param count, then by return type match.
213    fn match_by_type(
214        &self,
215        candidates: &[FunctionTypeInfo],
216        expected_param_count: Option<usize>,
217        expected_return_type: Option<&str>,
218    ) -> Option<usize> {
219        if candidates.is_empty() {
220            return None;
221        }
222
223        // If we have no type info to match against, return None (fallback to line proximity)
224        if expected_param_count.is_none() && expected_return_type.is_none() {
225            return None;
226        }
227
228        let mut best_idx = 0;
229        let mut best_score = 0;
230
231        for (idx, (_path, _line, params, ret_type)) in candidates.iter().enumerate() {
232            let mut score = 0;
233
234            // Score parameter count match
235            if let Some(expected_count) = expected_param_count
236                && !params.is_empty()
237            {
238                let actual_count = Self::count_parameters(&params[0]);
239                if actual_count == expected_count {
240                    score += 2;
241                }
242            }
243
244            // Score return type match
245            if let Some(expected_ret) = expected_return_type
246                && let Some(actual_ret) = ret_type
247                && actual_ret == expected_ret
248            {
249                score += 1;
250            }
251
252            // Prefer candidates with more type info
253            if !params.is_empty() {
254                score += 1;
255            }
256            if ret_type.is_some() {
257                score += 1;
258            }
259
260            if score > best_score {
261                best_score = score;
262                best_idx = idx;
263            }
264        }
265
266        // Only return a match if we found a meaningful score
267        (best_score > 0).then_some(best_idx)
268    }
269
270    /// Resolve a callee name using four strategies:
271    /// 1. Try the raw callee name first in definitions
272    /// 2. If not found, try the stripped name (via strip_scope_prefix)
273    /// 3. If multiple definitions exist, prefer same-file candidates
274    /// 4. Among same-file candidates, use type info as tiebreaker, then line proximity
275    /// 5. If no same-file candidates, use any definition (first one)
276    ///
277    /// Returns the resolved callee name (which may be the stripped version).
278    fn resolve_callee(
279        &self,
280        callee: &str,
281        call_file: &Path,
282        call_line: usize,
283        arg_count: Option<usize>,
284        definitions: &HashMap<String, Vec<(PathBuf, usize)>>,
285        function_types: &HashMap<String, Vec<FunctionTypeInfo>>,
286    ) -> String {
287        // Try raw callee name first
288        if let Some(defs) = definitions.get(callee) {
289            return self.pick_best_definition(
290                defs,
291                call_file,
292                call_line,
293                arg_count,
294                callee,
295                function_types,
296            );
297        }
298
299        // Try stripped name
300        let stripped = strip_scope_prefix(callee);
301        if stripped != callee
302            && let Some(defs) = definitions.get(stripped)
303        {
304            return self.pick_best_definition(
305                defs,
306                call_file,
307                call_line,
308                arg_count,
309                stripped,
310                function_types,
311            );
312        }
313
314        // No definition found; return the original callee
315        callee.to_string()
316    }
317
318    /// Pick the best definition from a list based on same-file preference, type matching, and line proximity.
319    fn pick_best_definition(
320        &self,
321        defs: &[(PathBuf, usize)],
322        call_file: &Path,
323        call_line: usize,
324        arg_count: Option<usize>,
325        resolved_name: &str,
326        function_types: &HashMap<String, Vec<FunctionTypeInfo>>,
327    ) -> String {
328        // Filter to same-file candidates
329        let same_file_defs: Vec<_> = defs.iter().filter(|(path, _)| path == call_file).collect();
330
331        if !same_file_defs.is_empty() {
332            // Try type-aware disambiguation if we have type info
333            if let Some(type_info) = function_types.get(resolved_name) {
334                let same_file_types: Vec<_> = type_info
335                    .iter()
336                    .filter(|(path, _, _, _)| path == call_file)
337                    .cloned()
338                    .collect();
339
340                if !same_file_types.is_empty() && same_file_types.len() > 1 {
341                    // Group candidates by line proximity (within 5 lines)
342                    let mut proximity_groups: Vec<Vec<usize>> = vec![];
343                    for (idx, (_, def_line, _, _)) in same_file_types.iter().enumerate() {
344                        let mut placed = false;
345                        for group in &mut proximity_groups {
346                            if let Some((_, first_line, _, _)) = same_file_types.get(group[0])
347                                && first_line.abs_diff(*def_line) <= 5
348                            {
349                                group.push(idx);
350                                placed = true;
351                                break;
352                            }
353                        }
354                        if !placed {
355                            proximity_groups.push(vec![idx]);
356                        }
357                    }
358
359                    // Find the closest proximity group
360                    let closest_group = proximity_groups.iter().min_by_key(|group| {
361                        group
362                            .iter()
363                            .map(|idx| {
364                                if let Some((_, def_line, _, _)) = same_file_types.get(*idx) {
365                                    def_line.abs_diff(call_line)
366                                } else {
367                                    usize::MAX
368                                }
369                            })
370                            .min()
371                            .unwrap_or(usize::MAX)
372                    });
373
374                    if let Some(group) = closest_group {
375                        // Within the closest group, try type matching
376                        if group.len() > 1 {
377                            // Collect candidates for type matching
378                            let candidates: Vec<_> = group
379                                .iter()
380                                .filter_map(|idx| same_file_types.get(*idx).cloned())
381                                .collect();
382                            // Try to match by type using argument count from call site
383                            if let Some(_best_idx) =
384                                self.match_by_type(&candidates, arg_count, None)
385                            {
386                                return resolved_name.to_string();
387                            }
388                        }
389                    }
390                }
391            }
392
393            // Fallback to line proximity
394            let _best = same_file_defs
395                .iter()
396                .min_by_key(|(_, def_line)| (*def_line).abs_diff(call_line));
397            return resolved_name.to_string();
398        }
399
400        // No same-file candidates; use any definition (first one)
401        resolved_name.to_string()
402    }
403
404    #[instrument(skip_all)]
405    pub fn build_from_results(
406        results: Vec<(PathBuf, SemanticAnalysis)>,
407    ) -> Result<Self, GraphError> {
408        let mut graph = CallGraph::new();
409
410        // Build definitions and function_types maps first
411        for (path, analysis) in &results {
412            for func in &analysis.functions {
413                graph
414                    .definitions
415                    .entry(func.name.clone())
416                    .or_default()
417                    .push((path.clone(), func.line));
418                graph
419                    .function_types
420                    .entry(func.name.clone())
421                    .or_default()
422                    .push((
423                        path.clone(),
424                        func.line,
425                        func.parameters.clone(),
426                        func.return_type.clone(),
427                    ));
428            }
429            for class in &analysis.classes {
430                graph
431                    .definitions
432                    .entry(class.name.clone())
433                    .or_default()
434                    .push((path.clone(), class.line));
435                graph
436                    .function_types
437                    .entry(class.name.clone())
438                    .or_default()
439                    .push((path.clone(), class.line, vec![], None));
440            }
441        }
442
443        // Process calls with resolved callee names
444        for (path, analysis) in &results {
445            for call in &analysis.calls {
446                let resolved_callee = graph.resolve_callee(
447                    &call.callee,
448                    path,
449                    call.line,
450                    call.arg_count,
451                    &graph.definitions,
452                    &graph.function_types,
453                );
454
455                graph.callees.entry(call.caller.clone()).or_default().push((
456                    path.clone(),
457                    call.line,
458                    resolved_callee.clone(),
459                ));
460                graph.callers.entry(resolved_callee).or_default().push((
461                    path.clone(),
462                    call.line,
463                    call.caller.clone(),
464                ));
465            }
466            for reference in &analysis.references {
467                graph
468                    .callers
469                    .entry(reference.symbol.clone())
470                    .or_default()
471                    .push((path.clone(), reference.line, "<reference>".to_string()));
472            }
473        }
474
475        let total_edges = graph.callees.values().map(|v| v.len()).sum::<usize>()
476            + graph.callers.values().map(|v| v.len()).sum::<usize>();
477        let file_count = results.len();
478
479        tracing::debug!(
480            definitions = graph.definitions.len(),
481            edges = total_edges,
482            files = file_count,
483            "graph built"
484        );
485
486        Ok(graph)
487    }
488
489    fn find_chains_bfs(
490        &self,
491        symbol: &str,
492        follow_depth: u32,
493        is_incoming: bool,
494    ) -> Result<Vec<CallChain>, GraphError> {
495        let graph_map = if is_incoming {
496            &self.callers
497        } else {
498            &self.callees
499        };
500
501        if !self.definitions.contains_key(symbol) && !graph_map.contains_key(symbol) {
502            return Err(GraphError::SymbolNotFound {
503                symbol: symbol.to_string(),
504                hint: "Symbol resolved but not found in graph. The symbol may have no calls or definitions in the indexed files.".to_string(),
505            });
506        }
507
508        let mut chains = Vec::new();
509        let mut visited = HashSet::new();
510        let mut queue = VecDeque::new();
511        queue.push_back((symbol.to_string(), 0));
512        visited.insert(symbol.to_string());
513
514        while let Some((current, depth)) = queue.pop_front() {
515            if depth > follow_depth {
516                continue;
517            }
518
519            if let Some(neighbors) = graph_map.get(&current) {
520                for (path, line, neighbor) in neighbors {
521                    let mut chain = vec![(current.clone(), path.clone(), *line)];
522                    let mut chain_node = neighbor.clone();
523                    let mut chain_depth = depth;
524
525                    while chain_depth < follow_depth {
526                        if let Some(next_neighbors) = graph_map.get(&chain_node) {
527                            if let Some((p, l, n)) = next_neighbors.first() {
528                                if is_incoming {
529                                    chain.insert(0, (chain_node.clone(), p.clone(), *l));
530                                } else {
531                                    chain.push((chain_node.clone(), p.clone(), *l));
532                                }
533                                chain_node = n.clone();
534                                chain_depth += 1;
535                            } else {
536                                break;
537                            }
538                        } else {
539                            break;
540                        }
541                    }
542
543                    if is_incoming {
544                        chain.insert(0, (neighbor.clone(), path.clone(), *line));
545                    } else {
546                        chain.push((neighbor.clone(), path.clone(), *line));
547                    }
548                    chains.push(CallChain { chain });
549
550                    if !visited.contains(neighbor) && depth < follow_depth {
551                        visited.insert(neighbor.clone());
552                        queue.push_back((neighbor.clone(), depth + 1));
553                    }
554                }
555            }
556        }
557
558        Ok(chains)
559    }
560
561    #[instrument(skip(self))]
562    pub fn find_incoming_chains(
563        &self,
564        symbol: &str,
565        follow_depth: u32,
566    ) -> Result<Vec<CallChain>, GraphError> {
567        self.find_chains_bfs(symbol, follow_depth, true)
568    }
569
570    #[instrument(skip(self))]
571    pub fn find_outgoing_chains(
572        &self,
573        symbol: &str,
574        follow_depth: u32,
575    ) -> Result<Vec<CallChain>, GraphError> {
576        self.find_chains_bfs(symbol, follow_depth, false)
577    }
578}
579
580impl Default for CallGraph {
581    fn default() -> Self {
582        Self::new()
583    }
584}
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589    use crate::types::{CallInfo, FunctionInfo};
590
591    fn make_analysis(
592        funcs: Vec<(&str, usize)>,
593        calls: Vec<(&str, &str, usize)>,
594    ) -> SemanticAnalysis {
595        SemanticAnalysis {
596            functions: funcs
597                .into_iter()
598                .map(|(n, l)| FunctionInfo {
599                    name: n.to_string(),
600                    line: l,
601                    end_line: l + 5,
602                    parameters: vec![],
603                    return_type: None,
604                })
605                .collect(),
606            classes: vec![],
607            imports: vec![],
608            references: vec![],
609            call_frequency: Default::default(),
610            calls: calls
611                .into_iter()
612                .map(|(c, e, l)| CallInfo {
613                    caller: c.to_string(),
614                    callee: e.to_string(),
615                    line: l,
616                    column: 0,
617                    arg_count: None,
618                })
619                .collect(),
620            assignments: vec![],
621            field_accesses: vec![],
622        }
623    }
624
625    fn make_typed_analysis(
626        funcs: Vec<(&str, usize, Vec<String>, Option<&str>)>,
627        calls: Vec<(&str, &str, usize, Option<usize>)>,
628    ) -> SemanticAnalysis {
629        SemanticAnalysis {
630            functions: funcs
631                .into_iter()
632                .map(|(n, l, params, ret_type)| FunctionInfo {
633                    name: n.to_string(),
634                    line: l,
635                    end_line: l + 5,
636                    parameters: params,
637                    return_type: ret_type.map(|s| s.to_string()),
638                })
639                .collect(),
640            classes: vec![],
641            imports: vec![],
642            references: vec![],
643            call_frequency: Default::default(),
644            calls: calls
645                .into_iter()
646                .map(|(c, e, l, arg_count)| CallInfo {
647                    caller: c.to_string(),
648                    callee: e.to_string(),
649                    line: l,
650                    column: 0,
651                    arg_count,
652                })
653                .collect(),
654            assignments: vec![],
655            field_accesses: vec![],
656        }
657    }
658
659    #[test]
660    fn test_graph_construction() {
661        let analysis = make_analysis(
662            vec![("main", 1), ("foo", 10), ("bar", 20)],
663            vec![("main", "foo", 2), ("foo", "bar", 15)],
664        );
665        let graph = CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)])
666            .expect("Failed to build graph");
667        assert!(graph.definitions.contains_key("main"));
668        assert!(graph.definitions.contains_key("foo"));
669        assert_eq!(graph.callees["main"][0].2, "foo");
670        assert_eq!(graph.callers["foo"][0].2, "main");
671    }
672
673    #[test]
674    fn test_find_incoming_chains_depth_zero() {
675        let analysis = make_analysis(vec![("main", 1), ("foo", 10)], vec![("main", "foo", 2)]);
676        let graph = CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)])
677            .expect("Failed to build graph");
678        assert!(
679            !graph
680                .find_incoming_chains("foo", 0)
681                .expect("Failed to find chains")
682                .is_empty()
683        );
684    }
685
686    #[test]
687    fn test_find_outgoing_chains_depth_zero() {
688        let analysis = make_analysis(vec![("main", 1), ("foo", 10)], vec![("main", "foo", 2)]);
689        let graph = CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)])
690            .expect("Failed to build graph");
691        assert!(
692            !graph
693                .find_outgoing_chains("main", 0)
694                .expect("Failed to find chains")
695                .is_empty()
696        );
697    }
698
699    #[test]
700    fn test_symbol_not_found() {
701        assert!(
702            CallGraph::new()
703                .find_incoming_chains("nonexistent", 0)
704                .is_err()
705        );
706    }
707
708    #[test]
709    fn test_same_file_preference() {
710        // Two files each define "helper". File a.rs has a call from "main" to "helper".
711        // Assert that the graph's callees for "main" point to "helper" and the callers
712        // for "helper" include an entry from a.rs (not b.rs).
713        let analysis_a = make_analysis(
714            vec![("main", 1), ("helper", 10)],
715            vec![("main", "helper", 5)],
716        );
717        let analysis_b = make_analysis(vec![("helper", 20)], vec![]);
718
719        let graph = CallGraph::build_from_results(vec![
720            (PathBuf::from("a.rs"), analysis_a),
721            (PathBuf::from("b.rs"), analysis_b),
722        ])
723        .expect("Failed to build graph");
724
725        // Check that main calls helper
726        assert!(graph.callees.contains_key("main"));
727        let main_callees = &graph.callees["main"];
728        assert_eq!(main_callees.len(), 1);
729        assert_eq!(main_callees[0].2, "helper");
730
731        // Check that the call is from a.rs (same file as main)
732        assert_eq!(main_callees[0].0, PathBuf::from("a.rs"));
733
734        // Check that helper has a caller from a.rs
735        assert!(graph.callers.contains_key("helper"));
736        let helper_callers = &graph.callers["helper"];
737        assert!(
738            helper_callers
739                .iter()
740                .any(|(path, _, _)| path == &PathBuf::from("a.rs"))
741        );
742    }
743
744    #[test]
745    fn test_line_proximity() {
746        // One file with "process" defined at line 10 and line 50, and a call at line 12.
747        // Assert resolution picks the definition at line 10 (closest).
748        let analysis = make_analysis(
749            vec![("process", 10), ("process", 50)],
750            vec![("main", "process", 12)],
751        );
752
753        let graph = CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)])
754            .expect("Failed to build graph");
755
756        // Check that main calls process
757        assert!(graph.callees.contains_key("main"));
758        let main_callees = &graph.callees["main"];
759        assert_eq!(main_callees.len(), 1);
760        assert_eq!(main_callees[0].2, "process");
761
762        // Check that process has a caller from main at line 12
763        assert!(graph.callers.contains_key("process"));
764        let process_callers = &graph.callers["process"];
765        assert!(
766            process_callers
767                .iter()
768                .any(|(_, line, caller)| *line == 12 && caller == "main")
769        );
770    }
771
772    #[test]
773    fn test_scope_prefix_stripping() {
774        // One file defines "method" at line 10. Calls use "self.method", "Type::method".
775        // Assert these resolve to "method" in the graph.
776        let analysis = make_analysis(
777            vec![("method", 10)],
778            vec![
779                ("caller1", "self.method", 5),
780                ("caller2", "Type::method", 15),
781                ("caller3", "module::method", 25),
782            ],
783        );
784
785        let graph = CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)])
786            .expect("Failed to build graph");
787
788        // Check that all three callers have "method" as their callee
789        assert_eq!(graph.callees["caller1"][0].2, "method");
790        assert_eq!(graph.callees["caller2"][0].2, "method");
791        assert_eq!(graph.callees["caller3"][0].2, "method");
792
793        // Check that method has three callers
794        assert!(graph.callers.contains_key("method"));
795        let method_callers = &graph.callers["method"];
796        assert_eq!(method_callers.len(), 3);
797        assert!(
798            method_callers
799                .iter()
800                .any(|(_, _, caller)| caller == "caller1")
801        );
802        assert!(
803            method_callers
804                .iter()
805                .any(|(_, _, caller)| caller == "caller2")
806        );
807        assert!(
808            method_callers
809                .iter()
810                .any(|(_, _, caller)| caller == "caller3")
811        );
812    }
813
814    #[test]
815    fn test_no_same_file_fallback() {
816        // File a.rs calls "helper" but "helper" is only defined in b.rs.
817        // Assert the call still resolves (graph has the edge).
818        let analysis_a = make_analysis(vec![("main", 1)], vec![("main", "helper", 5)]);
819        let analysis_b = make_analysis(vec![("helper", 10)], vec![]);
820
821        let graph = CallGraph::build_from_results(vec![
822            (PathBuf::from("a.rs"), analysis_a),
823            (PathBuf::from("b.rs"), analysis_b),
824        ])
825        .expect("Failed to build graph");
826
827        // Check that main calls helper
828        assert!(graph.callees.contains_key("main"));
829        let main_callees = &graph.callees["main"];
830        assert_eq!(main_callees.len(), 1);
831        assert_eq!(main_callees[0].2, "helper");
832
833        // Check that helper has a caller from a.rs
834        assert!(graph.callers.contains_key("helper"));
835        let helper_callers = &graph.callers["helper"];
836        assert!(
837            helper_callers
838                .iter()
839                .any(|(path, _, caller)| { path == &PathBuf::from("a.rs") && caller == "main" })
840        );
841    }
842
843    #[test]
844    fn test_type_disambiguation_by_params() {
845        // Two functions named 'process' in the same file with different parameter counts.
846        // process(x: i32) at line 10, process(x: i32, y: String) at line 12.
847        // Call from main at line 11 is equidistant from both (1 line away).
848        // Type matching should prefer the 2-param version since arg_count=2.
849        let analysis = make_typed_analysis(
850            vec![
851                ("process", 10, vec!["(x: i32)".to_string()], Some("i32")),
852                (
853                    "process",
854                    12,
855                    vec!["(x: i32, y: String)".to_string()],
856                    Some("String"),
857                ),
858                ("main", 1, vec![], None),
859            ],
860            vec![("main", "process", 11, Some(2))],
861        );
862
863        let graph = CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)])
864            .expect("Failed to build graph");
865
866        // Check that main calls process
867        assert!(graph.callees.contains_key("main"));
868        let main_callees = &graph.callees["main"];
869        assert_eq!(main_callees.len(), 1);
870        assert_eq!(main_callees[0].2, "process");
871
872        // Check that process has a caller from main at line 11
873        assert!(graph.callers.contains_key("process"));
874        let process_callers = &graph.callers["process"];
875        assert!(
876            process_callers
877                .iter()
878                .any(|(_, line, caller)| *line == 11 && caller == "main")
879        );
880    }
881
882    #[test]
883    fn test_type_disambiguation_fallback() {
884        // Two functions named 'process' with no type info (empty parameters, None return_type).
885        // Call from main at line 12 should resolve using line proximity (no regression).
886        // arg_count=None means type matching won't fire, fallback to line proximity.
887        let analysis = make_analysis(
888            vec![("process", 10), ("process", 50), ("main", 1)],
889            vec![("main", "process", 12)],
890        );
891
892        let graph = CallGraph::build_from_results(vec![(PathBuf::from("test.rs"), analysis)])
893            .expect("Failed to build graph");
894
895        // Check that main calls process
896        assert!(graph.callees.contains_key("main"));
897        let main_callees = &graph.callees["main"];
898        assert_eq!(main_callees.len(), 1);
899        assert_eq!(main_callees[0].2, "process");
900
901        // Check that process has a caller from main
902        assert!(graph.callers.contains_key("process"));
903        let process_callers = &graph.callers["process"];
904        assert!(
905            process_callers
906                .iter()
907                .any(|(_, line, caller)| *line == 12 && caller == "main")
908        );
909    }
910
911    // ---- resolve_symbol tests ----
912
913    fn known(names: &[&str]) -> Vec<String> {
914        names.iter().map(|s| s.to_string()).collect()
915    }
916
917    #[test]
918    fn test_resolve_symbol_exact_match() {
919        let syms = known(&["parse_config", "ParseConfig", "PARSE_CONFIG"]);
920        let result = resolve_symbol(syms.iter(), "parse_config", &SymbolMatchMode::Exact);
921        assert_eq!(result.unwrap(), "parse_config");
922    }
923
924    #[test]
925    fn test_resolve_symbol_exact_no_match() {
926        let syms = known(&["ParseConfig"]);
927        let err = resolve_symbol(syms.iter(), "parse_config", &SymbolMatchMode::Exact).unwrap_err();
928        assert!(matches!(err, GraphError::SymbolNotFound { .. }));
929    }
930
931    #[test]
932    fn test_resolve_symbol_insensitive_match() {
933        let syms = known(&["ParseConfig", "other"]);
934        let result = resolve_symbol(syms.iter(), "parseconfig", &SymbolMatchMode::Insensitive);
935        assert_eq!(result.unwrap(), "ParseConfig");
936    }
937
938    #[test]
939    fn test_resolve_symbol_insensitive_no_match() {
940        let syms = known(&["unrelated"]);
941        let err =
942            resolve_symbol(syms.iter(), "parseconfig", &SymbolMatchMode::Insensitive).unwrap_err();
943        assert!(matches!(err, GraphError::SymbolNotFound { .. }));
944    }
945
946    #[test]
947    fn test_resolve_symbol_prefix_single() {
948        let syms = known(&["parse_config", "parse_args", "build"]);
949        let result = resolve_symbol(syms.iter(), "build", &SymbolMatchMode::Prefix);
950        assert_eq!(result.unwrap(), "build");
951    }
952
953    #[test]
954    fn test_resolve_symbol_prefix_multiple_candidates() {
955        let syms = known(&["parse_config", "parse_args", "build"]);
956        let err = resolve_symbol(syms.iter(), "parse", &SymbolMatchMode::Prefix).unwrap_err();
957        assert!(matches!(&err, GraphError::MultipleCandidates { .. }));
958        if let GraphError::MultipleCandidates { candidates, .. } = err {
959            assert_eq!(candidates.len(), 2);
960        }
961    }
962
963    #[test]
964    fn test_resolve_symbol_contains_single() {
965        let syms = known(&["parse_config", "build_artifact"]);
966        let result = resolve_symbol(syms.iter(), "config", &SymbolMatchMode::Contains);
967        assert_eq!(result.unwrap(), "parse_config");
968    }
969
970    #[test]
971    fn test_resolve_symbol_contains_no_match() {
972        let syms = known(&["parse_config", "build_artifact"]);
973        let err = resolve_symbol(syms.iter(), "deploy", &SymbolMatchMode::Contains).unwrap_err();
974        assert!(matches!(err, GraphError::SymbolNotFound { .. }));
975    }
976}