use rmcp::ErrorData as McpError;
use rmcp::model::{CallToolResult, Content};
use serde::Serialize;
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ResponseFormat {
Json,
Toon,
}
impl ResponseFormat {
pub(super) fn parse(opt: Option<&str>) -> Self {
match opt.map(str::trim).map(str::to_ascii_lowercase).as_deref() {
Some("toon") => Self::Toon,
_ => Self::Json,
}
}
}
pub(super) fn format_result<T: Serialize>(
value: &T,
fmt: ResponseFormat,
) -> Result<CallToolResult, McpError> {
match fmt {
ResponseFormat::Json => super::helpers::json_result(value),
ResponseFormat::Toon => {
let json = serde_json::to_value(value)
.map_err(|e| McpError::internal_error(format!("toon: serialize: {e}"), None))?;
Ok(CallToolResult::success(vec![Content::text(encode(&json))]))
}
}
}
pub(super) fn encode(value: &Value) -> String {
match value {
Value::Array(items) => match encode_table(items) {
Some(table) => table,
None => compact_json(value),
},
Value::Object(map) => encode_envelope(map),
other => compact_json(other),
}
}
fn encode_envelope(map: &serde_json::Map<String, Value>) -> String {
let mut lines: Vec<String> = Vec::with_capacity(map.len());
let mut keys: Vec<&String> = map.keys().collect();
keys.sort_unstable();
for key in keys {
let val = &map[key];
match val {
Value::Array(items) => {
if let Some(table) = encode_table_block(key, items) {
lines.push(table);
} else {
lines.push(format!("{key}: {}", compact_json(val)));
}
}
Value::Object(_) => lines.push(format!("{key}: {}", compact_json(val))),
scalar => lines.push(format!("{key}: {}", scalar_to_toon(scalar))),
}
}
lines.join("\n")
}
fn encode_table_block(field: &str, items: &[Value]) -> Option<String> {
let (columns, rows) = table_parts(items)?;
let header = format!("{field}[{}]{{{}}}:", items.len(), columns.join(","));
if rows.is_empty() {
return Some(header);
}
let body = rows
.iter()
.map(|r| format!(" {r}"))
.collect::<Vec<_>>()
.join("\n");
Some(format!("{header}\n{body}"))
}
fn encode_table(items: &[Value]) -> Option<String> {
let (columns, rows) = table_parts(items)?;
let header = format!("[{}]{{{}}}:", items.len(), columns.join(","));
if rows.is_empty() {
return Some(header);
}
Some(format!("{header}\n{}", rows.join("\n")))
}
fn table_parts(items: &[Value]) -> Option<(Vec<String>, Vec<String>)> {
let first = items.first()?.as_object()?;
if first.is_empty() {
return None;
}
let mut columns: Vec<String> = first.keys().cloned().collect();
columns.sort_unstable();
let mut rows: Vec<String> = Vec::with_capacity(items.len());
for item in items {
let obj = item.as_object()?;
if obj.len() != columns.len() {
return None;
}
let mut cells: Vec<String> = Vec::with_capacity(columns.len());
for col in &columns {
let cell = obj.get(col)?;
if !is_scalar(cell) {
return None;
}
cells.push(scalar_cell(cell));
}
rows.push(cells.join(","));
}
Some((columns, rows))
}
fn is_scalar(value: &Value) -> bool {
!matches!(value, Value::Object(_) | Value::Array(_))
}
fn scalar_to_toon(value: &Value) -> String {
match value {
Value::String(s) => maybe_quote(s),
other => compact_json(other),
}
}
fn scalar_cell(value: &Value) -> String {
match value {
Value::String(s) => {
if s.contains(',') || s.contains('\n') || needs_quote(s) {
quote(s)
} else {
s.clone()
}
}
other => compact_json(other),
}
}
fn maybe_quote(s: &str) -> String {
if needs_quote(s) {
quote(s)
} else {
s.to_string()
}
}
fn needs_quote(s: &str) -> bool {
if s.is_empty() {
return true;
}
if s.starts_with(' ') || s.ends_with(' ') {
return true;
}
matches!(s, "true" | "false" | "null")
|| s.starts_with(['"', '-', '+'])
|| s.chars().next().is_some_and(|c| c.is_ascii_digit())
}
fn quote(s: &str) -> String {
serde_json::to_string(s).unwrap_or_else(|_| format!("\"{s}\""))
}
fn compact_json(value: &Value) -> String {
serde_json::to_string(value).unwrap_or_else(|_| "null".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn parse_recognizes_toon_case_insensitively() {
assert_eq!(ResponseFormat::parse(Some("toon")), ResponseFormat::Toon);
assert_eq!(ResponseFormat::parse(Some("TOON")), ResponseFormat::Toon);
assert_eq!(ResponseFormat::parse(Some(" Toon ")), ResponseFormat::Toon);
assert_eq!(ResponseFormat::parse(Some("json")), ResponseFormat::Json);
assert_eq!(ResponseFormat::parse(None), ResponseFormat::Json);
assert_eq!(ResponseFormat::parse(Some("garbage")), ResponseFormat::Json);
}
#[test]
fn encodes_uniform_array_as_exact_table() {
let value = json!([
{ "path": "a.rs", "line": 1 },
{ "path": "b.rs", "line": 2 },
]);
let toon = encode(&value);
assert_eq!(toon, "[2]{line,path}:\n1,a.rs\n2,b.rs");
}
#[test]
fn encodes_envelope_scalars_then_table() {
let value = json!({
"total": 2,
"truncated": false,
"results": [
{ "path": "a.rs", "name": "alpha" },
{ "path": "b.rs", "name": "beta" },
],
});
let toon = encode(&value);
assert_eq!(
toon,
"results[2]{name,path}:\n alpha,a.rs\n beta,b.rs\ntotal: 2\ntruncated: false"
);
}
#[test]
fn empty_array_field_renders_as_json_fallback() {
let value = json!({ "total": 0, "results": [] });
let toon = encode(&value);
assert_eq!(toon, "results: []\ntotal: 0");
}
#[test]
fn non_uniform_array_falls_back_to_json() {
let value = json!([
{ "path": "a.rs", "line": 1 },
{ "path": "b.rs" },
]);
let toon = encode(&value);
assert_eq!(toon, compact_json(&value));
}
#[test]
fn nested_object_value_falls_back_to_json() {
let value = json!([{ "path": "a.rs", "loc": { "line": 1 } }]);
let toon = encode(&value);
assert_eq!(toon, compact_json(&value));
}
#[test]
fn cells_with_delimiters_are_quoted() {
let value = json!([{ "sig": "fn f(a, b)", "name": "f" }]);
let toon = encode(&value);
assert_eq!(toon, "[1]{name,sig}:\nf,\"fn f(a, b)\"");
}
#[test]
fn toon_is_smaller_than_json_for_list_payload() {
let value = json!({
"total": 4,
"truncated": false,
"results": [
{ "path": "src/a.rs", "name": "alpha", "kind": "function", "start_row": 1 },
{ "path": "src/b.rs", "name": "beta", "kind": "function", "start_row": 2 },
{ "path": "src/c.rs", "name": "gamma", "kind": "struct", "start_row": 3 },
{ "path": "src/d.rs", "name": "delta", "kind": "method", "start_row": 4 },
],
});
let toon = encode(&value);
let json = serde_json::to_string(&value).unwrap();
assert!(
toon.len() < json.len(),
"TOON ({} bytes) should be smaller than JSON ({} bytes)\nTOON:\n{toon}",
toon.len(),
json.len(),
);
}
}