lean_ctx/tools/
ctx_callgraph.rs1use crate::core::call_graph::CallGraph;
2use crate::core::graph_index;
3
4const VALID_DIRECTIONS: &str = "callers|callees";
5
6pub fn handle(symbol: &str, file: Option<&str>, project_root: &str, direction: &str) -> String {
7 let normalized_direction = match direction.to_lowercase().as_str() {
8 "callers" | "caller" => "callers",
9 "callees" | "callee" => "callees",
10 _ => {
11 return format!("Unknown direction '{direction}'. Use: {VALID_DIRECTIONS}");
12 }
13 };
14
15 let index = graph_index::load_or_build(project_root);
16 let graph = CallGraph::load_or_build(project_root, &index);
17 let _ = graph.save();
18
19 let filter = file.map(|f| graph_file_filter(f, project_root));
20
21 match normalized_direction {
22 "callers" => format_callers(symbol, &graph, filter.as_deref()),
23 "callees" => format_callees(symbol, &graph, filter.as_deref()),
24 _ => unreachable!("direction normalized above"),
25 }
26}
27
28fn format_callers(symbol: &str, graph: &CallGraph, filter: Option<&str>) -> String {
29 let mut callers = graph.callers_of(symbol);
30 if let Some(f) = filter {
31 callers.retain(|e| graph_index::graph_match_key(&e.caller_file).contains(f));
32 }
33
34 if callers.is_empty() {
35 return format!(
36 "No callers found for '{}' ({} edges in graph)",
37 symbol,
38 graph.edges.len()
39 );
40 }
41
42 let mut out = format!("{} caller(s) of '{symbol}':\n", callers.len());
43 for edge in &callers {
44 out.push_str(&format!(
45 " {} → {} (L{})\n",
46 edge.caller_file, edge.caller_symbol, edge.caller_line
47 ));
48 }
49 out
50}
51
52fn format_callees(symbol: &str, graph: &CallGraph, filter: Option<&str>) -> String {
53 let mut callees = graph.callees_of(symbol);
54 if let Some(f) = filter {
55 callees.retain(|e| graph_index::graph_match_key(&e.caller_file).contains(f));
56 }
57
58 if callees.is_empty() {
59 return format!(
60 "No callees found for '{}' ({} edges in graph)",
61 symbol,
62 graph.edges.len()
63 );
64 }
65
66 let mut out = format!("{} callee(s) of '{symbol}':\n", callees.len());
67 for edge in &callees {
68 out.push_str(&format!(
69 " → {} ({}:L{})\n",
70 edge.callee_name, edge.caller_file, edge.caller_line
71 ));
72 }
73 out
74}
75
76fn graph_file_filter(file: &str, project_root: &str) -> String {
77 let rel = graph_index::graph_relative_key(file, project_root);
78 let rel_key = graph_index::graph_match_key(&rel);
79 if rel_key.is_empty() {
80 graph_index::graph_match_key(file)
81 } else {
82 rel_key
83 }
84}
85
86#[cfg(test)]
87mod tests {
88 use super::graph_file_filter;
89 use super::handle;
90
91 #[test]
92 fn graph_file_filter_normalizes_windows_styles() {
93 let filter = graph_file_filter(r"C:/repo/src/main/kotlin/Example.kt", r"C:\repo");
94 let expected = if cfg!(windows) {
95 "src/main/kotlin/Example.kt"
96 } else {
97 "C:/repo/src/main/kotlin/Example.kt"
98 };
99 assert_eq!(filter, expected);
100 }
101
102 #[test]
103 fn invalid_direction_returns_helpful_error() {
104 let output = handle("foo", None, "/tmp", "unknown");
105 assert!(output.contains("Unknown direction"));
106 assert!(output.contains("callers|callees"));
107 }
108}