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}