Skip to main content

raps_kernel/
output.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2025 Dmytro Yemelianov
3
4//! Output formatting module
5//!
6//! Provides support for multiple output formats (JSON, CSV, Plain, Table) with automatic
7//! detection when output is piped.
8
9use anyhow::Result;
10use console::Term;
11use serde::Serialize;
12use std::str::FromStr;
13
14/// Output format options
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum OutputFormat {
17    /// Colored table format (default for interactive use)
18    Table,
19    /// JSON format (default when piped)
20    Json,
21    /// YAML format (human-readable, machine-parsable)
22    Yaml,
23    /// CSV format (for tabular data)
24    Csv,
25    /// Plain text (no colors, simple formatting)
26    Plain,
27}
28
29impl FromStr for OutputFormat {
30    type Err = anyhow::Error;
31
32    fn from_str(s: &str) -> Result<Self, Self::Err> {
33        match s.to_lowercase().as_str() {
34            "table" => Ok(OutputFormat::Table),
35            "json" => Ok(OutputFormat::Json),
36            "yaml" | "yml" => Ok(OutputFormat::Yaml),
37            "csv" => Ok(OutputFormat::Csv),
38            "plain" => Ok(OutputFormat::Plain),
39            _ => anyhow::bail!(
40                "Invalid output format: {}. Use: table, json, yaml, csv, plain",
41                s
42            ),
43        }
44    }
45}
46
47impl OutputFormat {
48    /// Determine output format based on CLI flag, environment variable, and TTY detection
49    pub fn determine(cli_format: Option<OutputFormat>) -> OutputFormat {
50        // Explicit format takes precedence
51        if let Some(format) = cli_format {
52            return format;
53        }
54
55        // Check environment variable
56        if let Ok(env_format) = std::env::var("RAPS_OUTPUT_FORMAT")
57            && let Ok(format) = OutputFormat::from_str(&env_format)
58        {
59            return format;
60        }
61
62        // Auto-detect: if not a TTY, use JSON
63        if !Term::stdout().is_term() {
64            return OutputFormat::Json;
65        }
66
67        // Default to table for interactive use
68        OutputFormat::Table
69    }
70
71    /// Write data in the selected format
72    pub fn write<T: Serialize>(&self, data: &T) -> Result<()> {
73        match self {
74            OutputFormat::Table => write_table(data),
75            OutputFormat::Json => write_json(data),
76            OutputFormat::Yaml => write_yaml(data),
77            OutputFormat::Csv => write_csv(data),
78            OutputFormat::Plain => write_plain(data),
79        }
80    }
81
82    /// Write a simple message (for non-structured output)
83    pub fn write_message(&self, message: &str) -> Result<()> {
84        match self {
85            OutputFormat::Table | OutputFormat::Plain => {
86                println!("{}", message);
87                Ok(())
88            }
89            OutputFormat::Json => {
90                #[derive(Serialize)]
91                struct Message {
92                    message: String,
93                }
94                write_json(&Message {
95                    message: message.to_string(),
96                })
97            }
98            OutputFormat::Yaml => {
99                #[derive(Serialize)]
100                struct Message {
101                    message: String,
102                }
103                write_yaml(&Message {
104                    message: message.to_string(),
105                })
106            }
107            OutputFormat::Csv => {
108                // CSV doesn't make sense for simple messages, use plain
109                println!("{}", message);
110                Ok(())
111            }
112        }
113    }
114
115    /// Check if this format supports colors
116    pub fn supports_colors(&self) -> bool {
117        matches!(self, OutputFormat::Table)
118    }
119}
120
121/// Write data as JSON
122fn write_json<T: Serialize>(data: &T) -> Result<()> {
123    let json = serde_json::to_string_pretty(data)?;
124    println!("{}", json);
125    Ok(())
126}
127
128/// Write data as YAML
129fn write_yaml<T: Serialize>(data: &T) -> Result<()> {
130    let yaml = serde_yaml::to_string(data)?;
131    print!("{}", yaml);
132    Ok(())
133}
134
135/// Write data as CSV (only works for arrays of structs)
136fn write_csv<T: Serialize>(data: &T) -> Result<()> {
137    // Try to serialize as JSON first to get the structure
138    let json_value = serde_json::to_value(data)?;
139
140    match json_value {
141        serde_json::Value::Array(items) if !items.is_empty() => {
142            // Get headers from first item
143            if let Some(serde_json::Value::Object(map)) = items.first() {
144                let mut wtr = csv::Writer::from_writer(std::io::stdout());
145
146                // Write headers
147                let headers: Vec<String> = map.keys().cloned().collect();
148                wtr.write_record(&headers)?;
149
150                // Write each row
151                for item in items {
152                    if let serde_json::Value::Object(map) = item {
153                        let mut row = Vec::new();
154                        for header in &headers {
155                            let value = map.get(header).unwrap_or(&serde_json::Value::Null);
156                            row.push(format_value_for_csv(value));
157                        }
158                        wtr.write_record(&row)?;
159                    }
160                }
161                wtr.flush()?;
162                return Ok(());
163            }
164        }
165        _ => {
166            // For non-array data, fall back to JSON
167            return write_json(data);
168        }
169    }
170
171    // Fallback to JSON if CSV conversion fails
172    write_json(data)
173}
174
175/// Format a JSON value for CSV output
176fn format_value_for_csv(value: &serde_json::Value) -> String {
177    match value {
178        serde_json::Value::Null => String::new(),
179        serde_json::Value::Bool(b) => b.to_string(),
180        serde_json::Value::Number(n) => n.to_string(),
181        serde_json::Value::String(s) => s.clone(),
182        serde_json::Value::Array(arr) => {
183            // Join array elements with semicolon
184            arr.iter()
185                .map(format_value_for_csv)
186                .collect::<Vec<_>>()
187                .join("; ")
188        }
189        serde_json::Value::Object(obj) => {
190            // For nested objects, serialize as JSON string
191            serde_json::to_string(obj).unwrap_or_default()
192        }
193    }
194}
195
196/// Write data as plain text (no colors)
197fn write_plain<T: Serialize>(data: &T) -> Result<()> {
198    // For plain text, we'll use a simple JSON-like structure without colors
199    // This is a simplified version - could be enhanced
200    let json = serde_json::to_string_pretty(data)?;
201    println!("{}", json);
202    Ok(())
203}
204
205/// Write data as a formatted table (current default behavior)
206fn write_table<T: Serialize>(data: &T) -> Result<()> {
207    // For table format, we'll serialize to JSON for now
208    // Individual commands will override this with their custom table formatting
209    // This is a fallback for commands that don't have custom table formatting yet
210    write_json(data)
211}
212
213/// Helper trait for types that can be formatted as tables
214#[allow(dead_code)] // May be used in future
215pub trait TableFormat {
216    /// Write this data as a formatted table
217    fn write_table(&self) -> Result<()>;
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[derive(Serialize)]
225    #[allow(dead_code)]
226    struct TestData {
227        id: String,
228        name: String,
229        count: u32,
230    }
231
232    #[derive(Serialize)]
233    #[allow(dead_code)]
234    struct NestedData {
235        id: String,
236        items: Vec<String>,
237    }
238
239    #[test]
240    fn test_output_format_from_str_table() {
241        let format = OutputFormat::from_str("table").unwrap();
242        assert_eq!(format, OutputFormat::Table);
243    }
244
245    #[test]
246    fn test_output_format_from_str_json() {
247        let format = OutputFormat::from_str("json").unwrap();
248        assert_eq!(format, OutputFormat::Json);
249    }
250
251    #[test]
252    fn test_output_format_from_str_yaml() {
253        let format = OutputFormat::from_str("yaml").unwrap();
254        assert_eq!(format, OutputFormat::Yaml);
255    }
256
257    #[test]
258    fn test_output_format_from_str_yml() {
259        let format = OutputFormat::from_str("yml").unwrap();
260        assert_eq!(format, OutputFormat::Yaml);
261    }
262
263    #[test]
264    fn test_output_format_from_str_csv() {
265        let format = OutputFormat::from_str("csv").unwrap();
266        assert_eq!(format, OutputFormat::Csv);
267    }
268
269    #[test]
270    fn test_output_format_from_str_plain() {
271        let format = OutputFormat::from_str("plain").unwrap();
272        assert_eq!(format, OutputFormat::Plain);
273    }
274
275    #[test]
276    fn test_output_format_from_str_case_insensitive() {
277        assert_eq!(OutputFormat::from_str("JSON").unwrap(), OutputFormat::Json);
278        assert_eq!(
279            OutputFormat::from_str("Table").unwrap(),
280            OutputFormat::Table
281        );
282        assert_eq!(OutputFormat::from_str("YAML").unwrap(), OutputFormat::Yaml);
283    }
284
285    #[test]
286    fn test_output_format_from_str_invalid() {
287        let result = OutputFormat::from_str("invalid");
288        assert!(result.is_err());
289    }
290
291    #[test]
292    fn test_supports_colors_table() {
293        assert!(OutputFormat::Table.supports_colors());
294    }
295
296    #[test]
297    fn test_supports_colors_json() {
298        assert!(!OutputFormat::Json.supports_colors());
299    }
300
301    #[test]
302    fn test_supports_colors_yaml() {
303        assert!(!OutputFormat::Yaml.supports_colors());
304    }
305
306    #[test]
307    fn test_supports_colors_csv() {
308        assert!(!OutputFormat::Csv.supports_colors());
309    }
310
311    #[test]
312    fn test_supports_colors_plain() {
313        assert!(!OutputFormat::Plain.supports_colors());
314    }
315
316    #[test]
317    fn test_determine_explicit_format() {
318        let format = OutputFormat::determine(Some(OutputFormat::Json));
319        assert_eq!(format, OutputFormat::Json);
320    }
321
322    #[test]
323    fn test_determine_env_format() {
324        // SAFETY: Test runs with --test-threads=1 or in isolation
325        unsafe {
326            std::env::set_var("RAPS_OUTPUT_FORMAT", "yaml");
327        }
328        let _format = OutputFormat::determine(None);
329        // Note: This may be Json if not a TTY, but we're testing env var takes effect
330        unsafe {
331            std::env::remove_var("RAPS_OUTPUT_FORMAT");
332        }
333        // We can't reliably test this without controlling the terminal state
334    }
335
336    #[test]
337    fn test_format_value_for_csv_null() {
338        let value = serde_json::Value::Null;
339        assert_eq!(format_value_for_csv(&value), "");
340    }
341
342    #[test]
343    fn test_format_value_for_csv_bool() {
344        let value = serde_json::Value::Bool(true);
345        assert_eq!(format_value_for_csv(&value), "true");
346    }
347
348    #[test]
349    fn test_format_value_for_csv_number() {
350        let value = serde_json::json!(42);
351        assert_eq!(format_value_for_csv(&value), "42");
352    }
353
354    #[test]
355    fn test_format_value_for_csv_string() {
356        let value = serde_json::json!("hello");
357        assert_eq!(format_value_for_csv(&value), "hello");
358    }
359
360    #[test]
361    fn test_format_value_for_csv_array() {
362        let value = serde_json::json!(["a", "b", "c"]);
363        assert_eq!(format_value_for_csv(&value), "a; b; c");
364    }
365
366    #[test]
367    fn test_format_value_for_csv_object() {
368        let value = serde_json::json!({"key": "value"});
369        let result = format_value_for_csv(&value);
370        assert!(result.contains("key"));
371        assert!(result.contains("value"));
372    }
373}