use lean_ctx::core::call_graph::{CallEdge, CallGraph, CallGraphInputs, RiskLevel};
use lean_ctx::core::index_paths;
const MAX_BFS_DEPTH: usize = 5;
pub fn handle(
action: &str,
symbol: Option<&str>,
file: Option<&str>,
project_root: &str,
depth: usize,
from: Option<&str>,
to: Option<&str>,
) -> String {
match action {
"callers" | "callees" => {
let Some(sym) = symbol else {
return "symbol is required for callers/callees action".to_string();
};
with_handle_hint(handle_direction(sym, file, project_root, action, depth))
}
"trace" => with_handle_hint(handle_trace(from, to, project_root)),
"risk" => {
let Some(sym) = symbol else {
return "symbol is required for risk action".to_string();
};
handle_risk(sym, project_root)
}
_ => format!("Unknown action '{action}'. Use: callers|callees|trace|risk"),
}
}
fn load_graph(project_root: &str) -> CallGraph {
load_graph_with_inputs(project_root).0
}
fn load_graph_with_inputs(project_root: &str) -> (CallGraph, CallGraphInputs) {
let inputs = CallGraphInputs::open(project_root);
let graph = CallGraph::load_or_build(project_root, &inputs);
let _ = graph.save();
(graph, inputs)
}
fn target_annotations(
edges: &[&CallEdge],
graph: &CallGraph,
inputs: &CallGraphInputs,
project_root: &str,
) -> Vec<String> {
use lean_ctx::core::call_graph::{StructuralTarget, resolve_edge_callee_targets};
use lean_ctx::core::semantic::{EvidenceGrade, SemanticVerdict, escalate_calls};
let owned: Vec<CallEdge> = edges.iter().map(|e| (*e).clone()).collect();
let structural = resolve_edge_callee_targets(inputs, &owned);
let store = lean_ctx::core::property_graph::CodeGraph::open(project_root).ok();
let verdicts = match &store {
Some(store) => {
let mode = lean_ctx::core::config::SemanticMode::for_project(project_root);
escalate_calls(
store,
project_root,
inputs,
&owned,
&structural,
&graph.file_hashes,
mode,
lean_ctx::core::semantic::EscalationBudget::INTERACTIVE,
)
.verdicts
}
None => vec![None; owned.len()],
};
let served = |file: &str| store.as_ref().is_none_or(|store| store.serves_file(file));
structural
.iter()
.zip(verdicts)
.map(|(target, verdict)| {
let named = match (&verdict, target) {
(Some(SemanticVerdict::Verified { file, .. }), _)
| (None, StructuralTarget::Resolved { file, .. }) => Some(file.as_str()),
_ => None,
};
if named.is_some_and(|file| !served(file)) {
return String::new();
}
match (verdict, target) {
(Some(SemanticVerdict::Verified { file, .. }), _) => {
format!(" ⇒ {file} [verified]")
}
(Some(SemanticVerdict::NotInProject), _) => " ⇒ external [verified]".to_string(),
(None, StructuralTarget::Resolved { file, via }) => {
format!(" ⇒ {file} [{}]", EvidenceGrade::from_scope(*via).as_str())
}
(None, StructuralTarget::Ambiguous) => " ⇒ ? [ambiguous]".to_string(),
(None, StructuralTarget::Unknown) => String::new(),
}
})
.collect()
}
fn handle_direction(
symbol: &str,
file: Option<&str>,
project_root: &str,
direction: &str,
depth: usize,
) -> String {
let clamped_depth = depth.clamp(1, MAX_BFS_DEPTH);
if clamped_depth == 1 {
let (graph, inputs) = load_graph_with_inputs(project_root);
let filter = file.map(|f| graph_file_filter(f, project_root));
let annotate =
|edges: &[&CallEdge]| target_annotations(edges, &graph, &inputs, project_root);
match direction {
"callers" => format_callers(symbol, &graph, filter.as_deref(), annotate),
"callees" => format_callees(symbol, &graph, filter.as_deref(), annotate),
_ => unreachable!(),
}
} else {
let graph = load_graph(project_root);
let filter = file.map(|f| graph_file_filter(f, project_root));
match direction {
"callers" => format_bfs_callers(symbol, &graph, clamped_depth, filter.as_deref()),
"callees" => format_bfs_callees(symbol, &graph, clamped_depth, filter.as_deref()),
_ => unreachable!(),
}
}
}
fn handle_trace(from: Option<&str>, to: Option<&str>, project_root: &str) -> String {
let Some(from_sym) = from else {
return "'from' is required for trace action".to_string();
};
let Some(to_sym) = to else {
return "'to' is required for trace action".to_string();
};
let graph = load_graph(project_root);
match graph.find_call_path(from_sym, to_sym) {
Some(hops) => {
let mut out = format!("Call path ({} hop(s)):\n", hops.len() - 1);
for (i, hop) in hops.iter().enumerate() {
let loc = if hop.file.is_empty() {
String::new()
} else {
format!(" ({}:L{})", hop.file, hop.line)
};
if i == 0 {
out.push_str(&format!(" {}{loc}\n", hop.symbol));
} else {
out.push_str(&format!(" → {}{loc}\n", hop.symbol));
}
}
out
}
None => {
format!("No call path found from '{from_sym}' to '{to_sym}' (searched up to depth 10)")
}
}
}
fn handle_risk(symbol: &str, project_root: &str) -> String {
let graph = load_graph(project_root);
let count = graph.transitive_caller_count(symbol, MAX_BFS_DEPTH);
let level = RiskLevel::from_caller_count(count);
let direct = graph.callers_of(symbol).len();
let mut out = format!(
"Risk: {} — {} transitive caller(s) of '{}' (depth≤{}, {} direct)\n\
Thresholds: CRITICAL >10 | HIGH 5–10 | MEDIUM 2–4 | LOW 0–1",
level.label(),
count,
symbol,
MAX_BFS_DEPTH,
direct,
);
if let Some(cc) = lean_ctx::core::code_health::fabric::hotspot_cc(project_root, symbol) {
out.push_str(&format!(
"\nComplexity: cc={cc} (over navigability threshold) — high blast-radius and hard to read."
));
}
out
}
fn format_callers(
symbol: &str,
graph: &CallGraph,
filter: Option<&str>,
annotate: impl FnOnce(&[&CallEdge]) -> Vec<String>,
) -> String {
let mut callers = graph.callers_of(symbol);
if let Some(f) = filter {
callers.retain(|e| index_paths::graph_match_key(&e.caller_file).contains(f));
}
let mut seen = std::collections::HashSet::new();
callers.retain(|e| seen.insert((&e.caller_file, &e.caller_symbol, e.caller_line)));
if callers.is_empty() {
return format!(
"No callers found for '{}' ({} edges in graph)",
symbol,
graph.edges.len()
);
}
let mut out = format!("{} caller(s) of '{symbol}':\n", callers.len());
for (edge, target) in callers.iter().zip(annotate(&callers)) {
out.push_str(&format!(
" {} → {} (L{}){target}\n",
edge.caller_file, edge.caller_symbol, edge.caller_line
));
}
out
}
fn format_callees(
symbol: &str,
graph: &CallGraph,
filter: Option<&str>,
annotate: impl FnOnce(&[&CallEdge]) -> Vec<String>,
) -> String {
let mut callees = graph.callees_of(symbol);
if let Some(f) = filter {
callees.retain(|e| index_paths::graph_match_key(&e.caller_file).contains(f));
}
let mut seen = std::collections::HashSet::new();
callees.retain(|e| seen.insert((&e.callee_name, &e.caller_file, e.caller_line)));
if callees.is_empty() {
return format!(
"No callees found for '{}' ({} edges in graph)",
symbol,
graph.edges.len()
);
}
let mut out = format!("{} callee(s) of '{symbol}':\n", callees.len());
for (edge, target) in callees.iter().zip(annotate(&callees)) {
out.push_str(&format!(
" → {} ({}:L{}){target}\n",
edge.callee_name, edge.caller_file, edge.caller_line
));
}
out
}
fn format_bfs_callers(
symbol: &str,
graph: &CallGraph,
depth: usize,
filter: Option<&str>,
) -> String {
let mut nodes = graph.bfs_callers(symbol, depth);
if let Some(f) = filter {
nodes.retain(|n| index_paths::graph_match_key(&n.file).contains(f));
}
if nodes.is_empty() {
return format!(
"No callers found for '{}' (depth≤{}, {} edges in graph)",
symbol,
depth,
graph.edges.len()
);
}
let mut out = format!(
"{} caller(s) of '{}' (depth≤{}):\n",
nodes.len(),
symbol,
depth
);
for node in &nodes {
let indent = " ".repeat(node.depth);
out.push_str(&format!(
"{indent}{} ← {} ({}:L{})\n",
node.from_symbol, node.symbol, node.file, node.line
));
}
out
}
fn format_bfs_callees(
symbol: &str,
graph: &CallGraph,
depth: usize,
filter: Option<&str>,
) -> String {
let mut nodes = graph.bfs_callees(symbol, depth);
if let Some(f) = filter {
nodes.retain(|n| index_paths::graph_match_key(&n.file).contains(f));
}
if nodes.is_empty() {
return format!(
"No callees found for '{}' (depth≤{}, {} edges in graph)",
symbol,
depth,
graph.edges.len()
);
}
let mut out = format!(
"{} callee(s) of '{}' (depth≤{}):\n",
nodes.len(),
symbol,
depth
);
for node in &nodes {
let indent = " ".repeat(node.depth);
out.push_str(&format!(
"{indent}{} → {} ({}:L{})\n",
node.from_symbol, node.symbol, node.file, node.line
));
}
out
}
fn with_handle_hint(out: String) -> String {
if out.starts_with("No ") || out.trim().is_empty() {
out
} else {
format!("{out}{}\n", lean_ctx::core::handle::USAGE_HINT)
}
}
fn graph_file_filter(file: &str, project_root: &str) -> String {
let rel = index_paths::graph_relative_key(file, project_root);
let rel_key = index_paths::graph_match_key(&rel);
if rel_key.is_empty() {
index_paths::graph_match_key(file)
} else {
rel_key
}
}
#[cfg(test)]
mod tests {
use super::graph_file_filter;
#[test]
fn graph_file_filter_normalizes_windows_styles() {
let filter = graph_file_filter(r"C:/repo/src/main/kotlin/Example.kt", r"C:\repo");
let expected = if cfg!(windows) {
"src/main/kotlin/Example.kt"
} else {
"C:/repo/src/main/kotlin/Example.kt"
};
assert_eq!(filter, expected);
}
#[test]
fn invalid_action_returns_helpful_error() {
let output = super::handle("unknown", Some("foo"), None, "/tmp", 1, None, None);
assert!(output.contains("Unknown action"));
assert!(output.contains("callers|callees|trace|risk"));
}
#[test]
fn callers_action_without_symbol_returns_error() {
let output = super::handle("callers", None, None, "/tmp", 1, None, None);
assert!(output.contains("symbol is required"));
}
#[test]
fn trace_without_from_returns_error() {
let output = super::handle("trace", None, None, "/tmp", 1, None, Some("b"));
assert!(output.contains("'from' is required"));
}
#[test]
fn trace_without_to_returns_error() {
let output = super::handle("trace", None, None, "/tmp", 1, Some("a"), None);
assert!(output.contains("'to' is required"));
}
#[test]
fn risk_without_symbol_returns_error() {
let output = super::handle("risk", None, None, "/tmp", 1, None, None);
assert!(output.contains("symbol is required"));
}
#[test]
fn two_calls_from_one_function_render_one_caller_line() {
use lean_ctx::core::call_graph::{CallEdge, CallGraph};
let mut graph = CallGraph::new("/p");
for _ in 0..2 {
graph.edges.push(CallEdge {
caller_file: "vat.py".to_string(),
caller_symbol: "gross_from_net".to_string(),
caller_line: 16,
callee_name: "to_amount".to_string(),
..Default::default()
});
}
let out = super::format_callers("to_amount", &graph, None, |edges| {
vec![String::new(); edges.len()]
});
assert_eq!(out.matches("gross_from_net").count(), 1, "{out}");
assert!(out.starts_with("1 caller(s)"), "{out}");
}
#[test]
fn handle_hint_appended_to_results_only() {
use super::with_handle_hint;
let ok = with_handle_hint("1 caller(s) of 'x':\n a → b (L1)\n".to_string());
assert!(
ok.contains("handle=\""),
"success output gets the hint: {ok}"
);
let empty = with_handle_hint("No callers found for 'x' (0 edges in graph)".to_string());
assert!(
!empty.contains("handle=\""),
"empty result stays clean: {empty}"
);
}
}