use anyhow::Result;
use colored::Colorize;
use mcp_execution_core::cli::OutputFormat;
use serde::Serialize;
pub fn format_output<T: Serialize>(data: &T, format: OutputFormat) -> Result<String> {
match format {
OutputFormat::Json => json::format(data),
OutputFormat::Text => text::format(data),
OutputFormat::Pretty => pretty::format(data),
}
}
#[must_use]
pub fn escape_display(s: &str) -> String {
serde_json::Value::String(s.to_owned()).to_string()
}
const MAX_ERROR_TEXT_LEN: usize = 4000;
#[must_use]
pub fn escape_error_text(s: &str) -> String {
let redacted = mcp_execution_core::redact_urls_in_text(s);
mcp_execution_core::untrusted::sanitize_untrusted_text(&redacted, MAX_ERROR_TEXT_LEN)
}
pub mod json {
use super::{Result, Serialize};
pub fn format<T: Serialize>(data: &T) -> Result<String> {
let json = serde_json::to_string_pretty(data)?;
Ok(json)
}
pub fn format_compact<T: Serialize>(data: &T) -> Result<String> {
let json = serde_json::to_string(data)?;
Ok(json)
}
}
pub mod text {
use super::{Result, Serialize, json};
pub fn format<T: Serialize>(data: &T) -> Result<String> {
json::format_compact(data)
}
}
pub mod pretty {
use super::{Colorize, Result, Serialize, escape_display};
pub fn format<T: Serialize>(data: &T) -> Result<String> {
let value = serde_json::to_value(data)?;
format_value(&value, 0)
}
fn format_value(value: &serde_json::Value, indent: usize) -> Result<String> {
use serde_json::Value;
let indent_str = " ".repeat(indent);
let next_indent_str = " ".repeat(indent + 1);
match value {
Value::Null => Ok("null".dimmed().to_string()),
Value::Bool(b) => Ok(b.to_string().yellow().to_string()),
Value::Number(n) => Ok(n.to_string().cyan().to_string()),
Value::String(s) => Ok(escape_display(s).green().to_string()),
Value::Array(arr) => {
if arr.is_empty() {
return Ok("[]".to_string());
}
let mut result = "[\n".to_string();
for (i, item) in arr.iter().enumerate() {
result.push_str(&next_indent_str);
result.push_str(&format_value(item, indent + 1)?);
if i < arr.len() - 1 {
result.push(',');
}
result.push('\n');
}
result.push_str(&indent_str);
result.push(']');
Ok(result)
}
Value::Object(obj) => {
if obj.is_empty() {
return Ok("{}".to_string());
}
let mut result = "{\n".to_string();
let entries: Vec<_> = obj.iter().collect();
for (i, (key, val)) in entries.iter().enumerate() {
result.push_str(&next_indent_str);
let quoted_key = serde_json::to_string(key)?;
result.push_str("ed_key.blue().bold().to_string());
result.push_str(": ");
result.push_str(&format_value(val, indent + 1)?);
if i < entries.len() - 1 {
result.push(',');
}
result.push('\n');
}
result.push_str(&indent_str);
result.push('}');
Ok(result)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde::Serialize;
#[derive(Serialize)]
struct TestData {
name: String,
count: i32,
enabled: bool,
}
#[test]
fn test_json_format() {
let data = TestData {
name: "test".to_string(),
count: 42,
enabled: true,
};
let output = json::format(&data).unwrap();
assert!(output.contains("\"name\""));
assert!(output.contains("\"test\""));
assert!(output.contains("\"count\""));
assert!(output.contains("42"));
assert!(output.contains("\"enabled\""));
assert!(output.contains("true"));
}
#[test]
fn test_json_format_compact() {
let data = TestData {
name: "test".to_string(),
count: 42,
enabled: true,
};
let output = json::format_compact(&data).unwrap();
assert!(!output.contains('\n'));
assert!(output.contains("\"name\":\"test\""));
}
#[test]
fn test_text_format() {
let data = TestData {
name: "test".to_string(),
count: 42,
enabled: true,
};
let output = text::format(&data).unwrap();
assert!(!output.contains('\n'));
assert!(output.contains("\"name\":\"test\""));
}
#[test]
fn test_pretty_format() {
let data = TestData {
name: "test".to_string(),
count: 42,
enabled: true,
};
let output = pretty::format(&data).unwrap();
assert!(output.contains("name"));
assert!(output.contains("test"));
assert!(output.contains("count"));
assert!(output.contains("42"));
}
#[test]
fn test_format_output_json() {
let data = TestData {
name: "test".to_string(),
count: 42,
enabled: true,
};
let output = format_output(&data, OutputFormat::Json).unwrap();
assert!(output.contains("\"name\""));
}
#[test]
fn test_format_output_text() {
let data = TestData {
name: "test".to_string(),
count: 42,
enabled: true,
};
let output = format_output(&data, OutputFormat::Text).unwrap();
assert!(output.contains("\"name\""));
}
#[test]
fn test_pretty_format_escapes_quotes_and_newlines() {
#[derive(Serialize)]
struct Message {
text: String,
}
let data = Message {
text: "line one\nline \"two\" with \\backslash\\".to_string(),
};
let output = pretty::format(&data).unwrap();
let stripped = strip_ansi(&output);
let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
assert_eq!(parsed["text"], "line one\nline \"two\" with \\backslash\\");
}
#[test]
fn test_pretty_format_escapes_object_keys() {
let mut data = std::collections::BTreeMap::new();
data.insert("line one\nline \"two\" with \\backslash\\".to_string(), 1);
let output = pretty::format(&data).unwrap();
let stripped = strip_ansi(&output);
let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
assert_eq!(
parsed["line one\nline \"two\" with \\backslash\\"],
serde_json::json!(1)
);
}
fn strip_ansi(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\u{1b}' {
for c in chars.by_ref() {
if c == 'm' {
break;
}
}
} else {
result.push(c);
}
}
result
}
#[test]
fn test_escape_display_neutralizes_control_chars() {
let escaped = escape_display("evil\u{1b}[2Jname");
assert!(!escaped.contains('\u{1b}'));
assert!(escaped.contains("\\u001b"));
}
#[test]
fn test_escape_display_plain_string() {
assert_eq!(escape_display("hello"), "\"hello\"");
}
#[test]
fn test_escape_error_text_neutralizes_control_chars() {
let cause = "boom\u{1b}[2Jname, connection refused";
let escaped = escape_error_text(cause);
assert!(!escaped.contains('\u{1b}'));
assert!(escaped.contains("connection refused"));
}
#[test]
fn test_escape_error_text_plain_text_unaffected() {
let cause = "plain error, no control characters at all";
assert_eq!(escape_error_text(cause), cause);
}
#[test]
fn test_escape_error_text_newlines_do_not_survive() {
let hostile_cause = "boom\n\nCaused by:\n 0: Error: forged System component compromised";
let escaped = escape_error_text(hostile_cause);
assert!(!escaped.contains('\n'), "raw newline survived: {escaped}");
}
#[test]
fn test_escape_error_text_lone_carriage_return_neutralized() {
let hostile = "before\rafter";
let escaped = escape_error_text(hostile);
assert!(!escaped.contains('\r'));
}
#[test]
fn test_escape_error_text_redacts_embedded_url_secret() {
let cause = "Client error: error sending request for url (http://127.0.0.1:1/mcp?token=REFUSEDSECRET), when send initialize request";
let escaped = escape_error_text(cause);
assert!(!escaped.contains("REFUSEDSECRET"));
assert!(escaped.contains("http://127.0.0.1:1/mcp?<redacted>"));
assert!(escaped.contains("when send initialize request"));
}
#[test]
fn test_escape_error_text_redacts_secret_straddling_truncation_boundary() {
let secret = "verysecretvalue";
let url_prefix = "https://host.example.com/p?token=";
let chars_before_secret = MAX_ERROR_TEXT_LEN - 5;
let padding_len = chars_before_secret - 1 - url_prefix.chars().count();
let padding = "x".repeat(padding_len);
let cause = format!("{padding} {url_prefix}{secret}");
assert_eq!(
cause.chars().count(),
MAX_ERROR_TEXT_LEN - 5 + secret.chars().count()
);
let escaped = escape_error_text(&cause);
assert!(!escaped.contains(secret));
assert!(!escaped.contains(&secret[..5]));
}
#[test]
fn test_escape_error_text_caps_length() {
let long = "a".repeat(MAX_ERROR_TEXT_LEN + 500);
assert_eq!(escape_error_text(&long).chars().count(), MAX_ERROR_TEXT_LEN);
}
#[test]
fn test_max_error_text_len_matches_documented_value() {
assert_eq!(MAX_ERROR_TEXT_LEN, 4000);
}
#[test]
fn test_format_output_pretty() {
let data = TestData {
name: "test".to_string(),
count: 42,
enabled: true,
};
let output = format_output(&data, OutputFormat::Pretty).unwrap();
assert!(output.contains("name"));
}
}