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
116fn 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
168fn 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}