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), `json` 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/// Wraps `text` in an OSC 8 terminal-hyperlink escape so a clean visible
337/// label can point at a different target URL. Terminals that support OSC 8
338/// (iTerm2, kitty, wezterm, recent gnome-terminal, …) render `text` as a
339/// clickable link to `url`; terminals that don't simply show `text`.
340///
341/// The two arguments are intentionally separate: callers can display one URL
342/// and link to another (e.g. a clean URL with the click target carrying query
343/// params). Suppression is the caller's job — when hyperlinks aren't wanted,
344/// print the plain text instead of calling this.
345pub(crate) fn osc8_link(url: &str, text: &str) -> String {
346    format!("\x1b]8;;{url}\x1b\\{text}\x1b]8;;\x1b\\")
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use std::io::Cursor;
353
354    #[derive(Serialize)]
355    struct Sample {
356        id: String,
357        n: i64,
358    }
359
360    impl Render for Sample {
361        fn render_table(&self, w: &mut dyn Write, _: &OutputCtx) -> std::io::Result<()> {
362            writeln!(w, "{}\t{}", self.id, self.n)
363        }
364    }
365
366    fn ctx(format: Format) -> OutputCtx {
367        OutputCtx {
368            format,
369            color: false,
370            quiet: false,
371            verbose: false,
372            wide: false,
373            stdout_is_tty: false,
374        }
375    }
376
377    #[test]
378    fn json_path_serializes() {
379        let val = Sample {
380            id: "x".into(),
381            n: 7,
382        };
383        let s = serde_json::to_string(&val).unwrap();
384        assert!(s.contains("\"x\""));
385        let mut buf = Cursor::new(Vec::<u8>::new());
386        val.render_table(&mut buf, &ctx(Format::Table)).unwrap();
387        assert_eq!(String::from_utf8(buf.into_inner()).unwrap(), "x\t7\n");
388    }
389
390    #[test]
391    fn yaml_serializes_via_serde_yml() {
392        let val = Sample {
393            id: "x".into(),
394            n: 7,
395        };
396        let s = serde_yml::to_string(&val).unwrap();
397        assert!(s.contains("id"), "got:\n{s}");
398        assert!(s.contains('x'), "got:\n{s}");
399        assert!(s.contains('7'), "got:\n{s}");
400    }
401
402    #[test]
403    fn toon_serializes_directly_from_serialize() {
404        let val = Sample {
405            id: "x".into(),
406            n: 7,
407        };
408        let s = toon_format::encode_default(&val).expect("toon encode");
409        assert!(s.contains("id:") && s.contains('x'), "got:\n{s}");
410        assert!(s.contains("n:") && s.contains('7'), "got:\n{s}");
411    }
412
413    #[test]
414    fn markdown_table_uses_pipe_borders() {
415        let mut t = new_table(&ctx(Format::Md));
416        t.set_header(vec!["a", "b"]).add_row(vec!["1", "2"]);
417        let s = t.to_string();
418        assert!(s.contains('|'), "expected pipe-bordered table, got:\n{s}");
419        // ASCII_MARKDOWN doesn't use box-drawing chars.
420        assert!(!s.contains('╞'), "unexpected utf8 border in md table:\n{s}");
421    }
422
423    #[test]
424    fn table_format_is_borderless_docker_style() {
425        let mut t = new_table(&ctx(Format::Table));
426        set_header_bold(&mut t, &ctx(Format::Table), vec!["A", "B"]);
427        t.add_row(vec!["1", "2"]);
428        let s = t.to_string();
429        // No box-drawing characters from the UTF8_FULL preset.
430        assert!(!s.contains('╞'), "unexpected utf8 border:\n{s}");
431        assert!(!s.contains('│'), "unexpected utf8 border:\n{s}");
432        // Columns separated by spaces (the borderless preset has none).
433        assert!(s.contains("A") && s.contains("B"));
434        assert!(s.contains("1") && s.contains("2"));
435    }
436
437    fn ctx_for(
438        format: Format,
439        no_color: bool,
440        stdout_is_tty: bool,
441        no_color_env: Option<&str>,
442        term: Option<&str>,
443    ) -> OutputCtx {
444        OutputCtx::detect_with(
445            format,
446            no_color,
447            false,
448            false,
449            false,
450            stdout_is_tty,
451            no_color_env.map(std::ffi::OsString::from),
452            term.map(String::from),
453        )
454    }
455
456    #[test]
457    fn color_disabled_with_no_color_env() {
458        let ctx = ctx_for(Format::Table, false, true, Some("1"), None);
459        assert!(!ctx.color);
460    }
461
462    #[test]
463    fn empty_no_color_env_does_not_disable() {
464        let ctx = ctx_for(Format::Table, false, true, Some(""), None);
465        assert!(ctx.color);
466    }
467
468    #[test]
469    fn color_disabled_with_term_dumb() {
470        let ctx = ctx_for(Format::Table, false, true, None, Some("dumb"));
471        assert!(!ctx.color);
472    }
473
474    #[test]
475    fn color_disabled_when_not_tty() {
476        let ctx = ctx_for(Format::Table, false, false, None, None);
477        assert!(!ctx.color);
478    }
479
480    #[test]
481    fn color_disabled_for_non_table_formats() {
482        for f in [Format::Json, Format::Yaml, Format::Md, Format::Toon] {
483            let ctx = ctx_for(f, false, true, None, None);
484            assert!(!ctx.color, "color should be off for {f:?}");
485        }
486    }
487
488    #[test]
489    fn color_disabled_with_no_color_flag() {
490        let ctx = ctx_for(Format::Table, true, true, None, None);
491        assert!(!ctx.color);
492    }
493
494    #[test]
495    fn color_enabled_on_tty_with_no_overrides() {
496        let ctx = ctx_for(Format::Table, false, true, None, Some("xterm-256color"));
497        assert!(ctx.color);
498    }
499
500    #[test]
501    fn opt_cell_shows_dash_for_none() {
502        let cell: Cell = opt_cell::<String>(&None);
503        let mut t = new_table(&ctx(Format::Table));
504        t.set_header(vec!["x"]).add_row(vec![cell]);
505        let s = t.to_string();
506        assert!(s.contains("—"), "got:\n{s}");
507    }
508
509    #[test]
510    fn bool_cell_renders_check_or_cross() {
511        let mut t = new_table(&ctx(Format::Table));
512        t.set_header(vec!["y", "n", "u"]).add_row(vec![
513            bool_cell(Some(true)),
514            bool_cell(Some(false)),
515            bool_cell(None),
516        ]);
517        let s = t.to_string();
518        assert!(
519            s.contains("✓") && s.contains("✗") && s.contains("—"),
520            "got:\n{s}"
521        );
522    }
523
524    #[test]
525    fn is_structured_classification() {
526        assert!(Format::Json.is_structured());
527        assert!(Format::Yaml.is_structured());
528        assert!(Format::Toon.is_structured());
529        assert!(!Format::Table.is_structured());
530        assert!(!Format::Md.is_structured());
531    }
532
533    #[test]
534    fn osc8_link_frames_text_with_escape_and_target() {
535        let s = osc8_link("https://example.com/x", "click here");
536        assert_eq!(
537            s,
538            "\x1b]8;;https://example.com/x\x1b\\click here\x1b]8;;\x1b\\"
539        );
540    }
541
542    #[test]
543    fn osc8_link_display_text_can_differ_from_target() {
544        // The visible label stays clean while the click target carries params.
545        let clean = "https://www.quicknode.com/signup";
546        let tagged = "https://www.quicknode.com/signup?utm_source=cli";
547        let s = osc8_link(tagged, clean);
548        // The target appears in the escape; the visible label is the clean URL.
549        assert!(s.contains(tagged), "target missing: {s:?}");
550        assert!(s.contains(clean), "label missing: {s:?}");
551        // The clean label is what sits between the two escape sequences.
552        let label_start = s.find("\x1b\\").unwrap() + 2;
553        let label_end = s[label_start..].find('\x1b').unwrap() + label_start;
554        assert_eq!(&s[label_start..label_end], clean);
555    }
556
557    #[test]
558    fn flatten_joins_primitive_array_inside_array_element() {
559        let mut v = serde_json::json!({"data": [{"id": 1, "tags": ["a", "b", "c"]}]});
560        flatten_primitive_arrays(&mut v);
561        assert_eq!(
562            v,
563            serde_json::json!({"data": [{"id": 1, "tags": "a, b, c"}]})
564        );
565    }
566
567    #[test]
568    fn flatten_collapses_empty_primitive_array_to_empty_string() {
569        let mut v = serde_json::json!({"data": [{"tags": []}]});
570        flatten_primitive_arrays(&mut v);
571        assert_eq!(v, serde_json::json!({"data": [{"tags": ""}]}));
572    }
573
574    #[test]
575    fn flatten_leaves_top_level_primitive_array_alone() {
576        // Top-level primitive arrays are TOON-friendly already (`tags[2]: a,b`),
577        // and joining them would change the semantics observed by callers.
578        let mut v = serde_json::json!({"tags": ["a", "b"]});
579        flatten_primitive_arrays(&mut v);
580        assert_eq!(v, serde_json::json!({"tags": ["a", "b"]}));
581    }
582
583    #[test]
584    fn flatten_leaves_array_of_objects_alone() {
585        // The generic walker doesn't know how to summarize an array of
586        // structs — that's `Render::toon_projection`'s job.
587        let mut v = serde_json::json!({"data": [{"tags": [{"tag_id": 1, "label": "x"}]}]});
588        flatten_primitive_arrays(&mut v);
589        assert_eq!(
590            v,
591            serde_json::json!({"data": [{"tags": [{"tag_id": 1, "label": "x"}]}]})
592        );
593    }
594
595    #[test]
596    fn flatten_preserves_sibling_pagination_object() {
597        let mut v = serde_json::json!({
598            "data": [{"id": 1, "tags": ["x"]}],
599            "pagination": {"total": 1, "limit": 100, "offset": 0}
600        });
601        flatten_primitive_arrays(&mut v);
602        assert_eq!(
603            v,
604            serde_json::json!({
605                "data": [{"id": 1, "tags": "x"}],
606                "pagination": {"total": 1, "limit": 100, "offset": 0}
607            })
608        );
609    }
610
611    #[test]
612    fn flatten_then_toon_emits_tabular_header() {
613        let mut v = serde_json::json!({
614            "data": [
615                {"id": 1, "name": "a", "tags": ["prod"]},
616                {"id": 2, "name": "b", "tags": []}
617            ]
618        });
619        flatten_primitive_arrays(&mut v);
620        let s = toon_format::encode_default(&v).unwrap();
621        assert!(
622            s.contains("data[2]{") && s.contains("}:"),
623            "expected tabular header, got:\n{s}"
624        );
625        assert!(s.contains("prod"), "got:\n{s}");
626    }
627}