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}