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