Skip to main content

turbomcp_cli/
formatter.rs

1//! Rich output formatting for CLI
2
3use crate::cli::OutputFormat;
4use crate::error::{CliError, CliResult};
5use comfy_table::{Table, TableStyle, presets::UTF8_FULL};
6use owo_colors::OwoColorize;
7use serde::Serialize;
8use turbomcp_protocol::types::*;
9
10/// Border style shared by every table the CLI renders.
11const TABLE_STYLE: TableStyle = UTF8_FULL.with_rounded_corners();
12
13/// Format and display output based on format preference
14pub struct Formatter {
15    format: OutputFormat,
16    colored: bool,
17}
18
19impl Formatter {
20    #[must_use]
21    pub fn new(format: OutputFormat, colored: bool) -> Self {
22        Self { format, colored }
23    }
24
25    /// Display any serializable value
26    pub fn display<T: Serialize + ?Sized>(&self, value: &T) -> CliResult<()> {
27        match self.format {
28            OutputFormat::Human => self.display_human(value),
29            OutputFormat::Json => self.display_json(value, true),
30            OutputFormat::Compact => self.display_json(value, false),
31            OutputFormat::Yaml => self.display_yaml(value),
32            OutputFormat::Table => self.display_human(value), // fallback to human
33        }
34    }
35
36    /// Display tools list
37    pub fn display_tools(&self, tools: &[Tool]) -> CliResult<()> {
38        match self.format {
39            OutputFormat::Human => {
40                if tools.is_empty() {
41                    self.print_info("No tools available");
42                    return Ok(());
43                }
44
45                self.print_header("Available Tools");
46                for tool in tools {
47                    self.print_tool(tool);
48                }
49                self.print_footer(&format!("Total: {} tools", tools.len()));
50                Ok(())
51            }
52            OutputFormat::Table => {
53                let mut table = Table::new();
54                table.load_style(TABLE_STYLE).set_header(vec![
55                    "Name",
56                    "Description",
57                    "Input Schema",
58                ]);
59
60                for tool in tools {
61                    let schema_summary = format_schema_summary(&tool.input_schema);
62
63                    table.add_row(vec![
64                        &tool.name,
65                        tool.description.as_deref().unwrap_or("-"),
66                        &schema_summary,
67                    ]);
68                }
69
70                println!("{table}");
71                Ok(())
72            }
73            _ => self.display(tools),
74        }
75    }
76
77    /// Display resources list
78    pub fn display_resources(&self, resources: &[Resource]) -> CliResult<()> {
79        match self.format {
80            OutputFormat::Human => {
81                if resources.is_empty() {
82                    self.print_info("No resources available");
83                    return Ok(());
84                }
85
86                self.print_header("Available Resources");
87                for resource in resources {
88                    self.print_resource(resource);
89                }
90                self.print_footer(&format!("Total: {} resources", resources.len()));
91                Ok(())
92            }
93            OutputFormat::Table => {
94                let mut table = Table::new();
95                table.load_style(TABLE_STYLE).set_header(vec![
96                    "URI",
97                    "Name",
98                    "Description",
99                    "MIME Type",
100                ]);
101
102                for resource in resources {
103                    let mime_str = resource
104                        .mime_type
105                        .as_ref()
106                        .map(|m| m.as_str())
107                        .unwrap_or("-");
108
109                    table.add_row(vec![
110                        resource.uri.as_str(),
111                        &resource.name,
112                        resource.description.as_deref().unwrap_or("-"),
113                        mime_str,
114                    ]);
115                }
116
117                println!("{table}");
118                Ok(())
119            }
120            _ => self.display(resources),
121        }
122    }
123
124    /// Display prompts list
125    pub fn display_prompts(&self, prompts: &[Prompt]) -> CliResult<()> {
126        match self.format {
127            OutputFormat::Human => {
128                if prompts.is_empty() {
129                    self.print_info("No prompts available");
130                    return Ok(());
131                }
132
133                self.print_header("Available Prompts");
134                for prompt in prompts {
135                    self.print_prompt(prompt);
136                }
137                self.print_footer(&format!("Total: {} prompts", prompts.len()));
138                Ok(())
139            }
140            OutputFormat::Table => {
141                let mut table = Table::new();
142                table
143                    .load_style(TABLE_STYLE)
144                    .set_header(vec!["Name", "Description", "Arguments"]);
145
146                for prompt in prompts {
147                    let args = prompt
148                        .arguments
149                        .as_ref()
150                        .map(|a| {
151                            a.iter()
152                                .map(|arg| arg.name.as_str())
153                                .collect::<Vec<_>>()
154                                .join(", ")
155                        })
156                        .unwrap_or_else(|| "None".to_string());
157
158                    table.add_row(vec![
159                        &prompt.name,
160                        prompt.description.as_deref().unwrap_or("-"),
161                        &args,
162                    ]);
163                }
164
165                println!("{table}");
166                Ok(())
167            }
168            _ => self.display(prompts),
169        }
170    }
171
172    /// Display server info
173    pub fn display_server_info(&self, info: &Implementation) -> CliResult<()> {
174        match self.format {
175            OutputFormat::Human => {
176                self.print_header("Server Information");
177                self.print_kv("Name", &info.name);
178                self.print_kv("Version", &info.version);
179                Ok(())
180            }
181            _ => self.display(info),
182        }
183    }
184
185    /// Display error with suggestions
186    pub fn display_error(&self, error: &CliError) {
187        if self.colored {
188            eprintln!("{}: {}", "Error".bright_red().bold(), error);
189
190            let suggestions = error.suggestions();
191            if !suggestions.is_empty() {
192                eprintln!("\n{}", "Suggestions:".bright_yellow().bold());
193                for suggestion in suggestions {
194                    eprintln!("  {} {}", "•".bright_blue(), suggestion);
195                }
196            }
197        } else {
198            eprintln!("Error: {error}");
199
200            let suggestions = error.suggestions();
201            if !suggestions.is_empty() {
202                eprintln!("\nSuggestions:");
203                for suggestion in suggestions {
204                    eprintln!("  • {suggestion}");
205                }
206            }
207        }
208    }
209
210    // Internal formatting helpers
211
212    fn display_json<T: Serialize + ?Sized>(&self, value: &T, pretty: bool) -> CliResult<()> {
213        let json = if pretty {
214            serde_json::to_string_pretty(value)?
215        } else {
216            serde_json::to_string(value)?
217        };
218        println!("{json}");
219        Ok(())
220    }
221
222    fn display_yaml<T: Serialize + ?Sized>(&self, value: &T) -> CliResult<()> {
223        let yaml = serde_norway::to_string(value)?;
224        println!("{yaml}");
225        Ok(())
226    }
227
228    fn display_human<T: Serialize + ?Sized>(&self, value: &T) -> CliResult<()> {
229        // Fallback to pretty JSON for generic types
230        self.display_json(value, true)
231    }
232
233    fn print_header(&self, text: &str) {
234        if self.colored {
235            println!("\n{}", text.bright_cyan().bold());
236            println!("{}", "=".repeat(text.len()).bright_cyan());
237        } else {
238            println!("\n{text}");
239            println!("{}", "=".repeat(text.len()));
240        }
241    }
242
243    fn print_footer(&self, text: &str) {
244        if self.colored {
245            println!("\n{}", text.bright_black());
246        } else {
247            println!("\n{text}");
248        }
249    }
250
251    fn print_info(&self, text: &str) {
252        if self.colored {
253            println!("{}", text.bright_blue());
254        } else {
255            println!("{text}");
256        }
257    }
258
259    fn print_kv(&self, key: &str, value: &str) {
260        if self.colored {
261            println!("  {}: {}", key.bright_green().bold(), value);
262        } else {
263            println!("  {key}: {value}");
264        }
265    }
266
267    fn print_tool(&self, tool: &Tool) {
268        if self.colored {
269            println!(
270                "  {} {}",
271                "•".bright_blue(),
272                tool.name.bright_green().bold()
273            );
274            if let Some(desc) = &tool.description {
275                println!("    {desc}");
276            }
277        } else {
278            println!("  • {}", tool.name);
279            if let Some(desc) = &tool.description {
280                println!("    {desc}");
281            }
282        }
283    }
284
285    fn print_resource(&self, resource: &Resource) {
286        if self.colored {
287            println!(
288                "  {} {}",
289                "•".bright_blue(),
290                resource.uri.as_str().bright_green().bold()
291            );
292            println!("    Name: {}", resource.name);
293            if let Some(desc) = &resource.description {
294                println!("    {desc}");
295            }
296        } else {
297            println!("  • {}", resource.uri.as_str());
298            println!("    Name: {}", resource.name);
299            if let Some(desc) = &resource.description {
300                println!("    {desc}");
301            }
302        }
303    }
304
305    fn print_prompt(&self, prompt: &Prompt) {
306        if self.colored {
307            println!(
308                "  {} {}",
309                "•".bright_blue(),
310                prompt.name.bright_green().bold()
311            );
312            if let Some(desc) = &prompt.description {
313                println!("    {desc}");
314            }
315            if let Some(args) = &prompt.arguments {
316                if !args.is_empty() {
317                    let arg_names: Vec<_> = args.iter().map(|a| a.name.as_str()).collect();
318                    println!("    Arguments: {}", arg_names.join(", ").bright_yellow());
319                }
320            }
321        } else {
322            println!("  • {}", prompt.name);
323            if let Some(desc) = &prompt.description {
324                println!("    {desc}");
325            }
326            if let Some(args) = &prompt.arguments {
327                if !args.is_empty() {
328                    let arg_names: Vec<_> = args.iter().map(|a| a.name.as_str()).collect();
329                    println!("    Arguments: {}", arg_names.join(", "));
330                }
331            }
332        }
333    }
334}
335
336/// Format schema summary for table display
337fn format_schema_summary(schema: &ToolInputSchema) -> String {
338    if let Some(props) = schema.properties_as_object()
339        && !props.is_empty()
340    {
341        let prop_names: Vec<_> = props.keys().map(|k| k.as_str()).collect();
342        return prop_names.join(", ");
343    }
344    "No properties".to_string()
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    #[test]
352    fn test_formatter_creation() {
353        let formatter = Formatter::new(OutputFormat::Human, true);
354        assert!(formatter.colored);
355    }
356}