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}