use serde_json::Value;
use super::diff::{render_default, render_diff_value};
use super::{
normalize_tool_name, truncate_chars, BOLD, DIM, LIGHT_BLUE, LIGHT_CYAN, LIGHT_GREEN,
LIGHT_GREY, LIGHT_MAGENTA, LIGHT_RED, LIGHT_YELLOW, RESET, WHITE,
};
pub(super) fn header(title: &str, color: bool) -> String {
if color {
format!("{}── {} ──{}", LIGHT_CYAN, title, RESET)
} else {
format!("── {} ──", title)
}
}
fn field(name: &str, value: &str, color: bool) -> String {
if color {
format!(
" {}{}:{} {}{}{}\n",
BOLD, name, RESET, LIGHT_CYAN, value, RESET
)
} else {
format!(" {}: {}\n", name, value)
}
}
fn suffix(symbol_count: u64, color: &str, reset: &str) -> String {
if symbol_count == 0 {
String::new()
} else {
format!(" {}[{} symbols]{}", color, symbol_count, reset)
}
}
fn line_for(data: &Value) -> u64 {
if let Some(n) = data.get("line").and_then(|v| v.as_u64()) {
return n;
}
0
}
#[cfg(test)]
fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut iter = s.chars().peekable();
while let Some(c) = iter.next() {
if c == '\x1b' {
if iter.peek() == Some(&'[') {
iter.next();
for c2 in iter.by_ref() {
if c2.is_ascii_alphabetic() {
break;
}
}
continue;
}
}
out.push(c);
}
out
}
fn extract_array(data: &Value, keys: &[&str]) -> Vec<Value> {
if let Some(arr) = data.as_array() {
return arr.clone();
}
for k in keys {
if let Some(arr) = data.get(*k).and_then(|v| v.as_array()) {
return arr.clone();
}
}
Vec::new()
}
pub fn render_tree(nodes: &[Value], color: bool) -> String {
let mut out = String::new();
for (i, node) in nodes.iter().enumerate() {
out.push_str(&render_tree_node(
node,
"",
i == nodes.len() - 1,
color,
true,
));
}
out
}
fn render_tree_node(
node: &Value,
prefix: &str,
is_last: bool,
color: bool,
is_root: bool,
) -> String {
let mut out = String::new();
let name = node.get("name").and_then(|v| v.as_str()).unwrap_or("?");
let node_type = node.get("type").and_then(|v| v.as_str()).unwrap_or("file");
let symbol_count = node
.get("symbol_count")
.or_else(|| node.get("symbols"))
.and_then(|v| v.as_u64())
.unwrap_or(0);
let children = node.get("children").and_then(|v| v.as_array());
let connector = if is_root {
""
} else if is_last {
"└── "
} else {
"├── "
};
let name_color = if color {
match node_type {
"directory" | "dir" => LIGHT_BLUE,
"module" => LIGHT_MAGENTA,
_ => WHITE,
}
} else {
""
};
let count_color = if color { DIM } else { "" };
let reset = if color { RESET } else { "" };
let incoming = node
.get("incoming_dependencies")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let outgoing = node
.get("outgoing_dependencies")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let dep_suffix = if incoming > 0 || outgoing > 0 {
format!(
" {}[{}→{}←{}]{}",
count_color, outgoing, incoming, reset, reset,
)
} else {
String::new()
};
if is_root {
out.push_str(&format!(
"{}{}{}{}{}\n",
name_color,
name,
reset,
suffix(symbol_count, count_color, reset),
dep_suffix,
));
} else {
out.push_str(&format!(
"{}{}{}{}{}{}{}\n",
prefix,
connector,
name_color,
name,
reset,
suffix(symbol_count, count_color, reset),
dep_suffix,
));
}
if let Some(kids) = children {
let child_prefix = if is_last { " " } else { "│ " };
let combined_prefix = format!("{}{}", prefix, child_prefix);
for (i, child) in kids.iter().enumerate() {
out.push_str(&render_tree_node(
child,
&combined_prefix,
i == kids.len() - 1,
color,
false,
));
}
}
out
}
fn render_search(data: &Value, query: &str, color: bool) -> String {
let arr = extract_array(data, &["results", "items"]);
if arr.is_empty() {
return format!(
"{}\n No results for: {}\n",
header(&format!("Search: \"{}\"", query), color),
query,
);
}
let mut out = header(
&format!("Search: \"{}\" ({} results)", query, arr.len()),
color,
);
out.push('\n');
for (idx, r) in arr.iter().enumerate() {
let file = r.get("file_path").and_then(|v| v.as_str()).unwrap_or("?");
let symbol = r
.get("symbol")
.or_else(|| r.get("symbol_name"))
.and_then(|v| v.as_str());
let symbol_type = r.get("symbol_type").and_then(|v| v.as_str());
let score = r
.get("score")
.and_then(|v| v.get("overall"))
.and_then(|v| v.as_f64())
.or_else(|| r.get("score").and_then(|v| v.as_f64()));
let signature = r.get("signature").and_then(|v| v.as_str());
let context = r.get("context").and_then(|v| v.as_str());
let snippet = r.get("snippet").and_then(|v| v.as_str());
let byte_range = r.get("byte_range").and_then(|v| v.as_array());
let line_number = r.get("line_number").and_then(|v| v.as_u64());
out.push_str(&format!(
" {}{}.{} {}",
if color { BOLD } else { "" },
idx + 1,
if color { RESET } else { "" },
if color { LIGHT_YELLOW } else { "" },
));
out.push_str(file);
out.push_str(if color { RESET } else { "" });
if let Some(sym) = symbol {
out.push_str(&format!(
" :: {}{}{}",
if color { LIGHT_CYAN } else { "" },
sym,
if color { RESET } else { "" },
));
}
if let Some(typ) = symbol_type {
out.push_str(&format!(
" {}[{}]{}",
if color { DIM } else { "" },
typ,
if color { RESET } else { "" },
));
}
if let Some(ln) = line_number {
out.push_str(&format!(
" {}:{}{}",
if color { DIM } else { "" },
ln,
if color { RESET } else { "" },
));
}
if let Some(sc) = score {
let pct = (sc * 100.0).round() as usize;
out.push_str(&format!(
" {}{}%{}",
if color { DIM } else { "" },
pct,
if color { RESET } else { "" }
));
}
out.push('\n');
let mut printed = false;
if let Some(sig) = signature {
let trimmed = sig.trim();
if !trimmed.is_empty() {
out.push_str(&format!(
" {}{}{}\n",
if color { DIM } else { "" },
truncate_chars(trimmed, 160),
if color { RESET } else { "" },
));
printed = true;
}
}
if !printed {
if let Some(ctx) = context {
let first = ctx.lines().next().unwrap_or("").trim();
if !first.is_empty() {
out.push_str(&format!(
" {}{}{}\n",
if color { DIM } else { "" },
truncate_chars(first, 160),
if color { RESET } else { "" },
));
printed = true;
}
}
}
if !printed {
if let Some(snip) = snippet {
let trimmed = snip.trim();
if !trimmed.is_empty() {
out.push_str(&format!(
" {}{}{}\n",
if color { DIM } else { "" },
truncate_chars(trimmed, 160),
if color { RESET } else { "" },
));
printed = true;
}
}
}
if !printed {
if let Some(br) = byte_range {
if br.len() == 2 {
let start = br[0].as_u64().unwrap_or(0);
let end = br[1].as_u64().unwrap_or(0);
if end > start {
out.push_str(&format!(
" {}(bytes {}-{}){}\n",
if color { DIM } else { "" },
start,
end,
if color { RESET } else { "" },
));
}
}
}
}
}
out
}
fn render_context(data: &Value, node_id: &str, color: bool) -> String {
let mut out = header(&format!("Context: {}", node_id), color);
out.push('\n');
let anchor = data
.get("results")
.and_then(|v| v.as_array())
.and_then(|a| a.first());
if let Some(sym) = anchor
.and_then(|r| r.get("symbol_name"))
.and_then(|v| v.as_str())
.or_else(|| data.get("symbol").and_then(|v| v.as_str()))
{
out.push_str(&field("Symbol", sym, color));
}
if let Some(file) = anchor
.and_then(|r| r.get("file_path"))
.and_then(|v| v.as_str())
.or_else(|| data.get("file_path").and_then(|v| v.as_str()))
{
out.push_str(&field("File", file, color));
}
if let Some(typ) = anchor
.and_then(|r| r.get("symbol_type"))
.and_then(|v| v.as_str())
.or_else(|| data.get("symbol_type").and_then(|v| v.as_str()))
{
out.push_str(&field("Type", typ, color));
}
if let Some(line) = data.get("line").and_then(|v| v.as_u64()) {
out.push_str(&field("Line", &line.to_string(), color));
} else if let Some(br) = anchor
.and_then(|r| r.get("byte_range"))
.and_then(|v| v.as_array())
{
if br.len() == 2 {
let start = br[0].as_u64().unwrap_or(0);
let end = br[1].as_u64().unwrap_or(0);
if end > start {
out.push_str(&field("Range", &format!("bytes {}-{}", start, end), color));
}
}
}
let body = data
.get("context")
.and_then(|v| v.as_str())
.or_else(|| data.get("content").and_then(|v| v.as_str()));
if let Some(snippet) = body {
out.push('\n');
let base = line_for(data);
let gutter_base = if base == 0 { 1 } else { base };
for (i, l) in snippet.lines().enumerate() {
let n = gutter_base.saturating_add(i as u64);
let gutter = format!("{:>4}", n);
out.push_str(&format!(
" {}{}{}│ {}\n",
if color { DIM } else { "" },
gutter,
if color { RESET } else { "" },
l,
));
}
} else if let Some(results) = data.get("results").and_then(|v| v.as_array()) {
for r in results {
let symbol = r.get("symbol_name").and_then(|v| v.as_str()).unwrap_or("?");
let file = r.get("file_path").and_then(|v| v.as_str()).unwrap_or("?");
out.push_str(&format!(
" → {}{}{} {}{}{}\n",
if color { LIGHT_CYAN } else { "" },
symbol,
if color { RESET } else { "" },
if color { DIM } else { "" },
file,
if color { RESET } else { "" }
));
}
}
out
}
fn render_diagnostics(data: &Value, color: bool) -> String {
let mut out = header("Diagnostics", color);
out.push('\n');
if let Some(p) = data.get("project_path").and_then(|v| v.as_str()) {
out.push_str(&field("Project", p, color));
}
if let Some(v) = data.get("indexed_files").and_then(|v| v.as_u64()) {
out.push_str(&field("Indexed files", &v.to_string(), color));
}
if let Some(v) = data.get("symbol_count").and_then(|v| v.as_u64()) {
out.push_str(&field("Symbols", &v.to_string(), color));
}
if let Some(v) = data.get("index_size_mb").and_then(|v| v.as_f64()) {
out.push_str(&field("Index size", &format!("{:.2} MB", v), color));
}
if let Some(v) = data.get("memory_rss_mb").and_then(|v| v.as_f64()) {
out.push_str(&field("Memory RSS", &format!("{:.2} MB", v), color));
}
if let Some(v) = data.get("db_size_bytes").and_then(|v| v.as_u64()) {
out.push_str(&field("DB size", &format!("{} bytes", v), color));
}
if let Some(v) = data.get("stale").and_then(|v| v.as_bool()) {
out.push_str(&field("Stale", &v.to_string(), color));
}
if let Some(v) = data.get("last_indexed_secs_ago").and_then(|v| v.as_u64()) {
out.push_str(&field("Last indexed", &format!("{}s ago", v), color));
}
if let Some(v) = data.get("embedding_model").and_then(|v| v.as_str()) {
out.push_str(&field("Embedding model", v, color));
}
if let Some(v) = data.get("ort_version").and_then(|v| v.as_str()) {
out.push_str(&field("ORT version", v, color));
}
if let Some(v) = data.get("ort_path").and_then(|v| v.as_str()) {
out.push_str(&field("ORT dylib path", v, color));
}
if let Some(v) = data.get("execution_provider").and_then(|v| v.as_str()) {
out.push_str(&field("Execution provider", v, color));
}
if let Some(sh) = data.get("system_health") {
out.push('\n');
out.push_str(" System Health:\n");
if let Some(v) = sh.get("index_health").and_then(|v| v.as_str()) {
out.push_str(&field(" Index health", v, color));
}
if let Some(v) = sh.get("pdg_loaded").and_then(|v| v.as_bool()) {
out.push_str(&field(" PDG loaded", &v.to_string(), color));
}
if let Some(v) = sh.get("pdg_nodes").and_then(|v| v.as_u64()) {
out.push_str(&field(" PDG nodes", &v.to_string(), color));
}
if let Some(v) = sh.get("pdg_edges").and_then(|v| v.as_u64()) {
out.push_str(&field(" PDG edges", &v.to_string(), color));
}
if let Some(v) = sh.get("search_index_nodes").and_then(|v| v.as_u64()) {
out.push_str(&field(" Search nodes", &v.to_string(), color));
}
if let Some(v) = sh.get("embedding_model").and_then(|v| v.as_str()) {
out.push_str(&field(" Embedding model", v, color));
}
if let Some(v) = sh.get("total_signatures").and_then(|v| v.as_u64()) {
out.push_str(&field(" Total signatures", &v.to_string(), color));
}
if let Some(v) = sh.get("failed_parses").and_then(|v| v.as_u64()) {
out.push_str(&field(" Failed parses", &v.to_string(), color));
}
}
if let Some(arr) = data.get("issues").and_then(|v| v.as_array()) {
if !arr.is_empty() {
out.push('\n');
out.push_str(" Issues:\n");
for issue in arr.iter().take(10) {
let sev = issue
.get("severity")
.and_then(|v| v.as_str())
.unwrap_or("info");
let msg = issue.get("message").and_then(|v| v.as_str()).unwrap_or("?");
let sev_color = if color {
match sev {
"error" => LIGHT_RED,
"warning" => LIGHT_YELLOW,
_ => LIGHT_BLUE,
}
} else {
""
};
out.push_str(&format!(
" {}{}{} {}{}{}\n",
sev_color,
sev,
if color { RESET } else { "" },
msg,
"",
"",
));
}
}
}
out
}
fn render_project_map(data: &Value, color: bool) -> String {
let mut out = header("Project Structure", color);
out.push('\n');
if let Some(tree) = data.get("tree").and_then(|v| v.as_array()) {
out.push_str(&render_tree(tree, color));
} else if let Some(roots) = data.get("root").map(|v| vec![v.clone()]) {
out.push_str(&render_tree(&roots, color));
} else if let Some(files) = data.get("files").and_then(|v| v.as_array()) {
let tree = build_tree_from_files(files);
if tree.is_empty() {
out.push_str(&render_flat_files(files, color));
} else {
out.push_str(&render_tree(&tree, color));
}
}
if let Some(stats) = data.get("stats") {
out.push('\n');
if let Some(v) = stats.get("total_files").and_then(|v| v.as_u64()) {
out.push_str(&field("Files", &v.to_string(), color));
}
if let Some(v) = stats.get("total_symbols").and_then(|v| v.as_u64()) {
out.push_str(&field("Symbols", &v.to_string(), color));
}
if let Some(v) = stats.get("avg_complexity").and_then(|v| v.as_f64()) {
out.push_str(&field("Avg complexity", &format!("{:.1}", v), color));
}
if let Some(v) = stats.get("total_loc").and_then(|v| v.as_u64()) {
out.push_str(&field("Lines of code", &v.to_string(), color));
}
}
if let Some(v) = data.get("total_files_in_scope").and_then(|v| v.as_u64()) {
if data.get("stats").is_none() {
out.push('\n');
}
out.push_str(&field("Files in scope", &v.to_string(), color));
}
out
}
fn render_flat_files(files: &[Value], color: bool) -> String {
let mut out = String::new();
out.push_str(&format!(
" {}(flat list — files ordered as returned){}\n",
if color { DIM } else { "" },
if color { RESET } else { "" },
));
for (i, f) in files.iter().enumerate() {
let path = f
.get("path")
.and_then(|v| v.as_str())
.or_else(|| f.get("relative_path").and_then(|v| v.as_str()))
.unwrap_or("?");
let syms = f.get("symbol_count").and_then(|v| v.as_u64()).unwrap_or(0);
let cx = f
.get("total_complexity")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let deps = f
.get("incoming_dependencies")
.and_then(|v| v.as_u64())
.unwrap_or(0)
+ f.get("outgoing_dependencies")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let cx_color = if color {
match cx {
0..=20 => LIGHT_GREEN,
21..=60 => LIGHT_YELLOW,
_ => LIGHT_RED,
}
} else {
""
};
out.push_str(&format!(
" {}{:>3}.{} {}{}{} {}{} sym cx:{}{}{} deps:{}\n",
if color { BOLD } else { "" },
i + 1,
if color { RESET } else { "" },
if color { LIGHT_YELLOW } else { "" },
path,
if color { RESET } else { "" },
if color { DIM } else { "" },
syms,
cx_color,
cx,
if color { RESET } else { "" },
deps,
));
}
out
}
fn build_tree_from_files(files: &[Value]) -> Vec<Value> {
use std::collections::BTreeMap;
let any_with_dir = files.iter().any(|f| {
f.get("relative_path")
.and_then(|v| v.as_str())
.or_else(|| f.get("path").and_then(|v| v.as_str()))
.map(|p| p.contains('/') || p.contains('\\'))
.unwrap_or(false)
});
if !any_with_dir {
return Vec::new();
}
struct Node {
entry: Option<Value>,
children: BTreeMap<String, Node>,
}
impl Node {
fn new() -> Self {
Self {
entry: None,
children: BTreeMap::new(),
}
}
fn into_value(self, name: &str) -> Value {
let children: Vec<Value> = self
.children
.into_iter()
.map(|(child_name, child)| child.into_value(&child_name))
.collect();
if let Some(mut entry) = self.entry {
if let Some(obj) = entry.as_object_mut() {
if !children.is_empty() {
obj.insert("children".to_string(), Value::Array(children));
}
}
entry
} else {
serde_json::json!({
"name": name,
"type": "directory",
"children": children,
})
}
}
}
let mut root = Node::new();
for file in files {
let rel = file
.get("relative_path")
.and_then(|v| v.as_str())
.or_else(|| file.get("path").and_then(|v| v.as_str()))
.unwrap_or("?");
let rel = rel.replace('\\', "/");
let parts: Vec<&str> = rel.split('/').filter(|p| !p.is_empty()).collect();
let mut node = &mut root;
for (i, part) in parts.iter().enumerate() {
let key = part.to_string();
let is_file = i + 1 == parts.len();
let child = node.children.entry(key.clone()).or_insert_with(Node::new);
if is_file {
let mut entry = file.clone();
if let Some(obj) = entry.as_object_mut() {
obj.entry("name".to_string())
.or_insert(Value::String((*part).to_string()));
obj.entry("type".to_string())
.or_insert(Value::String("file".to_string()));
}
child.entry = Some(entry);
}
node = child;
}
}
let mut top: Vec<Value> = Vec::new();
for (name, child) in root.children.into_iter() {
top.push(child.into_value(&name));
}
top
}
fn render_impact(data: &Value, color: bool) -> String {
let mut out = header("Impact Analysis", color);
out.push('\n');
if let Some(sym) = data.get("symbol").and_then(|v| v.as_str()) {
out.push_str(&field("Symbol", sym, color));
}
if let Some(file) = data.get("file").and_then(|v| v.as_str()) {
out.push_str(&field("File", file, color));
}
if let Some(ct) = data.get("change_type").and_then(|v| v.as_str()) {
out.push_str(&field("Change type", ct, color));
}
if let Some(risk) = data.get("risk_level").and_then(|v| v.as_str()) {
let (icon, c) = if color {
match risk.to_lowercase().as_str() {
"high" => ("●", LIGHT_RED),
"medium" => ("●", LIGHT_YELLOW),
"low" => ("●", LIGHT_GREEN),
_ => ("○", WHITE),
}
} else {
("●", "")
};
out.push_str(&format!(
" {}{}:{} {}{} {}{}\n",
if color { BOLD } else { "" },
"Risk",
if color { RESET } else { "" },
c,
icon,
risk,
if color { RESET } else { "" },
));
}
if let Some(arr) = data.get("direct_callers").and_then(|v| v.as_array()) {
if !arr.is_empty() {
out.push('\n');
out.push_str(&format!(
" {}Direct callers ({}):{}\n",
if color { BOLD } else { "" },
arr.len(),
if color { RESET } else { "" },
));
for item in arr.iter().take(20) {
let name = item
.as_str()
.unwrap_or_else(|| item.get("name").and_then(|v| v.as_str()).unwrap_or("?"));
out.push_str(&format!(
" {}← {}{}\n",
if color { LIGHT_CYAN } else { "" },
name,
if color { RESET } else { "" },
));
}
}
}
if let Some(arr) = data
.get("transitive_affected_symbols")
.and_then(|v| v.as_array())
{
if !arr.is_empty() {
out.push('\n');
out.push_str(&format!(
" {}Transitive affected symbols ({}):{}\n",
if color { BOLD } else { "" },
arr.len(),
if color { RESET } else { "" },
));
for item in arr.iter().take(30) {
let name = item
.as_str()
.unwrap_or_else(|| item.get("name").and_then(|v| v.as_str()).unwrap_or("?"));
out.push_str(&format!(
" {}→ {}{}\n",
if color { LIGHT_YELLOW } else { "" },
name,
if color { RESET } else { "" },
));
}
if arr.len() > 30 {
out.push_str(&format!(
" {}… {} more{}\n",
if color { DIM } else { "" },
arr.len() - 30,
if color { RESET } else { "" },
));
}
}
}
if let Some(s) = data.get("summary").and_then(|v| v.as_str()) {
out.push('\n');
out.push_str(&format!(
" {}Summary:{} {}\n",
if color { BOLD } else { "" },
if color { RESET } else { "" },
s,
));
}
let affected_files = data
.get("transitive_affected_files")
.and_then(|v| v.as_u64());
let transitive_callers = data.get("transitive_callers").and_then(|v| v.as_u64());
if affected_files.is_some() || transitive_callers.is_some() {
out.push('\n');
if let Some(af) = affected_files {
out.push_str(&field("Affected files", &af.to_string(), color));
}
if let Some(tc) = transitive_callers {
out.push_str(&field("Transitive callers", &tc.to_string(), color));
}
}
out
}
fn render_symbol_lookup(data: &Value, color: bool) -> String {
if data.get("batch").and_then(|v| v.as_bool()) == Some(true) {
let count = data.get("count").and_then(|v| v.as_u64()).unwrap_or(0);
let mut out = header("Symbol Lookup (batch)", color);
out.push('\n');
out.push_str(&field("Count", &count.to_string(), color));
if let Some(arr) = data.get("results").and_then(|v| v.as_array()) {
if arr.is_empty() {
out.push_str(" (no results)\n");
return out;
}
for (idx, entry) in arr.iter().enumerate() {
out.push('\n');
out.push_str(&format!(
" {}{}#{}{} {}\n",
if color { BOLD } else { "" },
if color { DIM } else { "" },
idx + 1,
if color { RESET } else { "" },
entry.get("symbol").and_then(|v| v.as_str()).unwrap_or("?"),
));
out.push_str(&render_symbol_lookup_single(entry, color));
}
}
return out;
}
let mut out = header("Symbol Lookup", color);
out.push('\n');
out.push_str(&render_symbol_lookup_single(data, color));
out
}
fn render_symbol_lookup_single(data: &Value, color: bool) -> String {
let mut out = String::new();
if let Some(sym) = data.get("symbol").and_then(|v| v.as_str()) {
out.push_str(&field("Symbol", sym, color));
}
if let Some(file) = data.get("file").and_then(|v| v.as_str()) {
out.push_str(&field("File", file, color));
}
if let Some(typ) = data.get("type").and_then(|v| v.as_str()) {
out.push_str(&field("Type", typ, color));
}
if let Some(lang) = data.get("language").and_then(|v| v.as_str()) {
out.push_str(&field("Language", lang, color));
}
if let Some(br) = data.get("byte_range").and_then(|v| v.as_array()) {
if br.len() == 2 {
let start = br[0].as_u64().unwrap_or(0);
let end = br[1].as_u64().unwrap_or(0);
if end > start {
out.push_str(&field("Range", &format!("bytes {}-{}", start, end), color));
}
}
}
if let Some(cx) = data.get("complexity").and_then(|v| v.as_u64()) {
out.push_str(&field("Complexity", &cx.to_string(), color));
}
if let Some(ir) = data.get("impact_radius").and_then(|v| v.as_object()) {
let syms = ir
.get("affected_symbols")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let files = ir
.get("affected_files")
.and_then(|v| v.as_u64())
.unwrap_or(0);
out.push_str(&field(
"Impact",
&format!("{} symbols / {} files", syms, files),
color,
));
}
if let Some(src) = data.get("source").and_then(|v| v.as_str()) {
out.push('\n');
let mut shown = 0usize;
for l in src.lines() {
if l.trim().is_empty() {
continue;
}
out.push_str(&format!(
" {}{}{}\n",
if color { DIM } else { "" },
truncate_chars(l, 160),
if color { RESET } else { "" },
));
shown += 1;
if shown >= 12 {
break;
}
}
}
if let Some(arr) = data.get("callers").and_then(|v| v.as_array()) {
if !arr.is_empty() {
out.push('\n');
out.push_str(" Callers:\n");
for c in arr.iter().take(50) {
let name = c.get("name").and_then(|v| v.as_str()).unwrap_or("?");
let file = c.get("file").and_then(|v| v.as_str()).unwrap_or("");
let typ = c.get("type").and_then(|v| v.as_str()).unwrap_or("");
out.push_str(&format!(
" → {}{}{} {}{}{}{}\n",
if color { LIGHT_CYAN } else { "" },
name,
if color { RESET } else { "" },
if color { DIM } else { "" },
file,
if !typ.is_empty() {
format!(" [{}]", typ)
} else {
String::new()
},
if color { RESET } else { "" },
));
}
if data
.get("callers_truncated")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
out.push_str(&format!(
" {}... (showing 50 or more){}\n",
if color { DIM } else { "" },
if color { RESET } else { "" },
));
}
}
}
if let Some(arr) = data.get("callees").and_then(|v| v.as_array()) {
if !arr.is_empty() {
out.push('\n');
out.push_str(" Callees:\n");
for c in arr.iter().take(50) {
let name = c.get("name").and_then(|v| v.as_str()).unwrap_or("?");
let file = c.get("file").and_then(|v| v.as_str()).unwrap_or("");
let typ = c.get("type").and_then(|v| v.as_str()).unwrap_or("");
out.push_str(&format!(
" ← {}{}{} {}{}{}{}\n",
if color { LIGHT_CYAN } else { "" },
name,
if color { RESET } else { "" },
if color { DIM } else { "" },
file,
if !typ.is_empty() {
format!(" [{}]", typ)
} else {
String::new()
},
if color { RESET } else { "" },
));
}
if data
.get("callees_truncated")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
out.push_str(&format!(
" {}... (showing 50 or more){}\n",
if color { DIM } else { "" },
if color { RESET } else { "" },
));
}
}
}
out
}
fn render_phase(data: &Value, color: bool) -> String {
let mut out = header("Phase Analysis", color);
out.push('\n');
if let Some(ep) = data.get("executed_phases").and_then(|v| v.as_array()) {
let nums: Vec<String> = ep
.iter()
.filter_map(|v| v.as_u64().map(|n| n.to_string()))
.collect();
if !nums.is_empty() {
out.push_str(&field("Executed phases", &nums.join(", "), color));
}
}
if let Some(ch) = data.get("cache_hit").and_then(|v| v.as_bool()) {
out.push_str(&field(
"Cache hit",
if ch { "true" } else { "false" },
color,
));
}
if let Some(gen) = data.get("generation").and_then(|v| v.as_str()) {
out.push_str(&field("Generation", gen, color));
}
if let Some(p1) = data.get("phase1") {
out.push('\n');
let files = p1.get("total_files").and_then(|v| v.as_u64()).unwrap_or(0);
let sigs = p1.get("signatures").and_then(|v| v.as_u64()).unwrap_or(0);
let cache = p1
.get("cache_hit")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let label = if cache { "cache hit" } else { "parsed" };
out.push_str(&format!(
" {}Phase 1:{} {} files, {} signatures ({}){}\n",
if color { BOLD } else { "" },
if color { RESET } else { "" },
files,
sigs,
label,
if color { RESET } else { "" },
));
}
if let Some(p2) = data.get("phase2") {
let internal = p2
.get("internal_import_edges")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let external = p2
.get("external_import_edges")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let unresolved = p2
.get("unresolved_modules")
.and_then(|v| v.as_u64())
.unwrap_or(0);
out.push_str(&format!(
" {}Phase 2:{} {} internal, {} external, {} unresolved modules{}\n",
if color { BOLD } else { "" },
if color { RESET } else { "" },
internal,
external,
unresolved,
if color { RESET } else { "" },
));
}
if let Some(p3) = data.get("phase3") {
let entries = p3
.get("entry_points")
.and_then(|v| v.as_array())
.map(|a| a.len())
.unwrap_or(0);
let impacted = p3
.get("impacted_nodes")
.and_then(|v| v.as_u64())
.unwrap_or(0);
out.push_str(&format!(
" {}Phase 3:{} {} entry points, {} impacted nodes{}\n",
if color { BOLD } else { "" },
if color { RESET } else { "" },
entries,
impacted,
if color { RESET } else { "" },
));
}
if let Some(p4) = data.get("phase4") {
let hotspots = p4
.get("hotspots")
.and_then(|v| v.as_array())
.map(|a| a.len())
.unwrap_or(0);
out.push_str(&format!(
" {}Phase 4:{} {} hotspots{}\n",
if color { BOLD } else { "" },
if color { RESET } else { "" },
hotspots,
if color { RESET } else { "" },
));
if let Some(hs_arr) = p4.get("hotspots").and_then(|v| v.as_array()) {
for (i, h) in hs_arr.iter().take(5).enumerate() {
let name = h.get("node_id").and_then(|v| v.as_str()).unwrap_or("?");
let score = h.get("score").and_then(|v| v.as_f64()).unwrap_or(0.0);
let cx = h.get("complexity").and_then(|v| v.as_u64()).unwrap_or(0);
out.push_str(&format!(
" {}. {} {}(score: {:.2}, complexity: {}){}\n",
i + 1,
name,
if color { DIM } else { "" },
score,
cx,
if color { RESET } else { "" },
));
}
}
}
if let Some(p5) = data.get("phase5") {
let recs = p5
.get("recommendations")
.and_then(|v| v.as_array())
.map(|a| a.len())
.unwrap_or(0);
out.push_str(&format!(
" {}Phase 5:{} {} recommendations{}\n",
if color { BOLD } else { "" },
if color { RESET } else { "" },
recs,
if color { RESET } else { "" },
));
}
if let Some(formatted) = data.get("formatted_output").and_then(|v| v.as_str()) {
if !formatted.is_empty() {
out.push('\n');
let truncated = truncate_chars(formatted, 2000);
for line in truncated.lines() {
out.push_str(&format!(
" {}{}{}\n",
if color { DIM } else { "" },
line,
if color { RESET } else { "" },
));
}
}
}
out
}
fn render_git_status(data: &Value, color: bool) -> String {
let mut out = header("Git Status", color);
out.push('\n');
if let Some(b) = data.get("branch").and_then(|v| v.as_str()) {
out.push_str(&field("Branch", b, color));
}
if let Some(s) = data.get("summary").and_then(|v| v.as_object()) {
let modified = s.get("modified").and_then(|v| v.as_u64()).unwrap_or(0);
let staged = s.get("staged").and_then(|v| v.as_u64()).unwrap_or(0);
let untracked = s.get("untracked").and_then(|v| v.as_u64()).unwrap_or(0);
if modified > 0 || staged > 0 || untracked > 0 {
out.push_str(&format!(
" {}{} modified, {} staged, {} untracked{}\n",
if color { DIM } else { "" },
modified,
staged,
untracked,
if color { RESET } else { "" },
));
}
}
git_status_file_list(
data,
"modified_files",
"Modified",
"~",
LIGHT_YELLOW,
color,
&mut out,
);
git_status_file_list(
data,
"staged_files",
"Staged",
"+",
LIGHT_GREEN,
color,
&mut out,
);
git_status_file_list(
data,
"untracked_files",
"Untracked",
"?",
LIGHT_GREY,
color,
&mut out,
);
if let Some(arr) = data.get("changed_symbols").and_then(|v| v.as_array()) {
if !arr.is_empty() {
out.push('\n');
out.push_str(&format!(
" {}Changed Symbols:{}\n",
if color { BOLD } else { "" },
if color { RESET } else { "" },
));
for entry in arr.iter().take(20) {
let file = entry.get("file").and_then(|v| v.as_str()).unwrap_or("?");
let status = entry
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("modified");
let symbols = entry
.get("symbols")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
out.push_str(&format!(
" {}{}{} {}({}){}\n",
if color { LIGHT_YELLOW } else { "" },
file,
if color { RESET } else { "" },
if color { DIM } else { "" },
status,
if color { RESET } else { "" },
));
for sym in symbols.iter().take(10) {
let name = sym.get("name").and_then(|v| v.as_str()).unwrap_or("?");
let typ = sym.get("type").and_then(|v| v.as_str()).unwrap_or("symbol");
let caller_count = sym
.get("caller_count")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let impact = sym
.get("forward_impact_count")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let (icon, ic) = if color {
match typ {
"function" | "fn" => ("ƒ", LIGHT_GREEN),
"method" => ("m", LIGHT_CYAN),
"struct" => ("S", LIGHT_MAGENTA),
_ => ("•", WHITE),
}
} else {
("•", "")
};
out.push_str(&format!(
" {}{}{} {}{}{} {}{} callers, {} impact{}\n",
ic,
icon,
if color { RESET } else { "" },
if color { LIGHT_CYAN } else { "" },
name,
if color { RESET } else { "" },
if color { DIM } else { "" },
caller_count,
impact,
if color { RESET } else { "" },
));
}
if symbols.is_empty() {
out.push_str(&format!(
" {}No indexed symbols{}\n",
if color { DIM } else { "" },
if color { RESET } else { "" },
));
}
}
}
}
if let Some(imp) = data.get("impact_summary").and_then(|v| v.as_object()) {
let total = imp
.get("total_affected_symbols")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let affected = imp
.get("affected_files")
.and_then(|v| v.as_array())
.map(|a| a.len())
.unwrap_or(0);
if total > 0 {
out.push('\n');
out.push_str(&format!(
" {}Impact:{} {} affected symbols across {} files\n",
if color { BOLD } else { "" },
if color { RESET } else { "" },
total,
affected,
));
}
}
if let Some(pdg) = data.get("pdg_enrichment") {
let available = pdg
.get("available")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let (icon, icon_color) = if available {
("✓", LIGHT_GREEN)
} else {
("⚠", LIGHT_YELLOW)
};
let status_text = if available {
"available"
} else {
"unavailable"
};
out.push('\n');
out.push_str(&format!(
" {}PDG Enrichment:{} {}{}{} {}\n",
if color { BOLD } else { "" },
if color { RESET } else { "" },
if color { icon_color } else { "" },
icon,
if color { RESET } else { "" },
status_text,
));
if !available {
if let Some(reason) = pdg.get("reason").and_then(|v| v.as_str()) {
out.push_str(&format!(
" {}Reason:{} {}\n",
if color { DIM } else { "" },
if color { RESET } else { "" },
reason,
));
}
}
}
out
}
fn git_status_file_list(
data: &Value,
key: &str,
label: &str,
marker: &str,
marker_color: &str,
color: bool,
out: &mut String,
) {
if let Some(arr) = data.get(key).and_then(|v| v.as_array()) {
if !arr.is_empty() {
out.push('\n');
out.push_str(&format!(" {} ({}):\n", label, arr.len(),));
for f in arr.iter().take(30) {
let path = f
.as_str()
.unwrap_or_else(|| f.get("path").and_then(|v| v.as_str()).unwrap_or("?"));
out.push_str(&format!(
" {}{}{} {}\n",
if color { marker_color } else { "" },
marker,
if color { RESET } else { "" },
path,
));
}
if arr.len() > 30 {
out.push_str(&format!(
" {}… {} more{}\n",
if color { DIM } else { "" },
arr.len() - 30,
if color { RESET } else { "" },
));
}
}
}
}
fn render_file_summary(data: &Value, color: bool) -> String {
let mut out = header("File Summary", color);
out.push('\n');
if let Some(file) = data.get("file_path").and_then(|v| v.as_str()) {
out.push_str(&field("File", file, color));
}
if let Some(lang) = data.get("language").and_then(|v| v.as_str()) {
out.push_str(&field("Language", lang, color));
}
if let Some(lc) = data.get("line_count").and_then(|v| v.as_u64()) {
out.push_str(&field("Lines", &lc.to_string(), color));
}
if let Some(sc) = data.get("symbol_count").and_then(|v| v.as_u64()) {
out.push_str(&field("Symbols", &sc.to_string(), color));
}
if let Some(role) = data.get("module_role").and_then(|v| v.as_str()) {
out.push_str(&field("Role", role, color));
}
if let Some(arr) = data.get("symbols").and_then(|v| v.as_array()) {
if !arr.is_empty() {
out.push('\n');
out.push_str(" Symbols:\n");
for sym in arr.iter().take(50) {
let name = sym.get("name").and_then(|v| v.as_str()).unwrap_or("?");
let typ = sym.get("type").and_then(|v| v.as_str()).unwrap_or("symbol");
let (icon, c) = if color {
match typ {
"function" | "fn" => ("ƒ", LIGHT_GREEN),
"method" => ("m", LIGHT_CYAN),
"struct" => ("S", LIGHT_MAGENTA),
"enum" => ("E", LIGHT_YELLOW),
"trait" => ("T", LIGHT_BLUE),
"impl" => ("I", LIGHT_MAGENTA),
"const" | "static" => ("c", LIGHT_CYAN),
"field" => ("f", LIGHT_YELLOW),
_ => ("•", WHITE),
}
} else {
("•", "")
};
out.push_str(&format!(
" {}{}{} {}{}{}\n",
c,
icon,
if color { RESET } else { "" },
if color { LIGHT_CYAN } else { "" },
name,
if color { RESET } else { "" },
));
}
let truncated = data
.get("symbols_truncated")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let total = data
.get("symbol_count")
.and_then(|v| v.as_u64())
.unwrap_or(0) as usize;
let shown = arr.len().min(50);
if truncated || shown < total {
let hidden = total.saturating_sub(shown);
out.push_str(&format!(
" {}… {} more symbols (truncated){}\n",
if color { DIM } else { "" },
hidden,
if color { RESET } else { "" },
));
}
}
}
out
}
fn render_read_file(data: &Value, color: bool) -> String {
let mut out = String::new();
if let Some(path) = data.get("file_path").and_then(|v| v.as_str()) {
out.push_str(&header(&format!("Read: {}", path), color));
out.push('\n');
}
let content = data
.get("content")
.and_then(|v| v.as_str())
.unwrap_or_default();
let start = data.get("start_line").and_then(|v| v.as_u64()).unwrap_or(1);
for (i, line) in content.lines().enumerate() {
let n = start + i as u64;
let gutter = format!("{:>4}", n);
out.push_str(&format!(
" {}{}{}│ {}\n",
if color { DIM } else { "" },
gutter,
if color { RESET } else { "" },
line,
));
}
out
}
fn render_write(data: &Value, color: bool) -> String {
let mut out = String::new();
let success = data
.get("success")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let (status_label, status_color) = if success {
("Wrote", if color { LIGHT_GREEN } else { "" })
} else {
("Write failed", if color { LIGHT_RED } else { "" })
};
out.push_str(&format!(
"{}{}{}\n",
status_color,
status_label,
if color { RESET } else { "" },
));
if let Some(path) = data.get("file_path").and_then(|v| v.as_str()) {
out.push_str(&field("File", path, color));
}
if let Some(lang) = data.get("language").and_then(|v| v.as_str()) {
out.push_str(&field("Language", lang, color));
}
if let Some(arr) = data.get("symbols").and_then(|v| v.as_array()) {
if !arr.is_empty() {
out.push('\n');
out.push_str(&format!(
" {}Symbols ({}):{}\n",
if color { DIM } else { "" },
arr.len(),
if color { RESET } else { "" },
));
for s in arr.iter().take(20) {
let name = s.get("name").and_then(|v| v.as_str()).unwrap_or("?");
let typ = s.get("type").and_then(|v| v.as_str()).unwrap_or("");
out.push_str(&format!(
" {}{}{} {}{}{}\n",
if color { LIGHT_CYAN } else { "" },
name,
if color { RESET } else { "" },
if color { DIM } else { "" },
if typ.is_empty() {
String::new()
} else {
format!("[{}]", typ)
},
if color { RESET } else { "" },
));
}
if arr.len() > 20 {
out.push_str(&format!(
" {}…and {} more{}\n",
if color { DIM } else { "" },
arr.len() - 20,
if color { RESET } else { "" },
));
}
}
}
out
}
fn render_edit_preview(data: &Value, color: bool) -> String {
let diff_text = render_diff_value(data, color);
let mut out = diff_text;
let mut meta_lines = Vec::new();
if let Some(symbols) = data.get("affected_symbols").and_then(|v| v.as_array()) {
if !symbols.is_empty() {
let names: Vec<&str> = symbols.iter().filter_map(|v| v.as_str()).collect();
meta_lines.push(format!(
" {}Affected symbols:{} {}",
if color { BOLD } else { "" },
if color { RESET } else { "" },
names.join(", ")
));
}
}
if let Some(files) = data.get("affected_files").and_then(|v| v.as_array()) {
if !files.is_empty() {
let names: Vec<&str> = files.iter().filter_map(|v| v.as_str()).collect();
meta_lines.push(format!(
" {}Affected files:{} {}",
if color { BOLD } else { "" },
if color { RESET } else { "" },
names.join(", ")
));
}
}
if let Some(risk) = data.get("risk_level").and_then(|v| v.as_str()) {
meta_lines.push(format!(
" {}Risk level:{} {}",
if color { BOLD } else { "" },
if color { RESET } else { "" },
risk
));
}
if let Some(count) = data.get("change_count").and_then(|v| v.as_u64()) {
meta_lines.push(format!(
" {}Change count:{} {}",
if color { BOLD } else { "" },
if color { RESET } else { "" },
count
));
}
if let Some(breaks) = data.get("breaking_changes").and_then(|v| v.as_array()) {
if !breaks.is_empty() {
for b in breaks {
if let Some(s) = b.as_str() {
meta_lines.push(format!(
" {}Breaking:{} {}",
if color { BOLD } else { "" },
if color { RESET } else { "" },
s
));
}
}
}
}
if !meta_lines.is_empty() {
if !out.is_empty() {
out.push('\n');
}
out.push_str(&meta_lines.join("\n"));
out.push('\n');
}
out
}
fn render_rename_symbol(data: &Value, color: bool) -> String {
let diff_text = render_diff_value(data, color);
let mut out = diff_text;
let mut meta_lines = Vec::new();
if let Some(old) = data.get("old_name").and_then(|v| v.as_str()) {
if let Some(new) = data.get("new_name").and_then(|v| v.as_str()) {
meta_lines.push(format!(
" {}Rename:{} {} → {}",
if color { BOLD } else { "" },
if color { RESET } else { "" },
old,
new
));
}
}
if let Some(count) = data.get("files_affected").and_then(|v| v.as_u64()) {
meta_lines.push(format!(
" {}Files affected:{} {}",
if color { BOLD } else { "" },
if color { RESET } else { "" },
count
));
}
if let Some(diffs_more) = data.get("diffs_more").and_then(|v| v.as_u64()) {
if diffs_more > 0 {
meta_lines.push(format!(
" {}Additional diffs:{} {} more (not shown)",
if color { BOLD } else { "" },
if color { RESET } else { "" },
diffs_more
));
}
}
if let Some(preview) = data.get("preview_only").and_then(|v| v.as_bool()) {
if preview {
meta_lines.push(format!(
" {}Preview only:{} changes not applied",
if color { BOLD } else { "" },
if color { RESET } else { "" }
));
}
}
if !meta_lines.is_empty() {
if !out.is_empty() {
out.push('\n');
}
out.push_str(&meta_lines.join("\n"));
out.push('\n');
}
out
}
fn render_edit_apply(data: &Value, color: bool) -> String {
let mut out = String::new();
let success = data
.get("success")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let changes_applied = data
.get("changes_applied")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let (status_label, status_color) = if !success {
("Edit apply failed", if color { LIGHT_RED } else { "" })
} else if changes_applied == 0 {
(
"No-op (content identical)",
if color { LIGHT_YELLOW } else { "" },
)
} else {
("Applied", if color { LIGHT_GREEN } else { "" })
};
out.push_str(&format!(
"{}{}{}\n",
status_color,
status_label,
if color { RESET } else { "" },
));
if let Some(path) = data.get("file_path").and_then(|v| v.as_str()) {
out.push_str(&field("File", path, color));
}
if let Some(msg) = data.get("message").and_then(|v| v.as_str()) {
out.push_str(&field("Message", msg, color));
}
if let Some(arr) = data.get("affected_symbols").and_then(|v| v.as_array()) {
if !arr.is_empty() {
out.push_str(&field("Affected symbols", &arr.len().to_string(), color));
}
}
if let Some(arr) = data.get("affected_files").and_then(|v| v.as_array()) {
if !arr.is_empty() {
out.push_str(&field("Affected files", &arr.len().to_string(), color));
}
}
if let Some(bc) = data.get("breaking_changes").and_then(|v| v.as_array()) {
if !bc.is_empty() {
out.push_str(&field("Breaking changes", &bc.len().to_string(), color));
}
}
if let Some(region_value) = data.get("edit_region") {
let region_text: Option<String> = if let Some(s) = region_value.as_str() {
if s.is_empty() {
None
} else {
Some(s.to_string())
}
} else if let Some(obj) = region_value.as_object() {
let start = obj.get("start").and_then(|v| v.as_u64());
let end = obj.get("end").and_then(|v| v.as_u64());
match (start, end) {
(Some(s), Some(e)) => Some(format!("bytes {s}..{e}")),
(Some(s), None) => Some(format!("bytes {s}..")),
(None, Some(e)) => Some(format!("bytes ..{e}")),
(None, None) => Some("bytes ?".to_string()),
}
} else {
None
};
if let Some(text) = region_text {
out.push('\n');
if region_value.is_string() {
out.push_str(&format!(
" {}Surrounding region:{}\n",
if color { DIM } else { "" },
if color { RESET } else { "" },
));
for l in text.lines() {
out.push_str(&format!(
" {}{}{}\n",
if color { DIM } else { "" },
truncate_chars(l, 160),
if color { RESET } else { "" },
));
}
} else {
out.push_str(&format!(
" {}Surrounding region:{} {}\n",
if color { DIM } else { "" },
if color { RESET } else { "" },
text,
));
}
}
}
out
}
pub fn render_tool_output(name: &str, data: &Value, args: &Value) -> String {
render_tool_output_with_color(name, data, args, true)
}
pub fn render_tool_output_plain(name: &str, data: &Value, args: &Value) -> String {
render_tool_output_with_color(name, data, args, false)
}
fn render_tool_output_with_color(name: &str, data: &Value, args: &Value, color: bool) -> String {
let normalized = normalize_tool_name(name);
let query = args.get("query").and_then(|v| v.as_str()).unwrap_or("");
let node_id = args.get("node_id").and_then(|v| v.as_str()).unwrap_or("");
match normalized.as_str() {
"leindex_search" | "search" => render_search(data, query, color),
"leindex_context" | "context" => render_context(data, node_id, color),
"leindex_diagnostics" | "diagnostics" => render_diagnostics(data, color),
"leindex_project_map" | "project_map" => render_project_map(data, color),
"leindex_impact_analysis" | "impact_analysis" => render_impact(data, color),
"leindex_symbol_lookup" | "symbol_lookup" => render_symbol_lookup(data, color),
"leindex_phase_analysis" | "phase_analysis" => render_phase(data, color),
"leindex_git_status" | "git_status" => render_git_status(data, color),
"leindex_file_summary" | "file_summary" => render_file_summary(data, color),
"leindex_read_file" | "read_file" => render_read_file(data, color),
"leindex_edit_preview" | "edit_preview" => render_edit_preview(data, color),
"leindex_edit_apply" | "edit_apply" => render_edit_apply(data, color),
"leindex_write" | "write" => render_write(data, color),
"leindex_rename_symbol" | "rename_symbol" => render_rename_symbol(data, color),
_ => render_default(data, color),
}
}
pub struct SearchFormatter {
color: bool,
}
impl SearchFormatter {
pub fn new() -> Self {
Self { color: true }
}
pub fn with_color(mut self, color: bool) -> Self {
self.color = color;
self
}
pub fn format(&self, results: &Value, query: &str) -> String {
render_search(results, query, self.color)
}
}
impl Default for SearchFormatter {
fn default() -> Self {
Self::new()
}
}
pub struct ProjectMapFormatter {
color: bool,
}
impl ProjectMapFormatter {
pub fn new() -> Self {
Self { color: true }
}
pub fn with_color(mut self, color: bool) -> Self {
self.color = color;
self
}
pub fn format(&self, data: &Value) -> String {
render_project_map(data, self.color)
}
}
impl Default for ProjectMapFormatter {
fn default() -> Self {
Self::new()
}
}
pub struct DiagnosticsFormatter {
color: bool,
}
impl DiagnosticsFormatter {
pub fn new() -> Self {
Self { color: true }
}
pub fn with_color(mut self, color: bool) -> Self {
self.color = color;
self
}
pub fn format(&self, data: &Value) -> String {
render_diagnostics(data, self.color)
}
}
impl Default for DiagnosticsFormatter {
fn default() -> Self {
Self::new()
}
}
pub struct ImpactFormatter {
color: bool,
}
impl ImpactFormatter {
pub fn new() -> Self {
Self { color: true }
}
pub fn with_color(mut self, color: bool) -> Self {
self.color = color;
self
}
pub fn format(&self, data: &Value) -> String {
render_impact(data, self.color)
}
}
impl Default for ImpactFormatter {
fn default() -> Self {
Self::new()
}
}
pub struct SymbolLookupFormatter {
color: bool,
}
impl SymbolLookupFormatter {
pub fn new() -> Self {
Self { color: true }
}
pub fn with_color(mut self, color: bool) -> Self {
self.color = color;
self
}
pub fn format(&self, data: &Value) -> String {
render_symbol_lookup(data, self.color)
}
}
impl Default for SymbolLookupFormatter {
fn default() -> Self {
Self::new()
}
}
pub struct PhaseFormatter {
color: bool,
}
impl PhaseFormatter {
pub fn new() -> Self {
Self { color: true }
}
pub fn with_color(mut self, color: bool) -> Self {
self.color = color;
self
}
pub fn format(&self, data: &Value) -> String {
render_phase(data, self.color)
}
}
impl Default for PhaseFormatter {
fn default() -> Self {
Self::new()
}
}
pub struct GitStatusFormatter {
color: bool,
}
impl GitStatusFormatter {
pub fn new() -> Self {
Self { color: true }
}
pub fn with_color(mut self, color: bool) -> Self {
self.color = color;
self
}
pub fn format(&self, data: &Value) -> String {
render_git_status(data, self.color)
}
}
impl Default for GitStatusFormatter {
fn default() -> Self {
Self::new()
}
}
pub struct FileSummaryFormatter {
color: bool,
}
impl FileSummaryFormatter {
pub fn new() -> Self {
Self { color: true }
}
pub fn with_color(mut self, color: bool) -> Self {
self.color = color;
self
}
pub fn format(&self, data: &Value) -> String {
render_file_summary(data, self.color)
}
}
impl Default for FileSummaryFormatter {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::mcp::output::trim::trim_search;
fn v(s: &str) -> Value {
serde_json::from_str(s).unwrap()
}
#[test]
fn test_render_tree_basic() {
let tree = v(r#"[
{"name": "src", "type": "directory", "children": [
{"name": "main.rs", "type": "file", "symbol_count": 5, "children": []},
{"name": "lib.rs", "type": "file", "symbol_count": 12, "children": []}
]}
]"#);
let s = render_tree(tree.as_array().unwrap(), false);
assert!(s.contains("src"), "root dir name missing: {}", s);
assert!(s.contains("main.rs"), "child file missing: {}", s);
assert!(s.contains("lib.rs"), "child file missing: {}", s);
assert!(s.contains("├──"), "missing connector: {}", s);
}
#[test]
fn test_build_tree_preserves_child_names() {
let files = v(r#"[
{"relative_path": "src/cli/main.rs"},
{"relative_path": "src/cli/sub/lib.rs"},
{"relative_path": "tests/integration.rs"}
]"#);
let tree = build_tree_from_files(files.as_array().unwrap());
let names: Vec<String> = tree
.iter()
.map(|n| {
n.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string()
})
.collect();
assert_eq!(names, vec!["src", "tests"]);
let src = tree.iter().find(|n| n["name"] == "src").unwrap();
let src_children = src.get("children").and_then(|v| v.as_array()).unwrap();
let src_child_names: Vec<String> = src_children
.iter()
.map(|n| {
n.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string()
})
.collect();
assert_eq!(src_child_names, vec!["cli"]);
let cli = src_children.iter().find(|n| n["name"] == "cli").unwrap();
let cli_children = cli.get("children").and_then(|v| v.as_array()).unwrap();
let cli_child_names: Vec<String> = cli_children
.iter()
.map(|n| {
n.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string()
})
.collect();
assert_eq!(cli_child_names, vec!["main.rs", "sub"]);
}
#[test]
fn test_render_tree_indents_children() {
let tree = v(r#"[
{"name": "src", "type": "directory", "children": [
{"name": "a.rs", "type": "file", "symbol_count": 1, "children": []}
]},
{"name": "tests", "type": "directory", "children": [
{"name": "b.rs", "type": "file", "symbol_count": 1, "children": []}
]}
]"#);
let s = render_tree(tree.as_array().unwrap(), false);
assert!(s.contains("a.rs"));
assert!(s.contains("b.rs"));
let lines: Vec<&str> = s.lines().collect();
let a_line = lines.iter().find(|l| l.contains("a.rs")).unwrap();
assert!(
a_line.contains("│ └──"),
"a.rs should be under 'src' with continuation: {:?}",
a_line
);
let b_line = lines.iter().find(|l| l.contains("b.rs")).unwrap();
assert!(
b_line.starts_with(" └──"),
"b.rs should be under 'tests' with space indent: {:?}",
b_line
);
}
#[test]
fn test_render_tool_output_dispatches_by_name() {
let args = v(r#"{"query": "foo", "top_k": 1}"#);
let search_data = trim_search(&v(
r#"{"results": [{"file_path": "/p.rs", "symbol_name": "f", "score": {"overall": 0.5}}]}"#,
));
let s = render_tool_output("leindex.search", &search_data, &args);
assert!(s.contains("Search: \"foo\""), "got: {}", s);
assert!(s.contains("/p.rs"), "got: {}", s);
}
#[test]
fn test_render_search_uses_snippet_field() {
let args = v(r#"{"query": "foo", "top_k": 1}"#);
let payload = v(r#"{
"count": 1,
"results": [{
"file_path": "/p.rs",
"symbol": "main",
"symbol_type": "function",
"score": 0.9,
"snippet": "fn main() { return 0; }"
}]
}"#);
let s = render_tool_output("leindex.search", &payload, &args);
assert!(s.contains("/p.rs"), "got: {}", s);
assert!(s.contains("fn main()"), "snippet preview missing: {}", s);
}
#[test]
fn test_render_tool_output_falls_back_to_pretty_json() {
let data = v(r#"{"custom_field": 42, "items": [1, 2, 3]}"#);
let s = render_tool_output("leindex.unknown_tool", &data, &Value::Null);
assert!(s.contains("\"custom_field\""));
assert!(s.contains("42"));
}
#[test]
fn test_render_search_signature_empty_falls_through_to_snippet() {
let args = v(r#"{"query": "foo", "top_k": 1}"#);
let payload = v(r#"{
"count": 1,
"results": [{
"file_path": "/p.rs",
"symbol": "main",
"symbol_type": "function",
"score": 0.9,
"signature": " ",
"snippet": "fn main() { return 0; }"
}]
}"#);
let s = render_tool_output("leindex.search", &payload, &args);
assert!(
s.contains("fn main()"),
"snippet must print when signature is empty: {}",
s
);
}
#[test]
fn test_render_context_reads_from_results_anchor() {
let args = v(r#"{"node_id": "main"}"#);
let payload = v(r#"{
"query": "Context for main",
"results": [{
"rank": 1,
"node_id": "src/main.rs:main",
"file_path": "src/main.rs",
"symbol_name": "main",
"symbol_type": "function",
"byte_range": [10, 50]
}],
"context": "fn main() { return 0; }\nfn helper() {}",
"tokens_used": 12,
"processing_time_ms": 1
}"#);
let s = render_tool_output("leindex.context", &payload, &args);
assert!(s.contains("Symbol"), "missing Symbol field: {}", s);
assert!(s.contains("main"), "missing symbol name: {}", s);
assert!(s.contains("src/main.rs"), "missing file path: {}", s);
assert!(s.contains("fn main()"), "missing expanded body: {}", s);
}
#[test]
fn test_render_symbol_lookup_uses_real_field_names() {
let args = v(r#"{"symbol": "main", "include_source": true}"#);
let payload = v(r#"{
"symbol": "main",
"type": "function",
"file": "src/main.rs",
"byte_range": [10, 60],
"complexity": 3,
"language": "rust",
"callers": [{"name": "caller_a", "file": "src/lib.rs", "type": "function"}],
"callees": [],
"impact_radius": {"affected_symbols": 5, "affected_files": 2},
"source": "fn main() { return 0; }"
}"#);
let s = render_tool_output("leindex.symbol-lookup", &payload, &args);
assert!(s.contains("Symbol"));
assert!(s.contains("main"));
assert!(s.contains("File"), "missing File field: {}", s);
assert!(s.contains("src/main.rs"), "missing real file: {}", s);
assert!(s.contains("Type"), "missing Type field: {}", s);
assert!(s.contains("function"), "missing real type: {}", s);
assert!(s.contains("Language"), "missing Language field: {}", s);
assert!(s.contains("rust"), "missing language: {}", s);
assert!(s.contains("Range"), "missing Range field: {}", s);
assert!(s.contains("bytes 10-60"), "missing byte range: {}", s);
assert!(s.contains("Complexity"), "missing Complexity field: {}", s);
assert!(s.contains("Impact"), "missing Impact field: {}", s);
assert!(
s.contains("5 symbols / 2 files"),
"missing impact counts: {}",
s
);
assert!(s.contains("caller_a"), "missing caller: {}", s);
assert!(s.contains("Callers"));
assert!(s.contains("fn main()"), "missing source preview: {}", s);
assert!(
!s.contains("file_path"),
"renderer still emits file_path alias: {}",
s
);
assert!(
!s.contains("symbol_type"),
"renderer still emits symbol_type alias: {}",
s
);
assert!(
!s.contains("Signature"),
"renderer still emits Signature (legacy alias): {}",
s
);
}
#[test]
fn test_render_write_shows_confirmation_not_diff() {
let args = v(r#"{"file_path": "src/lib.rs", "content": "// hello"}"#);
let payload = v(r#"{
"success": true,
"file_path": "src/lib.rs",
"language": "rust",
"symbols": [
{"name": "alpha", "type": "fn() -> ()", "range": [0, 9]},
{"name": "beta", "type": "fn() -> ()", "range": [10, 19]}
]
}"#);
let s = render_tool_output("leindex.write", &payload, &args);
assert!(s.contains("Wrote"), "missing confirmation header: {}", s);
assert!(s.contains("src/lib.rs"), "missing file path: {}", s);
assert!(s.contains("Language"), "missing language field: {}", s);
assert!(s.contains("rust"), "missing language value: {}", s);
assert!(s.contains("alpha"), "missing symbol name: {}", s);
assert!(s.contains("beta"), "missing symbol name: {}", s);
assert!(!s.contains("│"), "write must not render diff gutter: {}", s);
}
#[test]
fn test_render_edit_apply_shows_confirmation_not_diff() {
let args = v(r#"{"file_path": "src/lib.rs"}"#);
let payload = v(r#"{
"success": true,
"changes_applied": 2,
"file_path": "src/lib.rs",
"edit_region": "1: // hello\n2: fn alpha() {}",
"affected_symbols": ["alpha", "beta"],
"affected_files": ["src/lib.rs"],
"breaking_changes": []
}"#);
let s = render_tool_output("leindex.edit-apply", &payload, &args);
assert!(s.contains("Applied"), "missing applied header: {}", s);
assert!(s.contains("src/lib.rs"), "missing file path: {}", s);
assert!(
s.contains("Affected symbols"),
"missing affected symbols: {}",
s
);
assert!(
s.contains("Affected files"),
"missing affected files: {}",
s
);
assert!(s.contains("// hello"), "missing surrounding region: {}", s);
assert!(
!s.contains("│"),
"edit-apply must not render diff gutter: {}",
s
);
}
#[test]
fn test_render_edit_apply_noop_shows_message() {
let args = v(r#"{"file_path": "src/lib.rs"}"#);
let payload = v(r#"{
"success": true,
"changes_applied": 0,
"message": "No changes to apply (content identical)"
}"#);
let s = render_tool_output("leindex.edit-apply", &payload, &args);
assert!(s.contains("No-op"), "missing no-op header: {}", s);
assert!(
s.contains("content identical"),
"missing no-op message: {}",
s
);
}
#[test]
fn test_render_edit_apply_renders_object_edit_region() {
let args = v(r#"{"file_path": "src/lib.rs"}"#);
let payload = v(r#"{
"success": true,
"changes_applied": 3,
"file_path": "src/lib.rs",
"edit_region": {"start": 10, "end": 25},
"message": "Applied 3 changes"
}"#);
let s = render_tool_output("leindex.edit-apply", &payload, &args);
assert!(
s.contains("Surrounding region"),
"missing surrounding region label: {}",
s
);
assert!(
s.contains("bytes 10..25"),
"missing structured edit_region range: {}",
s
);
}
#[test]
fn test_render_edit_apply_string_region_no_duplicate_text() {
let args = v(r#"{"file_path": "src/lib.rs"}"#);
let payload = v(r#"{
"success": true,
"changes_applied": 1,
"file_path": "src/lib.rs",
"edit_region": "1: // hello\n2: fn alpha() {}\n3: fn beta() {}\n",
"message": "Applied 1 change"
}"#);
let s = render_tool_output("leindex.edit-apply", &payload, &args);
let stripped = strip_ansi(&s);
assert!(
stripped.contains("// hello"),
"per-line expansion missing: {}",
stripped
);
assert!(stripped.contains("fn alpha() {}"));
assert!(stripped.contains("fn beta() {}"));
let header_line = stripped
.lines()
.find(|l| l.contains("Surrounding region"))
.unwrap_or("");
assert!(
!header_line.contains("fn alpha()"),
"string edit_region body leaked into header line: {:?}",
header_line
);
assert!(
!header_line.contains("fn beta()"),
"string edit_region body leaked into header line: {:?}",
header_line
);
}
#[test]
fn test_render_edit_apply_renders_partial_object_edit_region() {
let args = v(r#"{"file_path": "src/lib.rs"}"#);
let payload = v(r#"{
"success": true,
"changes_applied": 1,
"file_path": "src/lib.rs",
"edit_region": {"start": 7},
"message": "Applied 1 change"
}"#);
let s = render_tool_output("leindex.edit-apply", &payload, &args);
assert!(
s.contains("bytes 7.."),
"missing open-ended start range: {}",
s
);
}
#[test]
fn test_render_context_does_not_mislabel_byte_offset_as_line() {
let args = v(r#"{"node_id": "main"}"#);
let payload = v(r#"{
"query": "Context for main",
"results": [{
"rank": 1,
"node_id": "src/main.rs:main",
"file_path": "src/main.rs",
"symbol_name": "main",
"symbol_type": "function",
"byte_range": [15342, 15400]
}],
"context": "fn main() {\n return 0;\n}"
}"#);
let s = render_tool_output("leindex.context", &payload, &args);
assert!(
!s.contains("Line: 15342"),
"renderer mislabelled byte offset as line number: {}",
s
);
assert!(s.contains("Range"), "missing range hint: {}", s);
assert!(s.contains("bytes 15342-15400"), "missing byte range: {}", s);
let stripped = strip_ansi(&s);
let first_gutter = stripped.lines().find(|l| l.contains('│')).unwrap_or("");
assert!(
first_gutter.contains(" 1│"),
"first gutter line must start at 1, got {:?}",
first_gutter
);
for l in stripped.lines() {
if l.contains('│') {
assert!(
!l.contains("15342│"),
"gutter line must not use byte offset: {:?}",
l
);
}
}
}
#[test]
fn test_render_context_uses_legacy_line_field() {
let args = v(r#"{"node_id": "main"}"#);
let payload = v(r#"{
"symbol": "main",
"file_path": "src/main.rs",
"symbol_type": "function",
"line": 42,
"content": "fn main() { return 0; }"
}"#);
let s = render_tool_output("leindex.context", &payload, &args);
let stripped = strip_ansi(&s);
assert!(
stripped.contains("Line: 42"),
"missing line field: {}",
stripped
);
let first_gutter = stripped.lines().find(|l| l.contains('│')).unwrap_or("");
assert!(
first_gutter.contains(" 42│"),
"gutter must start at 42, got {:?}",
first_gutter
);
}
#[test]
fn test_render_flat_files_drops_unused_label_string() {
let files = v(r#"[
{"path": "src/main.rs", "symbol_count": 3, "total_complexity": 5, "incoming_dependencies": 0, "outgoing_dependencies": 0},
{"path": "src/lib.rs", "symbol_count": 12, "total_complexity": 25, "incoming_dependencies": 1, "outgoing_dependencies": 0}
]"#);
let s = render_flat_files(files.as_array().unwrap(), false);
assert!(s.contains("src/main.rs"));
assert!(s.contains("src/lib.rs"));
assert!(s.contains("cx:5"), "missing complexity value: {}", s);
assert!(s.contains("cx:25"), "missing complexity value: {}", s);
assert!(!s.contains("low"), "unused label leaked: {}", s);
assert!(!s.contains("med"), "unused label leaked: {}", s);
assert!(!s.contains("high"), "unused label leaked: {}", s);
}
#[test]
fn test_render_symbol_lookup_batch_renders_each_entry() {
let args = v(r#"{"symbols": ["main", "lib_init"]}"#);
let payload = v(r#"{
"batch": true,
"count": 2,
"results": [
{
"symbol": "main",
"type": "function",
"file": "src/main.rs",
"byte_range": [10, 60],
"complexity": 3,
"language": "rust",
"callers": [],
"callees": [],
"impact_radius": {"affected_symbols": 5, "affected_files": 2}
},
{
"symbol": "lib_init",
"type": "function",
"file": "src/lib.rs",
"byte_range": [100, 200],
"complexity": 7,
"language": "rust",
"callers": [],
"callees": [],
"impact_radius": {"affected_symbols": 0, "affected_files": 0}
}
]
}"#);
let s = render_tool_output("leindex.symbol-lookup", &payload, &args);
assert!(
s.contains("Symbol Lookup (batch)"),
"missing batch header: {}",
s
);
assert!(s.contains("Count"), "missing Count field: {}", s);
assert!(s.contains("main"), "missing first entry symbol: {}", s);
assert!(s.contains("lib_init"), "missing second entry symbol: {}", s);
assert!(s.contains("src/main.rs"), "missing first entry file: {}", s);
assert!(s.contains("src/lib.rs"), "missing second entry file: {}", s);
assert!(
s.contains("bytes 10-60"),
"missing first entry range: {}",
s
);
assert!(
s.contains("bytes 100-200"),
"missing second entry range: {}",
s
);
}
#[test]
fn test_render_symbol_lookup_batch_empty() {
let args = v(r#"{"symbols": []}"#);
let payload = v(r#"{"batch": true, "count": 0, "results": []}"#);
let s = render_tool_output("leindex.symbol-lookup", &payload, &args);
assert!(
s.contains("Symbol Lookup (batch)"),
"missing batch header: {}",
s
);
assert!(s.contains("(no results)"), "missing empty marker: {}", s);
}
}