Skip to main content

lean_ctx/tools/
ctx_callgraph.rs

1use crate::core::call_graph::{CallGraph, RiskLevel};
2use crate::core::graph_index;
3
4const MAX_BFS_DEPTH: usize = 5;
5
6pub fn handle(
7    action: &str,
8    symbol: Option<&str>,
9    file: Option<&str>,
10    project_root: &str,
11    depth: usize,
12    from: Option<&str>,
13    to: Option<&str>,
14) -> String {
15    match action {
16        "callers" | "callees" => {
17            let Some(sym) = symbol else {
18                return "symbol is required for callers/callees action".to_string();
19            };
20            handle_direction(sym, file, project_root, action, depth)
21        }
22        "trace" => handle_trace(from, to, project_root),
23        "risk" => {
24            let Some(sym) = symbol else {
25                return "symbol is required for risk action".to_string();
26            };
27            handle_risk(sym, project_root)
28        }
29        _ => format!("Unknown action '{action}'. Use: callers|callees|trace|risk"),
30    }
31}
32
33fn load_graph(project_root: &str) -> CallGraph {
34    let index = graph_index::load_or_build(project_root);
35    let graph = CallGraph::load_or_build(project_root, &index);
36    let _ = graph.save();
37    graph
38}
39
40fn handle_direction(
41    symbol: &str,
42    file: Option<&str>,
43    project_root: &str,
44    direction: &str,
45    depth: usize,
46) -> String {
47    let graph = load_graph(project_root);
48    let filter = file.map(|f| graph_file_filter(f, project_root));
49    let clamped_depth = depth.clamp(1, MAX_BFS_DEPTH);
50
51    if clamped_depth == 1 {
52        match direction {
53            "callers" => format_callers(symbol, &graph, filter.as_deref()),
54            "callees" => format_callees(symbol, &graph, filter.as_deref()),
55            _ => unreachable!(),
56        }
57    } else {
58        match direction {
59            "callers" => format_bfs_callers(symbol, &graph, clamped_depth, filter.as_deref()),
60            "callees" => format_bfs_callees(symbol, &graph, clamped_depth, filter.as_deref()),
61            _ => unreachable!(),
62        }
63    }
64}
65
66fn handle_trace(from: Option<&str>, to: Option<&str>, project_root: &str) -> String {
67    let Some(from_sym) = from else {
68        return "'from' is required for trace action".to_string();
69    };
70    let Some(to_sym) = to else {
71        return "'to' is required for trace action".to_string();
72    };
73
74    let graph = load_graph(project_root);
75
76    match graph.find_call_path(from_sym, to_sym) {
77        Some(hops) => {
78            let mut out = format!("Call path ({} hop(s)):\n", hops.len() - 1);
79            for (i, hop) in hops.iter().enumerate() {
80                let loc = if hop.file.is_empty() {
81                    String::new()
82                } else {
83                    format!("  ({}:L{})", hop.file, hop.line)
84                };
85                if i == 0 {
86                    out.push_str(&format!("  {}{loc}\n", hop.symbol));
87                } else {
88                    out.push_str(&format!("  → {}{loc}\n", hop.symbol));
89                }
90            }
91            out
92        }
93        None => {
94            format!("No call path found from '{from_sym}' to '{to_sym}' (searched up to depth 10)")
95        }
96    }
97}
98
99fn handle_risk(symbol: &str, project_root: &str) -> String {
100    let graph = load_graph(project_root);
101    let count = graph.transitive_caller_count(symbol, MAX_BFS_DEPTH);
102    let level = RiskLevel::from_caller_count(count);
103    let direct = graph.callers_of(symbol).len();
104
105    format!(
106        "Risk: {} — {} transitive caller(s) of '{}' (depth≤{}, {} direct)\n\
107         Thresholds: CRITICAL >10 | HIGH 5–10 | MEDIUM 2–4 | LOW 0–1",
108        level.label(),
109        count,
110        symbol,
111        MAX_BFS_DEPTH,
112        direct,
113    )
114}
115
116// ---------------------------------------------------------------------------
117// Single-hop formatters (existing behavior)
118// ---------------------------------------------------------------------------
119
120fn format_callers(symbol: &str, graph: &CallGraph, filter: Option<&str>) -> String {
121    let mut callers = graph.callers_of(symbol);
122    if let Some(f) = filter {
123        callers.retain(|e| graph_index::graph_match_key(&e.caller_file).contains(f));
124    }
125
126    if callers.is_empty() {
127        return format!(
128            "No callers found for '{}' ({} edges in graph)",
129            symbol,
130            graph.edges.len()
131        );
132    }
133
134    let mut out = format!("{} caller(s) of '{symbol}':\n", callers.len());
135    for edge in &callers {
136        out.push_str(&format!(
137            "  {} → {}  (L{})\n",
138            edge.caller_file, edge.caller_symbol, edge.caller_line
139        ));
140    }
141    out
142}
143
144fn format_callees(symbol: &str, graph: &CallGraph, filter: Option<&str>) -> String {
145    let mut callees = graph.callees_of(symbol);
146    if let Some(f) = filter {
147        callees.retain(|e| graph_index::graph_match_key(&e.caller_file).contains(f));
148    }
149
150    if callees.is_empty() {
151        return format!(
152            "No callees found for '{}' ({} edges in graph)",
153            symbol,
154            graph.edges.len()
155        );
156    }
157
158    let mut out = format!("{} callee(s) of '{symbol}':\n", callees.len());
159    for edge in &callees {
160        out.push_str(&format!(
161            "  → {}  ({}:L{})\n",
162            edge.callee_name, edge.caller_file, edge.caller_line
163        ));
164    }
165    out
166}
167
168// ---------------------------------------------------------------------------
169// Multi-hop BFS formatters
170// ---------------------------------------------------------------------------
171
172fn format_bfs_callers(
173    symbol: &str,
174    graph: &CallGraph,
175    depth: usize,
176    filter: Option<&str>,
177) -> String {
178    let mut nodes = graph.bfs_callers(symbol, depth);
179    if let Some(f) = filter {
180        nodes.retain(|n| graph_index::graph_match_key(&n.file).contains(f));
181    }
182
183    if nodes.is_empty() {
184        return format!(
185            "No callers found for '{}' (depth≤{}, {} edges in graph)",
186            symbol,
187            depth,
188            graph.edges.len()
189        );
190    }
191
192    let mut out = format!(
193        "{} caller(s) of '{}' (depth≤{}):\n",
194        nodes.len(),
195        symbol,
196        depth
197    );
198    for node in &nodes {
199        let indent = "  ".repeat(node.depth);
200        out.push_str(&format!(
201            "{indent}{} ← {}  ({}:L{})\n",
202            node.from_symbol, node.symbol, node.file, node.line
203        ));
204    }
205    out
206}
207
208fn format_bfs_callees(
209    symbol: &str,
210    graph: &CallGraph,
211    depth: usize,
212    filter: Option<&str>,
213) -> String {
214    let mut nodes = graph.bfs_callees(symbol, depth);
215    if let Some(f) = filter {
216        nodes.retain(|n| graph_index::graph_match_key(&n.file).contains(f));
217    }
218
219    if nodes.is_empty() {
220        return format!(
221            "No callees found for '{}' (depth≤{}, {} edges in graph)",
222            symbol,
223            depth,
224            graph.edges.len()
225        );
226    }
227
228    let mut out = format!(
229        "{} callee(s) of '{}' (depth≤{}):\n",
230        nodes.len(),
231        symbol,
232        depth
233    );
234    for node in &nodes {
235        let indent = "  ".repeat(node.depth);
236        out.push_str(&format!(
237            "{indent}{} → {}  ({}:L{})\n",
238            node.from_symbol, node.symbol, node.file, node.line
239        ));
240    }
241    out
242}
243
244fn graph_file_filter(file: &str, project_root: &str) -> String {
245    let rel = graph_index::graph_relative_key(file, project_root);
246    let rel_key = graph_index::graph_match_key(&rel);
247    if rel_key.is_empty() {
248        graph_index::graph_match_key(file)
249    } else {
250        rel_key
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::graph_file_filter;
257
258    #[test]
259    fn graph_file_filter_normalizes_windows_styles() {
260        let filter = graph_file_filter(r"C:/repo/src/main/kotlin/Example.kt", r"C:\repo");
261        let expected = if cfg!(windows) {
262            "src/main/kotlin/Example.kt"
263        } else {
264            "C:/repo/src/main/kotlin/Example.kt"
265        };
266        assert_eq!(filter, expected);
267    }
268
269    #[test]
270    fn invalid_action_returns_helpful_error() {
271        let output = super::handle("unknown", Some("foo"), None, "/tmp", 1, None, None);
272        assert!(output.contains("Unknown action"));
273        assert!(output.contains("callers|callees|trace|risk"));
274    }
275
276    #[test]
277    fn callers_action_without_symbol_returns_error() {
278        let output = super::handle("callers", None, None, "/tmp", 1, None, None);
279        assert!(output.contains("symbol is required"));
280    }
281
282    #[test]
283    fn trace_without_from_returns_error() {
284        let output = super::handle("trace", None, None, "/tmp", 1, None, Some("b"));
285        assert!(output.contains("'from' is required"));
286    }
287
288    #[test]
289    fn trace_without_to_returns_error() {
290        let output = super::handle("trace", None, None, "/tmp", 1, Some("a"), None);
291        assert!(output.contains("'to' is required"));
292    }
293
294    #[test]
295    fn risk_without_symbol_returns_error() {
296        let output = super::handle("risk", None, None, "/tmp", 1, None, None);
297        assert!(output.contains("symbol is required"));
298    }
299}