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/// Strips control characters (C0, DEL, C1) from a server- or
63/// writer-supplied string before it reaches the terminal.
64///
65/// Every CSI escape sequence starts with ESC (a C0 control character), so
66/// this neutralizes embedded ANSI escape sequences at the source without
67/// needing sequence-aware parsing; it also strips embedded newlines that
68/// could otherwise be used to spoof extra output rows. The one place a
69/// newline legitimately appears in rendered output is the record separator
70/// the renderer itself writes between rows — never a value coming from a
71/// sanitized field (#1137).
72///
73/// Also strips the bidirectional-control code points (LRE/RLE/PDF/LRO/RLO,
74/// LRI/RLI/FSI/PDI, LRM/RLM) — a Trojan-Source-style spoofing vector
75/// (CVE-2021-42574) distinct from escape injection, since a terminal that
76/// honors them can visually reorder rendered text (e.g. a filename crafted
77/// so `evil.exe` displays as `exe.live`). These are the only code points
78/// pulled from Unicode category `Cf`; the rest of `Cf` (e.g. ZWJ in emoji
79/// sequences, joiners in Arabic/Indic text) is left alone since stripping
80/// it would mangle otherwise-valid international text (#1552).
81pub fn sanitize_for_terminal(s: &str) -> String {
82    s.chars()
83        .filter(|c| !c.is_control() && !is_bidi_control(*c))
84        .collect()
85}
86
87/// True for the Unicode bidirectional-control code points: LRE/RLE/PDF/LRO/RLO
88/// (U+202A–U+202E), LRI/RLI/FSI/PDI (U+2066–U+2069), and LRM/RLM (U+200E/U+200F).
89fn is_bidi_control(c: char) -> bool {
90    matches!(c, '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' | '\u{200E}' | '\u{200F}')
91}
92
93/// Writes each item in an iterator as a single compact JSON line.
94pub fn write_items_jsonl<'a, I, T>(items: I, out: &mut dyn Write) -> Result<()>
95where
96    I: IntoIterator<Item = &'a T>,
97    T: Serialize + 'a,
98{
99    for item in items {
100        let line = serde_json::to_string(item).context("Failed to serialize as JSON")?;
101        writeln!(out, "{line}").context("Failed to write JSONL line")?;
102    }
103    Ok(())
104}
105
106/// Writes a single serializable value as one compact JSON line.
107pub fn write_scalar_jsonl<T: Serialize>(item: &T, out: &mut dyn Write) -> Result<()> {
108    let line = serde_json::to_string(item).context("Failed to serialize as JSON")?;
109    writeln!(out, "{line}").context("Failed to write JSONL line")?;
110    Ok(())
111}
112
113impl<T: Serialize> JsonlSerialize for Vec<T> {
114    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
115        write_items_jsonl(self.iter(), out)
116    }
117}
118
119/// Writes `data` to `out` in the requested format.
120///
121/// Returns `Ok(true)` when `data` was written (json/yaml/yamls/jsonl), `Ok(false)`
122/// when `format` is `Table` (the caller is expected to render its own table).
123pub fn write_output<T: Serialize + JsonlSerialize>(
124    data: &T,
125    format: &OutputFormat,
126    out: &mut dyn Write,
127) -> Result<bool> {
128    match format {
129        OutputFormat::Table => Ok(false),
130        OutputFormat::Json => {
131            let rendered =
132                serde_json::to_string_pretty(data).context("Failed to serialize as JSON")?;
133            writeln!(out, "{rendered}").context("Failed to write JSON output")?;
134            Ok(true)
135        }
136        OutputFormat::Yaml => {
137            let rendered = serde_yaml::to_string(data).context("Failed to serialize as YAML")?;
138            write!(out, "{rendered}").context("Failed to write YAML output")?;
139            Ok(true)
140        }
141        OutputFormat::Yamls => {
142            let rendered = format_yaml_stream(data)?;
143            write!(out, "{rendered}").context("Failed to write YAML stream output")?;
144            Ok(true)
145        }
146        OutputFormat::Jsonl => {
147            data.write_jsonl(out)?;
148            Ok(true)
149        }
150    }
151}
152
153/// Serializes a single YAML value as one `---`-prefixed document.
154fn yaml_stream_doc(value: &serde_yaml::Value) -> Result<String> {
155    let s = serde_yaml::to_string(value).context("Failed to serialize YAML stream item")?;
156    Ok(format!("---\n{s}"))
157}
158
159/// Serializes data as a YAML multi-document stream.
160///
161/// If the serialized value is a sequence, each element is emitted as its own
162/// `---`-prefixed YAML document. Otherwise the whole value is emitted as a
163/// single `---`-prefixed document. The result always ends with a newline.
164fn format_yaml_stream<T: Serialize>(data: &T) -> Result<String> {
165    match serde_yaml::to_value(data).context("Failed to serialize as YAML stream")? {
166        serde_yaml::Value::Sequence(items) => items.iter().map(yaml_stream_doc).collect(),
167        other => yaml_stream_doc(&other),
168    }
169}
170
171/// Serializes data in the requested output format to stdout.
172/// Returns `Ok(true)` if data was printed (json/yaml/yamls/jsonl), `Ok(false)`
173/// if the caller should handle table output.
174pub fn output_as<T: Serialize + JsonlSerialize>(data: &T, format: &OutputFormat) -> Result<bool> {
175    let stdout = std::io::stdout();
176    let mut handle = stdout.lock();
177    write_output(data, format, &mut handle)
178}
179
180#[cfg(test)]
181#[allow(clippy::unwrap_used)]
182mod tests {
183    use super::*;
184
185    // ── sanitize_for_terminal ──────────────────────────────────────
186
187    #[test]
188    fn sanitize_for_terminal_leaves_clean_string_unchanged() {
189        assert_eq!(sanitize_for_terminal("report.pdf"), "report.pdf");
190    }
191
192    #[test]
193    fn sanitize_for_terminal_strips_ansi_escape_sequence() {
194        assert_eq!(sanitize_for_terminal("evil\x1b[31mrepo"), "evil[31mrepo");
195    }
196
197    #[test]
198    fn sanitize_for_terminal_strips_c0_c1_and_del_control_bytes() {
199        let input = "a\rb\x07c\u{7f}d\u{9b}e";
200        assert_eq!(sanitize_for_terminal(input), "abcde");
201    }
202
203    #[test]
204    fn sanitize_for_terminal_strips_embedded_newlines() {
205        assert_eq!(sanitize_for_terminal("line1\nline2"), "line1line2");
206    }
207
208    #[test]
209    fn sanitize_for_terminal_strips_bidi_override_characters() {
210        let input = "evil\u{202E}exe.live";
211        assert_eq!(sanitize_for_terminal(input), "evilexe.live");
212    }
213
214    #[test]
215    fn sanitize_for_terminal_strips_all_bidi_control_code_points() {
216        let input = "a\u{202A}b\u{202B}c\u{202C}d\u{202D}e\u{202E}f\u{2066}g\u{2067}h\u{2068}i\u{2069}j\u{200E}k\u{200F}l";
217        assert_eq!(sanitize_for_terminal(input), "abcdefghijkl");
218    }
219
220    #[test]
221    fn sanitize_for_terminal_leaves_other_format_characters_unchanged() {
222        // Zero-width joiner (used in emoji ZWJ sequences) is `Cf` but not a
223        // bidi control, so it must survive.
224        let input = "\u{1F468}\u{200D}\u{1F469}";
225        assert_eq!(sanitize_for_terminal(input), input);
226    }
227
228    #[test]
229    fn output_default_is_table() {
230        assert!(matches!(OutputFormat::default(), OutputFormat::Table));
231    }
232
233    #[test]
234    fn output_json_variant() {
235        assert!(matches!(OutputFormat::Json, OutputFormat::Json));
236    }
237
238    #[test]
239    fn output_yaml_variant() {
240        assert!(matches!(OutputFormat::Yaml, OutputFormat::Yaml));
241    }
242
243    #[test]
244    fn output_yamls_variant() {
245        assert!(matches!(OutputFormat::Yamls, OutputFormat::Yamls));
246    }
247
248    #[test]
249    fn output_jsonl_variant() {
250        assert!(matches!(OutputFormat::Jsonl, OutputFormat::Jsonl));
251    }
252
253    #[test]
254    fn output_debug_format() {
255        assert_eq!(format!("{:?}", OutputFormat::Jsonl), "Jsonl");
256    }
257
258    #[test]
259    fn output_clone() {
260        let format = OutputFormat::Jsonl;
261        let cloned = format;
262        assert!(matches!(cloned, OutputFormat::Jsonl));
263    }
264
265    // ── output_as ──────────────────────────────────────────────────
266
267    #[test]
268    fn output_as_table_returns_false() {
269        let data = vec![1, 2, 3];
270        assert!(!output_as(&data, &OutputFormat::Table).unwrap());
271    }
272
273    #[test]
274    fn output_as_json_returns_true() {
275        let data = vec![1, 2, 3];
276        assert!(output_as(&data, &OutputFormat::Json).unwrap());
277    }
278
279    #[test]
280    fn output_as_yaml_returns_true() {
281        let data = vec![1, 2, 3];
282        assert!(output_as(&data, &OutputFormat::Yaml).unwrap());
283    }
284
285    #[test]
286    fn output_as_yamls_returns_true() {
287        let data = vec![1, 2, 3];
288        assert!(output_as(&data, &OutputFormat::Yamls).unwrap());
289    }
290
291    #[test]
292    fn output_as_jsonl_returns_true() {
293        let data = vec![1, 2, 3];
294        assert!(output_as(&data, &OutputFormat::Jsonl).unwrap());
295    }
296
297    // ── write_items_jsonl / Vec impl ───────────────────────────────
298
299    #[test]
300    fn vec_jsonl_empty_emits_nothing() {
301        let data: Vec<i32> = vec![];
302        let mut buf = Vec::new();
303        data.write_jsonl(&mut buf).unwrap();
304        assert_eq!(buf, b"");
305    }
306
307    #[test]
308    fn vec_jsonl_emits_one_line_per_item() {
309        let data = vec![1_i32, 2, 3];
310        let mut buf = Vec::new();
311        data.write_jsonl(&mut buf).unwrap();
312        assert_eq!(String::from_utf8(buf).unwrap(), "1\n2\n3\n");
313    }
314
315    #[test]
316    fn vec_jsonl_emits_compact_objects() {
317        #[derive(Serialize)]
318        struct Item {
319            key: &'static str,
320            val: u32,
321        }
322        let data = vec![Item { key: "a", val: 1 }, Item { key: "b", val: 2 }];
323        let mut buf = Vec::new();
324        data.write_jsonl(&mut buf).unwrap();
325        let out = String::from_utf8(buf).unwrap();
326        assert_eq!(
327            out,
328            "{\"key\":\"a\",\"val\":1}\n{\"key\":\"b\",\"val\":2}\n"
329        );
330    }
331
332    #[test]
333    fn write_items_jsonl_over_slice() {
334        let data = [10_i32, 20];
335        let mut buf = Vec::new();
336        write_items_jsonl(data.iter(), &mut buf).unwrap();
337        assert_eq!(String::from_utf8(buf).unwrap(), "10\n20\n");
338    }
339
340    #[test]
341    fn write_scalar_jsonl_emits_one_line() {
342        #[derive(Serialize)]
343        struct Scalar {
344            name: &'static str,
345        }
346        let item = Scalar { name: "solo" };
347        let mut buf = Vec::new();
348        write_scalar_jsonl(&item, &mut buf).unwrap();
349        assert_eq!(String::from_utf8(buf).unwrap(), "{\"name\":\"solo\"}\n");
350    }
351
352    // ── format_yaml_stream ─────────────────────────────────────────
353
354    #[derive(serde::Serialize)]
355    struct Issue {
356        key: &'static str,
357        summary: &'static str,
358    }
359
360    #[test]
361    fn yaml_stream_emits_one_doc_per_sequence_item() {
362        let data = vec![
363            Issue {
364                key: "PROJ-1",
365                summary: "Fix login",
366            },
367            Issue {
368                key: "PROJ-2",
369                summary: "Add feature",
370            },
371        ];
372        let out = format_yaml_stream(&data).unwrap();
373        assert_eq!(
374            out,
375            "---\nkey: PROJ-1\nsummary: Fix login\n---\nkey: PROJ-2\nsummary: Add feature\n"
376        );
377    }
378
379    #[test]
380    fn yaml_stream_empty_sequence_emits_nothing() {
381        let data: Vec<Issue> = vec![];
382        let out = format_yaml_stream(&data).unwrap();
383        assert_eq!(out, "");
384    }
385
386    #[test]
387    fn yaml_stream_single_item_sequence() {
388        let data = vec![Issue {
389            key: "PROJ-1",
390            summary: "Fix login",
391        }];
392        let out = format_yaml_stream(&data).unwrap();
393        assert_eq!(out, "---\nkey: PROJ-1\nsummary: Fix login\n");
394    }
395
396    #[test]
397    fn yaml_stream_non_sequence_emits_single_doc() {
398        let data = Issue {
399            key: "PROJ-1",
400            summary: "Fix login",
401        };
402        let out = format_yaml_stream(&data).unwrap();
403        assert_eq!(out, "---\nkey: PROJ-1\nsummary: Fix login\n");
404    }
405
406    #[test]
407    fn yaml_stream_scalar_emits_single_doc() {
408        let data: i32 = 42;
409        let out = format_yaml_stream(&data).unwrap();
410        assert_eq!(out, "---\n42\n");
411    }
412
413    #[test]
414    fn yaml_stream_nested_sequences_treat_outer_only() {
415        let data = vec![vec![1, 2], vec![3, 4]];
416        let out = format_yaml_stream(&data).unwrap();
417        assert_eq!(out, "---\n- 1\n- 2\n---\n- 3\n- 4\n");
418    }
419
420    #[test]
421    fn yaml_stream_round_trips_via_safe_load_all() {
422        use serde::Deserialize;
423
424        let data = vec![
425            Issue {
426                key: "PROJ-1",
427                summary: "Fix login",
428            },
429            Issue {
430                key: "PROJ-2",
431                summary: "Add feature",
432            },
433        ];
434        let out = format_yaml_stream(&data).unwrap();
435
436        let docs: Vec<serde_yaml::Value> = serde_yaml::Deserializer::from_str(&out)
437            .map(serde_yaml::Value::deserialize)
438            .collect::<Result<_, _>>()
439            .unwrap();
440
441        assert_eq!(docs.len(), 2);
442        assert_eq!(docs[0]["key"], serde_yaml::Value::from("PROJ-1"));
443        assert_eq!(docs[1]["key"], serde_yaml::Value::from("PROJ-2"));
444    }
445
446    // ── write_output ───────────────────────────────────────────────
447
448    #[test]
449    fn write_output_table_returns_false_and_writes_nothing() {
450        let data = vec![1_i32, 2];
451        let mut buf = Vec::new();
452        let wrote = write_output(&data, &OutputFormat::Table, &mut buf).unwrap();
453        assert!(!wrote);
454        assert!(buf.is_empty());
455    }
456
457    #[test]
458    fn write_output_json_emits_pretty_array() {
459        let data = vec![1_i32, 2, 3];
460        let mut buf = Vec::new();
461        let wrote = write_output(&data, &OutputFormat::Json, &mut buf).unwrap();
462        assert!(wrote);
463        let out = String::from_utf8(buf).unwrap();
464        assert!(out.starts_with('['));
465        assert!(out.contains("  1,\n"));
466        assert!(out.ends_with("]\n"));
467    }
468
469    #[test]
470    fn write_output_yaml_emits_list() {
471        let data = vec![1_i32, 2];
472        let mut buf = Vec::new();
473        let wrote = write_output(&data, &OutputFormat::Yaml, &mut buf).unwrap();
474        assert!(wrote);
475        let out = String::from_utf8(buf).unwrap();
476        assert_eq!(out, "- 1\n- 2\n");
477    }
478
479    #[test]
480    fn write_output_yamls_emits_yaml_stream() {
481        let data = vec![1_i32, 2];
482        let mut buf = Vec::new();
483        let wrote = write_output(&data, &OutputFormat::Yamls, &mut buf).unwrap();
484        assert!(wrote);
485        let out = String::from_utf8(buf).unwrap();
486        assert_eq!(out, "---\n1\n---\n2\n");
487    }
488
489    #[test]
490    fn write_output_jsonl_emits_one_line_per_item() {
491        let data = vec![1_i32, 2, 3];
492        let mut buf = Vec::new();
493        let wrote = write_output(&data, &OutputFormat::Jsonl, &mut buf).unwrap();
494        assert!(wrote);
495        assert_eq!(String::from_utf8(buf).unwrap(), "1\n2\n3\n");
496    }
497
498    struct FailingWriter;
499
500    impl Write for FailingWriter {
501        fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
502            Err(std::io::Error::other("boom"))
503        }
504        fn flush(&mut self) -> std::io::Result<()> {
505            Err(std::io::Error::other("boom"))
506        }
507    }
508
509    #[test]
510    fn write_output_propagates_write_errors() {
511        let data = vec![1_i32];
512        let mut writer = FailingWriter;
513
514        assert!(write_output(&data, &OutputFormat::Json, &mut writer).is_err());
515        assert!(write_output(&data, &OutputFormat::Yaml, &mut writer).is_err());
516        assert!(write_output(&data, &OutputFormat::Yamls, &mut writer).is_err());
517        assert!(write_output(&data, &OutputFormat::Jsonl, &mut writer).is_err());
518        assert!(writer.write(b"x").is_err());
519        assert!(writer.flush().is_err());
520    }
521
522    struct FailingSerialize;
523
524    impl Serialize for FailingSerialize {
525        fn serialize<S>(&self, _serializer: S) -> std::result::Result<S::Ok, S::Error>
526        where
527            S: serde::Serializer,
528        {
529            Err(serde::ser::Error::custom("serialize failed"))
530        }
531    }
532
533    impl JsonlSerialize for FailingSerialize {
534        fn write_jsonl(&self, _out: &mut dyn Write) -> Result<()> {
535            Ok(())
536        }
537    }
538
539    #[test]
540    fn write_output_propagates_json_serialize_errors() {
541        let mut buf = Vec::new();
542        let err = write_output(&FailingSerialize, &OutputFormat::Json, &mut buf).unwrap_err();
543        assert!(err.to_string().contains("Failed to serialize as JSON"));
544    }
545
546    #[test]
547    fn write_output_propagates_yaml_serialize_errors() {
548        let mut buf = Vec::new();
549        let err = write_output(&FailingSerialize, &OutputFormat::Yaml, &mut buf).unwrap_err();
550        assert!(err.to_string().contains("Failed to serialize as YAML"));
551    }
552
553    #[test]
554    fn failing_serialize_jsonl_impl_is_a_noop() {
555        let mut buf = Vec::new();
556        FailingSerialize.write_jsonl(&mut buf).unwrap();
557        assert!(buf.is_empty());
558    }
559}