Skip to main content

mcp_execution_cli/
formatters.rs

1//! Output formatters for CLI commands.
2//!
3//! Provides consistent formatting across all CLI commands for JSON, text, and pretty output modes.
4
5use anyhow::Result;
6use colored::Colorize;
7use mcp_execution_core::cli::OutputFormat;
8use serde::Serialize;
9
10/// Format data according to the specified output format.
11///
12/// # Arguments
13///
14/// * `data` - The data to format (must be serializable)
15/// * `format` - The output format (Json, Text, Pretty)
16///
17/// # Errors
18///
19/// Returns an error if JSON serialization fails.
20///
21/// # Examples
22///
23/// ```
24/// use mcp_execution_cli::formatters::format_output;
25/// use mcp_execution_core::cli::OutputFormat;
26/// use serde::Serialize;
27///
28/// #[derive(Serialize)]
29/// struct ServerInfo {
30///     name: String,
31///     version: String,
32/// }
33///
34/// let info = ServerInfo {
35///     name: "test-server".to_string(),
36///     version: "1.0.0".to_string(),
37/// };
38///
39/// let output = format_output(&info, OutputFormat::Json)?;
40/// assert!(output.contains("\"name\""));
41/// # Ok::<(), anyhow::Error>(())
42/// ```
43pub fn format_output<T: Serialize>(data: &T, format: OutputFormat) -> Result<String> {
44    match format {
45        OutputFormat::Json => json::format(data),
46        OutputFormat::Text => text::format(data),
47        OutputFormat::Pretty => pretty::format(data),
48    }
49}
50
51/// JSON output formatting.
52pub mod json {
53    use super::{Result, Serialize};
54
55    /// Format data as JSON.
56    ///
57    /// Uses pretty-printing with 2-space indentation.
58    ///
59    /// # Errors
60    ///
61    /// Returns an error if JSON serialization fails (e.g., if the data
62    /// contains non-serializable types or custom serialization fails).
63    pub fn format<T: Serialize>(data: &T) -> Result<String> {
64        let json = serde_json::to_string_pretty(data)?;
65        Ok(json)
66    }
67
68    /// Format data as compact JSON (no formatting).
69    ///
70    /// # Errors
71    ///
72    /// Returns an error if JSON serialization fails (e.g., if the data
73    /// contains non-serializable types or custom serialization fails).
74    pub fn format_compact<T: Serialize>(data: &T) -> Result<String> {
75        let json = serde_json::to_string(data)?;
76        Ok(json)
77    }
78}
79
80/// Plain text output formatting.
81pub mod text {
82    use super::{Result, Serialize, json};
83
84    /// Format data as plain text.
85    ///
86    /// Uses JSON representation but without colors or fancy formatting.
87    /// Suitable for piping to other commands or scripts.
88    ///
89    /// # Errors
90    ///
91    /// Returns an error if JSON serialization fails (propagated from the
92    /// underlying `json::format_compact` call).
93    pub fn format<T: Serialize>(data: &T) -> Result<String> {
94        // For text mode, use JSON without pretty printing
95        json::format_compact(data)
96    }
97}
98
99/// Pretty (human-readable) output formatting.
100pub mod pretty {
101    use super::{Colorize, Result, Serialize};
102
103    /// Format data as colorized, human-readable output.
104    ///
105    /// Uses colors and formatting for better terminal readability.
106    ///
107    /// # Errors
108    ///
109    /// Returns an error if JSON serialization fails (e.g., if the data
110    /// contains non-serializable types). Value formatting itself cannot fail.
111    pub fn format<T: Serialize>(data: &T) -> Result<String> {
112        // Convert to JSON value first for inspection
113        let value = serde_json::to_value(data)?;
114
115        // Format with colors
116        format_value(&value, 0)
117    }
118
119    /// Recursively format a JSON value with colors and indentation.
120    fn format_value(value: &serde_json::Value, indent: usize) -> Result<String> {
121        use serde_json::Value;
122
123        let indent_str = "  ".repeat(indent);
124        let next_indent_str = "  ".repeat(indent + 1);
125
126        match value {
127            Value::Null => Ok("null".dimmed().to_string()),
128            Value::Bool(b) => Ok(b.to_string().yellow().to_string()),
129            Value::Number(n) => Ok(n.to_string().cyan().to_string()),
130            Value::String(s) => {
131                let quoted = serde_json::to_string(s)?;
132                Ok(quoted.green().to_string())
133            }
134            Value::Array(arr) => {
135                if arr.is_empty() {
136                    return Ok("[]".to_string());
137                }
138
139                let mut result = "[\n".to_string();
140                for (i, item) in arr.iter().enumerate() {
141                    result.push_str(&next_indent_str);
142                    result.push_str(&format_value(item, indent + 1)?);
143                    if i < arr.len() - 1 {
144                        result.push(',');
145                    }
146                    result.push('\n');
147                }
148                result.push_str(&indent_str);
149                result.push(']');
150                Ok(result)
151            }
152            Value::Object(obj) => {
153                if obj.is_empty() {
154                    return Ok("{}".to_string());
155                }
156
157                let mut result = "{\n".to_string();
158                let entries: Vec<_> = obj.iter().collect();
159                for (i, (key, val)) in entries.iter().enumerate() {
160                    result.push_str(&next_indent_str);
161                    let quoted_key = serde_json::to_string(key)?;
162                    result.push_str(&quoted_key.blue().bold().to_string());
163                    result.push_str(": ");
164                    result.push_str(&format_value(val, indent + 1)?);
165                    if i < entries.len() - 1 {
166                        result.push(',');
167                    }
168                    result.push('\n');
169                }
170                result.push_str(&indent_str);
171                result.push('}');
172                Ok(result)
173            }
174        }
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use serde::Serialize;
182
183    #[derive(Serialize)]
184    struct TestData {
185        name: String,
186        count: i32,
187        enabled: bool,
188    }
189
190    #[test]
191    fn test_json_format() {
192        let data = TestData {
193            name: "test".to_string(),
194            count: 42,
195            enabled: true,
196        };
197
198        let output = json::format(&data).unwrap();
199        assert!(output.contains("\"name\""));
200        assert!(output.contains("\"test\""));
201        assert!(output.contains("\"count\""));
202        assert!(output.contains("42"));
203        assert!(output.contains("\"enabled\""));
204        assert!(output.contains("true"));
205    }
206
207    #[test]
208    fn test_json_format_compact() {
209        let data = TestData {
210            name: "test".to_string(),
211            count: 42,
212            enabled: true,
213        };
214
215        let output = json::format_compact(&data).unwrap();
216        // Compact format should not have newlines
217        assert!(!output.contains('\n'));
218        assert!(output.contains("\"name\":\"test\""));
219    }
220
221    #[test]
222    fn test_text_format() {
223        let data = TestData {
224            name: "test".to_string(),
225            count: 42,
226            enabled: true,
227        };
228
229        let output = text::format(&data).unwrap();
230        // Text format uses compact JSON
231        assert!(!output.contains('\n'));
232        assert!(output.contains("\"name\":\"test\""));
233    }
234
235    #[test]
236    fn test_pretty_format() {
237        let data = TestData {
238            name: "test".to_string(),
239            count: 42,
240            enabled: true,
241        };
242
243        let output = pretty::format(&data).unwrap();
244        // Pretty format should have structure
245        assert!(output.contains("name"));
246        assert!(output.contains("test"));
247        assert!(output.contains("count"));
248        assert!(output.contains("42"));
249    }
250
251    #[test]
252    fn test_format_output_json() {
253        let data = TestData {
254            name: "test".to_string(),
255            count: 42,
256            enabled: true,
257        };
258
259        let output = format_output(&data, OutputFormat::Json).unwrap();
260        assert!(output.contains("\"name\""));
261    }
262
263    #[test]
264    fn test_format_output_text() {
265        let data = TestData {
266            name: "test".to_string(),
267            count: 42,
268            enabled: true,
269        };
270
271        let output = format_output(&data, OutputFormat::Text).unwrap();
272        assert!(output.contains("\"name\""));
273    }
274
275    #[test]
276    fn test_pretty_format_escapes_quotes_and_newlines() {
277        // Regression test: strings containing embedded quotes, backslashes,
278        // or newlines must round-trip through valid JSON once ANSI color
279        // codes are stripped, not just be wrapped in literal quotes.
280        #[derive(Serialize)]
281        struct Message {
282            text: String,
283        }
284
285        let data = Message {
286            text: "line one\nline \"two\" with \\backslash\\".to_string(),
287        };
288
289        let output = pretty::format(&data).unwrap();
290        let stripped = strip_ansi(&output);
291
292        let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
293        assert_eq!(parsed["text"], "line one\nline \"two\" with \\backslash\\");
294    }
295
296    #[test]
297    fn test_pretty_format_escapes_object_keys() {
298        // Regression test: object keys containing embedded quotes, backslashes,
299        // or newlines must also be escaped, not just values (the schema-derived
300        // property names rendered by `introspect --detailed` are attacker-controlled
301        // by the remote MCP server).
302        let mut data = std::collections::BTreeMap::new();
303        data.insert("line one\nline \"two\" with \\backslash\\".to_string(), 1);
304
305        let output = pretty::format(&data).unwrap();
306        let stripped = strip_ansi(&output);
307
308        let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
309        assert_eq!(
310            parsed["line one\nline \"two\" with \\backslash\\"],
311            serde_json::json!(1)
312        );
313    }
314
315    /// Strips ANSI color escape sequences emitted by the `colored` crate.
316    fn strip_ansi(s: &str) -> String {
317        let mut result = String::with_capacity(s.len());
318        let mut chars = s.chars();
319        while let Some(c) = chars.next() {
320            if c == '\u{1b}' {
321                for c in chars.by_ref() {
322                    if c == 'm' {
323                        break;
324                    }
325                }
326            } else {
327                result.push(c);
328            }
329        }
330        result
331    }
332
333    #[test]
334    fn test_format_output_pretty() {
335        let data = TestData {
336            name: "test".to_string(),
337            count: 42,
338            enabled: true,
339        };
340
341        let output = format_output(&data, OutputFormat::Pretty).unwrap();
342        assert!(output.contains("name"));
343    }
344}