Skip to main content

lean_ctx/tools/
ctx_callgraph.rs

1use crate::core::call_graph::{CallGraph, CallGraphInputs, RiskLevel};
2use crate::core::index_paths;
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            with_handle_hint(handle_direction(sym, file, project_root, action, depth))
21        }
22        "trace" => with_handle_hint(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 inputs = CallGraphInputs::open(project_root);
35    let graph = CallGraph::load_or_build(project_root, &inputs);
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    let mut out = 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    // Fold in the code-health dimension (#1084): a symbol that is both
116    // widely-called AND cognitively complex is the highest-leverage refactor.
117    // Sourced from the persisted health fabric (best-effort, no parsing).
118    if let Some(cc) = crate::core::code_health::fabric::hotspot_cc(project_root, symbol) {
119        out.push_str(&format!(
120            "\nComplexity: cc={cc} (over navigability threshold) — high blast-radius and hard to read."
121        ));
122    }
123    out
124}
125
126// ---------------------------------------------------------------------------
127// Single-hop formatters (existing behavior)
128// ---------------------------------------------------------------------------
129
130fn format_callers(symbol: &str, graph: &CallGraph, filter: Option<&str>) -> String {
131    let mut callers = graph.callers_of(symbol);
132    if let Some(f) = filter {
133        callers.retain(|e| index_paths::graph_match_key(&e.caller_file).contains(f));
134    }
135
136    if callers.is_empty() {
137        return format!(
138            "No callers found for '{}' ({} edges in graph)",
139            symbol,
140            graph.edges.len()
141        );
142    }
143
144    let mut out = format!("{} caller(s) of '{symbol}':\n", callers.len());
145    for edge in &callers {
146        out.push_str(&format!(
147            "  {} → {}  (L{})\n",
148            edge.caller_file, edge.caller_symbol, edge.caller_line
149        ));
150    }
151    out
152}
153
154fn format_callees(symbol: &str, graph: &CallGraph, filter: Option<&str>) -> String {
155    let mut callees = graph.callees_of(symbol);
156    if let Some(f) = filter {
157        callees.retain(|e| index_paths::graph_match_key(&e.caller_file).contains(f));
158    }
159
160    if callees.is_empty() {
161        return format!(
162            "No callees found for '{}' ({} edges in graph)",
163            symbol,
164            graph.edges.len()
165        );
166    }
167
168    let mut out = format!("{} callee(s) of '{symbol}':\n", callees.len());
169    for edge in &callees {
170        out.push_str(&format!(
171            "  → {}  ({}:L{})\n",
172            edge.callee_name, edge.caller_file, edge.caller_line
173        ));
174    }
175    out
176}
177
178// ---------------------------------------------------------------------------
179// Multi-hop BFS formatters
180// ---------------------------------------------------------------------------
181
182fn format_bfs_callers(
183    symbol: &str,
184    graph: &CallGraph,
185    depth: usize,
186    filter: Option<&str>,
187) -> String {
188    let mut nodes = graph.bfs_callers(symbol, depth);
189    if let Some(f) = filter {
190        nodes.retain(|n| index_paths::graph_match_key(&n.file).contains(f));
191    }
192
193    if nodes.is_empty() {
194        return format!(
195            "No callers found for '{}' (depth≤{}, {} edges in graph)",
196            symbol,
197            depth,
198            graph.edges.len()
199        );
200    }
201
202    let mut out = format!(
203        "{} caller(s) of '{}' (depth≤{}):\n",
204        nodes.len(),
205        symbol,
206        depth
207    );
208    for node in &nodes {
209        let indent = "  ".repeat(node.depth);
210        out.push_str(&format!(
211            "{indent}{} ← {}  ({}:L{})\n",
212            node.from_symbol, node.symbol, node.file, node.line
213        ));
214    }
215    out
216}
217
218fn format_bfs_callees(
219    symbol: &str,
220    graph: &CallGraph,
221    depth: usize,
222    filter: Option<&str>,
223) -> String {
224    let mut nodes = graph.bfs_callees(symbol, depth);
225    if let Some(f) = filter {
226        nodes.retain(|n| index_paths::graph_match_key(&n.file).contains(f));
227    }
228
229    if nodes.is_empty() {
230        return format!(
231            "No callees found for '{}' (depth≤{}, {} edges in graph)",
232            symbol,
233            depth,
234            graph.edges.len()
235        );
236    }
237
238    let mut out = format!(
239        "{} callee(s) of '{}' (depth≤{}):\n",
240        nodes.len(),
241        symbol,
242        depth
243    );
244    for node in &nodes {
245        let indent = "  ".repeat(node.depth);
246        out.push_str(&format!(
247            "{indent}{} → {}  ({}:L{})\n",
248            node.from_symbol, node.symbol, node.file, node.line
249        ));
250    }
251    out
252}
253
254/// Append the stable-handle usage hint (#607) to a non-empty result so the
255/// agent can re-target any listed symbol via `ctx_search(action="symbol",
256/// handle=…)`. Skips error/empty messages (which start with "No ") so failures
257/// stay clean.
258fn with_handle_hint(out: String) -> String {
259    if out.starts_with("No ") || out.trim().is_empty() {
260        out
261    } else {
262        format!("{out}{}\n", crate::core::handle::USAGE_HINT)
263    }
264}
265
266fn graph_file_filter(file: &str, project_root: &str) -> String {
267    let rel = index_paths::graph_relative_key(file, project_root);
268    let rel_key = index_paths::graph_match_key(&rel);
269    if rel_key.is_empty() {
270        index_paths::graph_match_key(file)
271    } else {
272        rel_key
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::graph_file_filter;
279
280    #[test]
281    fn graph_file_filter_normalizes_windows_styles() {
282        let filter = graph_file_filter(r"C:/repo/src/main/kotlin/Example.kt", r"C:\repo");
283        let expected = if cfg!(windows) {
284            "src/main/kotlin/Example.kt"
285        } else {
286            "C:/repo/src/main/kotlin/Example.kt"
287        };
288        assert_eq!(filter, expected);
289    }
290
291    #[test]
292    fn invalid_action_returns_helpful_error() {
293        let output = super::handle("unknown", Some("foo"), None, "/tmp", 1, None, None);
294        assert!(output.contains("Unknown action"));
295        assert!(output.contains("callers|callees|trace|risk"));
296    }
297
298    #[test]
299    fn callers_action_without_symbol_returns_error() {
300        let output = super::handle("callers", None, None, "/tmp", 1, None, None);
301        assert!(output.contains("symbol is required"));
302    }
303
304    #[test]
305    fn trace_without_from_returns_error() {
306        let output = super::handle("trace", None, None, "/tmp", 1, None, Some("b"));
307        assert!(output.contains("'from' is required"));
308    }
309
310    #[test]
311    fn trace_without_to_returns_error() {
312        let output = super::handle("trace", None, None, "/tmp", 1, Some("a"), None);
313        assert!(output.contains("'to' is required"));
314    }
315
316    #[test]
317    fn risk_without_symbol_returns_error() {
318        let output = super::handle("risk", None, None, "/tmp", 1, None, None);
319        assert!(output.contains("symbol is required"));
320    }
321
322    #[test]
323    fn handle_hint_appended_to_results_only() {
324        use super::with_handle_hint;
325        let ok = with_handle_hint("1 caller(s) of 'x':\n  a → b  (L1)\n".to_string());
326        assert!(
327            ok.contains("handle=\""),
328            "success output gets the hint: {ok}"
329        );
330        let empty = with_handle_hint("No callers found for 'x' (0 edges in graph)".to_string());
331        assert!(
332            !empty.contains("handle=\""),
333            "empty result stays clean: {empty}"
334        );
335    }
336}