Skip to main content

release_kit/
output.rs

1//! The output boundary every handler emits through.
2//!
3//! One rule in two halves, identical in both modes: stdout carries the
4//! result and only the result — human text by default, machine output
5//! under `--json` — and stderr carries everything else. No handler in
6//! `commands/` prints directly; a source-scan test below holds that, so
7//! the contract cannot regrow a second personality one `println!` at a
8//! time.
9
10use std::io::Write as _;
11use std::sync::Mutex;
12use std::sync::atomic::{AtomicBool, Ordering};
13
14use serde::Serialize;
15
16use crate::error::RkError;
17
18/// The consumer closed its pipe: it is done listening, which is normal
19/// control flow and not a failure of the command's work.
20static STDOUT_CLOSED: AtomicBool = AtomicBool::new(false);
21
22/// Stdout failed for a reason other than a closed pipe. The work may
23/// still complete, but the result was not delivered; the retained error
24/// becomes the run's typed failure at the process boundary, so it is
25/// rendered once, in the invocation's own mode, and logged honestly.
26static STDOUT_ERROR: Mutex<Option<std::io::Error>> = Mutex::new(None);
27
28/// Write one chunk to stdout — a `println!` would panic on a failed
29/// write, which is exactly the exit the contract forbids. A failure never
30/// interrupts the command either: a mutating handler mid-apply must
31/// finish its work, so a dead stdout only suppresses further rendering,
32/// and [`take_stdout_failure`] settles the outcome at the boundary.
33fn to_stdout(text: &str) {
34    to_stdout_bytes(text.as_bytes());
35}
36
37/// The byte form of [`to_stdout`], for child passthrough where invalid
38/// UTF-8 must reach the pipe unchanged.
39fn to_stdout_bytes(bytes: &[u8]) {
40    if STDOUT_CLOSED.load(Ordering::Relaxed) {
41        return;
42    }
43    let Ok(mut retained) = STDOUT_ERROR.lock() else {
44        return;
45    };
46    if retained.is_some() {
47        return;
48    }
49    let mut stdout = std::io::stdout().lock();
50    let outcome = stdout.write_all(bytes).and_then(|()| stdout.flush());
51    if let Err(source) = outcome {
52        if source.kind() == std::io::ErrorKind::BrokenPipe {
53            STDOUT_CLOSED.store(true, Ordering::Relaxed);
54        } else {
55            *retained = Some(source);
56        }
57    }
58}
59
60/// The stdout failure a successful run still has to answer for, if any.
61///
62/// `None` when the result was delivered or the consumer stopped
63/// listening. The caller turns the retained error into the run's one
64/// typed failure; a closed pipe stays the reason-free clean-suppression
65/// case.
66#[must_use]
67pub fn take_stdout_failure() -> Option<std::io::Error> {
68    STDOUT_ERROR.lock().ok().and_then(|mut held| held.take())
69}
70
71/// Write one line to stderr, best effort: a failing stderr must never
72/// change what the command was doing.
73fn to_stderr(text: &str) {
74    let _ = writeln!(std::io::stderr(), "{text}");
75}
76
77/// Which caller the result serves.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum Format {
80    /// The default rendering, for a person at a terminal.
81    Human,
82    /// One JSON document on stdout, for an agent or a script.
83    Json,
84}
85
86/// The boundary a handler writes results through.
87#[derive(Debug, Clone, Copy)]
88pub struct Output {
89    format: Format,
90}
91
92impl Output {
93    /// The boundary for a command carrying a `--json` flag.
94    #[must_use]
95    pub const fn new(json: bool) -> Self {
96        Self {
97            format: if json { Format::Json } else { Format::Human },
98        }
99    }
100
101    /// The boundary for a command whose result is the document itself —
102    /// a chapter, a binding, a license — where a JSON wrapper would add
103    /// nothing an agent can use.
104    #[must_use]
105    pub const fn human() -> Self {
106        Self {
107            format: Format::Human,
108        }
109    }
110
111    /// Whether this boundary serves the machine form.
112    #[must_use]
113    pub const fn is_json(&self) -> bool {
114        matches!(self.format, Format::Json)
115    }
116
117    /// One human result line on stdout; silent under `--json`, where the
118    /// emitted document is the whole result.
119    pub fn result_line(&self, line: impl AsRef<str>) {
120        if !self.is_json() {
121            to_stdout(&format!("{}\n", line.as_ref()));
122        }
123    }
124
125    /// A human result without a trailing newline, for byte-identical
126    /// payload prints; silent under `--json`.
127    pub fn result_raw(&self, text: &str) {
128        if !self.is_json() {
129            to_stdout(text);
130        }
131    }
132
133    /// A generated byte result — completions, and nothing else today —
134    /// through the same pipe-safe path as every other result.
135    pub fn result_bytes(&self, bytes: &[u8]) {
136        if !self.is_json() {
137            to_stdout(&String::from_utf8_lossy(bytes));
138        }
139    }
140
141    /// The machine result: one JSON document on stdout, and nothing in
142    /// human mode.
143    ///
144    /// # Errors
145    ///
146    /// Returns [`RkError::Other`] when the report cannot serialize, which
147    /// is a defect in this binary rather than anything a caller can
148    /// correct.
149    pub fn emit<T: Serialize>(&self, report: &T) -> Result<(), RkError> {
150        if self.is_json() {
151            let text = serde_json::to_string_pretty(report).map_err(anyhow::Error::from)?;
152            to_stdout(&format!("{text}\n"));
153        }
154        Ok(())
155    }
156
157    /// One NDJSON event line on stdout under `--json`, and nothing in
158    /// human mode: the long-running commands' machine stream, one complete
159    /// object per line.
160    pub fn event<T: Serialize>(&self, event: &T) {
161        if self.is_json() {
162            if let Ok(line) = serde_json::to_string(event) {
163                to_stdout(&format!("{line}\n"));
164            }
165        }
166    }
167
168    /// One line of framing on stderr in human mode — step frames, the
169    /// command echo, warnings — and nothing under `--json`, where the
170    /// events carry the run.
171    pub fn frame(&self, line: impl AsRef<str>) {
172        if !self.is_json() {
173            to_stderr(line.as_ref());
174        }
175    }
176
177    /// One warning line on stderr, in both modes.
178    pub fn warn(&self, line: impl AsRef<str>) {
179        to_stderr(&format!("warning: {}", line.as_ref()));
180    }
181
182    /// Raw child bytes to the parent's matching stream, human mode only:
183    /// never swallow a subprocess, and never corrupt a pipe either.
184    pub fn child_passthrough(&self, stream: crate::events::ChildStream, bytes: &[u8]) {
185        if self.is_json() {
186            return;
187        }
188        match stream {
189            crate::events::ChildStream::Stdout => to_stdout_bytes(bytes),
190            crate::events::ChildStream::Stderr => {
191                let _ = std::io::stderr().lock().write_all(bytes);
192            }
193        }
194    }
195
196    /// The `Next:` block closing a human success: two to four lines
197    /// naming the commands that plausibly follow, so no output is a dead
198    /// end. Under `--json` the report's own `next` field carries them.
199    pub fn next(&self, lines: &[String]) {
200        if self.is_json() || lines.is_empty() {
201            return;
202        }
203        let mut block = String::from("Next:\n");
204        for line in lines {
205            block.push_str("  ");
206            block.push_str(line);
207            block.push('\n');
208        }
209        to_stdout(&block);
210    }
211}
212
213/// Render one failure on stderr: the human five-question form by default,
214/// the same fields as one JSON line under `--json`.
215pub fn render_error(err: &RkError, json: bool) {
216    if json {
217        let diagnostic = err.diagnostic();
218        match serde_json::to_string(&diagnostic) {
219            Ok(line) => to_stderr(&line),
220            Err(_) => to_stderr(
221                r#"{"schema":"rk.diagnostic/1","reason":"internal","message":"a diagnostic failed to serialize"}"#,
222            ),
223        }
224        return;
225    }
226    match err {
227        RkError::Refusal(diagnostic)
228        | RkError::Missing(diagnostic)
229        | RkError::CheckFailed(diagnostic)
230        | RkError::Subprocess(diagnostic) => to_stderr(&diagnostic.render_human()),
231        _ => to_stderr(&format!("error: {err}")),
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    #![allow(clippy::expect_used)]
238
239    /// No handler prints past the boundary: neither a print macro nor a
240    /// direct standard-stream handle appears anywhere under `src/` outside
241    /// this module, so every result and every diagnostic goes through one
242    /// door — including output produced by a library into a buffer.
243    #[test]
244    fn no_handler_prints_past_the_boundary() {
245        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
246        let mut offenders = Vec::new();
247        scan(&root, &mut offenders);
248        assert!(
249            offenders.is_empty(),
250            "these print directly instead of using the output boundary: {offenders:?}"
251        );
252    }
253
254    fn scan(dir: &std::path::Path, offenders: &mut Vec<String>) {
255        for entry in std::fs::read_dir(dir).expect("the source directory reads") {
256            let path = entry.expect("the entry reads").path();
257            if path.is_dir() {
258                scan(&path, offenders);
259                continue;
260            }
261            if path.extension().is_none_or(|ext| ext != "rs")
262                || path.file_name().is_some_and(|name| name == "output.rs")
263            {
264                continue;
265            }
266            let text = std::fs::read_to_string(&path).expect("the source reads");
267            for (idx, line) in text.lines().enumerate() {
268                let trimmed = line.trim_start();
269                if trimmed.starts_with("//") {
270                    continue;
271                }
272                for needle in [
273                    "println!",
274                    "print!",
275                    "eprintln!",
276                    "eprint!",
277                    "io::stdout(",
278                    "io::stderr(",
279                ] {
280                    if trimmed.contains(needle) {
281                        offenders.push(format!("{}:{}", path.display(), idx + 1));
282                    }
283                }
284            }
285        }
286    }
287}