Skip to main content

lean_ctx/tools/
ctx_callgraph.rs

1use crate::core::call_graph::CallGraph;
2use crate::core::graph_index;
3
4const VALID_DIRECTIONS: &str = "callers|callees";
5
6pub fn handle(symbol: &str, file: Option<&str>, project_root: &str, direction: &str) -> String {
7    let normalized_direction = match direction.to_lowercase().as_str() {
8        "callers" | "caller" => "callers",
9        "callees" | "callee" => "callees",
10        _ => {
11            return format!("Unknown direction '{direction}'. Use: {VALID_DIRECTIONS}");
12        }
13    };
14
15    let index = graph_index::load_or_build(project_root);
16    let graph = CallGraph::load_or_build(project_root, &index);
17    let _ = graph.save();
18
19    let filter = file.map(|f| graph_file_filter(f, project_root));
20
21    match normalized_direction {
22        "callers" => format_callers(symbol, &graph, filter.as_deref()),
23        "callees" => format_callees(symbol, &graph, filter.as_deref()),
24        _ => unreachable!("direction normalized above"),
25    }
26}
27
28fn format_callers(symbol: &str, graph: &CallGraph, filter: Option<&str>) -> String {
29    let mut callers = graph.callers_of(symbol);
30    if let Some(f) = filter {
31        callers.retain(|e| graph_index::graph_match_key(&e.caller_file).contains(f));
32    }
33
34    if callers.is_empty() {
35        return format!(
36            "No callers found for '{}' ({} edges in graph)",
37            symbol,
38            graph.edges.len()
39        );
40    }
41
42    let mut out = format!("{} caller(s) of '{symbol}':\n", callers.len());
43    for edge in &callers {
44        out.push_str(&format!(
45            "  {} → {}  (L{})\n",
46            edge.caller_file, edge.caller_symbol, edge.caller_line
47        ));
48    }
49    out
50}
51
52fn format_callees(symbol: &str, graph: &CallGraph, filter: Option<&str>) -> String {
53    let mut callees = graph.callees_of(symbol);
54    if let Some(f) = filter {
55        callees.retain(|e| graph_index::graph_match_key(&e.caller_file).contains(f));
56    }
57
58    if callees.is_empty() {
59        return format!(
60            "No callees found for '{}' ({} edges in graph)",
61            symbol,
62            graph.edges.len()
63        );
64    }
65
66    let mut out = format!("{} callee(s) of '{symbol}':\n", callees.len());
67    for edge in &callees {
68        out.push_str(&format!(
69            "  → {}  ({}:L{})\n",
70            edge.callee_name, edge.caller_file, edge.caller_line
71        ));
72    }
73    out
74}
75
76fn graph_file_filter(file: &str, project_root: &str) -> String {
77    let rel = graph_index::graph_relative_key(file, project_root);
78    let rel_key = graph_index::graph_match_key(&rel);
79    if rel_key.is_empty() {
80        graph_index::graph_match_key(file)
81    } else {
82        rel_key
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::graph_file_filter;
89    use super::handle;
90
91    #[test]
92    fn graph_file_filter_normalizes_windows_styles() {
93        let filter = graph_file_filter(r"C:/repo/src/main/kotlin/Example.kt", r"C:\repo");
94        let expected = if cfg!(windows) {
95            "src/main/kotlin/Example.kt"
96        } else {
97            "C:/repo/src/main/kotlin/Example.kt"
98        };
99        assert_eq!(filter, expected);
100    }
101
102    #[test]
103    fn invalid_direction_returns_helpful_error() {
104        let output = handle("foo", None, "/tmp", "unknown");
105        assert!(output.contains("Unknown direction"));
106        assert!(output.contains("callers|callees"));
107    }
108}