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