Skip to main content

omni_dev/cli/
format.rs

1//! Shared output-format types for the CLI.
2//!
3//! One `-o/--output <format>` convention is used across the whole CLI surface
4//! (issue #1125). This module owns the machine-readable [`OutputFormat`] enum
5//! and the rendering machinery (`write_output`/`output_as` and the
6//! [`JsonlSerialize`] trait); command modules bind `-o/--output` to this enum
7//! and delegate serialization here, rendering their own `Table` branch when
8//! `output_as` returns `Ok(false)`.
9//!
10//! Atlassian-specific `JsonlSerialize` impls for its collection wrapper types
11//! live in [`crate::cli::atlassian::format`] (they need the wrapper types); this
12//! module carries only the trait, the blanket `Vec<T>` impl, and the generic
13//! helpers.
14
15use std::io::Write;
16
17use anyhow::{Context, Result};
18use clap::ValueEnum;
19use serde::Serialize;
20
21/// Display format for list/table commands.
22#[derive(Clone, Debug, Default, ValueEnum)]
23pub enum OutputFormat {
24    /// Human-readable table.
25    #[default]
26    Table,
27    /// JSON.
28    Json,
29    /// YAML (single document).
30    Yaml,
31    /// YAML stream (`---`-separated multi-document).
32    Yamls,
33    /// JSON Lines: one compact JSON object per line, streaming-friendly.
34    Jsonl,
35}
36
37/// A two-way `-o/--output` selector for commands that render either a
38/// human-readable table or machine-readable JSON (no YAML/JSONL variants).
39///
40/// Used by the daemon-facing status/list commands that historically took a
41/// boolean `--json` flag (issue #1125).
42#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)]
43#[value(rename_all = "lowercase")]
44pub enum TableOrJson {
45    /// Human-readable table.
46    #[default]
47    Table,
48    /// Pretty-printed JSON.
49    Json,
50}
51
52/// Writes a value as newline-terminated JSON Lines.
53///
54/// For collection-like types, implementations emit one JSON object per
55/// contained item. For scalar types, implementations emit the value as a
56/// single JSON line.
57pub trait JsonlSerialize {
58    /// Writes the value as JSONL to `out`, newline-terminated.
59    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()>;
60}
61
62/// Writes each item in an iterator as a single compact JSON line.
63pub fn write_items_jsonl<'a, I, T>(items: I, out: &mut dyn Write) -> Result<()>
64where
65    I: IntoIterator<Item = &'a T>,
66    T: Serialize + 'a,
67{
68    for item in items {
69        let line = serde_json::to_string(item).context("Failed to serialize as JSON")?;
70        writeln!(out, "{line}").context("Failed to write JSONL line")?;
71    }
72    Ok(())
73}
74
75/// Writes a single serializable value as one compact JSON line.
76pub fn write_scalar_jsonl<T: Serialize>(item: &T, out: &mut dyn Write) -> Result<()> {
77    let line = serde_json::to_string(item).context("Failed to serialize as JSON")?;
78    writeln!(out, "{line}").context("Failed to write JSONL line")?;
79    Ok(())
80}
81
82impl<T: Serialize> JsonlSerialize for Vec<T> {
83    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
84        write_items_jsonl(self.iter(), out)
85    }
86}
87
88/// Writes `data` to `out` in the requested format.
89///
90/// Returns `Ok(true)` when `data` was written (json/yaml/yamls/jsonl), `Ok(false)`
91/// when `format` is `Table` (the caller is expected to render its own table).
92pub fn write_output<T: Serialize + JsonlSerialize>(
93    data: &T,
94    format: &OutputFormat,
95    out: &mut dyn Write,
96) -> Result<bool> {
97    match format {
98        OutputFormat::Table => Ok(false),
99        OutputFormat::Json => {
100            let rendered =
101                serde_json::to_string_pretty(data).context("Failed to serialize as JSON")?;
102            writeln!(out, "{rendered}").context("Failed to write JSON output")?;
103            Ok(true)
104        }
105        OutputFormat::Yaml => {
106            let rendered = serde_yaml::to_string(data).context("Failed to serialize as YAML")?;
107            write!(out, "{rendered}").context("Failed to write YAML output")?;
108            Ok(true)
109        }
110        OutputFormat::Yamls => {
111            let rendered = format_yaml_stream(data)?;
112            write!(out, "{rendered}").context("Failed to write YAML stream output")?;
113            Ok(true)
114        }
115        OutputFormat::Jsonl => {
116            data.write_jsonl(out)?;
117            Ok(true)
118        }
119    }
120}
121
122/// Serializes a single YAML value as one `---`-prefixed document.
123fn yaml_stream_doc(value: &serde_yaml::Value) -> Result<String> {
124    let s = serde_yaml::to_string(value).context("Failed to serialize YAML stream item")?;
125    Ok(format!("---\n{s}"))
126}
127
128/// Serializes data as a YAML multi-document stream.
129///
130/// If the serialized value is a sequence, each element is emitted as its own
131/// `---`-prefixed YAML document. Otherwise the whole value is emitted as a
132/// single `---`-prefixed document. The result always ends with a newline.
133fn format_yaml_stream<T: Serialize>(data: &T) -> Result<String> {
134    match serde_yaml::to_value(data).context("Failed to serialize as YAML stream")? {
135        serde_yaml::Value::Sequence(items) => items.iter().map(yaml_stream_doc).collect(),
136        other => yaml_stream_doc(&other),
137    }
138}
139
140/// Serializes data in the requested output format to stdout.
141/// Returns `Ok(true)` if data was printed (json/yaml/yamls/jsonl), `Ok(false)`
142/// if the caller should handle table output.
143pub fn output_as<T: Serialize + JsonlSerialize>(data: &T, format: &OutputFormat) -> Result<bool> {
144    let stdout = std::io::stdout();
145    let mut handle = stdout.lock();
146    write_output(data, format, &mut handle)
147}
148
149#[cfg(test)]
150#[allow(clippy::unwrap_used)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn output_default_is_table() {
156        assert!(matches!(OutputFormat::default(), OutputFormat::Table));
157    }
158
159    #[test]
160    fn output_json_variant() {
161        assert!(matches!(OutputFormat::Json, OutputFormat::Json));
162    }
163
164    #[test]
165    fn output_yaml_variant() {
166        assert!(matches!(OutputFormat::Yaml, OutputFormat::Yaml));
167    }
168
169    #[test]
170    fn output_yamls_variant() {
171        assert!(matches!(OutputFormat::Yamls, OutputFormat::Yamls));
172    }
173
174    #[test]
175    fn output_jsonl_variant() {
176        assert!(matches!(OutputFormat::Jsonl, OutputFormat::Jsonl));
177    }
178
179    #[test]
180    fn output_debug_format() {
181        assert_eq!(format!("{:?}", OutputFormat::Jsonl), "Jsonl");
182    }
183
184    #[test]
185    fn output_clone() {
186        let format = OutputFormat::Jsonl;
187        let cloned = format;
188        assert!(matches!(cloned, OutputFormat::Jsonl));
189    }
190
191    // ── output_as ──────────────────────────────────────────────────
192
193    #[test]
194    fn output_as_table_returns_false() {
195        let data = vec![1, 2, 3];
196        assert!(!output_as(&data, &OutputFormat::Table).unwrap());
197    }
198
199    #[test]
200    fn output_as_json_returns_true() {
201        let data = vec![1, 2, 3];
202        assert!(output_as(&data, &OutputFormat::Json).unwrap());
203    }
204
205    #[test]
206    fn output_as_yaml_returns_true() {
207        let data = vec![1, 2, 3];
208        assert!(output_as(&data, &OutputFormat::Yaml).unwrap());
209    }
210
211    #[test]
212    fn output_as_yamls_returns_true() {
213        let data = vec![1, 2, 3];
214        assert!(output_as(&data, &OutputFormat::Yamls).unwrap());
215    }
216
217    #[test]
218    fn output_as_jsonl_returns_true() {
219        let data = vec![1, 2, 3];
220        assert!(output_as(&data, &OutputFormat::Jsonl).unwrap());
221    }
222
223    // ── write_items_jsonl / Vec impl ───────────────────────────────
224
225    #[test]
226    fn vec_jsonl_empty_emits_nothing() {
227        let data: Vec<i32> = vec![];
228        let mut buf = Vec::new();
229        data.write_jsonl(&mut buf).unwrap();
230        assert_eq!(buf, b"");
231    }
232
233    #[test]
234    fn vec_jsonl_emits_one_line_per_item() {
235        let data = vec![1_i32, 2, 3];
236        let mut buf = Vec::new();
237        data.write_jsonl(&mut buf).unwrap();
238        assert_eq!(String::from_utf8(buf).unwrap(), "1\n2\n3\n");
239    }
240
241    #[test]
242    fn vec_jsonl_emits_compact_objects() {
243        #[derive(Serialize)]
244        struct Item {
245            key: &'static str,
246            val: u32,
247        }
248        let data = vec![Item { key: "a", val: 1 }, Item { key: "b", val: 2 }];
249        let mut buf = Vec::new();
250        data.write_jsonl(&mut buf).unwrap();
251        let out = String::from_utf8(buf).unwrap();
252        assert_eq!(
253            out,
254            "{\"key\":\"a\",\"val\":1}\n{\"key\":\"b\",\"val\":2}\n"
255        );
256    }
257
258    #[test]
259    fn write_items_jsonl_over_slice() {
260        let data = [10_i32, 20];
261        let mut buf = Vec::new();
262        write_items_jsonl(data.iter(), &mut buf).unwrap();
263        assert_eq!(String::from_utf8(buf).unwrap(), "10\n20\n");
264    }
265
266    #[test]
267    fn write_scalar_jsonl_emits_one_line() {
268        #[derive(Serialize)]
269        struct Scalar {
270            name: &'static str,
271        }
272        let item = Scalar { name: "solo" };
273        let mut buf = Vec::new();
274        write_scalar_jsonl(&item, &mut buf).unwrap();
275        assert_eq!(String::from_utf8(buf).unwrap(), "{\"name\":\"solo\"}\n");
276    }
277
278    // ── format_yaml_stream ─────────────────────────────────────────
279
280    #[derive(serde::Serialize)]
281    struct Issue {
282        key: &'static str,
283        summary: &'static str,
284    }
285
286    #[test]
287    fn yaml_stream_emits_one_doc_per_sequence_item() {
288        let data = vec![
289            Issue {
290                key: "PROJ-1",
291                summary: "Fix login",
292            },
293            Issue {
294                key: "PROJ-2",
295                summary: "Add feature",
296            },
297        ];
298        let out = format_yaml_stream(&data).unwrap();
299        assert_eq!(
300            out,
301            "---\nkey: PROJ-1\nsummary: Fix login\n---\nkey: PROJ-2\nsummary: Add feature\n"
302        );
303    }
304
305    #[test]
306    fn yaml_stream_empty_sequence_emits_nothing() {
307        let data: Vec<Issue> = vec![];
308        let out = format_yaml_stream(&data).unwrap();
309        assert_eq!(out, "");
310    }
311
312    #[test]
313    fn yaml_stream_single_item_sequence() {
314        let data = vec![Issue {
315            key: "PROJ-1",
316            summary: "Fix login",
317        }];
318        let out = format_yaml_stream(&data).unwrap();
319        assert_eq!(out, "---\nkey: PROJ-1\nsummary: Fix login\n");
320    }
321
322    #[test]
323    fn yaml_stream_non_sequence_emits_single_doc() {
324        let data = Issue {
325            key: "PROJ-1",
326            summary: "Fix login",
327        };
328        let out = format_yaml_stream(&data).unwrap();
329        assert_eq!(out, "---\nkey: PROJ-1\nsummary: Fix login\n");
330    }
331
332    #[test]
333    fn yaml_stream_scalar_emits_single_doc() {
334        let data: i32 = 42;
335        let out = format_yaml_stream(&data).unwrap();
336        assert_eq!(out, "---\n42\n");
337    }
338
339    #[test]
340    fn yaml_stream_nested_sequences_treat_outer_only() {
341        let data = vec![vec![1, 2], vec![3, 4]];
342        let out = format_yaml_stream(&data).unwrap();
343        assert_eq!(out, "---\n- 1\n- 2\n---\n- 3\n- 4\n");
344    }
345
346    #[test]
347    fn yaml_stream_round_trips_via_safe_load_all() {
348        use serde::Deserialize;
349
350        let data = vec![
351            Issue {
352                key: "PROJ-1",
353                summary: "Fix login",
354            },
355            Issue {
356                key: "PROJ-2",
357                summary: "Add feature",
358            },
359        ];
360        let out = format_yaml_stream(&data).unwrap();
361
362        let docs: Vec<serde_yaml::Value> = serde_yaml::Deserializer::from_str(&out)
363            .map(serde_yaml::Value::deserialize)
364            .collect::<Result<_, _>>()
365            .unwrap();
366
367        assert_eq!(docs.len(), 2);
368        assert_eq!(docs[0]["key"], serde_yaml::Value::from("PROJ-1"));
369        assert_eq!(docs[1]["key"], serde_yaml::Value::from("PROJ-2"));
370    }
371
372    // ── write_output ───────────────────────────────────────────────
373
374    #[test]
375    fn write_output_table_returns_false_and_writes_nothing() {
376        let data = vec![1_i32, 2];
377        let mut buf = Vec::new();
378        let wrote = write_output(&data, &OutputFormat::Table, &mut buf).unwrap();
379        assert!(!wrote);
380        assert!(buf.is_empty());
381    }
382
383    #[test]
384    fn write_output_json_emits_pretty_array() {
385        let data = vec![1_i32, 2, 3];
386        let mut buf = Vec::new();
387        let wrote = write_output(&data, &OutputFormat::Json, &mut buf).unwrap();
388        assert!(wrote);
389        let out = String::from_utf8(buf).unwrap();
390        assert!(out.starts_with('['));
391        assert!(out.contains("  1,\n"));
392        assert!(out.ends_with("]\n"));
393    }
394
395    #[test]
396    fn write_output_yaml_emits_list() {
397        let data = vec![1_i32, 2];
398        let mut buf = Vec::new();
399        let wrote = write_output(&data, &OutputFormat::Yaml, &mut buf).unwrap();
400        assert!(wrote);
401        let out = String::from_utf8(buf).unwrap();
402        assert_eq!(out, "- 1\n- 2\n");
403    }
404
405    #[test]
406    fn write_output_yamls_emits_yaml_stream() {
407        let data = vec![1_i32, 2];
408        let mut buf = Vec::new();
409        let wrote = write_output(&data, &OutputFormat::Yamls, &mut buf).unwrap();
410        assert!(wrote);
411        let out = String::from_utf8(buf).unwrap();
412        assert_eq!(out, "---\n1\n---\n2\n");
413    }
414
415    #[test]
416    fn write_output_jsonl_emits_one_line_per_item() {
417        let data = vec![1_i32, 2, 3];
418        let mut buf = Vec::new();
419        let wrote = write_output(&data, &OutputFormat::Jsonl, &mut buf).unwrap();
420        assert!(wrote);
421        assert_eq!(String::from_utf8(buf).unwrap(), "1\n2\n3\n");
422    }
423
424    struct FailingWriter;
425
426    impl Write for FailingWriter {
427        fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
428            Err(std::io::Error::other("boom"))
429        }
430        fn flush(&mut self) -> std::io::Result<()> {
431            Err(std::io::Error::other("boom"))
432        }
433    }
434
435    #[test]
436    fn write_output_propagates_write_errors() {
437        let data = vec![1_i32];
438        let mut writer = FailingWriter;
439
440        assert!(write_output(&data, &OutputFormat::Json, &mut writer).is_err());
441        assert!(write_output(&data, &OutputFormat::Yaml, &mut writer).is_err());
442        assert!(write_output(&data, &OutputFormat::Yamls, &mut writer).is_err());
443        assert!(write_output(&data, &OutputFormat::Jsonl, &mut writer).is_err());
444        assert!(writer.write(b"x").is_err());
445        assert!(writer.flush().is_err());
446    }
447
448    struct FailingSerialize;
449
450    impl Serialize for FailingSerialize {
451        fn serialize<S>(&self, _serializer: S) -> std::result::Result<S::Ok, S::Error>
452        where
453            S: serde::Serializer,
454        {
455            Err(serde::ser::Error::custom("serialize failed"))
456        }
457    }
458
459    impl JsonlSerialize for FailingSerialize {
460        fn write_jsonl(&self, _out: &mut dyn Write) -> Result<()> {
461            Ok(())
462        }
463    }
464
465    #[test]
466    fn write_output_propagates_json_serialize_errors() {
467        let mut buf = Vec::new();
468        let err = write_output(&FailingSerialize, &OutputFormat::Json, &mut buf).unwrap_err();
469        assert!(err.to_string().contains("Failed to serialize as JSON"));
470    }
471
472    #[test]
473    fn write_output_propagates_yaml_serialize_errors() {
474        let mut buf = Vec::new();
475        let err = write_output(&FailingSerialize, &OutputFormat::Yaml, &mut buf).unwrap_err();
476        assert!(err.to_string().contains("Failed to serialize as YAML"));
477    }
478
479    #[test]
480    fn failing_serialize_jsonl_impl_is_a_noop() {
481        let mut buf = Vec::new();
482        FailingSerialize.write_jsonl(&mut buf).unwrap();
483        assert!(buf.is_empty());
484    }
485}