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}