use std::io::Write;
use anyhow::{Context, Result};
use rmcp::model::{CallToolResult, ContentBlock};
use serde_json::Value;
const MAX_INLINE_LEN: usize = 200;
const MAX_HUMAN_ITEMS: usize = 1000;
const MS_THRESHOLD_US: u64 = 1_000;
pub struct Emit {
pub json: bool,
pub startup_us: u64,
}
fn format_us(us: u64) -> String {
if us < MS_THRESHOLD_US {
format!("{us} µs")
} else {
format!("{:.1} ms", us as f64 / 1_000.0)
}
}
pub fn emit(tool_name: &str, result: &CallToolResult, opts: &Emit, out: &mut impl Write) -> Result<()> {
let mut value = result_to_value(result)?;
if opts.json {
if let Value::Object(map) = &mut value {
map.insert("startup_us".to_string(), Value::from(opts.startup_us));
}
return render_json(&value, out);
}
let elapsed_us = match &mut value {
Value::Object(map) => map.remove("elapsed_us").and_then(|v| v.as_u64()),
_ => None,
};
render_human(tool_name, &value, out)?;
match elapsed_us {
Some(us) => writeln!(
out,
"\n({} query · {} startup)",
format_us(us),
format_us(opts.startup_us)
)?,
None => writeln!(out, "\n({} startup)", format_us(opts.startup_us))?,
}
Ok(())
}
pub fn result_to_value(result: &CallToolResult) -> Result<Value> {
for content in &result.content {
if let ContentBlock::Text(text) = content {
return serde_json::from_str(&text.text).with_context(|| "parse tool JSON response");
}
}
anyhow::bail!("tool returned no text content")
}
pub fn render_json(value: &Value, out: &mut impl Write) -> Result<()> {
let s = serde_json::to_string_pretty(value).context("serialize JSON output")?;
writeln!(out, "{s}").context("write JSON output")?;
Ok(())
}
fn truncate(s: &str) -> String {
let flat = s.replace('\n', " ");
if flat.chars().count() <= MAX_INLINE_LEN {
return flat;
}
let cut: String = flat.chars().take(MAX_INLINE_LEN).collect();
format!("{cut}…")
}
fn scalar_to_string(value: &Value) -> String {
match value {
Value::Null => "null".to_string(),
Value::Bool(b) => b.to_string(),
Value::Number(n) => n.to_string(),
Value::String(s) => truncate(s),
other => truncate(&other.to_string()),
}
}
pub fn render_human(tool_name: &str, value: &Value, out: &mut impl Write) -> Result<()> {
match value {
Value::Object(map) => {
let object_arrays: Vec<(&str, &Vec<Value>)> = map
.iter()
.filter_map(|(k, v)| match v {
Value::Array(items) if items.first().is_some_and(|i| i.is_object()) => Some((k.as_str(), items)),
_ => None,
})
.collect();
for (key, v) in map.iter() {
if object_arrays.iter().any(|(k, _)| *k == key) {
continue;
}
match v {
Value::Array(items) if !items.is_empty() => {
let joined: Vec<String> = items.iter().map(scalar_to_string).collect();
writeln!(out, "{key}: {}", joined.join(", "))?;
}
Value::Array(_) => writeln!(out, "{key}: (empty)")?,
Value::Object(_) => writeln!(out, "{key}: {}", scalar_to_string(v))?,
_ => writeln!(out, "{key}: {}", scalar_to_string(v))?,
}
}
for (key, items) in &object_arrays {
writeln!(out, "\n{key} ({} items):", items.len())?;
render_table(tool_name, items, out)?;
}
render_grep_truncation(tool_name, map, out)?;
}
Value::Array(items) if items.first().is_some_and(|i| i.is_object()) => {
render_table(tool_name, items, out)?;
}
Value::Array(items) => {
for item in items {
writeln!(out, "{}", scalar_to_string(item))?;
}
}
other => writeln!(out, "{}", scalar_to_string(other))?,
}
Ok(())
}
fn render_grep_truncation(tool_name: &str, map: &serde_json::Map<String, Value>, out: &mut impl Write) -> Result<()> {
if tool_name != "workspace_grep" || map.get("truncated").and_then(Value::as_bool) != Some(true) {
return Ok(());
}
let shown = map.get("hits").and_then(Value::as_array).map_or(0, Vec::len);
let total = map.get("total_matches").and_then(Value::as_u64).unwrap_or(0);
match map.get("truncation_reason").and_then(Value::as_str) {
Some("byte_budget") => writeln!(
out,
"\nwarning: TRUNCATED — the corpus exceeds what one grep may read, so files were left \
unscanned. Narrow with --path-contains / --language."
)?,
_ => writeln!(
out,
"\nwarning: TRUNCATED — showing {shown} of {total} matches. Raise --limit, or narrow \
with --path-contains / --language."
)?,
}
Ok(())
}
fn render_table(tool_name: &str, items: &[Value], out: &mut impl Write) -> Result<()> {
if items.is_empty() {
writeln!(out, " (none)")?;
return Ok(());
}
if let Some(rendered) = render_special(tool_name, items, out)? {
return Ok(rendered);
}
let Some(first) = items.first().and_then(Value::as_object) else {
for item in items {
writeln!(out, " {}", scalar_to_string(item))?;
}
return Ok(());
};
let columns: Vec<&str> = first.keys().map(String::as_str).collect();
let mut widths: Vec<usize> = columns.iter().map(|c| c.len()).collect();
let display = items.len().min(MAX_HUMAN_ITEMS);
let rows: Vec<Vec<String>> = items
.iter()
.take(display)
.map(|item| {
columns
.iter()
.map(|col| item.get(*col).map(scalar_to_string).unwrap_or_default())
.collect()
})
.collect();
for row in &rows {
for (i, cell) in row.iter().enumerate() {
widths[i] = widths[i].max(cell.chars().count());
}
}
let header: Vec<String> = columns
.iter()
.enumerate()
.map(|(i, c)| format!("{:<width$}", c, width = widths[i]))
.collect();
writeln!(out, " {}", header.join(" "))?;
for row in &rows {
let cells: Vec<String> = row
.iter()
.enumerate()
.map(|(i, c)| format!("{:<width$}", c, width = widths[i]))
.collect();
writeln!(out, " {}", cells.join(" "))?;
}
if items.len() > display {
writeln!(out, " … and {} more", items.len() - display)?;
}
Ok(())
}
fn render_special(tool_name: &str, items: &[Value], out: &mut impl Write) -> Result<Option<()>> {
match tool_name {
"outline" | "search_symbols" => {
for item in items.iter().take(MAX_HUMAN_ITEMS) {
let Some(obj) = item.as_object() else {
return Ok(None);
};
let name = obj.get("name").and_then(Value::as_str).unwrap_or("");
let kind = obj.get("kind").and_then(Value::as_str).unwrap_or("");
let row = obj.get("start_row").and_then(Value::as_u64).map(|r| r + 1).unwrap_or(0);
let path = obj.get("path").and_then(Value::as_str);
let sig = obj.get("signature").and_then(Value::as_str).unwrap_or("");
match path {
Some(p) => writeln!(out, " {p}:{row} {kind:<10} {name} {sig}", sig = truncate(sig))?,
None => writeln!(out, " {row:>5} {kind:<10} {name} {sig}", sig = truncate(sig))?,
}
}
if items.len() > MAX_HUMAN_ITEMS {
writeln!(out, " … and {} more", items.len() - MAX_HUMAN_ITEMS)?;
}
Ok(Some(()))
}
"find_references" | "find_callers" => {
for item in items.iter().take(MAX_HUMAN_ITEMS) {
let Some(obj) = item.as_object() else {
return Ok(None);
};
let path = obj.get("path").and_then(Value::as_str).unwrap_or("");
let line = obj.get("line").and_then(Value::as_u64).unwrap_or(0);
let col = obj.get("column").and_then(Value::as_u64).unwrap_or(0);
let callee = obj.get("callee").and_then(Value::as_str).unwrap_or("");
writeln!(out, " {path}:{line}:{col} {callee}")?;
}
if items.len() > MAX_HUMAN_ITEMS {
writeln!(out, " … and {} more", items.len() - MAX_HUMAN_ITEMS)?;
}
Ok(Some(()))
}
_ => Ok(None),
}
}
#[cfg(test)]
mod tests {
use super::render_human;
use serde_json::json;
fn render(value: &serde_json::Value) -> String {
let mut buf: Vec<u8> = Vec::new();
render_human("diff_outline", value, &mut buf).expect("render");
String::from_utf8(buf).expect("utf8")
}
fn render_grep(value: &serde_json::Value) -> String {
let mut buf: Vec<u8> = Vec::new();
render_human("workspace_grep", value, &mut buf).expect("render");
String::from_utf8(buf).expect("utf8")
}
#[test]
fn warns_in_prose_when_a_grep_result_is_truncated_by_the_limit() {
let out = render_grep(&json!({
"pattern": "OptimizationStatus",
"total_matches": 101,
"truncated": true,
"truncation_reason": "limit",
"hits": [{"path": "a.rs", "line_num": 1, "column": 0, "matched_text": "OptimizationStatus"}],
}));
assert!(
out.contains("TRUNCATED"),
"truncation must be shouted, not buried: {out}"
);
assert!(
out.contains("showing 1 of 101 matches"),
"must name the withheld count: {out}"
);
}
#[test]
fn a_complete_grep_result_carries_no_warning() {
let out = render_grep(&json!({
"pattern": "OptimizationStatus",
"total_matches": 1,
"truncated": false,
"hits": [{"path": "a.rs", "line_num": 1, "column": 0, "matched_text": "OptimizationStatus"}],
}));
assert!(!out.contains("TRUNCATED"), "a complete result must not cry wolf: {out}");
}
#[test]
fn renders_every_object_array_as_labeled_table() {
let value = json!({
"added": [{"name": "alpha"}],
"removed": [{"name": "beta"}],
"common": [{"name": "gamma"}],
});
let out = render(&value);
assert!(out.contains("added (1 items):"), "missing added table: {out}");
assert!(out.contains("removed (1 items):"), "missing removed table: {out}");
assert!(out.contains("common (1 items):"), "missing common table: {out}");
assert!(out.contains("alpha") && out.contains("beta") && out.contains("gamma"));
assert!(!out.contains("items)\nremoved"), "removed was summarized: {out}");
}
}