Skip to main content

ssh_cli/output/
emit.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// G-COMP: stdout/stderr emit primitives (extracted from output monólito).
3#![forbid(unsafe_code)]
4//! Quiet/JSON-error flags and LF writers for agent + human paths.
5
6use crate::json_wire::{self, ErrorEnvelope, SuccessEnvelope};
7use std::fmt;
8use std::io::{self, Write};
9use std::sync::atomic::{AtomicBool, Ordering};
10
11/// Global `--quiet` flag (suppresses human messages on stdout).
12///
13/// Concurrent access: process-wide flag only (no other data published with it).
14/// `Ordering::Relaxed` is sufficient — no acquire/release of dependent state.
15static QUIET: AtomicBool = AtomicBool::new(false);
16
17/// When true, errors in `main` use a JSON envelope on stderr (IO-003).
18///
19/// Concurrent access: independent CLI mode bit; `Ordering::Relaxed` (no data fence).
20static JSON_ERRORS: AtomicBool = AtomicBool::new(false);
21
22/// Sets whether the CLI is in quiet mode (GAP-SSH-IO-004).
23pub fn set_quiet(quiet: bool) {
24    QUIET.store(quiet, Ordering::Relaxed);
25}
26
27/// Sets whether errors are emitted as a JSON envelope on stderr.
28pub fn set_json_errors(json: bool) {
29    JSON_ERRORS.store(json, Ordering::Relaxed);
30}
31
32/// Returns whether quiet mode is active.
33#[must_use]
34pub fn is_quiet() -> bool {
35    QUIET.load(Ordering::Relaxed)
36}
37
38/// Returns whether errors should use a JSON envelope.
39#[must_use]
40pub fn wants_json_errors() -> bool {
41    JSON_ERRORS.load(Ordering::Relaxed)
42}
43
44/// Writes a line to an arbitrary [`Write`] with pure LF, then flushes (G-IO-11).
45///
46/// Dependency-injection primitive: unit tests and alternate sinks pass a
47/// `Cursor`/`Vec`/`File` instead of process stdout. Production paths call
48/// [`write_line`] which locks real stdout.
49///
50/// Prefer [`write_line_to_fmt`] / [`write_line_fmt`] when the content is built
51/// with `format_args!` so no intermediate `String` is allocated (G-MAC-01).
52///
53/// # Examples
54///
55/// ```
56/// use ssh_cli::output::write_line_to;
57/// use std::io::Cursor;
58///
59/// let mut buf = Cursor::new(Vec::new());
60/// write_line_to(&mut buf, "hello").unwrap();
61/// assert_eq!(String::from_utf8(buf.into_inner()).unwrap(), "hello\n");
62/// ```
63///
64/// # Errors
65/// Propagates I/O errors from the underlying writer (including `BrokenPipe`).
66pub fn write_line_to(out: &mut impl Write, content: &str) -> io::Result<()> {
67    out.write_all(content.as_bytes())?;
68    out.write_all(b"\n")?;
69    out.flush()?;
70    Ok(())
71}
72
73/// Writes formatted content + pure LF via [`Write::write_fmt`] (G-MAC-01).
74///
75/// Call with `format_args!(...)` to avoid `format!` → temporary `String` →
76/// `write_all` double work. Same LF + flush contract as [`write_line_to`].
77///
78/// # Examples
79///
80/// ```
81/// use ssh_cli::output::write_line_to_fmt;
82/// use std::io::Cursor;
83///
84/// let mut buf = Cursor::new(Vec::new());
85/// let name = "lab";
86/// write_line_to_fmt(&mut buf, format_args!("host={name}")).unwrap();
87/// assert_eq!(String::from_utf8(buf.into_inner()).unwrap(), "host=lab\n");
88/// ```
89///
90/// # Errors
91/// Propagates I/O errors from the underlying writer (including `BrokenPipe`).
92pub fn write_line_to_fmt(out: &mut impl Write, args: fmt::Arguments<'_>) -> io::Result<()> {
93    out.write_fmt(args)?;
94    out.write_all(b"\n")?;
95    out.flush()?;
96    Ok(())
97}
98
99/// Writes a line to stdout with pure LF (never CRLF), then flushes.
100///
101/// Uses `write_all` + explicit flush (rules: never rely on Drop alone).
102/// BrokenPipe is propagated so callers / `main` can exit **141**.
103///
104/// # Errors
105/// Returns an error if stdout I/O fails (including `BrokenPipe`).
106pub fn write_line(content: &str) -> io::Result<()> {
107    let stdout = io::stdout();
108    let mut handle = stdout.lock();
109    write_line_to(&mut handle, content)
110}
111
112/// Writes a formatted line to stdout without an intermediate `String` (G-MAC-01).
113///
114/// # Errors
115/// Returns an error if stdout I/O fails (including `BrokenPipe`).
116pub fn write_line_fmt(args: fmt::Arguments<'_>) -> io::Result<()> {
117    let stdout = io::stdout();
118    let mut handle = stdout.lock();
119    write_line_to_fmt(&mut handle, args)
120}
121
122/// Writes many short lines under a single stdout lock (list/doctor/text dumps).
123///
124/// Batches under `BufWriter` then a single flush (not per-line flush).
125/// Prefer direct `writeln!` into a locked `BufWriter` when building lines with
126/// formatting (avoids a `Vec<String>` of `format!` results — G-MAC-02).
127///
128/// # Errors
129/// Propagates I/O errors including `BrokenPipe`.
130pub fn write_lines(lines: impl IntoIterator<Item = impl AsRef<str>>) -> io::Result<()> {
131    let stdout = io::stdout();
132    let mut handle = io::BufWriter::new(stdout.lock());
133    for line in lines {
134        handle.write_all(line.as_ref().as_bytes())?;
135        handle.write_all(b"\n")?;
136    }
137    handle.flush()?;
138    Ok(())
139}
140
141/// Best-effort human line on stdout; ignores BrokenPipe (consumer hung up).
142pub(crate) fn write_line_human(content: &str) {
143    match write_line(content) {
144        Ok(()) => {}
145        Err(e) if e.kind() == io::ErrorKind::BrokenPipe => {}
146        Err(_) => {}
147    }
148}
149
150/// Writes a diagnostic line to an arbitrary [`Write`] (G-IO-11 DI primitive).
151///
152/// Unlike [`write_line_to`], **BrokenPipe is treated as success** (downstream
153/// closed) so human/error paths never panic the process on a closed pipe.
154///
155/// # Examples
156///
157/// ```
158/// use ssh_cli::output::write_stderr_line_to;
159/// use std::io::Cursor;
160///
161/// let mut buf = Cursor::new(Vec::new());
162/// write_stderr_line_to(&mut buf, "warn").unwrap();
163/// assert_eq!(String::from_utf8(buf.into_inner()).unwrap(), "warn\n");
164/// ```
165///
166/// # Errors
167/// Non-pipe I/O failures from the underlying writer.
168pub fn write_stderr_line_to(err: &mut impl Write, content: &str) -> io::Result<()> {
169    match write_line_to(err, content) {
170        Ok(()) => Ok(()),
171        Err(e) if e.kind() == io::ErrorKind::BrokenPipe => Ok(()),
172        Err(e) => Err(e),
173    }
174}
175
176/// Formatted stderr line via `write_fmt` (G-MAC-01); BrokenPipe → Ok.
177///
178/// # Errors
179/// Non-pipe I/O failures from the underlying writer.
180pub fn write_stderr_line_to_fmt(
181    err: &mut impl Write,
182    args: fmt::Arguments<'_>,
183) -> io::Result<()> {
184    match write_line_to_fmt(err, args) {
185        Ok(()) => Ok(()),
186        Err(e) if e.kind() == io::ErrorKind::BrokenPipe => Ok(()),
187        Err(e) => Err(e),
188    }
189}
190
191/// Writes a line to stderr with flush (warnings / human errors).
192///
193/// # Errors
194/// Returns I/O errors except BrokenPipe (treated as Ok — downstream closed).
195pub fn write_stderr_line(content: &str) -> io::Result<()> {
196    let stderr = io::stderr();
197    let mut handle = stderr.lock();
198    write_stderr_line_to(&mut handle, content)
199}
200
201/// Writes a formatted line to stderr without an intermediate `String` (G-MAC-01).
202///
203/// # Errors
204/// Returns I/O errors except BrokenPipe (treated as Ok — downstream closed).
205pub fn write_stderr_fmt(args: fmt::Arguments<'_>) -> io::Result<()> {
206    let stderr = io::stderr();
207    let mut handle = stderr.lock();
208    write_stderr_line_to_fmt(&mut handle, args)
209}
210
211/// Shared stderr diagnostic when a typed JSON emit fails to serialize.
212pub(crate) fn report_json_serialize_error(err: &impl fmt::Display) {
213    let _ = write_stderr_fmt(format_args!("failed to serialize JSON: {err}"));
214}
215
216/// Prints a human success message (suppressed with `--quiet`).
217pub fn print_success(message: &str) {
218    if is_quiet() {
219        return;
220    }
221    write_line_human(message);
222}
223
224/// Human success via `format_args!` — no intermediate `String` (G-MAC-01).
225pub fn print_success_fmt(args: fmt::Arguments<'_>) {
226    if is_quiet() {
227        return;
228    }
229    match write_line_fmt(args) {
230        Ok(()) => {}
231        Err(e) if e.kind() == io::ErrorKind::BrokenPipe => {}
232        Err(_) => {}
233    }
234}
235
236/// Agent-first success emitter (GAP-AUD-003/008).
237///
238/// When `json` is true, writes a single **compact** stdout envelope:
239/// `{ "ok": true, "event": <event>, …fields }`.
240/// Otherwise prints the human `message` (respecting `--quiet`).
241///
242/// # Errors
243/// Returns I/O errors from writing stdout (including BrokenPipe for JSON path).
244pub fn emit_success(
245    event: &str,
246    fields: serde_json::Value,
247    human: &str,
248    json: bool,
249) -> io::Result<()> {
250    if json {
251        let envelope = SuccessEnvelope::from_value(event, fields);
252        json_wire::print_json_line(&envelope)?;
253    } else {
254        print_success(human);
255    }
256    Ok(())
257}
258
259/// Like [`emit_success`], but the human line is built with `format_args!` (G-MAC-01).
260///
261/// Prefer this when the human text is dynamic and the JSON path does not need
262/// the formatted string (avoids allocating when `json` is true *and* when false).
263///
264/// # Errors
265/// Returns I/O errors from writing stdout (including BrokenPipe for JSON path).
266pub fn emit_success_fmt(
267    event: &str,
268    fields: serde_json::Value,
269    human: fmt::Arguments<'_>,
270    json: bool,
271) -> io::Result<()> {
272    if json {
273        let envelope = SuccessEnvelope::from_value(event, fields);
274        json_wire::print_json_line(&envelope)?;
275    } else {
276        print_success_fmt(human);
277    }
278    Ok(())
279}
280
281/// Human banner (tunnel etc.): Text+TTY+!quiet+!JSON errors only (GAP-SSH-IO-006).
282///
283/// In pipes/agents, progress goes to `tracing` (stderr), never stdout.
284pub fn print_human_banner(message: &str) {
285    if is_quiet() || wants_json_errors() {
286        return;
287    }
288    if !std::io::IsTerminal::is_terminal(&std::io::stdout()) {
289        return;
290    }
291    // G-AUD-12: no FORCE_TEXT env — human banner only on TTY text path above.
292    write_line_human(message);
293}
294
295/// Prints an error message on stderr (human-facing).
296///
297/// # Errors
298/// Propagates non-pipe stderr write failures.
299pub fn print_error(message: &str) -> io::Result<()> {
300    write_stderr_line(message)
301}
302
303/// Stderr error via `format_args!` — no intermediate `String` (G-MAC-01).
304///
305/// # Errors
306/// Propagates non-pipe stderr write failures.
307pub fn print_error_fmt(args: fmt::Arguments<'_>) -> io::Result<()> {
308    write_stderr_fmt(args)
309}
310
311/// Prints a warning on stderr (agent-visible, never stdout).
312pub fn print_warning(message: &str) {
313    // G-MAC-01: `format_args!` + `write_fmt` — no temporary `String`.
314    let _ = write_stderr_fmt(format_args!("warning: {message}"));
315}
316
317/// Warning with dynamic body via `format_args!` (G-MAC-01 residual close).
318///
319/// `fmt::Arguments` implements [`Display`], so the `"warning: "` prefix composes
320/// without a second allocation.
321pub fn print_warning_fmt(args: fmt::Arguments<'_>) {
322    let _ = write_stderr_fmt(format_args!("warning: {args}"));
323}
324
325/// Emits a JSON error envelope on stderr (GAP-SSH-IO-003 / G-RETRY / G-ERR-08).
326pub fn print_error_envelope(
327    exit_code: i32,
328    error_code: &str,
329    message: &str,
330    remote_exit_code: Option<i32>,
331    error_class: crate::errors::ErrorClass,
332    retryable: bool,
333    suggestion: Option<&str>,
334) -> io::Result<()> {
335    let env = ErrorEnvelope {
336        exit_code,
337        error_code: error_code.to_string(),
338        message: message.to_string(),
339        remote_exit_code,
340        error_class,
341        retryable,
342        suggestion: suggestion.map(str::to_string),
343    };
344    // Fallback if serialization ever fails (should not for this plain struct).
345    match json_wire::print_json_line_stderr(&env) {
346        Ok(()) => Ok(()),
347        Err(e) if e.kind() == io::ErrorKind::Other => write_stderr_fmt(format_args!(
348            r#"{{"exit_code":{exit_code},"message":"serialization error"}}"#
349        )),
350        Err(e) => Err(e as io::Error),
351    }
352}
353
354/// Prints **compact** JSON on stdout (agent wire; always respects quiet=false).
355///
356/// Prefer typed DTOs in [`crate::json_wire`] for known payloads. This helper
357/// remains for dynamic documents (`meta command-tree`, doctor ad-hoc maps).
358///
359/// # Errors
360/// Serialization or stdout I/O (including BrokenPipe → exit 141).
361pub fn print_json_value(v: &serde_json::Value) -> io::Result<()> {
362    json_wire::print_json_line(v)
363}