Skip to main content

xurl/
output.rs

1//! Output formatting helpers for `--output`, `--quiet`, `--color`, and
2//! `NO_COLOR` support.
3//!
4//! `OutputConfig` is a pure `Send + Sync + Clone` configuration object — it
5//! owns no I/O handles. Print methods accept `&mut dyn Write` at the call site
6//! so the same config can drive real stdout, real stderr, or a captured
7//! `Vec<u8>` in library tests.
8//!
9//! This module is the single owner of `println!` / `eprintln!`. Every other
10//! `src/**/*.rs` site routes through one of [`OutputConfig`]'s methods or
11//! [`warn_stderr`] for the rare deep call sites that cannot carry an
12//! `OutputConfig`. A CI guard in `scripts/lint-stdio.sh` enforces the
13//! invariant.
14
15use std::io::{IsTerminal, Write};
16
17use clap::ValueEnum;
18use serde_json::Value;
19
20use crate::cli::ColorChoice;
21use crate::error::XurlError;
22
23/// Output format for machine/human consumption.
24#[derive(Clone, Debug, ValueEnum, PartialEq, Eq)]
25pub enum OutputFormat {
26    /// Default: colored, human-readable
27    Text,
28    /// Machine-readable JSON, no color
29    Json,
30    /// JSON Lines (useful for streaming)
31    Jsonl,
32    /// Newline-delimited JSON; alias of `jsonl`. Same wire shape, different name.
33    Ndjson,
34    /// YAML document (best-effort serialization of the JSON shape).
35    Yaml,
36    /// Comma-separated values (best-effort flattening of the top-level shape).
37    Csv,
38    /// Tab-separated values (best-effort flattening of the top-level shape).
39    Tsv,
40}
41
42impl OutputFormat {
43    /// Returns true for every machine-readable format. Equivalent to
44    /// `*self != OutputFormat::Text`. Used by stderr-emitter methods
45    /// (`info`, `status`, `warning`, `verbose`, `progress`) to suppress
46    /// human-targeted chatter under any structured mode.
47    #[must_use]
48    pub fn is_structured(&self) -> bool {
49        !matches!(self, OutputFormat::Text)
50    }
51}
52
53/// Output configuration threaded through command handlers.
54///
55/// `OutputConfig` is intentionally a pure data carrier — no I/O handles, no
56/// interior mutability. This keeps it `Send + Sync + Clone`, which is required
57/// for the planned async/concurrent `ApiClient` (see `project_async_requirement`).
58///
59/// `use_color` is the resolved color decision after combining `--color`, the
60/// `NO_COLOR` env var, and stderr's TTY-ness. `no_color` is preserved as the
61/// negation (`!use_color`) for source-compatibility with existing call sites
62/// and tests that constructed `OutputConfig { … }` directly.
63///
64/// `raw` (from `--raw`) forces compact JSON (no pretty-printing) and strips
65/// ANSI styling from text output. Useful for pipelines that line-buffer.
66///
67/// # Example
68///
69/// ```rust,no_run
70/// use xurl::cli::ColorChoice;
71/// use xurl::output::{OutputConfig, OutputFormat};
72///
73/// let cfg = OutputConfig::new(OutputFormat::Json, false, false, ColorChoice::Never);
74///
75/// let mut buf: Vec<u8> = Vec::new();
76/// let payload = serde_json::json!({ "status": "ok" });
77/// cfg.print_response(&mut buf, &payload);
78///
79/// let rendered = String::from_utf8(buf).unwrap();
80/// assert!(rendered.contains("\"status\""));
81/// ```
82#[derive(Clone, Debug)]
83pub struct OutputConfig {
84    /// Resolved output format from `--output` / `--json` / `--jsonl` /
85    /// `XURL_OUTPUT`.
86    pub format: OutputFormat,
87    /// Suppress non-essential human chatter (`--quiet` / `XURL_QUIET`).
88    pub quiet: bool,
89    /// Negation of [`Self::use_color`], kept for source compatibility with
90    /// call sites that pattern-match on the negative form.
91    pub no_color: bool,
92    /// Resolved color decision after combining `--color`, `NO_COLOR`, and
93    /// stderr's TTY-ness. The single source of truth for "should I emit
94    /// ANSI escapes".
95    pub use_color: bool,
96    /// Enable verbose request/response logging
97    /// (`--verbose` / `-v` / `XURL_VERBOSE`).
98    pub verbose: bool,
99    /// Emit unstyled, compact output (`--raw` / `XURL_RAW`). Strips ANSI in
100    /// text mode and forces compact JSON in machine modes.
101    pub raw: bool,
102    /// Set when the user passed `--no-interactive` (or `XURL_NO_INTERACTIVE`).
103    ///
104    /// Routed into `OutputConfig` so dialoguer-gating call sites can ask
105    /// [`Self::is_interactive_terminal`] without re-reading the parsed `Cli`
106    /// struct. Constructors default this to `false`; the runner sets it via
107    /// [`Self::with_no_interactive`] right after construction.
108    pub no_interactive: bool,
109}
110
111impl OutputConfig {
112    /// Creates an `OutputConfig` from resolved CLI flags and environment.
113    ///
114    /// `use_color` is computed from `color` together with `NO_COLOR` and
115    /// `std::io::stderr().is_terminal()`:
116    /// - `NO_COLOR` is absolute (per <https://no-color.org/>): when set,
117    ///   color is disabled regardless of `--color`.
118    /// - `--color always` overrides the TTY check (still loses to `NO_COLOR`).
119    /// - `--color never` disables color unconditionally.
120    /// - `--color auto` enables color only when stderr is a TTY.
121    ///
122    /// `raw` forces `use_color = false` and switches JSON output to compact
123    /// form (no pretty-printing).
124    #[must_use]
125    pub fn new(format: OutputFormat, quiet: bool, verbose: bool, color: ColorChoice) -> Self {
126        Self::new_with_raw(format, quiet, verbose, color, false)
127    }
128
129    /// Like [`new`], with an explicit `raw` flag.
130    ///
131    /// [`new`]: Self::new
132    #[must_use]
133    pub fn new_with_raw(
134        format: OutputFormat,
135        quiet: bool,
136        verbose: bool,
137        color: ColorChoice,
138        raw: bool,
139    ) -> Self {
140        let no_color_env = std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty());
141        let use_color = if raw || no_color_env {
142            false
143        } else {
144            match color {
145                ColorChoice::Always => true,
146                ColorChoice::Never => false,
147                ColorChoice::Auto => std::io::stderr().is_terminal(),
148            }
149        };
150        Self {
151            format,
152            quiet,
153            no_color: !use_color,
154            use_color,
155            verbose,
156            raw,
157            no_interactive: false,
158        }
159    }
160
161    /// Returns a copy of this config with the `no_interactive` field set.
162    ///
163    /// Used by the runner immediately after construction to thread the parsed
164    /// `--no-interactive` (or `XURL_NO_INTERACTIVE`) flag into
165    /// [`Self::is_interactive_terminal`].
166    #[must_use]
167    pub fn with_no_interactive(mut self, no_interactive: bool) -> Self {
168        self.no_interactive = no_interactive;
169        self
170    }
171
172    /// Returns `true` when the active session can drive interactive prompts.
173    ///
174    /// True only when:
175    /// - `--no-interactive` is NOT set,
176    /// - stdin is a TTY,
177    /// - stderr is a TTY (dialoguer renders prompts on stderr).
178    ///
179    /// Call sites that drive `dialoguer::Select` / `dialoguer::Confirm`
180    /// MUST gate on this — auto-engaging a prompt under a non-TTY session
181    /// leaves the dialoguer state machine waiting on `/dev/null`.
182    #[must_use]
183    pub fn is_interactive_terminal(&self) -> bool {
184        !self.no_interactive && std::io::stdin().is_terminal() && std::io::stderr().is_terminal()
185    }
186
187    /// Emits a canonical error envelope with an explicit kebab-case `reason`.
188    ///
189    /// Mirrors [`Self::print_error`] but lets the caller pin the `reason`
190    /// (e.g. `"no-tty"`) rather than reading it from `XurlError::kind()`.
191    /// Under text mode falls back to a plain "Error: …" line.
192    pub fn print_error_envelope(
193        &self,
194        err: &mut dyn Write,
195        reason: &str,
196        exit_code: i32,
197        message: &str,
198    ) {
199        let envelope = serde_json::json!({
200            "status": "error",
201            "reason": reason,
202            "exit_code": exit_code,
203            "message": message,
204        });
205        self.write_envelope_or_text_error(err, &envelope, message);
206    }
207
208    /// Prints an informational message (suppressed by --quiet or any
209    /// structured `--output` mode).
210    ///
211    /// The runner passes a stderr writer here in the binary path; tests pass a `Vec<u8>`.
212    pub fn info(&self, err: &mut dyn Write, msg: &str) {
213        if self.quiet || self.format.is_structured() {
214            return;
215        }
216        let _ = writeln!(err, "{msg}");
217    }
218
219    /// Prints a success/status message with optional color.
220    ///
221    /// The runner passes a stderr writer here in the binary path; tests pass a `Vec<u8>`.
222    pub fn status(&self, err: &mut dyn Write, msg: &str) {
223        if self.quiet || self.format.is_structured() {
224            return;
225        }
226        if self.no_color {
227            let _ = writeln!(err, "{msg}");
228        } else {
229            let _ = writeln!(err, "\x1b[32m{msg}\x1b[0m");
230        }
231    }
232
233    /// Prints an API response according to the configured output format.
234    ///
235    /// I/O errors are intentionally swallowed (best-effort posture) so a
236    /// closed downstream pipe doesn't abort the program — the SIGPIPE
237    /// restoration in `main` handles the more general case.
238    ///
239    /// Under `--raw`, JSON output is emitted compactly (one line, no
240    /// whitespace) rather than pretty-printed.
241    pub fn print_response(&self, out: &mut dyn Write, value: &serde_json::Value) {
242        match self.format {
243            OutputFormat::Json | OutputFormat::Jsonl | OutputFormat::Ndjson => {
244                let body = if self.raw || matches!(self.format, OutputFormat::Ndjson) {
245                    serde_json::to_string(value).unwrap_or_else(|_| value.to_string())
246                } else {
247                    serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())
248                };
249                let _ = writeln!(out, "{body}");
250            }
251            OutputFormat::Yaml => {
252                let body = serde_yaml::to_string(value).unwrap_or_else(|_| value.to_string());
253                let _ = write!(out, "{body}");
254            }
255            OutputFormat::Csv => {
256                let _ = write_flattened(out, value, ',');
257            }
258            OutputFormat::Tsv => {
259                let _ = write_flattened(out, value, '\t');
260            }
261            OutputFormat::Text => {
262                if self.no_color {
263                    let pretty =
264                        serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string());
265                    let _ = writeln!(out, "{pretty}");
266                } else {
267                    let _ = crate::api::response::format_response(out, value);
268                }
269            }
270        }
271    }
272
273    /// Prints a streaming line according to the configured output format.
274    pub fn print_stream_line(&self, out: &mut dyn Write, line: &str) {
275        let _ = writeln!(out, "{line}");
276    }
277
278    /// Formats and prints an error to the supplied stderr writer.
279    /// Under any structured `--output`, emits the canonical envelope shape:
280    /// `{"status":"error","reason":<kind>,"exit_code":<code>,"message":<display>}`.
281    /// Json/Jsonl/Ndjson emit one JSON line; Yaml emits a YAML document; Csv/Tsv
282    /// emit a JSON line carrying the envelope (delimited formats are not a good
283    /// fit for nested error metadata).
284    pub fn print_error(&self, err: &mut dyn Write, error: &XurlError, exit_code: i32) {
285        let display = error.to_string();
286        let mut obj = serde_json::Map::new();
287        obj.insert("status".into(), Value::String("error".into()));
288        obj.insert("reason".into(), Value::String(error.kind().to_string()));
289        obj.insert("exit_code".into(), Value::from(exit_code));
290        // `AuthMethodMismatch` carries structured fields: the envelope folds
291        // `endpoint` (template), `rendered_url` (substituted), `method`,
292        // `requested`, `supported`, `available_in_app`, `app`, and
293        // `other_apps_with_creds` alongside the standard `message`. Agents
294        // pattern-match on these without re-parsing the human message.
295        if let XurlError::AuthMethodMismatch {
296            endpoint,
297            rendered_url,
298            method,
299            requested,
300            supported,
301            available_in_app,
302            app,
303            other_apps_with_creds,
304        } = error
305        {
306            obj.insert("endpoint".into(), Value::String(endpoint.clone()));
307            if let Some(url) = rendered_url {
308                obj.insert("rendered_url".into(), Value::String(url.clone()));
309            }
310            obj.insert("method".into(), Value::String(method.clone()));
311            obj.insert(
312                "requested".into(),
313                match requested {
314                    Some(s) => Value::String(s.clone()),
315                    None => Value::Null,
316                },
317            );
318            obj.insert(
319                "supported".into(),
320                Value::Array(supported.iter().cloned().map(Value::String).collect()),
321            );
322            if let Some(avail) = available_in_app {
323                obj.insert(
324                    "available_in_app".into(),
325                    Value::Array(avail.iter().cloned().map(Value::String).collect()),
326                );
327            }
328            if let Some(app_name) = app {
329                obj.insert("app".into(), Value::String(app_name.clone()));
330            }
331            if let Some(others) = other_apps_with_creds {
332                obj.insert(
333                    "other_apps_with_creds".into(),
334                    Value::Array(others.iter().cloned().map(Value::String).collect()),
335                );
336            }
337        }
338        obj.insert("message".into(), Value::String(display.clone()));
339        let envelope = Value::Object(obj);
340        self.write_envelope_or_text_error(err, &envelope, &display);
341    }
342
343    /// Emits a canonical success envelope under structured modes.
344    ///
345    /// Wraps `payload` (treated as a JSON object whose keys flatten in at
346    /// the top level) with `{"status":"ok", ...payload}`. Under text mode
347    /// the payload is passed through to [`Self::print_response`] so existing
348    /// formatters keep their shape.
349    pub fn print_success(&self, out: &mut dyn Write, payload: &Value) {
350        if !self.format.is_structured() {
351            self.print_response(out, payload);
352            return;
353        }
354        let mut obj = serde_json::Map::new();
355        obj.insert("status".into(), Value::String("ok".into()));
356        if let Some(map) = payload.as_object() {
357            for (k, v) in map {
358                obj.insert(k.clone(), v.clone());
359            }
360        } else {
361            obj.insert("payload".into(), payload.clone());
362        }
363        let envelope = Value::Object(obj);
364        self.write_structured(out, &envelope);
365    }
366
367    /// Emits a canonical dry-run envelope under structured modes.
368    ///
369    /// Shape: `{"status":"dry_run","would_succeed":<bool>,"exit_code":<int>, ...ctx}`.
370    /// Under text mode, falls back to a pass-through of `ctx`.
371    pub fn print_dry_run(
372        &self,
373        out: &mut dyn Write,
374        would_succeed: bool,
375        exit_code: i32,
376        ctx: &Value,
377    ) {
378        if !self.format.is_structured() {
379            self.print_response(out, ctx);
380            return;
381        }
382        let mut obj = serde_json::Map::new();
383        obj.insert("status".into(), Value::String("dry_run".into()));
384        obj.insert("would_succeed".into(), Value::Bool(would_succeed));
385        obj.insert("exit_code".into(), Value::from(exit_code));
386        if let Some(map) = ctx.as_object() {
387            for (k, v) in map {
388                obj.insert(k.clone(), v.clone());
389            }
390        }
391        let envelope = Value::Object(obj);
392        self.write_structured(out, &envelope);
393    }
394
395    /// Prints a canonical confirmation-required error envelope (U7).
396    ///
397    /// Emitted when a destructive op was invoked under `--no-interactive`
398    /// without `--force`. `ctx` carries verb-context fields; the helper folds
399    /// `status: "error"`, `reason: "confirmation-required"`, and `exit_code`
400    /// into the same object on stderr.
401    pub fn print_confirmation_required(
402        &self,
403        err: &mut dyn Write,
404        ctx: &serde_json::Value,
405        exit_code: i32,
406    ) {
407        let mut obj = if let serde_json::Value::Object(m) = ctx {
408            m.clone()
409        } else {
410            serde_json::Map::new()
411        };
412        obj.insert(
413            "status".to_string(),
414            serde_json::Value::String("error".to_string()),
415        );
416        obj.insert(
417            "reason".to_string(),
418            serde_json::Value::String("confirmation-required".to_string()),
419        );
420        obj.insert(
421            "exit_code".to_string(),
422            serde_json::Value::Number(serde_json::Number::from(exit_code)),
423        );
424        if self.format.is_structured() {
425            let value = serde_json::Value::Object(obj);
426            self.write_structured(err, &value);
427        } else {
428            let cmd = obj
429                .get("command")
430                .and_then(serde_json::Value::as_str)
431                .unwrap_or("operation");
432            let line = if self.no_color {
433                format!(
434                    "Error: confirmation required for {cmd} — pass --force or run interactively"
435                )
436            } else {
437                format!(
438                    "\x1b[31mError: confirmation required for {cmd} — pass --force or run interactively\x1b[0m"
439                )
440            };
441            let _ = writeln!(err, "{line}");
442        }
443    }
444
445    /// Emits a verbose diagnostic line to `err` when verbose is on, quiet is
446    /// off, and the format is text.
447    ///
448    /// Under `--output json` or `--output jsonl`, agents parsing structured
449    /// output must not encounter interleaved human text on stderr, so
450    /// `verbose` is suppressed (per the agent-native semantic-fields-over-
451    /// stderr-warnings principle). Mirrors the `diag!` macro pattern from the
452    /// bird CLI without the macro.
453    pub fn verbose(&self, err: &mut dyn Write, msg: &str) {
454        if !self.verbose || self.quiet || self.format.is_structured() {
455            return;
456        }
457        let _ = writeln!(err, "{msg}");
458    }
459
460    /// Emits a warning to `err`. Always goes to stderr in text mode; under
461    /// JSON modes the line is suppressed (the canonical envelope is the
462    /// channel for structured warnings — see plan U8's deferred
463    /// `warnings: []` envelope promotion).
464    ///
465    /// Suppressed entirely under `--quiet` combined with JSON modes; under
466    /// `--quiet` text mode, warnings still surface (errors and warnings are
467    /// the load-bearing signals the operator must see).
468    pub fn warning(&self, err: &mut dyn Write, msg: &str) {
469        if self.format.is_structured() {
470            return;
471        }
472        if self.no_color {
473            let _ = writeln!(err, "warning: {msg}");
474        } else {
475            let _ = writeln!(err, "\x1b[1;33mwarning:\x1b[0m {msg}");
476        }
477    }
478
479    /// Emits a progress / status line to `err` when the format is text and
480    /// stderr is a TTY. Quiet suppresses progress unconditionally.
481    pub fn progress(&self, err: &mut dyn Write, msg: &str) {
482        if self.quiet || self.format.is_structured() {
483            return;
484        }
485        if !std::io::stderr().is_terminal() {
486            return;
487        }
488        let _ = writeln!(err, "{msg}");
489    }
490
491    /// Prints a simple text message (e.g. version, auth status) to the supplied writer.
492    /// Respects --output json by wrapping in a JSON object.
493    pub fn print_message(&self, out: &mut dyn Write, msg: &str) {
494        if self.format.is_structured() {
495            let clean = strip_ansi(msg);
496            let value = serde_json::json!({"message": clean});
497            self.write_structured(out, &value);
498            return;
499        }
500        if self.no_color {
501            let _ = writeln!(out, "{}", strip_ansi(msg));
502        } else {
503            let _ = writeln!(out, "{msg}");
504        }
505    }
506
507    /// Renders `value` to `w` in the active structured format.
508    ///
509    /// Json/Jsonl pretty-print (compact under `--raw`); Ndjson always emits a
510    /// single line; Yaml emits a YAML document; Csv/Tsv fall back to one line
511    /// of JSON because nested envelope metadata isn't a good fit for a
512    /// flat delimited table.
513    fn write_structured(&self, w: &mut dyn Write, value: &Value) {
514        match self.format {
515            OutputFormat::Json | OutputFormat::Jsonl => {
516                let body = if self.raw {
517                    serde_json::to_string(value).unwrap_or_else(|_| value.to_string())
518                } else {
519                    serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())
520                };
521                let _ = writeln!(w, "{body}");
522            }
523            OutputFormat::Ndjson => {
524                let body = serde_json::to_string(value).unwrap_or_else(|_| value.to_string());
525                let _ = writeln!(w, "{body}");
526            }
527            OutputFormat::Yaml => {
528                let body = serde_yaml::to_string(value).unwrap_or_else(|_| value.to_string());
529                let _ = write!(w, "{body}");
530            }
531            OutputFormat::Csv | OutputFormat::Tsv => {
532                // Envelopes are nested by design; fall back to one JSON line.
533                let body = serde_json::to_string(value).unwrap_or_else(|_| value.to_string());
534                let _ = writeln!(w, "{body}");
535            }
536            OutputFormat::Text => {
537                // Unreachable in practice — guarded by callers — but keep a
538                // safe fallback rather than panic.
539                let body =
540                    serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string());
541                let _ = writeln!(w, "{body}");
542            }
543        }
544    }
545
546    /// Common helper for the error printers: routes the structured envelope
547    /// through [`Self::write_structured`] in machine modes, falling back to a
548    /// colored "Error: {display}" line in text mode.
549    fn write_envelope_or_text_error(&self, err: &mut dyn Write, envelope: &Value, display: &str) {
550        if self.format.is_structured() {
551            self.write_structured(err, envelope);
552        } else if self.no_color {
553            let _ = writeln!(err, "Error: {display}");
554        } else {
555            let _ = writeln!(err, "\x1b[31mError: {display}\x1b[0m");
556        }
557    }
558}
559
560impl Default for OutputConfig {
561    /// Library-friendly default: text format, color-auto, no verbose, no quiet,
562    /// no raw. Matches what an interactive operator gets without flags. Used
563    /// when an `ApiClient` is constructed before the runner has resolved the
564    /// real `OutputConfig`.
565    fn default() -> Self {
566        Self {
567            format: OutputFormat::Text,
568            quiet: false,
569            no_color: true,
570            use_color: false,
571            verbose: false,
572            raw: false,
573            no_interactive: false,
574        }
575    }
576}
577
578/// Emits a one-line warning to stderr. Single-owner escape hatch for deep
579/// call sites that cannot reasonably carry an `OutputConfig` (token-store
580/// migration, env-var rejection during config resolution, callback partial-
581/// bind notices, OAuth2 salvage warnings). The CI guard at
582/// `scripts/lint-stdio.sh` allow-lists this module so the discipline holds:
583/// every `eprintln!` lives here.
584pub fn warn_stderr(msg: &str) {
585    eprintln!("warning: {msg}");
586}
587
588/// Best-effort delimited-table emitter for the `csv` / `tsv` output formats.
589///
590/// Strategy:
591/// - Object → one header row + one value row.
592/// - Array of objects → header is the union of keys (insertion-ordered by
593///   first occurrence), followed by one row per element. Missing keys emit
594///   an empty cell.
595/// - Array of scalars → single-column `value` table, one row per element.
596/// - Scalar → single-column `value` table with one row.
597///
598/// Nested values (objects, arrays inside a cell) are serialized as JSON so
599/// each cell stays a single delimited field. A `_warning` column is appended
600/// with the note "nested values JSON-stringified" so downstream tools see why
601/// some cells carry JSON.
602///
603/// The result is forward-only and best-effort: malformed shapes degrade
604/// gracefully into a one-line JSON blob in a `_payload` column rather than
605/// erroring out.
606fn write_flattened(out: &mut dyn Write, value: &Value, sep: char) -> std::io::Result<()> {
607    let rows: Vec<&Value> = match value {
608        Value::Array(arr) => arr.iter().collect(),
609        other => vec![other],
610    };
611
612    // Collect the union of object keys to use as the column header.
613    let mut header: Vec<String> = Vec::new();
614    let mut all_objects = true;
615    for row in &rows {
616        if let Value::Object(map) = row {
617            for k in map.keys() {
618                if !header.iter().any(|h| h == k) {
619                    header.push(k.clone());
620                }
621            }
622        } else {
623            all_objects = false;
624        }
625    }
626
627    if !all_objects || header.is_empty() {
628        // Scalar / mixed shapes: single-column "value" table.
629        writeln!(out, "value")?;
630        for row in &rows {
631            let cell = scalar_cell(row);
632            writeln!(out, "{}", delimited_escape(&cell, sep))?;
633        }
634        return Ok(());
635    }
636
637    // Detect whether any cell needed JSON stringification so we can emit a
638    // single `_warning` column at the end, only when relevant.
639    let mut needs_warning = false;
640    let row_cells: Vec<Vec<String>> = rows
641        .iter()
642        .map(|row| {
643            header
644                .iter()
645                .map(|key| {
646                    let cell =
647                        row.as_object()
648                            .and_then(|m| m.get(key))
649                            .map_or_else(String::new, |v| {
650                                if matches!(v, Value::Object(_) | Value::Array(_)) {
651                                    needs_warning = true;
652                                    serde_json::to_string(v).unwrap_or_else(|_| v.to_string())
653                                } else {
654                                    scalar_cell(v)
655                                }
656                            });
657                    delimited_escape(&cell, sep)
658                })
659                .collect()
660        })
661        .collect();
662
663    // Header line.
664    let mut header_out: Vec<String> = header.iter().map(|h| delimited_escape(h, sep)).collect();
665    if needs_warning {
666        header_out.push(delimited_escape("_warning", sep));
667    }
668    writeln!(out, "{}", header_out.join(&sep.to_string()))?;
669
670    for cells in &row_cells {
671        let mut line = cells.clone();
672        if needs_warning {
673            line.push(delimited_escape("nested values JSON-stringified", sep));
674        }
675        writeln!(out, "{}", line.join(&sep.to_string()))?;
676    }
677    Ok(())
678}
679
680/// Renders a scalar [`Value`] as the unquoted cell body (the caller wraps with
681/// `delimited_escape` to handle the delimiter / quote rules).
682fn scalar_cell(v: &Value) -> String {
683    match v {
684        Value::Null => String::new(),
685        Value::Bool(b) => b.to_string(),
686        Value::Number(n) => n.to_string(),
687        Value::String(s) => s.clone(),
688        Value::Object(_) | Value::Array(_) => {
689            serde_json::to_string(v).unwrap_or_else(|_| v.to_string())
690        }
691    }
692}
693
694/// Applies RFC 4180 quoting when needed.
695///
696/// For comma-delimited output: wraps in double quotes when the cell contains
697/// a comma, double quote, CR, or LF, and escapes embedded quotes. For tab-
698/// delimited output: replaces embedded tabs and newlines with spaces (TSV
699/// has no canonical quoting rule, and most consumers reject control chars
700/// inside fields).
701fn delimited_escape(cell: &str, sep: char) -> String {
702    if sep == '\t' {
703        return cell.replace(['\t', '\n', '\r'], " ");
704    }
705    if cell.contains(sep) || cell.contains('"') || cell.contains('\n') || cell.contains('\r') {
706        let escaped = cell.replace('"', "\"\"");
707        format!("\"{escaped}\"")
708    } else {
709        cell.to_string()
710    }
711}
712
713/// Strips ANSI escape codes from a string.
714fn strip_ansi(s: &str) -> String {
715    let mut result = String::with_capacity(s.len());
716    let mut chars = s.chars();
717    while let Some(c) = chars.next() {
718        if c == '\x1b' {
719            // Skip until 'm' (end of ANSI escape)
720            for inner in chars.by_ref() {
721                if inner == 'm' {
722                    break;
723                }
724            }
725        } else {
726            result.push(c);
727        }
728    }
729    result
730}
731
732#[cfg(test)]
733mod tests {
734    use super::*;
735
736    #[test]
737    fn test_strip_ansi_removes_color_codes() {
738        assert_eq!(strip_ansi("\x1b[32mhello\x1b[0m"), "hello");
739        assert_eq!(strip_ansi("\x1b[1;31mError\x1b[0m"), "Error");
740        assert_eq!(strip_ansi("no codes here"), "no codes here");
741    }
742
743    #[test]
744    fn test_xurl_error_kind_mapping() {
745        assert_eq!(XurlError::Auth("test".into()).kind(), "auth-required");
746        assert_eq!(XurlError::Http("test".into()).kind(), "network-error");
747        assert_eq!(XurlError::api(400, "test").kind(), "network-error");
748        assert_eq!(XurlError::api(401, "x").kind(), "auth-required");
749        assert_eq!(XurlError::api(404, "x").kind(), "not-found");
750        assert_eq!(XurlError::api(429, "x").kind(), "rate-limited");
751        assert_eq!(XurlError::validation("test").kind(), "validation");
752        assert_eq!(XurlError::Io("test".into()).kind(), "io");
753        assert_eq!(XurlError::Json("test".into()).kind(), "serialization");
754        assert_eq!(
755            XurlError::InvalidMethod("X".into()).kind(),
756            "invalid-method"
757        );
758        assert_eq!(XurlError::token_store("x").kind(), "token-store");
759    }
760
761    #[test]
762    fn test_output_config_json_format() {
763        let cfg = OutputConfig {
764            format: OutputFormat::Json,
765            quiet: false,
766            no_color: false,
767            use_color: true,
768            verbose: false,
769            raw: false,
770            no_interactive: false,
771        };
772        assert!(!cfg.quiet);
773    }
774
775    #[test]
776    fn test_with_no_interactive_threads_field() {
777        let cfg = OutputConfig::new(OutputFormat::Text, false, false, ColorChoice::Never)
778            .with_no_interactive(true);
779        assert!(cfg.no_interactive);
780        // is_interactive_terminal is false when no_interactive is true regardless of TTY.
781        assert!(!cfg.is_interactive_terminal());
782    }
783
784    #[test]
785    fn test_print_error_envelope_json_shape() {
786        let cfg = OutputConfig::new(OutputFormat::Json, false, false, ColorChoice::Never);
787        let mut buf: Vec<u8> = Vec::new();
788        cfg.print_error_envelope(&mut buf, "no-tty", 1, "stdin is not a terminal");
789        let s = String::from_utf8(buf).expect("utf8");
790        let v: serde_json::Value = serde_json::from_str(s.trim()).expect("valid json");
791        assert_eq!(v["status"], "error");
792        assert_eq!(v["reason"], "no-tty");
793        assert_eq!(v["exit_code"], 1);
794        assert_eq!(v["message"], "stdin is not a terminal");
795    }
796
797    #[test]
798    fn test_no_color_env_overrides_color_always() {
799        // Save and clear any existing NO_COLOR so the assertion is deterministic
800        // in the unlikely case the test runner has it set.
801        // SAFETY: tests in this module are single-threaded under cargo test by default;
802        // no other thread reads NO_COLOR concurrently.
803        let prior = std::env::var_os("NO_COLOR");
804        // SAFETY: see above.
805        unsafe {
806            std::env::set_var("NO_COLOR", "1");
807        }
808        let cfg = OutputConfig::new(OutputFormat::Text, false, false, ColorChoice::Always);
809        assert!(!cfg.use_color, "NO_COLOR must defeat --color always");
810        assert!(cfg.no_color, "no_color mirrors !use_color");
811        // SAFETY: see above.
812        unsafe {
813            match prior {
814                Some(v) => std::env::set_var("NO_COLOR", v),
815                None => std::env::remove_var("NO_COLOR"),
816            }
817        }
818    }
819
820    #[test]
821    fn test_color_never_disables_color() {
822        // Force NO_COLOR off so the --color flag is the only driver here.
823        // SAFETY: tests in this module are single-threaded under cargo test by default.
824        let prior = std::env::var_os("NO_COLOR");
825        // SAFETY: see above.
826        unsafe {
827            std::env::remove_var("NO_COLOR");
828        }
829        let cfg = OutputConfig::new(OutputFormat::Text, false, false, ColorChoice::Never);
830        assert!(!cfg.use_color);
831        // SAFETY: see above.
832        unsafe {
833            if let Some(v) = prior {
834                std::env::set_var("NO_COLOR", v);
835            }
836        }
837    }
838
839    #[test]
840    fn test_color_always_enables_color_when_no_color_unset() {
841        // SAFETY: tests in this module are single-threaded under cargo test by default.
842        let prior = std::env::var_os("NO_COLOR");
843        // SAFETY: see above.
844        unsafe {
845            std::env::remove_var("NO_COLOR");
846        }
847        let cfg = OutputConfig::new(OutputFormat::Text, false, false, ColorChoice::Always);
848        assert!(cfg.use_color, "--color always must enable color");
849        // SAFETY: see above.
850        unsafe {
851            if let Some(v) = prior {
852                std::env::set_var("NO_COLOR", v);
853            }
854        }
855    }
856
857    #[test]
858    fn test_raw_forces_color_off() {
859        let cfg =
860            OutputConfig::new_with_raw(OutputFormat::Text, false, false, ColorChoice::Always, true);
861        assert!(!cfg.use_color, "--raw must force use_color = false");
862        assert!(cfg.raw);
863    }
864
865    #[test]
866    fn test_verbose_emits_under_text_when_verbose_flag_set() {
867        let cfg = OutputConfig::new(OutputFormat::Text, false, true, ColorChoice::Never);
868        let mut buf = Vec::new();
869        cfg.verbose(&mut buf, "hello");
870        assert_eq!(buf, b"hello\n");
871    }
872
873    #[test]
874    fn test_verbose_suppressed_under_json() {
875        let cfg = OutputConfig::new(OutputFormat::Json, false, true, ColorChoice::Never);
876        let mut buf = Vec::new();
877        cfg.verbose(&mut buf, "hello");
878        assert!(buf.is_empty(), "verbose must not leak under JSON");
879    }
880
881    #[test]
882    fn test_verbose_suppressed_under_quiet() {
883        let cfg = OutputConfig::new(OutputFormat::Text, true, true, ColorChoice::Never);
884        let mut buf = Vec::new();
885        cfg.verbose(&mut buf, "hello");
886        assert!(buf.is_empty());
887    }
888
889    #[test]
890    fn test_verbose_suppressed_when_flag_off() {
891        let cfg = OutputConfig::new(OutputFormat::Text, false, false, ColorChoice::Never);
892        let mut buf = Vec::new();
893        cfg.verbose(&mut buf, "hello");
894        assert!(buf.is_empty());
895    }
896
897    #[test]
898    fn test_warning_emits_under_text() {
899        let cfg = OutputConfig::new(OutputFormat::Text, false, false, ColorChoice::Never);
900        let mut buf = Vec::new();
901        cfg.warning(&mut buf, "rate limited");
902        let s = String::from_utf8(buf).unwrap();
903        assert!(s.contains("warning: rate limited"));
904    }
905
906    #[test]
907    fn test_warning_suppressed_under_json() {
908        let cfg = OutputConfig::new(OutputFormat::Json, false, false, ColorChoice::Never);
909        let mut buf = Vec::new();
910        cfg.warning(&mut buf, "rate limited");
911        assert!(
912            buf.is_empty(),
913            "warnings on stderr must be suppressed under JSON modes"
914        );
915    }
916}