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 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
126fn 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
178fn 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
254fn 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}