Skip to main content

qn/
output.rs

1//! Output rendering.
2//!
3//! Five formats, selected by the global `--format/-o` flag. When the flag and
4//! the config file both leave the format unset, the default is TTY-aware:
5//! `table` when stdout is a terminal (interactive use), `toon` otherwise
6//! (piped / agent invocations). See [`crate::context::GlobalArgs::resolve_output`].
7//!
8//! - `table`: comfy-table with UTF-8 borders for humans on a TTY.
9//! - `json`:  pretty-printed JSON via serde_json.
10//! - `yaml`:  YAML via serde_yml — same shape as JSON.
11//! - `md`:    GitHub-flavored markdown tables (same data, markdown borders).
12//! - `toon`:  Token-Oriented Object Notation (toon-format crate, default opts).
13//!
14//! The `Render` trait is only used for `table` and `md`. The other three
15//! formats serialize directly off `Serialize`.
16//!
17//! Color is suppressed when any of: `--no-color`, `NO_COLOR` env, `TERM=dumb`,
18//! stdout is not a TTY, or the format is anything other than `table`.
19//!
20//! State-change confirmations go to stderr through [`OutputCtx::note`], and
21//! advisory warnings through [`OutputCtx::warn`]; only `--quiet` suppresses
22//! them.
23
24use std::io::{IsTerminal, Write};
25
26use clap::ValueEnum;
27use comfy_table::{Attribute, Cell, CellAlignment, ContentArrangement, Table};
28use serde::Serialize;
29
30use crate::errors::CliError;
31
32/// Output format selected by `--format/-o`.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Default, Serialize, serde::Deserialize)]
34#[value(rename_all = "lower")]
35#[serde(rename_all = "lowercase")]
36pub enum Format {
37    /// Pretty UTF-8 tables for humans.
38    #[default]
39    Table,
40    /// Pretty-printed JSON.
41    Json,
42    /// YAML (same shape as JSON).
43    Yaml,
44    /// GitHub-flavored markdown tables.
45    Md,
46    /// Token-Oriented Object Notation.
47    Toon,
48}
49
50impl Format {
51    /// True when the format is a structured/serialized one (json/yaml/toon),
52    /// as opposed to a human-rendered table or markdown.
53    ///
54    /// Used by single-value commands (`stream enabled-count`, etc.) to decide
55    /// between emitting the structured response and printing the bare value.
56    pub fn is_structured(self) -> bool {
57        matches!(self, Self::Json | Self::Yaml | Self::Toon)
58    }
59}
60
61/// Carries the user's output preferences and TTY state.
62#[derive(Debug, Clone, Copy)]
63pub struct OutputCtx {
64    pub format: Format,
65    pub color: bool,
66    pub quiet: bool,
67    pub verbose: bool,
68    /// `--wide` was passed; list-style table/md renderers should show extra
69    /// columns. Has no effect on json/yaml/toon (which always include
70    /// everything from the SDK response).
71    pub wide: bool,
72    pub stdout_is_tty: bool,
73}
74
75impl OutputCtx {
76    /// Detect from environment + CLI flags.
77    pub fn detect(format: Format, no_color: bool, quiet: bool, verbose: bool, wide: bool) -> Self {
78        Self::detect_with(
79            format,
80            no_color,
81            quiet,
82            verbose,
83            wide,
84            std::io::stdout().is_terminal(),
85            std::env::var_os("NO_COLOR"),
86            std::env::var("TERM").ok(),
87        )
88    }
89
90    /// Pure version of [`detect`] for testing.
91    #[allow(clippy::too_many_arguments)] // test injection seam; pass env values in
92    pub fn detect_with(
93        format: Format,
94        no_color: bool,
95        quiet: bool,
96        verbose: bool,
97        wide: bool,
98        stdout_is_tty: bool,
99        no_color_env: Option<std::ffi::OsString>,
100        term_env: Option<String>,
101    ) -> Self {
102        let color = !no_color
103            && format == Format::Table
104            && stdout_is_tty
105            && no_color_env.map_or(true, |v| v.is_empty())
106            && term_env.map_or(true, |t| t != "dumb");
107        Self {
108            format,
109            color,
110            quiet,
111            verbose,
112            wide,
113            stdout_is_tty,
114        }
115    }
116
117    /// Writes a state-change note to stderr (e.g. "✓ Paused endpoint ep-123").
118    /// Suppressed under `--quiet`.
119    pub fn note(&self, message: &str) {
120        if self.quiet {
121            return;
122        }
123        let _ = writeln!(std::io::stderr(), "{message}");
124    }
125
126    /// Writes an advisory warning to stderr (e.g. "⚠ option is disabled…").
127    /// Suppressed under `--quiet`, like [`note`](Self::note).
128    pub fn warn(&self, message: &str) {
129        if self.quiet {
130            return;
131        }
132        let _ = writeln!(std::io::stderr(), "{message}");
133    }
134}
135
136/// Trait every printable response implements.
137pub trait Render: Serialize {
138    /// Render a human-facing representation to `w`. Implementations should use
139    /// [`new_table`] (which picks the right preset for the current `ctx.format`)
140    /// for tabular data so markdown and table formats share one code path.
141    fn render_table(&self, w: &mut dyn Write, ctx: &OutputCtx) -> std::io::Result<()>;
142
143    /// Override only when the default `Serialize` shape produces TOON output
144    /// that can't tabularize — typically a `Vec<struct>` field on each row of a
145    /// list response. The returned [`serde_json::Value`] is used **only** for
146    /// TOON encoding (JSON/YAML stay lossless via the default `Serialize` impl).
147    fn toon_projection(&self) -> Option<serde_json::Value> {
148        None
149    }
150}
151
152/// Top-level emit: serializes through the chosen format.
153pub fn emit<T: Render>(ctx: &OutputCtx, value: &T) -> Result<(), CliError> {
154    let mut out = std::io::stdout().lock();
155    match ctx.format {
156        Format::Json => {
157            serde_json::to_writer_pretty(&mut out, value)?;
158            out.write_all(b"\n")?;
159        }
160        Format::Yaml => {
161            serde_yml::to_writer(&mut out, value).map_err(|e| CliError::Format(e.to_string()))?;
162        }
163        Format::Toon => {
164            // TOON tabularizes uniform arrays of primitives (one CSV row per
165            // record) but bails to a verbose per-object form as soon as a row
166            // has an Array or Object field. Two interventions, TOON-only:
167            //   1. Render::toon_projection lets a view project Vec<struct>
168            //      fields down to primitives (lossless JSON/YAML preserved).
169            //   2. flatten_primitive_arrays joins primitive-only arrays inside
170            //      array-of-objects so the tabular check passes.
171            let mut json = match value.toon_projection() {
172                Some(v) => v,
173                None => serde_json::to_value(value).map_err(|e| CliError::Format(e.to_string()))?,
174            };
175            flatten_primitive_arrays(&mut json);
176            let s =
177                toon_format::encode_default(&json).map_err(|e| CliError::Format(e.to_string()))?;
178            out.write_all(s.as_bytes())?;
179            if !s.ends_with('\n') {
180                out.write_all(b"\n")?;
181            }
182        }
183        Format::Table | Format::Md => {
184            value.render_table(&mut out, ctx)?;
185        }
186    }
187    Ok(())
188}
189
190/// Walks `value` and, for every object that lives inside an array, replaces
191/// any field whose value is a primitive-only array with a single string of the
192/// comma-joined elements. This unlocks TOON's tabular form for the common case
193/// where a row has e.g. `tags: ["prod","staging"]`.
194///
195/// Scope is deliberately narrow: only fields *inside array elements* are
196/// joined. A top-level `Value::Array` of primitives is left alone — TOON
197/// already renders that form compactly via its own primitive-array rule.
198/// Non-primitive arrays (arrays of objects, arrays of arrays) are also left
199/// alone; those need a [`Render::toon_projection`] to summarize.
200pub(crate) fn flatten_primitive_arrays(value: &mut serde_json::Value) {
201    use serde_json::Value;
202    match value {
203        Value::Array(arr) => {
204            for el in arr.iter_mut() {
205                if let Value::Object(obj) = el {
206                    for v in obj.values_mut() {
207                        if let Value::Array(inner) = v {
208                            // Empty arrays count: an empty `Vec<EndpointTag>`
209                            // still blocks tabular until we collapse it.
210                            if inner.iter().all(is_json_primitive) {
211                                *v = Value::String(join_primitives(inner));
212                                continue;
213                            }
214                        }
215                        flatten_primitive_arrays(v);
216                    }
217                } else {
218                    flatten_primitive_arrays(el);
219                }
220            }
221        }
222        Value::Object(obj) => {
223            for v in obj.values_mut() {
224                flatten_primitive_arrays(v);
225            }
226        }
227        _ => {}
228    }
229}
230
231fn is_json_primitive(v: &serde_json::Value) -> bool {
232    use serde_json::Value;
233    matches!(
234        v,
235        Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_)
236    )
237}
238
239fn join_primitives(arr: &[serde_json::Value]) -> String {
240    use serde_json::Value;
241    arr.iter()
242        .map(|v| match v {
243            Value::Null => String::new(),
244            Value::Bool(b) => b.to_string(),
245            Value::Number(n) => n.to_string(),
246            Value::String(s) => s.clone(),
247            _ => unreachable!("guarded by is_json_primitive"),
248        })
249        .collect::<Vec<_>>()
250        .join(", ")
251}
252
253/// Builds a fresh table.
254///
255/// - For `Format::Md` we use the ASCII markdown preset (pipes + dashes) so
256///   the output can be pasted into a doc.
257/// - For `Format::Table` we use a borderless, docker-/kubectl-style layout:
258///   no row separators, no outer frame, columns separated by two spaces.
259///   Headers get [`set_header_bold`] applied at the call site.
260pub fn new_table(ctx: &OutputCtx) -> Table {
261    let mut t = Table::new();
262    t.set_content_arrangement(ContentArrangement::Dynamic);
263    if ctx.format == Format::Md {
264        t.load_preset(comfy_table::presets::ASCII_MARKDOWN);
265        return t;
266    }
267    t.load_preset(comfy_table::presets::NOTHING);
268    t
269}
270
271/// Sets the table header docker/kubectl-style: ALL-CAPS bold cells (bold only
272/// when colors are active — otherwise we'd dump raw ANSI escapes into piped
273/// output). Callers should pass already-uppercased strings.
274///
275/// Also configures two-space right padding on every column; with the
276/// borderless preset that gap is the only thing separating columns.
277pub fn set_header_bold<I, T>(table: &mut Table, ctx: &OutputCtx, columns: I)
278where
279    I: IntoIterator<Item = T>,
280    T: Into<String>,
281{
282    let cells = columns.into_iter().map(|c| {
283        let mut cell = Cell::new(c.into());
284        if ctx.color {
285            cell = cell.add_attribute(Attribute::Bold);
286        }
287        cell
288    });
289    table.set_header(cells);
290    if ctx.format != Format::Md {
291        for col in table.column_iter_mut() {
292            col.set_padding((0, 2));
293            col.set_cell_alignment(CellAlignment::Left);
294        }
295    }
296}
297
298/// Helper: a Cell whose text is `value.map_or("—", |v| &v.to_string())`.
299pub fn opt_cell<T: ToString>(v: &Option<T>) -> Cell {
300    match v {
301        Some(x) => Cell::new(x.to_string()),
302        None => Cell::new("—"),
303    }
304}
305
306/// Helper for boolean cells: ✓ / ✗ / —.
307pub fn bool_cell(v: Option<bool>) -> Cell {
308    match v {
309        Some(true) => Cell::new("✓"),
310        Some(false) => Cell::new("✗"),
311        None => Cell::new("—"),
312    }
313}
314
315/// Writes `table` to `w`.
316pub fn write_table(w: &mut dyn Write, table: &Table) -> std::io::Result<()> {
317    writeln!(w, "{table}")
318}
319
320/// Writes a `"showing X–Y of Z"` footer below a list-style table. Handles the
321/// empty-page case (`page_len == 0`) without underflowing to `"1-0 of N"`.
322pub fn write_pagination_footer(
323    w: &mut dyn Write,
324    offset: i64,
325    page_len: usize,
326    total: i64,
327) -> std::io::Result<()> {
328    if page_len == 0 {
329        writeln!(w, "showing 0 of {total}")
330    } else {
331        let end = (offset + page_len as i64).min(total);
332        writeln!(w, "showing {}–{} of {}", offset + 1, end, total)
333    }
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use std::io::Cursor;
340
341    #[derive(Serialize)]
342    struct Sample {
343        id: String,
344        n: i64,
345    }
346
347    impl Render for Sample {
348        fn render_table(&self, w: &mut dyn Write, _: &OutputCtx) -> std::io::Result<()> {
349            writeln!(w, "{}\t{}", self.id, self.n)
350        }
351    }
352
353    fn ctx(format: Format) -> OutputCtx {
354        OutputCtx {
355            format,
356            color: false,
357            quiet: false,
358            verbose: false,
359            wide: false,
360            stdout_is_tty: false,
361        }
362    }
363
364    #[test]
365    fn json_path_serializes() {
366        let val = Sample {
367            id: "x".into(),
368            n: 7,
369        };
370        let s = serde_json::to_string(&val).unwrap();
371        assert!(s.contains("\"x\""));
372        let mut buf = Cursor::new(Vec::<u8>::new());
373        val.render_table(&mut buf, &ctx(Format::Table)).unwrap();
374        assert_eq!(String::from_utf8(buf.into_inner()).unwrap(), "x\t7\n");
375    }
376
377    #[test]
378    fn yaml_serializes_via_serde_yml() {
379        let val = Sample {
380            id: "x".into(),
381            n: 7,
382        };
383        let s = serde_yml::to_string(&val).unwrap();
384        assert!(s.contains("id"), "got:\n{s}");
385        assert!(s.contains('x'), "got:\n{s}");
386        assert!(s.contains('7'), "got:\n{s}");
387    }
388
389    #[test]
390    fn toon_serializes_directly_from_serialize() {
391        let val = Sample {
392            id: "x".into(),
393            n: 7,
394        };
395        let s = toon_format::encode_default(&val).expect("toon encode");
396        assert!(s.contains("id:") && s.contains('x'), "got:\n{s}");
397        assert!(s.contains("n:") && s.contains('7'), "got:\n{s}");
398    }
399
400    #[test]
401    fn markdown_table_uses_pipe_borders() {
402        let mut t = new_table(&ctx(Format::Md));
403        t.set_header(vec!["a", "b"]).add_row(vec!["1", "2"]);
404        let s = t.to_string();
405        assert!(s.contains('|'), "expected pipe-bordered table, got:\n{s}");
406        // ASCII_MARKDOWN doesn't use box-drawing chars.
407        assert!(!s.contains('╞'), "unexpected utf8 border in md table:\n{s}");
408    }
409
410    #[test]
411    fn table_format_is_borderless_docker_style() {
412        let mut t = new_table(&ctx(Format::Table));
413        set_header_bold(&mut t, &ctx(Format::Table), vec!["A", "B"]);
414        t.add_row(vec!["1", "2"]);
415        let s = t.to_string();
416        // No box-drawing characters from the UTF8_FULL preset.
417        assert!(!s.contains('╞'), "unexpected utf8 border:\n{s}");
418        assert!(!s.contains('│'), "unexpected utf8 border:\n{s}");
419        // Columns separated by spaces (the borderless preset has none).
420        assert!(s.contains("A") && s.contains("B"));
421        assert!(s.contains("1") && s.contains("2"));
422    }
423
424    fn ctx_for(
425        format: Format,
426        no_color: bool,
427        stdout_is_tty: bool,
428        no_color_env: Option<&str>,
429        term: Option<&str>,
430    ) -> OutputCtx {
431        OutputCtx::detect_with(
432            format,
433            no_color,
434            false,
435            false,
436            false,
437            stdout_is_tty,
438            no_color_env.map(std::ffi::OsString::from),
439            term.map(String::from),
440        )
441    }
442
443    #[test]
444    fn color_disabled_with_no_color_env() {
445        let ctx = ctx_for(Format::Table, false, true, Some("1"), None);
446        assert!(!ctx.color);
447    }
448
449    #[test]
450    fn empty_no_color_env_does_not_disable() {
451        let ctx = ctx_for(Format::Table, false, true, Some(""), None);
452        assert!(ctx.color);
453    }
454
455    #[test]
456    fn color_disabled_with_term_dumb() {
457        let ctx = ctx_for(Format::Table, false, true, None, Some("dumb"));
458        assert!(!ctx.color);
459    }
460
461    #[test]
462    fn color_disabled_when_not_tty() {
463        let ctx = ctx_for(Format::Table, false, false, None, None);
464        assert!(!ctx.color);
465    }
466
467    #[test]
468    fn color_disabled_for_non_table_formats() {
469        for f in [Format::Json, Format::Yaml, Format::Md, Format::Toon] {
470            let ctx = ctx_for(f, false, true, None, None);
471            assert!(!ctx.color, "color should be off for {f:?}");
472        }
473    }
474
475    #[test]
476    fn color_disabled_with_no_color_flag() {
477        let ctx = ctx_for(Format::Table, true, true, None, None);
478        assert!(!ctx.color);
479    }
480
481    #[test]
482    fn color_enabled_on_tty_with_no_overrides() {
483        let ctx = ctx_for(Format::Table, false, true, None, Some("xterm-256color"));
484        assert!(ctx.color);
485    }
486
487    #[test]
488    fn opt_cell_shows_dash_for_none() {
489        let cell: Cell = opt_cell::<String>(&None);
490        let mut t = new_table(&ctx(Format::Table));
491        t.set_header(vec!["x"]).add_row(vec![cell]);
492        let s = t.to_string();
493        assert!(s.contains("—"), "got:\n{s}");
494    }
495
496    #[test]
497    fn bool_cell_renders_check_or_cross() {
498        let mut t = new_table(&ctx(Format::Table));
499        t.set_header(vec!["y", "n", "u"]).add_row(vec![
500            bool_cell(Some(true)),
501            bool_cell(Some(false)),
502            bool_cell(None),
503        ]);
504        let s = t.to_string();
505        assert!(
506            s.contains("✓") && s.contains("✗") && s.contains("—"),
507            "got:\n{s}"
508        );
509    }
510
511    #[test]
512    fn is_structured_classification() {
513        assert!(Format::Json.is_structured());
514        assert!(Format::Yaml.is_structured());
515        assert!(Format::Toon.is_structured());
516        assert!(!Format::Table.is_structured());
517        assert!(!Format::Md.is_structured());
518    }
519
520    #[test]
521    fn flatten_joins_primitive_array_inside_array_element() {
522        let mut v = serde_json::json!({"data": [{"id": 1, "tags": ["a", "b", "c"]}]});
523        flatten_primitive_arrays(&mut v);
524        assert_eq!(
525            v,
526            serde_json::json!({"data": [{"id": 1, "tags": "a, b, c"}]})
527        );
528    }
529
530    #[test]
531    fn flatten_collapses_empty_primitive_array_to_empty_string() {
532        let mut v = serde_json::json!({"data": [{"tags": []}]});
533        flatten_primitive_arrays(&mut v);
534        assert_eq!(v, serde_json::json!({"data": [{"tags": ""}]}));
535    }
536
537    #[test]
538    fn flatten_leaves_top_level_primitive_array_alone() {
539        // Top-level primitive arrays are TOON-friendly already (`tags[2]: a,b`),
540        // and joining them would change the semantics observed by callers.
541        let mut v = serde_json::json!({"tags": ["a", "b"]});
542        flatten_primitive_arrays(&mut v);
543        assert_eq!(v, serde_json::json!({"tags": ["a", "b"]}));
544    }
545
546    #[test]
547    fn flatten_leaves_array_of_objects_alone() {
548        // The generic walker doesn't know how to summarize an array of
549        // structs — that's `Render::toon_projection`'s job.
550        let mut v = serde_json::json!({"data": [{"tags": [{"tag_id": 1, "label": "x"}]}]});
551        flatten_primitive_arrays(&mut v);
552        assert_eq!(
553            v,
554            serde_json::json!({"data": [{"tags": [{"tag_id": 1, "label": "x"}]}]})
555        );
556    }
557
558    #[test]
559    fn flatten_preserves_sibling_pagination_object() {
560        let mut v = serde_json::json!({
561            "data": [{"id": 1, "tags": ["x"]}],
562            "pagination": {"total": 1, "limit": 100, "offset": 0}
563        });
564        flatten_primitive_arrays(&mut v);
565        assert_eq!(
566            v,
567            serde_json::json!({
568                "data": [{"id": 1, "tags": "x"}],
569                "pagination": {"total": 1, "limit": 100, "offset": 0}
570            })
571        );
572    }
573
574    #[test]
575    fn flatten_then_toon_emits_tabular_header() {
576        let mut v = serde_json::json!({
577            "data": [
578                {"id": 1, "name": "a", "tags": ["prod"]},
579                {"id": 2, "name": "b", "tags": []}
580            ]
581        });
582        flatten_primitive_arrays(&mut v);
583        let s = toon_format::encode_default(&v).unwrap();
584        assert!(
585            s.contains("data[2]{") && s.contains("}:"),
586            "expected tabular header, got:\n{s}"
587        );
588        assert!(s.contains("prod"), "got:\n{s}");
589    }
590}