1use 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
63fn 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
75fn 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 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
131fn 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 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 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 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 for col in ["id", "from", "size_estimate"] {
201 assert!(out.contains(col), "missing column `{col}`:\n{out}");
202 }
203 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}