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