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}