1use lean_ctx::core::call_graph::{CallEdge, CallGraph, CallGraphInputs, RiskLevel};
3use lean_ctx::core::index_paths;
4
5const MAX_BFS_DEPTH: usize = 5;
6
7pub fn handle(
8 action: &str,
9 symbol: Option<&str>,
10 file: Option<&str>,
11 project_root: &str,
12 depth: usize,
13 from: Option<&str>,
14 to: Option<&str>,
15) -> String {
16 match action {
17 "callers" | "callees" => {
18 let Some(sym) = symbol else {
19 return "symbol is required for callers/callees action".to_string();
20 };
21 with_handle_hint(handle_direction(sym, file, project_root, action, depth))
22 }
23 "trace" => with_handle_hint(handle_trace(from, to, project_root)),
24 "risk" => {
25 let Some(sym) = symbol else {
26 return "symbol is required for risk action".to_string();
27 };
28 handle_risk(sym, project_root)
29 }
30 _ => format!("Unknown action '{action}'. Use: callers|callees|trace|risk"),
31 }
32}
33
34fn load_graph(project_root: &str) -> CallGraph {
35 load_graph_with_inputs(project_root).0
36}
37
38fn load_graph_with_inputs(project_root: &str) -> (CallGraph, CallGraphInputs) {
39 let inputs = CallGraphInputs::open(project_root);
40 let graph = CallGraph::load_or_build(project_root, &inputs);
41 let _ = graph.save();
42 (graph, inputs)
43}
44
45fn target_annotations(
50 edges: &[&CallEdge],
51 graph: &CallGraph,
52 inputs: &CallGraphInputs,
53 project_root: &str,
54) -> Vec<String> {
55 use lean_ctx::core::call_graph::{StructuralTarget, resolve_edge_callee_targets};
56 use lean_ctx::core::semantic::{EvidenceGrade, SemanticVerdict, escalate_calls};
57
58 let owned: Vec<CallEdge> = edges.iter().map(|e| (*e).clone()).collect();
59 let structural = resolve_edge_callee_targets(inputs, &owned);
60 let store = lean_ctx::core::property_graph::CodeGraph::open(project_root).ok();
61 let verdicts = match &store {
62 Some(store) => {
63 let mode = lean_ctx::core::config::SemanticMode::for_project(project_root);
64 escalate_calls(
65 store,
66 project_root,
67 inputs,
68 &owned,
69 &structural,
70 &graph.file_hashes,
71 mode,
72 lean_ctx::core::semantic::EscalationBudget::INTERACTIVE,
73 )
74 .verdicts
75 }
76 None => vec![None; owned.len()],
77 };
78 let served = |file: &str| store.as_ref().is_none_or(|store| store.serves_file(file));
81 structural
82 .iter()
83 .zip(verdicts)
84 .map(|(target, verdict)| {
85 let named = match (&verdict, target) {
86 (Some(SemanticVerdict::Verified { file, .. }), _)
87 | (None, StructuralTarget::Resolved { file, .. }) => Some(file.as_str()),
88 _ => None,
89 };
90 if named.is_some_and(|file| !served(file)) {
91 return String::new();
92 }
93 match (verdict, target) {
94 (Some(SemanticVerdict::Verified { file, .. }), _) => {
95 format!(" ⇒ {file} [verified]")
96 }
97 (Some(SemanticVerdict::NotInProject), _) => " ⇒ external [verified]".to_string(),
98 (None, StructuralTarget::Resolved { file, via }) => {
99 format!(" ⇒ {file} [{}]", EvidenceGrade::from_scope(*via).as_str())
100 }
101 (None, StructuralTarget::Ambiguous) => " ⇒ ? [ambiguous]".to_string(),
102 (None, StructuralTarget::Unknown) => String::new(),
103 }
104 })
105 .collect()
106}
107
108fn handle_direction(
109 symbol: &str,
110 file: Option<&str>,
111 project_root: &str,
112 direction: &str,
113 depth: usize,
114) -> String {
115 let clamped_depth = depth.clamp(1, MAX_BFS_DEPTH);
116
117 if clamped_depth == 1 {
118 let (graph, inputs) = load_graph_with_inputs(project_root);
119 let filter = file.map(|f| graph_file_filter(f, project_root));
120 let annotate =
121 |edges: &[&CallEdge]| target_annotations(edges, &graph, &inputs, project_root);
122 match direction {
123 "callers" => format_callers(symbol, &graph, filter.as_deref(), annotate),
124 "callees" => format_callees(symbol, &graph, filter.as_deref(), annotate),
125 _ => unreachable!(),
126 }
127 } else {
128 let graph = load_graph(project_root);
129 let filter = file.map(|f| graph_file_filter(f, project_root));
130 match direction {
131 "callers" => format_bfs_callers(symbol, &graph, clamped_depth, filter.as_deref()),
132 "callees" => format_bfs_callees(symbol, &graph, clamped_depth, filter.as_deref()),
133 _ => unreachable!(),
134 }
135 }
136}
137
138fn handle_trace(from: Option<&str>, to: Option<&str>, project_root: &str) -> String {
139 let Some(from_sym) = from else {
140 return "'from' is required for trace action".to_string();
141 };
142 let Some(to_sym) = to else {
143 return "'to' is required for trace action".to_string();
144 };
145
146 let graph = load_graph(project_root);
147
148 match graph.find_call_path(from_sym, to_sym) {
149 Some(hops) => {
150 let mut out = format!("Call path ({} hop(s)):\n", hops.len() - 1);
151 for (i, hop) in hops.iter().enumerate() {
152 let loc = if hop.file.is_empty() {
153 String::new()
154 } else {
155 format!(" ({}:L{})", hop.file, hop.line)
156 };
157 if i == 0 {
158 out.push_str(&format!(" {}{loc}\n", hop.symbol));
159 } else {
160 out.push_str(&format!(" → {}{loc}\n", hop.symbol));
161 }
162 }
163 out
164 }
165 None => {
166 format!("No call path found from '{from_sym}' to '{to_sym}' (searched up to depth 10)")
167 }
168 }
169}
170
171fn handle_risk(symbol: &str, project_root: &str) -> String {
172 let graph = load_graph(project_root);
173 let count = graph.transitive_caller_count(symbol, MAX_BFS_DEPTH);
174 let level = RiskLevel::from_caller_count(count);
175 let direct = graph.callers_of(symbol).len();
176
177 let mut out = format!(
178 "Risk: {} — {} transitive caller(s) of '{}' (depth≤{}, {} direct)\n\
179 Thresholds: CRITICAL >10 | HIGH 5–10 | MEDIUM 2–4 | LOW 0–1",
180 level.label(),
181 count,
182 symbol,
183 MAX_BFS_DEPTH,
184 direct,
185 );
186
187 if let Some(cc) = lean_ctx::core::code_health::fabric::hotspot_cc(project_root, symbol) {
191 out.push_str(&format!(
192 "\nComplexity: cc={cc} (over navigability threshold) — high blast-radius and hard to read."
193 ));
194 }
195 out
196}
197
198fn format_callers(
203 symbol: &str,
204 graph: &CallGraph,
205 filter: Option<&str>,
206 annotate: impl FnOnce(&[&CallEdge]) -> Vec<String>,
207) -> String {
208 let mut callers = graph.callers_of(symbol);
209 if let Some(f) = filter {
210 callers.retain(|e| index_paths::graph_match_key(&e.caller_file).contains(f));
211 }
212 let mut seen = std::collections::HashSet::new();
214 callers.retain(|e| seen.insert((&e.caller_file, &e.caller_symbol, e.caller_line)));
215
216 if callers.is_empty() {
217 return format!(
218 "No callers found for '{}' ({} edges in graph)",
219 symbol,
220 graph.edges.len()
221 );
222 }
223
224 let mut out = format!("{} caller(s) of '{symbol}':\n", callers.len());
225 for (edge, target) in callers.iter().zip(annotate(&callers)) {
226 out.push_str(&format!(
227 " {} → {} (L{}){target}\n",
228 edge.caller_file, edge.caller_symbol, edge.caller_line
229 ));
230 }
231 out
232}
233
234fn format_callees(
235 symbol: &str,
236 graph: &CallGraph,
237 filter: Option<&str>,
238 annotate: impl FnOnce(&[&CallEdge]) -> Vec<String>,
239) -> String {
240 let mut callees = graph.callees_of(symbol);
241 if let Some(f) = filter {
242 callees.retain(|e| index_paths::graph_match_key(&e.caller_file).contains(f));
243 }
244 let mut seen = std::collections::HashSet::new();
245 callees.retain(|e| seen.insert((&e.callee_name, &e.caller_file, e.caller_line)));
246
247 if callees.is_empty() {
248 return format!(
249 "No callees found for '{}' ({} edges in graph)",
250 symbol,
251 graph.edges.len()
252 );
253 }
254
255 let mut out = format!("{} callee(s) of '{symbol}':\n", callees.len());
256 for (edge, target) in callees.iter().zip(annotate(&callees)) {
257 out.push_str(&format!(
258 " → {} ({}:L{}){target}\n",
259 edge.callee_name, edge.caller_file, edge.caller_line
260 ));
261 }
262 out
263}
264
265fn format_bfs_callers(
270 symbol: &str,
271 graph: &CallGraph,
272 depth: usize,
273 filter: Option<&str>,
274) -> String {
275 let mut nodes = graph.bfs_callers(symbol, depth);
276 if let Some(f) = filter {
277 nodes.retain(|n| index_paths::graph_match_key(&n.file).contains(f));
278 }
279
280 if nodes.is_empty() {
281 return format!(
282 "No callers found for '{}' (depth≤{}, {} edges in graph)",
283 symbol,
284 depth,
285 graph.edges.len()
286 );
287 }
288
289 let mut out = format!(
290 "{} caller(s) of '{}' (depth≤{}):\n",
291 nodes.len(),
292 symbol,
293 depth
294 );
295 for node in &nodes {
296 let indent = " ".repeat(node.depth);
297 out.push_str(&format!(
298 "{indent}{} ← {} ({}:L{})\n",
299 node.from_symbol, node.symbol, node.file, node.line
300 ));
301 }
302 out
303}
304
305fn format_bfs_callees(
306 symbol: &str,
307 graph: &CallGraph,
308 depth: usize,
309 filter: Option<&str>,
310) -> String {
311 let mut nodes = graph.bfs_callees(symbol, depth);
312 if let Some(f) = filter {
313 nodes.retain(|n| index_paths::graph_match_key(&n.file).contains(f));
314 }
315
316 if nodes.is_empty() {
317 return format!(
318 "No callees found for '{}' (depth≤{}, {} edges in graph)",
319 symbol,
320 depth,
321 graph.edges.len()
322 );
323 }
324
325 let mut out = format!(
326 "{} callee(s) of '{}' (depth≤{}):\n",
327 nodes.len(),
328 symbol,
329 depth
330 );
331 for node in &nodes {
332 let indent = " ".repeat(node.depth);
333 out.push_str(&format!(
334 "{indent}{} → {} ({}:L{})\n",
335 node.from_symbol, node.symbol, node.file, node.line
336 ));
337 }
338 out
339}
340
341fn with_handle_hint(out: String) -> String {
346 if out.starts_with("No ") || out.trim().is_empty() {
347 out
348 } else {
349 format!("{out}{}\n", lean_ctx::core::handle::USAGE_HINT)
350 }
351}
352
353fn graph_file_filter(file: &str, project_root: &str) -> String {
354 let rel = index_paths::graph_relative_key(file, project_root);
355 let rel_key = index_paths::graph_match_key(&rel);
356 if rel_key.is_empty() {
357 index_paths::graph_match_key(file)
358 } else {
359 rel_key
360 }
361}
362
363#[cfg(test)]
364mod tests {
365 use super::graph_file_filter;
366
367 #[test]
368 fn graph_file_filter_normalizes_windows_styles() {
369 let filter = graph_file_filter(r"C:/repo/src/main/kotlin/Example.kt", r"C:\repo");
370 let expected = if cfg!(windows) {
371 "src/main/kotlin/Example.kt"
372 } else {
373 "C:/repo/src/main/kotlin/Example.kt"
374 };
375 assert_eq!(filter, expected);
376 }
377
378 #[test]
379 fn invalid_action_returns_helpful_error() {
380 let output = super::handle("unknown", Some("foo"), None, "/tmp", 1, None, None);
381 assert!(output.contains("Unknown action"));
382 assert!(output.contains("callers|callees|trace|risk"));
383 }
384
385 #[test]
386 fn callers_action_without_symbol_returns_error() {
387 let output = super::handle("callers", None, None, "/tmp", 1, None, None);
388 assert!(output.contains("symbol is required"));
389 }
390
391 #[test]
392 fn trace_without_from_returns_error() {
393 let output = super::handle("trace", None, None, "/tmp", 1, None, Some("b"));
394 assert!(output.contains("'from' is required"));
395 }
396
397 #[test]
398 fn trace_without_to_returns_error() {
399 let output = super::handle("trace", None, None, "/tmp", 1, Some("a"), None);
400 assert!(output.contains("'to' is required"));
401 }
402
403 #[test]
404 fn risk_without_symbol_returns_error() {
405 let output = super::handle("risk", None, None, "/tmp", 1, None, None);
406 assert!(output.contains("symbol is required"));
407 }
408
409 #[test]
410 fn two_calls_from_one_function_render_one_caller_line() {
411 use lean_ctx::core::call_graph::{CallEdge, CallGraph};
412 let mut graph = CallGraph::new("/p");
413 for _ in 0..2 {
414 graph.edges.push(CallEdge {
415 caller_file: "vat.py".to_string(),
416 caller_symbol: "gross_from_net".to_string(),
417 caller_line: 16,
418 callee_name: "to_amount".to_string(),
419 ..Default::default()
420 });
421 }
422 let out = super::format_callers("to_amount", &graph, None, |edges| {
423 vec![String::new(); edges.len()]
424 });
425 assert_eq!(out.matches("gross_from_net").count(), 1, "{out}");
426 assert!(out.starts_with("1 caller(s)"), "{out}");
427 }
428
429 #[test]
430 fn handle_hint_appended_to_results_only() {
431 use super::with_handle_hint;
432 let ok = with_handle_hint("1 caller(s) of 'x':\n a → b (L1)\n".to_string());
433 assert!(
434 ok.contains("handle=\""),
435 "success output gets the hint: {ok}"
436 );
437 let empty = with_handle_hint("No callers found for 'x' (0 edges in graph)".to_string());
438 assert!(
439 !empty.contains("handle=\""),
440 "empty result stays clean: {empty}"
441 );
442 }
443}