Skip to main content

subx_cli/cli/
output.rs

1//! Machine-readable output renderer for SubX-CLI.
2//!
3//! This module defines the versioned JSON envelope contract used by
4//! `--output json` mode, the [`OutputMode`] enum surfaced as a top-level
5//! CLI flag, and the [`OutputRenderer`] abstraction routed through the
6//! command dispatcher. The text-mode renderer is a thin shim that
7//! preserves today's interactive UX; the JSON renderer owns stdout
8//! exclusively and emits exactly one JSON document per invocation.
9//!
10//! # Stdout / stderr discipline
11//!
12//! - **JSON mode (`--output json`)**: stdout SHALL receive *exactly* one
13//!   `serde_json` document terminated by a trailing `\n`. No other writes
14//!   to stdout from any command, helper, or library code are permitted —
15//!   `print_success`/`print_warning`/`display_match_results`/progress
16//!   bars are silenced for the lifetime of the process. Stderr is also
17//!   tightened: free-form `eprintln!` / `println!` chatter (matcher
18//!   `🔍 AI Analysis Results:` block, `Total matches:` summaries,
19//!   `   - file_<id>` candidate lines, `Warning: Skipping relocation` /
20//!   `Warning: Conflict resolution prompt not implemented`, etc.) is
21//!   suppressed. Structured `tracing` / `log` records (gated by
22//!   `RUST_LOG`) are still allowed; ANSI styling and status symbols are
23//!   stripped (see [`crate::cli::ui`]). With `--quiet`, even those
24//!   structured records are suppressed except for fatal errors emitted
25//!   by the renderer itself.
26//! - **Text mode (default)**: behavior is unchanged from prior releases.
27//!
28//! # Schema versioning
29//!
30//! [`SCHEMA_VERSION`] follows semver. Additive payload changes are
31//! minor bumps; renames or removals are major bumps and require a new
32//! OpenSpec change proposal.
33
34use crate::cli::error_ext::SubXErrorExt;
35use serde::Serialize;
36use std::io::{self, Write};
37use std::sync::OnceLock;
38use subx_core::error::SubXError;
39
40/// Schema version emitted in every JSON envelope.
41pub const SCHEMA_VERSION: &str = "1.0";
42
43/// Output mode selected by the user via `--output` or `SUBX_OUTPUT`.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
45pub enum OutputMode {
46    /// Human-oriented colored text output (default, unchanged contract).
47    #[default]
48    Text,
49    /// Machine-readable JSON envelope on stdout.
50    Json,
51}
52
53impl OutputMode {
54    /// Parse a string token (case-insensitive) into an [`OutputMode`].
55    ///
56    /// Returns `None` for unrecognized tokens; callers default to
57    /// [`OutputMode::Text`].
58    pub fn from_token(s: &str) -> Option<Self> {
59        match s.trim().to_ascii_lowercase().as_str() {
60            "text" => Some(OutputMode::Text),
61            "json" => Some(OutputMode::Json),
62            _ => None,
63        }
64    }
65
66    /// Returns true when machine-readable mode is active.
67    pub fn is_json(self) -> bool {
68        matches!(self, OutputMode::Json)
69    }
70}
71
72// ─── Process-global active mode (set once during dispatch) ──────────────
73//
74// `OnceLock` (never `static mut` or `Lazy<Mutex>`) holds the resolved
75// output mode for the lifetime of the process so UI helpers in
76// `crate::cli::ui` can suppress stdout chatter and strip ANSI without
77// threading the mode through every callsite.
78
79static ACTIVE_MODE: OnceLock<OutputMode> = OnceLock::new();
80static QUIET: OnceLock<bool> = OnceLock::new();
81
82/// Install the resolved output mode and quiet flag globally.
83///
84/// Calling this more than once has no effect — the first install wins.
85/// `main.rs`/`run_with_config` SHALL invoke this once before any command
86/// runs.
87pub fn install_active_mode(mode: OutputMode, quiet: bool) {
88    let _ = ACTIVE_MODE.set(mode);
89    let _ = QUIET.set(quiet);
90}
91
92/// Returns the active output mode, defaulting to [`OutputMode::Text`]
93/// when not yet installed.
94pub fn active_mode() -> OutputMode {
95    ACTIVE_MODE.get().copied().unwrap_or(OutputMode::Text)
96}
97
98/// Returns whether `--quiet` is active.
99pub fn is_quiet() -> bool {
100    QUIET.get().copied().unwrap_or(false)
101}
102
103// ─── Envelope types ──────────────────────────────────────────────────────
104
105/// Top-level JSON envelope written to stdout in JSON mode.
106///
107/// Successful runs SHALL omit the `error` field; failed runs SHALL omit
108/// the `data` field. Both omissions are achieved through `serde`'s
109/// `skip_serializing_if = "Option::is_none"`.
110#[derive(Debug, Serialize)]
111pub struct Envelope<'a, T: Serialize> {
112    /// Stable schema version (semver-style).
113    pub schema_version: &'static str,
114    /// Command name (e.g. `"match"`, `"sync"`, `"convert"`).
115    pub command: &'a str,
116    /// Either `"ok"` or `"error"`.
117    pub status: &'static str,
118    /// Command-specific payload. Omitted when `status == "error"`.
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub data: Option<T>,
121    /// Error envelope. Omitted when `status == "ok"`.
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub error: Option<ErrorEnvelope>,
124    /// Optional non-fatal warnings.
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub warnings: Option<Vec<String>>,
127}
128
129/// Stable error payload carried in [`Envelope::error`].
130///
131/// Field naming is locked by the
132/// `machine-readable-output`/`error-handling` specs.
133#[derive(Debug, Serialize)]
134pub struct ErrorEnvelope {
135    /// Stable snake_case category from the closed [`SubXError`] set or
136    /// the synthetic `"argument_parsing"` category for clap failures.
137    pub category: String,
138    /// Stable upper-snake-case machine code (e.g. `E_AI_SERVICE`).
139    pub code: String,
140    /// Process exit code returned alongside the envelope.
141    pub exit_code: i32,
142    /// Human-readable message (English; matches `user_friendly_message`).
143    pub message: String,
144    /// Optional short remediation hint.
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub hint: Option<String>,
147    /// Optional structured details (partial results, etc.).
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub details: Option<serde_json::Value>,
150}
151
152impl ErrorEnvelope {
153    /// Build an envelope from a [`SubXError`].
154    pub fn from_error(err: &SubXError) -> Self {
155        Self {
156            category: err.category().to_string(),
157            code: err.machine_code().to_string(),
158            exit_code: err.exit_code(),
159            message: err.user_friendly_message(),
160            hint: err.hint().map(str::to_string),
161            details: None,
162        }
163    }
164
165    /// Build the synthetic envelope used for clap argument-parsing
166    /// failures. The category `argument_parsing` is intentionally NOT
167    /// part of the closed [`SubXError`]-derived set.
168    pub fn argument_parsing(message: String, exit_code: i32) -> Self {
169        Self {
170            category: "argument_parsing".to_string(),
171            code: "E_ARGUMENT_PARSING".to_string(),
172            message,
173            exit_code,
174            hint: None,
175            details: None,
176        }
177    }
178}
179
180// ─── Renderer abstraction ────────────────────────────────────────────────
181
182/// Renderer trait routing success/error envelopes to the active output
183/// stream.
184///
185/// The trait uses generic methods (not object-safe). Commands typically
186/// use the free [`emit_success`]/[`emit_error`] helpers instead of
187/// constructing renderers directly.
188pub trait OutputRenderer {
189    /// Emit a success envelope.
190    fn render_success<T: Serialize>(&self, command: &str, data: T) -> io::Result<()>;
191    /// Emit an error envelope.
192    fn render_error(&self, command: &str, err: &SubXError) -> io::Result<()>;
193}
194
195/// Text renderer — preserves today's UX as a no-op for the envelope.
196///
197/// Per-command text rendering is performed by the command itself through
198/// the existing `crate::cli::ui` helpers. The text renderer's
199/// `render_success` is therefore a no-op; `render_error` is also a no-op
200/// because `main.rs` already prints `user_friendly_message()` via
201/// `print_error` in text mode.
202#[derive(Debug, Default, Clone, Copy)]
203pub struct TextRenderer;
204
205impl OutputRenderer for TextRenderer {
206    fn render_success<T: Serialize>(&self, _command: &str, _data: T) -> io::Result<()> {
207        Ok(())
208    }
209    fn render_error(&self, _command: &str, _err: &SubXError) -> io::Result<()> {
210        Ok(())
211    }
212}
213
214/// JSON renderer — owns stdout exclusively in JSON mode.
215///
216/// Each call writes EXACTLY one `serde_json` document followed by a
217/// single `\n` and flushes the underlying writer. Multiple invocations
218/// of the binary therefore stream as NDJSON when concatenated.
219pub struct JsonRenderer<W: Write> {
220    writer: std::cell::RefCell<W>,
221}
222
223impl JsonRenderer<io::Stdout> {
224    /// Construct a renderer that writes to the process stdout handle.
225    pub fn stdout() -> Self {
226        Self {
227            writer: std::cell::RefCell::new(io::stdout()),
228        }
229    }
230}
231
232impl<W: Write> JsonRenderer<W> {
233    /// Construct a renderer wrapping any [`Write`] implementation.
234    pub fn new(writer: W) -> Self {
235        Self {
236            writer: std::cell::RefCell::new(writer),
237        }
238    }
239
240    fn write_envelope<T: Serialize>(&self, envelope: &Envelope<'_, T>) -> io::Result<()> {
241        let mut w = self.writer.borrow_mut();
242        serde_json::to_writer(&mut *w, envelope).map_err(io::Error::other)?;
243        w.write_all(b"\n")?;
244        w.flush()?;
245        Ok(())
246    }
247}
248
249impl<W: Write> OutputRenderer for JsonRenderer<W> {
250    fn render_success<T: Serialize>(&self, command: &str, data: T) -> io::Result<()> {
251        let envelope = Envelope::<T> {
252            schema_version: SCHEMA_VERSION,
253            command,
254            status: "ok",
255            data: Some(data),
256            error: None,
257            warnings: None,
258        };
259        self.write_envelope(&envelope)
260    }
261
262    fn render_error(&self, command: &str, err: &SubXError) -> io::Result<()> {
263        let envelope = Envelope::<serde_json::Value> {
264            schema_version: SCHEMA_VERSION,
265            command,
266            status: "error",
267            data: None,
268            error: Some(ErrorEnvelope::from_error(err)),
269            warnings: None,
270        };
271        self.write_envelope(&envelope)
272    }
273}
274
275/// Emit a success envelope through the appropriate renderer.
276///
277/// In [`OutputMode::Text`] this is a no-op. In [`OutputMode::Json`] this
278/// writes exactly one JSON document followed by `\n` to stdout and
279/// flushes. I/O errors are silently ignored at the boundary because the
280/// process exits immediately after; callers wanting to surface I/O
281/// errors should use a [`JsonRenderer`] directly.
282pub fn emit_success<T: Serialize>(mode: OutputMode, command: &str, data: T) {
283    match mode {
284        OutputMode::Text => {}
285        OutputMode::Json => {
286            let _ = JsonRenderer::stdout().render_success(command, data);
287        }
288    }
289}
290
291/// Emit a success envelope with optional non-fatal warnings attached.
292///
293/// Behaves like [`emit_success`] in [`OutputMode::Text`] (no-op) and in
294/// [`OutputMode::Json`] writes a JSON envelope whose `warnings` field is
295/// set to `Some(warnings)` when the supplied vector is non-empty, or
296/// `None` (omitted via `skip_serializing_if`) when the vector is empty.
297/// This keeps the JSON document byte-equivalent to the no-warnings shape
298/// for callers that pass in an empty list.
299pub fn emit_success_with_warnings<T: Serialize>(
300    mode: OutputMode,
301    command: &str,
302    data: T,
303    warnings: Vec<String>,
304) {
305    match mode {
306        OutputMode::Text => {}
307        OutputMode::Json => {
308            let warnings = if warnings.is_empty() {
309                None
310            } else {
311                Some(warnings)
312            };
313            let envelope = Envelope::<T> {
314                schema_version: SCHEMA_VERSION,
315                command,
316                status: "ok",
317                data: Some(data),
318                error: None,
319                warnings,
320            };
321            let _ = JsonRenderer::stdout().write_envelope(&envelope);
322        }
323    }
324}
325
326/// Emit an error envelope through the appropriate renderer.
327///
328/// In [`OutputMode::Text`] this is a no-op (the existing `print_error`
329/// path in `main.rs` is responsible for stderr rendering). In
330/// [`OutputMode::Json`] this writes the JSON error envelope to stdout.
331pub fn emit_error(mode: OutputMode, command: &str, err: &SubXError) {
332    match mode {
333        OutputMode::Text => {}
334        OutputMode::Json => {
335            let _ = JsonRenderer::stdout().render_error(command, err);
336        }
337    }
338}
339
340/// Emit a synthetic argument-parsing error envelope (clap failures).
341///
342/// Used by `main.rs` when `Cli::try_parse()` returns an error other
343/// than help/version display.
344pub fn emit_argument_parsing_error(command: Option<&str>, message: String, exit_code: i32) {
345    let envelope = Envelope::<serde_json::Value> {
346        schema_version: SCHEMA_VERSION,
347        command: command.unwrap_or(""),
348        status: "error",
349        data: None,
350        error: Some(ErrorEnvelope::argument_parsing(message, exit_code)),
351        warnings: None,
352    };
353    let renderer = JsonRenderer::stdout();
354    let _ = renderer.write_envelope(&envelope);
355}
356
357/// Strip ANSI CSI escape sequences from a borrowed string.
358///
359/// Used by clap-error rendering and by stderr UI helpers in JSON mode.
360pub fn strip_ansi(input: &str) -> String {
361    let mut out = String::with_capacity(input.len());
362    let bytes = input.as_bytes();
363    let mut i = 0;
364    while i < bytes.len() {
365        if bytes[i] == 0x1b && i + 1 < bytes.len() && bytes[i + 1] == b'[' {
366            // CSI: ESC [ ... <final byte 0x40..0x7e>
367            i += 2;
368            while i < bytes.len() {
369                let b = bytes[i];
370                i += 1;
371                if (0x40..=0x7e).contains(&b) {
372                    break;
373                }
374            }
375        } else {
376            out.push(bytes[i] as char);
377            i += 1;
378        }
379    }
380    out
381}
382
383// ─── Test-only helpers ──────────────────────────────────────────────────
384
385/// Test-only assertion: stdout in JSON mode contains exactly one JSON
386/// document followed by a single trailing `\n` and no ANSI sequences.
387///
388/// Returns `Ok(parsed)` on success or a descriptive error string.
389#[cfg(test)]
390pub fn assert_json_stdout_clean(stdout: &[u8]) -> Result<serde_json::Value, String> {
391    if stdout.is_empty() {
392        return Err("stdout was empty".to_string());
393    }
394    if !stdout.ends_with(b"\n") {
395        return Err("stdout did not end with newline".to_string());
396    }
397    if stdout.contains(&0x1b) {
398        return Err("stdout contained ANSI escape sequence".to_string());
399    }
400    // Trim trailing newline, refuse multi-document output.
401    let body = &stdout[..stdout.len() - 1];
402    if body.contains(&b'\n') {
403        return Err("stdout contained more than one line".to_string());
404    }
405    serde_json::from_slice(body).map_err(|e| format!("stdout did not parse as JSON: {e}"))
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    #[derive(Serialize)]
413    struct Sample {
414        value: u32,
415    }
416
417    #[test]
418    fn output_mode_from_token_is_case_insensitive() {
419        assert_eq!(OutputMode::from_token("json"), Some(OutputMode::Json));
420        assert_eq!(OutputMode::from_token("JSON"), Some(OutputMode::Json));
421        assert_eq!(OutputMode::from_token(" Text "), Some(OutputMode::Text));
422        assert_eq!(OutputMode::from_token("yaml"), None);
423    }
424
425    #[test]
426    fn json_renderer_emits_single_document_with_newline() {
427        let mut buf = Vec::new();
428        let renderer = JsonRenderer::new(&mut buf);
429        renderer
430            .render_success("match", Sample { value: 42 })
431            .expect("write");
432        // Drop renderer to release borrow before reading buf.
433        drop(renderer);
434        let parsed = assert_json_stdout_clean(&buf).expect("clean JSON");
435        assert_eq!(parsed["schema_version"], SCHEMA_VERSION);
436        assert_eq!(parsed["command"], "match");
437        assert_eq!(parsed["status"], "ok");
438        assert_eq!(parsed["data"]["value"], 42);
439        assert!(parsed.get("error").is_none(), "error must be omitted on ok");
440    }
441
442    #[test]
443    fn json_renderer_omits_data_on_error() {
444        let mut buf = Vec::new();
445        let renderer = JsonRenderer::new(&mut buf);
446        let err = SubXError::config("bad");
447        renderer.render_error("convert", &err).expect("write");
448        drop(renderer);
449        let parsed = assert_json_stdout_clean(&buf).expect("clean JSON");
450        assert_eq!(parsed["status"], "error");
451        assert!(
452            parsed.get("data").is_none(),
453            "data must be omitted on error"
454        );
455        assert_eq!(parsed["error"]["category"], "config");
456        assert_eq!(parsed["error"]["code"], "E_CONFIG");
457        assert_eq!(parsed["error"]["exit_code"], 2);
458    }
459
460    #[test]
461    fn argument_parsing_envelope_shape() {
462        let env = ErrorEnvelope::argument_parsing("unknown flag --foo".into(), 2);
463        assert_eq!(env.category, "argument_parsing");
464        assert_eq!(env.code, "E_ARGUMENT_PARSING");
465        assert_eq!(env.exit_code, 2);
466    }
467
468    #[test]
469    fn strip_ansi_removes_csi_sequences() {
470        let input = "\x1b[31m\x1b[1mfailed\x1b[0m";
471        assert_eq!(strip_ansi(input), "failed");
472        assert_eq!(strip_ansi("plain"), "plain");
473    }
474
475    #[test]
476    fn text_renderer_is_noop() {
477        let r = TextRenderer;
478        r.render_success("x", Sample { value: 1 }).unwrap();
479        r.render_error("x", &SubXError::config("y")).unwrap();
480    }
481}