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(err: &mut impl Write, args: fmt::Arguments<'_>) -> io::Result<()> {
181 match write_line_to_fmt(err, args) {
182 Ok(()) => Ok(()),
183 Err(e) if e.kind() == io::ErrorKind::BrokenPipe => Ok(()),
184 Err(e) => Err(e),
185 }
186}
187
188/// Writes a line to stderr with flush (warnings / human errors).
189///
190/// # Errors
191/// Returns I/O errors except BrokenPipe (treated as Ok — downstream closed).
192pub fn write_stderr_line(content: &str) -> io::Result<()> {
193 let stderr = io::stderr();
194 let mut handle = stderr.lock();
195 write_stderr_line_to(&mut handle, content)
196}
197
198/// Writes a formatted line to stderr without an intermediate `String` (G-MAC-01).
199///
200/// # Errors
201/// Returns I/O errors except BrokenPipe (treated as Ok — downstream closed).
202pub fn write_stderr_fmt(args: fmt::Arguments<'_>) -> io::Result<()> {
203 let stderr = io::stderr();
204 let mut handle = stderr.lock();
205 write_stderr_line_to_fmt(&mut handle, args)
206}
207
208/// Shared stderr diagnostic when a typed JSON emit fails to serialize.
209pub(crate) fn report_json_serialize_error(err: &impl fmt::Display) {
210 let _ = write_stderr_fmt(format_args!("failed to serialize JSON: {err}"));
211}
212
213/// Prints a human success message (suppressed with `--quiet`).
214pub fn print_success(message: &str) {
215 if is_quiet() {
216 return;
217 }
218 write_line_human(message);
219}
220
221/// Human success via `format_args!` — no intermediate `String` (G-MAC-01).
222pub fn print_success_fmt(args: fmt::Arguments<'_>) {
223 if is_quiet() {
224 return;
225 }
226 match write_line_fmt(args) {
227 Ok(()) => {}
228 Err(e) if e.kind() == io::ErrorKind::BrokenPipe => {}
229 Err(_) => {}
230 }
231}
232
233/// Agent-first success emitter (GAP-AUD-003/008).
234///
235/// When `json` is true, writes a single **compact** stdout envelope:
236/// `{ "ok": true, "event": <event>, …fields }`.
237/// Otherwise prints the human `message` (respecting `--quiet`).
238///
239/// # Errors
240/// Returns I/O errors from writing stdout (including BrokenPipe for JSON path).
241pub fn emit_success(
242 event: &str,
243 fields: serde_json::Value,
244 human: &str,
245 json: bool,
246) -> io::Result<()> {
247 if json {
248 let envelope = SuccessEnvelope::from_value(event, fields);
249 json_wire::print_json_line(&envelope)?;
250 } else {
251 print_success(human);
252 }
253 Ok(())
254}
255
256/// Like [`emit_success`], but the human line is built with `format_args!` (G-MAC-01).
257///
258/// Prefer this when the human text is dynamic and the JSON path does not need
259/// the formatted string (avoids allocating when `json` is true *and* when false).
260///
261/// # Errors
262/// Returns I/O errors from writing stdout (including BrokenPipe for JSON path).
263pub fn emit_success_fmt(
264 event: &str,
265 fields: serde_json::Value,
266 human: fmt::Arguments<'_>,
267 json: bool,
268) -> io::Result<()> {
269 if json {
270 let envelope = SuccessEnvelope::from_value(event, fields);
271 json_wire::print_json_line(&envelope)?;
272 } else {
273 print_success_fmt(human);
274 }
275 Ok(())
276}
277
278/// Human banner (tunnel etc.): Text+TTY+!quiet+!JSON errors only (GAP-SSH-IO-006).
279///
280/// In pipes/agents, progress goes to `tracing` (stderr), never stdout.
281pub fn print_human_banner(message: &str) {
282 if is_quiet() || wants_json_errors() {
283 return;
284 }
285 if !std::io::IsTerminal::is_terminal(&std::io::stdout()) {
286 return;
287 }
288 // G-AUD-12: no FORCE_TEXT env — human banner only on TTY text path above.
289 write_line_human(message);
290}
291
292/// Prints an error message on stderr (human-facing).
293///
294/// # Errors
295/// Propagates non-pipe stderr write failures.
296pub fn print_error(message: &str) -> io::Result<()> {
297 write_stderr_line(message)
298}
299
300/// Stderr error via `format_args!` — no intermediate `String` (G-MAC-01).
301///
302/// # Errors
303/// Propagates non-pipe stderr write failures.
304pub fn print_error_fmt(args: fmt::Arguments<'_>) -> io::Result<()> {
305 write_stderr_fmt(args)
306}
307
308/// Prints a warning on stderr (agent-visible, never stdout).
309pub fn print_warning(message: &str) {
310 // G-MAC-01: `format_args!` + `write_fmt` — no temporary `String`.
311 let _ = write_stderr_fmt(format_args!("warning: {message}"));
312}
313
314/// Warning with dynamic body via `format_args!` (G-MAC-01 residual close).
315///
316/// `fmt::Arguments` implements [`std::fmt::Display`], so the `"warning: "` prefix composes
317/// without a second allocation.
318pub fn print_warning_fmt(args: fmt::Arguments<'_>) {
319 let _ = write_stderr_fmt(format_args!("warning: {args}"));
320}
321
322/// Emits a JSON error envelope on stderr (GAP-SSH-IO-003 / G-RETRY / G-ERR-08).
323pub fn print_error_envelope(
324 exit_code: i32,
325 error_code: &str,
326 message: &str,
327 remote_exit_code: Option<i32>,
328 error_class: crate::errors::ErrorClass,
329 retryable: bool,
330 suggestion: Option<&str>,
331) -> io::Result<()> {
332 let env = ErrorEnvelope {
333 exit_code,
334 error_code: error_code.to_string(),
335 message: message.to_string(),
336 remote_exit_code,
337 error_class,
338 retryable,
339 suggestion: suggestion.map(str::to_string),
340 };
341 // Fallback if serialization ever fails (should not for this plain struct).
342 match json_wire::print_json_line_stderr(&env) {
343 Ok(()) => Ok(()),
344 Err(e) if e.kind() == io::ErrorKind::Other => write_stderr_fmt(format_args!(
345 r#"{{"exit_code":{exit_code},"message":"serialization error"}}"#
346 )),
347 Err(e) => Err(e as io::Error),
348 }
349}
350
351/// Prints **compact** JSON on stdout (agent wire; always respects quiet=false).
352///
353/// Prefer typed DTOs in [`crate::json_wire`] for known payloads. This helper
354/// remains for dynamic documents (`meta command-tree`, doctor ad-hoc maps).
355///
356/// # Errors
357/// Serialization or stdout I/O (including BrokenPipe → exit 141).
358pub fn print_json_value(v: &serde_json::Value) -> io::Result<()> {
359 json_wire::print_json_line(v)
360}