release-kit 0.2.0

A canonical release workflow: a technology-agnostic method, per-technology bindings, and the rk CLI that lands and serves them.
Documentation
//! The output boundary every handler emits through.
//!
//! One rule in two halves, identical in both modes: stdout carries the
//! result and only the result — human text by default, machine output
//! under `--json` — and stderr carries everything else. No handler in
//! `commands/` prints directly; a source-scan test below holds that, so
//! the contract cannot regrow a second personality one `println!` at a
//! time.

use std::io::Write as _;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};

use serde::Serialize;

use crate::error::RkError;

/// The consumer closed its pipe: it is done listening, which is normal
/// control flow and not a failure of the command's work.
static STDOUT_CLOSED: AtomicBool = AtomicBool::new(false);

/// Stdout failed for a reason other than a closed pipe. The work may
/// still complete, but the result was not delivered; the retained error
/// becomes the run's typed failure at the process boundary, so it is
/// rendered once, in the invocation's own mode, and logged honestly.
static STDOUT_ERROR: Mutex<Option<std::io::Error>> = Mutex::new(None);

/// Write one chunk to stdout — a `println!` would panic on a failed
/// write, which is exactly the exit the contract forbids. A failure never
/// interrupts the command either: a mutating handler mid-apply must
/// finish its work, so a dead stdout only suppresses further rendering,
/// and [`take_stdout_failure`] settles the outcome at the boundary.
fn to_stdout(text: &str) {
    to_stdout_bytes(text.as_bytes());
}

/// The byte form of [`to_stdout`], for child passthrough where invalid
/// UTF-8 must reach the pipe unchanged.
fn to_stdout_bytes(bytes: &[u8]) {
    if STDOUT_CLOSED.load(Ordering::Relaxed) {
        return;
    }
    let Ok(mut retained) = STDOUT_ERROR.lock() else {
        return;
    };
    if retained.is_some() {
        return;
    }
    let mut stdout = std::io::stdout().lock();
    let outcome = stdout.write_all(bytes).and_then(|()| stdout.flush());
    if let Err(source) = outcome {
        if source.kind() == std::io::ErrorKind::BrokenPipe {
            STDOUT_CLOSED.store(true, Ordering::Relaxed);
        } else {
            *retained = Some(source);
        }
    }
}

/// The stdout failure a successful run still has to answer for, if any.
///
/// `None` when the result was delivered or the consumer stopped
/// listening. The caller turns the retained error into the run's one
/// typed failure; a closed pipe stays the reason-free clean-suppression
/// case.
#[must_use]
pub fn take_stdout_failure() -> Option<std::io::Error> {
    STDOUT_ERROR.lock().ok().and_then(|mut held| held.take())
}

/// Write one line to stderr, best effort: a failing stderr must never
/// change what the command was doing.
fn to_stderr(text: &str) {
    let _ = writeln!(std::io::stderr(), "{text}");
}

/// Which caller the result serves.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Format {
    /// The default rendering, for a person at a terminal.
    Human,
    /// One JSON document on stdout, for an agent or a script.
    Json,
}

/// The boundary a handler writes results through.
#[derive(Debug, Clone, Copy)]
pub struct Output {
    format: Format,
}

impl Output {
    /// The boundary for a command carrying a `--json` flag.
    #[must_use]
    pub const fn new(json: bool) -> Self {
        Self {
            format: if json { Format::Json } else { Format::Human },
        }
    }

    /// The boundary for a command whose result is the document itself —
    /// a chapter, a binding, a license — where a JSON wrapper would add
    /// nothing an agent can use.
    #[must_use]
    pub const fn human() -> Self {
        Self {
            format: Format::Human,
        }
    }

    /// Whether this boundary serves the machine form.
    #[must_use]
    pub const fn is_json(&self) -> bool {
        matches!(self.format, Format::Json)
    }

    /// One human result line on stdout; silent under `--json`, where the
    /// emitted document is the whole result.
    pub fn result_line(&self, line: impl AsRef<str>) {
        if !self.is_json() {
            to_stdout(&format!("{}\n", line.as_ref()));
        }
    }

    /// A human result without a trailing newline, for byte-identical
    /// payload prints; silent under `--json`.
    pub fn result_raw(&self, text: &str) {
        if !self.is_json() {
            to_stdout(text);
        }
    }

    /// A generated byte result — completions, and nothing else today —
    /// through the same pipe-safe path as every other result.
    pub fn result_bytes(&self, bytes: &[u8]) {
        if !self.is_json() {
            to_stdout(&String::from_utf8_lossy(bytes));
        }
    }

