locode-exec 0.1.16

Minimal headless runner for the locode agent engine - one JSON report on stdout
Documentation
//! The crate's ONLY stdout/stderr writers (ADR-0009 stdout discipline).
//!
//! Everything else in the crate is denied `print_stdout`/`print_stderr` by the
//! workspace lints; the narrow `#[allow]`s below are the audited exceptions —
//! exactly Codex-exec's pattern (named, narrow emitters; the crate-wide deny
//! stays intact).

use std::process::ExitCode;

use locode_core::Status;

/// Write one JSON value as one stdout line (the report, or one stream event).
///
/// A serialize failure (realistically unreachable — every field is plain data)
/// emits a `{"type":"error"}` object instead, so stdout still carries exactly
/// one machine-readable line (Codex's jsonl fallback). Write errors (EPIPE
/// from `| head`, a closed pipe) are deliberately ignored — panicking on a
/// consumer closing the pipe would be wrong for a CLI.
pub fn write_json_line(value: &impl serde::Serialize) {
    use std::io::Write;
    let line = serde_json::to_string(value).unwrap_or_else(|e| {
        format!(r#"{{"type":"error","message":"failed to serialize output: {e}"}}"#)
    });
    let mut stdout = std::io::stdout().lock();
    let _ = writeln!(stdout, "{line}");
    let _ = stdout.flush();
}

/// Write the `text`-mode artifact (the final assistant message).
pub fn write_text(text: &str) {
    use std::io::Write;
    let mut stdout = std::io::stdout().lock();
    let _ = writeln!(stdout, "{text}");
    let _ = stdout.flush();
}

/// Write a pre-run failure to stderr (`error: …`), Codex's pre-run pattern.
pub fn error_line(message: &str) {
    use std::io::Write;
    let _ = writeln!(std::io::stderr().lock(), "error: {message}");
}

/// Write a non-fatal diagnostic to stderr (`warning: …`) — settings-layer
/// degradations (ADR-0024: skipped layers, stripped keys, invalid entries).
pub fn warning_line(message: &str) {
    use std::io::Write;
    let _ = writeln!(std::io::stderr().lock(), "warning: {message}");
}

/// ADR-0009 exit-code mapping: 0 for any **structured** terminal state
/// (`completed`/`max_turns`/`cancelled` — the run produced a valid report,
/// ADR-0018), 1 for fatal (`model_error`/`error`); clap owns exit 2 for usage
/// errors. `Status` is `#[non_exhaustive]` under the additive envelope policy
/// (`schema_version: 1`): unknown future statuses map conservatively to 1.
pub fn exit_code(status: Status) -> ExitCode {
    match status {
        Status::Completed | Status::MaxTurns | Status::Cancelled => ExitCode::SUCCESS,
        // `model_error`/`error` — and, per the additive policy, any unknown
        // future status — map conservatively to failure.
        _ => ExitCode::from(1),
    }
}

/// The notice shown when `--restricted` is used.
///
/// Restricted mode predates the permission rules that would make it usable: the
/// approval seam exists (ADR-0017) but there is no allow/deny store behind it, so
/// an answer cannot be remembered and every call asks again. Saying so is the
/// truthful-user-facing-text rule (AGENTS.md) — a half-built mode that looks
/// finished is worse than one that announces itself.
pub const RESTRICTED_MODE_NOTICE: &str = "--restricted is incomplete: file access is limited to the working directory \
     and every tool call asks for approval, but answers cannot be saved yet, so \
     the same call asks again each time. Omit the flag to run without either.";

/// The notice shown on a normal (unrestricted) run.
///
/// Kept to one line: the default changed to "no jail, no prompts", and a user who
/// does not know that cannot make an informed choice about where they run this.
pub const UNRESTRICTED_MODE_NOTICE: &str = "running without approval prompts, and with file access outside the working \
     directory; pass --restricted to limit both.";

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn exit_codes_map_all_statuses() {
        assert_eq!(exit_code(Status::Completed), ExitCode::SUCCESS);
        assert_eq!(exit_code(Status::MaxTurns), ExitCode::SUCCESS);
        assert_eq!(exit_code(Status::Cancelled), ExitCode::SUCCESS);
        assert_eq!(exit_code(Status::ModelError), ExitCode::from(1));
        assert_eq!(exit_code(Status::Error), ExitCode::from(1));
    }
}