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