    /// The machine result: one JSON document on stdout, and nothing in
    /// human mode.
    ///
    /// # Errors
    ///
    /// Returns [`RkError::Other`] when the report cannot serialize, which
    /// is a defect in this binary rather than anything a caller can
    /// correct.
    pub fn emit<T: Serialize>(&self, report: &T) -> Result<(), RkError> {
        if self.is_json() {
            let text = serde_json::to_string_pretty(report).map_err(anyhow::Error::from)?;
            to_stdout(&format!("{text}\n"));
        }
        Ok(())
    }

    /// One NDJSON event line on stdout under `--json`, and nothing in
    /// human mode: the long-running commands' machine stream, one complete
    /// object per line.
    pub fn event<T: Serialize>(&self, event: &T) {
        if self.is_json() {
            if let Ok(line) = serde_json::to_string(event) {
                to_stdout(&format!("{line}\n"));
            }
        }
    }

    /// One line of framing on stderr in human mode — step frames, the
    /// command echo, warnings — and nothing under `--json`, where the
    /// events carry the run.
    pub fn frame(&self, line: impl AsRef<str>) {
        if !self.is_json() {
            to_stderr(line.as_ref());
        }
    }

    /// One warning line on stderr, in both modes.
    pub fn warn(&self, line: impl AsRef<str>) {
        to_stderr(&format!("warning: {}", line.as_ref()));
    }

    /// Raw child bytes to the parent's matching stream, human mode only:
    /// never swallow a subprocess, and never corrupt a pipe either.
    pub fn child_passthrough(&self, stream: crate::events::ChildStream, bytes: &[u8]) {
        if self.is_json() {
            return;
        }
        match stream {
            crate::events::ChildStream::Stdout => to_stdout_bytes(bytes),
            crate::events::ChildStream::Stderr => {
                let _ = std::io::stderr().lock().write_all(bytes);
            }
        }
    }

    /// The `Next:` block closing a human success: two to four lines
    /// naming the commands that plausibly follow, so no output is a dead
    /// end. Under `--json` the report's own `next` field carries them.
    pub fn next(&self, lines: &[String]) {
        if self.is_json() || lines.is_empty() {
            return;
        }
        let mut block = String::from("Next:\n");
        for line in lines {
            block.push_str("  ");
            block.push_str(line);
            block.push('\n');
        }
        to_stdout(&block);
    }
}

/// Render one failure on stderr: the human five-question form by default,
/// the same fields as one JSON line under `--json`.
pub fn render_error(err: &RkError, json: bool) {
    if json {
        let diagnostic = err.diagnostic();
        match serde_json::to_string(&diagnostic) {
            Ok(line) => to_stderr(&line),
            Err(_) => to_stderr(
                r#"{"schema":"rk.diagnostic/1","reason":"internal","message":"a diagnostic failed to serialize"}"#,
            ),
        }
        return;
    }
    match err {
        RkError::Refusal(diagnostic)
        | RkError::Missing(diagnostic)
        | RkError::CheckFailed(diagnostic)
        | RkError::Subprocess(diagnostic) => to_stderr(&diagnostic.render_human()),
        _ => to_stderr(&format!("error: {err}")),
    }
}

#[cfg(test)]
mod tests {
    #![allow(clippy::expect_used)]

    /// No handler prints past the boundary: neither a print macro nor a
    /// direct standard-stream handle appears anywhere under `src/` outside
    /// this module, so every result and every diagnostic goes through one
    /// door — including output produced by a library into a buffer.
    #[test]
    fn no_handler_prints_past_the_boundary() {
        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
        let mut offenders = Vec::new();
        scan(&root, &mut offenders);
        assert!(
            offenders.is_empty(),
            "these print directly instead of using the output boundary: {offenders:?}"
        );
    }

    fn scan(dir: &std::path::Path, offenders: &mut Vec<String>) {
        for entry in std::fs::read_dir(dir).expect("the source directory reads") {
            let path = entry.expect("the entry reads").path();
            if path.is_dir() {
                scan(&path, offenders);
                continue;
            }
            if path.extension().is_none_or(|ext| ext != "rs")
                || path.file_name().is_some_and(|name| name == "output.rs")
            {
                continue;
            }
            let text = std::fs::read_to_string(&path).expect("the source reads");
            for (idx, line) in text.lines().enumerate() {
                let trimmed = line.trim_start();
                if trimmed.starts_with("//") {
                    continue;
                }
                for needle in [
                    "println!",
                    "print!",
                    "eprintln!",
                    "eprint!",
                    "io::stdout(",
                    "io::stderr(",
                ] {
                    if trimmed.contains(needle) {
                        offenders.push(format!("{}:{}", path.display(), idx + 1));
                    }
                }
            }
        }
    }
}