Skip to main content

browser_automation_cli/
output.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Canonical stdout/stderr writers for the agent contract.
3//!
4//! Rules (`rules_rust_cli_stdin_stdout`):
5//! - **stdout** = structured data only (JSON envelope, NDJSON steps, human one-liners)
6//! - **stderr** = logs / progress / human diagnostics
7//! - Never mix streams; always `write_all` + explicit flush on record boundaries
8//! - Map `ErrorKind::BrokenPipe` → exit **141**
9//!
10//! All agent-consumable lines should go through this module (not raw `println!`).
11
12use std::io::{self, Write};
13
14use crate::error::{CliError, ErrorKind};
15
16/// Map an I/O error from stdout/stderr into a typed [`CliError`].
17///
18/// Borrows the error (kind + Display only) so callers keep the original if needed
19/// (rules: prefer `&T` when the body does not consume ownership).
20pub fn map_io_error(err: &io::Error, stream: &str) -> CliError {
21    if err.kind() == io::ErrorKind::BrokenPipe {
22        CliError::new(
23            ErrorKind::BrokenPipe,
24            format!("{stream}: broken pipe"),
25        )
26    } else {
27        CliError::new(ErrorKind::Io, format!("{stream}: {err}"))
28    }
29}
30
31/// Write raw bytes to stdout with `write_all` (no trailing newline).
32pub fn write_stdout(bytes: &[u8]) -> Result<(), CliError> {
33    let mut out = io::stdout().lock();
34    out.write_all(bytes)
35        .map_err(|e| map_io_error(&e, "stdout"))?;
36    Ok(())
37}
38
39/// Write one complete line to stdout (adds `\n`) and flush immediately.
40///
41/// Use for JSON envelopes, NDJSON steps, and human one-line results.
42pub fn writeln_stdout(line: impl AsRef<str>) -> Result<(), CliError> {
43    let mut out = io::stdout().lock();
44    out.write_all(line.as_ref().as_bytes())
45        .map_err(|e| map_io_error(&e, "stdout"))?;
46    out.write_all(b"\n")
47        .map_err(|e| map_io_error(&e, "stdout"))?;
48    out.flush().map_err(|e| map_io_error(&e, "stdout"))?;
49    Ok(())
50}
51
52/// Write one complete line to stderr (adds `\n`) and flush.
53pub fn writeln_stderr(line: impl AsRef<str>) -> Result<(), CliError> {
54    let mut err = io::stderr().lock();
55    err.write_all(line.as_ref().as_bytes())
56        .map_err(|e| map_io_error(&e, "stderr"))?;
57    err.write_all(b"\n")
58        .map_err(|e| map_io_error(&e, "stderr"))?;
59    err.flush().map_err(|e| map_io_error(&e, "stderr"))?;
60    Ok(())
61}
62
63/// Flush stdout (call before process return when partial buffers may remain).
64pub fn flush_stdout() -> Result<(), CliError> {
65    io::stdout()
66        .flush()
67        .map_err(|e| map_io_error(&e, "stdout"))
68}
69
70/// Flush stderr.
71pub fn flush_stderr() -> Result<(), CliError> {
72    io::stderr()
73        .flush()
74        .map_err(|e| map_io_error(&e, "stderr"))
75}
76
77/// Serialize `value` as **compact** JSON (no pretty print) and write one LF line
78/// to stdout (flushed). Used for agent envelopes and NDJSON steps.
79///
80/// Never emits UTF-8 BOM. One complete JSON value per line (NDJSON-safe).
81pub fn write_json_line(value: &serde_json::Value) -> Result<(), CliError> {
82    write_json_line_ser(value)
83}
84
85/// Serialize any [`serde::Serialize`] value as compact JSON and write one LF line.
86pub fn write_json_line_ser<T: serde::Serialize>(value: &T) -> Result<(), CliError> {
87    let s = crate::json_util::to_compact_string(value)?;
88    // Defense: compact encoder must not introduce embedded newlines (would break NDJSON).
89    if s.as_bytes().contains(&b'\n') {
90        return Err(CliError::new(
91            ErrorKind::Software,
92            "json encode produced embedded newline (breaks NDJSON/envelope contract)",
93        ));
94    }
95    writeln_stdout(s)
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    #[test]
103    fn broken_pipe_maps_to_141() {
104        let io_err = io::Error::new(io::ErrorKind::BrokenPipe, "pipe");
105        let err = map_io_error(&io_err, "stdout");
106        assert_eq!(err.kind(), ErrorKind::BrokenPipe);
107        assert_eq!(err.exit_code(), 141);
108    }
109
110    #[test]
111    fn other_io_maps_to_74() {
112        let io_err = io::Error::new(io::ErrorKind::PermissionDenied, "nope");
113        let err = map_io_error(&io_err, "stdout");
114        assert_eq!(err.kind(), ErrorKind::Io);
115        assert_eq!(err.exit_code(), 74);
116    }
117}