Skip to main content

grr_cli/
output.rs

1//! Output formatting for CLI commands
2
3use clap::ValueEnum;
4use comfy_table::{Attribute, Cell, Color, ContentArrangement, Table};
5use serde::Serialize;
6use serde_json::Value;
7use std::io::{self, Write};
8
9#[derive(Debug, Clone, ValueEnum, Default)]
10pub enum OutputFormat {
11    #[default]
12    Json,
13    #[value(name = "jsonl")]
14    JsonLines,
15    Table,
16    Pretty,
17}
18
19pub fn print_output<T: Serialize>(value: &T, format: OutputFormat) -> io::Result<()> {
20    let stdout = io::stdout();
21    let mut handle = stdout.lock();
22
23    match format {
24        OutputFormat::Json => {
25            serde_json::to_writer(&mut handle, value)?;
26            writeln!(handle)?;
27        }
28        OutputFormat::JsonLines => {
29            let json_value = serde_json::to_value(value)?;
30            if let Some(arr) = json_value.as_array() {
31                for item in arr {
32                    serde_json::to_writer(&mut handle, item)?;
33                    writeln!(handle)?;
34                }
35            } else {
36                serde_json::to_writer(&mut handle, value)?;
37                writeln!(handle)?;
38            }
39        }
40        OutputFormat::Pretty => {
41            serde_json::to_writer_pretty(&mut handle, value)?;
42            writeln!(handle)?;
43        }
44        OutputFormat::Table => {
45            print_table(value, &mut handle)?;
46        }
47    }
48    Ok(())
49}
50
51fn print_table<T: Serialize>(value: &T, handle: &mut dyn Write) -> io::Result<()> {
52    let json_value = serde_json::to_value(value)?;
53    match json_value {
54        Value::Array(arr) => print_array_as_table(&arr, handle),
55        Value::Object(obj) => print_object_as_table(&obj, handle),
56        scalar => {
57            serde_json::to_writer_pretty(&mut *handle, &scalar)?;
58            writeln!(handle)
59        }
60    }
61}
62
63/// Stringify a table cell: scalars render plainly; nested objects and
64/// arrays render as compact JSON strings.
65fn stringify_cell(val: &Value) -> String {
66    match val {
67        Value::String(s) => s.clone(),
68        Value::Number(n) => n.to_string(),
69        Value::Bool(b) => b.to_string(),
70        Value::Null => "null".to_string(),
71        other => serde_json::to_string(other).unwrap_or_default(),
72    }
73}
74
75/// Arrays render as ONE table: one row per element, columns the union of
76/// all element keys in first-seen order. An empty array renders an empty
77/// table with a "(no results)" header. Arrays of scalars (first element
78/// not an object) keep the Index/Value layout.
79fn print_array_as_table(arr: &[Value], handle: &mut dyn Write) -> io::Result<()> {
80    let mut table = Table::new();
81    table.set_content_arrangement(ContentArrangement::Dynamic);
82
83    if arr.is_empty() {
84        table.set_header(vec!["(no results)"]);
85        return writeln!(handle, "{}", table);
86    }
87
88    if matches!(arr.first(), Some(Value::Object(_))) {
89        // Union of keys across all elements. Non-object elements (mixed
90        // arrays) land in a `value` column instead of being dropped.
91        let mut columns: Vec<String> = Vec::new();
92        for item in arr {
93            match item {
94                Value::Object(obj) => {
95                    for key in obj.keys() {
96                        if !columns.iter().any(|k| k == key) {
97                            columns.push(key.clone());
98                        }
99                    }
100                }
101                _ => {
102                    if !columns.iter().any(|k| k.as_str() == "value") {
103                        columns.push("value".to_string());
104                    }
105                }
106            }
107        }
108
109        table.set_header(columns.clone());
110        for item in arr {
111            let row: Vec<String> = columns
112                .iter()
113                .map(|col| match item {
114                    Value::Object(obj) => obj.get(col).map(stringify_cell).unwrap_or_default(),
115                    other if col.as_str() == "value" => stringify_cell(other),
116                    _ => String::new(),
117                })
118                .collect();
119            table.add_row(row);
120        }
121    } else {
122        table.set_header(vec!["Index", "Value"]);
123        for (idx, item) in arr.iter().enumerate() {
124            table.add_row(vec![idx.to_string(), stringify_cell(item)]);
125        }
126    }
127
128    writeln!(handle, "{}", table)
129}
130
131/// A single object renders as a vertical Field/Value table; nested
132/// values are summarized, not expanded.
133fn print_object_as_table(
134    obj: &serde_json::Map<String, Value>,
135    handle: &mut dyn Write,
136) -> io::Result<()> {
137    let mut table = Table::new();
138    table.set_content_arrangement(ContentArrangement::Dynamic);
139    table.set_header(vec!["Field", "Value"]);
140
141    for (key, val) in obj {
142        let cell_val = match val {
143            Value::Array(arr) => format!("[{} items]", arr.len()),
144            Value::Object(obj) => format!("{{{} fields}}", obj.len()),
145            other => stringify_cell(other),
146        };
147        table.add_row(vec![
148            Cell::new(key)
149                .add_attribute(Attribute::Bold)
150                .fg(Color::Cyan),
151            Cell::new(cell_val),
152        ]);
153    }
154
155    writeln!(handle, "{}", table)
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use serde_json::json;
162
163    fn render(value: serde_json::Value) -> String {
164        let mut buf: Vec<u8> = Vec::new();
165        print_table(&value, &mut buf).unwrap();
166        String::from_utf8(buf).unwrap()
167    }
168
169    #[test]
170    fn array_of_objects_renders_one_row_per_element() {
171        let out = render(json!([
172            {"id": "m1", "snippet": "hello", "label_ids": ["INBOX", "UNREAD"]},
173            {"id": "m2", "snippet": "world", "thread": {"id": "t1"}},
174        ]));
175
176        // One header from the union of keys, not stacked Field/Value blocks.
177        for col in ["id", "snippet", "label_ids", "thread"] {
178            assert!(out.contains(col), "missing column `{col}`:\n{out}");
179        }
180        assert!(!out.contains("Field"), "stacked layout leaked:\n{out}");
181
182        // One row per element.
183        assert_eq!(out.lines().filter(|l| l.contains("m1")).count(), 1);
184        assert_eq!(out.lines().filter(|l| l.contains("m2")).count(), 1);
185
186        // Nested values render as compact JSON.
187        assert!(out.contains(r#"["INBOX","UNREAD"]"#), "in:\n{out}");
188        assert!(out.contains(r#"{"id":"t1"}"#), "in:\n{out}");
189    }
190
191    #[test]
192    fn array_columns_span_elements_with_missing_keys() {
193        let out = render(json!([
194            {"id": "m1", "from": "a@example.com"},
195            {"id": "m2", "size_estimate": 2048},
196        ]));
197
198        // Union of keys: every element's key appears even when siblings
199        // lack it.
200        for col in ["id", "from", "size_estimate"] {
201            assert!(out.contains(col), "missing column `{col}`:\n{out}");
202        }
203        // Values from both elements present.
204        assert!(out.contains("a@example.com"));
205        assert!(out.contains("2048"));
206    }
207
208    #[test]
209    fn mixed_array_keeps_non_object_elements() {
210        let out = render(json!([{"id": "m1"}, "stray"]));
211
212        assert!(out.contains("id"));
213        assert!(out.contains("value"));
214        assert!(out.contains("m1"));
215        assert!(out.contains("stray"));
216    }
217
218    #[test]
219    fn empty_array_renders_no_results_header() {
220        let out = render(json!([]));
221
222        assert!(out.contains("(no results)"));
223        assert!(!out.contains("Field"));
224    }
225
226    #[test]
227    fn scalar_array_keeps_index_table() {
228        let out = render(json!(["alpha", "beta"]));
229
230        assert!(out.contains("Index"));
231        assert!(out.contains("Value"));
232        assert!(out.contains("alpha"));
233        assert!(out.contains("beta"));
234    }
235
236    #[test]
237    fn single_object_keeps_field_value_table() {
238        let out = render(json!({"id": "m1", "labels": ["INBOX"]}));
239
240        assert!(out.contains("Field"));
241        assert!(out.contains("id"));
242        assert!(out.contains("[1 items]"), "in:\n{out}");
243    }
244}