crates/nornir-cli-outcome/src/cli_outcome.rs
//! The uniform CLI command result — the CLI twin of a Facet's `state_json`.
//!
//! UI elements emit `state_json` (readable data the matrix asserts is sane). CLI
//! commands had only ad-hoc per-command printing. [`CommandOutcome`] gives every
//! migrated command ONE shape: `{command, ok, data, human}`. The global `--json`
//! flag prints the whole outcome (machine); otherwise the `human` rendering goes
//! to the terminal. The autopilot check runs each command and asserts
//! [`CommandOutcome::is_sannr`] — "sane data on every CLI command", exactly the way
//! the atom-walk + `state_json` check asserts it on every UI element.
//!
//! See `.nornir/cli-command-contract.md` (AUT9). EMIT-DOCTRINE still applies: the
//! `data` is the data the command ALREADY computes, surfaced as readable JSON —
//! not a parallel made-up payload.
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// A single command's machine-readable result. `data` is the structured payload
/// (the check's assertion target, and the parity target vs the UI twin's
/// `state_json` slice); `human` is the pretty terminal rendering.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommandOutcome {
/// Canonical leaf path, e.g. `"bench history"` (matches `clap_command_paths`).
pub command: String,
/// Semantic success — NOT merely process exit 0. A command that ran but found
/// a red/empty/blocked state sets `ok=false` with the reason in `human`.
pub ok: bool,
/// The readable data the command produced (the sannr check (truth-verdict) target).
pub data: Value,
/// Terminal rendering (table/summary). Printed when `--json` is off.
#[serde(default)]
pub human: String,
}
impl CommandOutcome {
/// A successful outcome carrying real `data` and a `human` rendering.
pub fn ok(command: impl Into<String>, data: Value, human: impl Into<String>) -> Self {
Self { command: command.into(), ok: true, data, human: human.into() }
}
/// A semantic failure (ran, but the state is red/blocked) — `data` is null,
/// the reason lives in `human`. Distinct from a process error (`Result::Err`).
pub fn fail(command: impl Into<String>, why: impl Into<String>) -> Self {
Self { command: command.into(), ok: false, data: Value::Null, human: why.into() }
}
/// Print to stdout: the whole outcome as pretty JSON when `json`, else `human`.
pub fn print(&self, json: bool) {
if json {
println!("{}", serde_json::to_string_pretty(self).unwrap_or_else(|_| "{}".into()));
} else if !self.human.is_empty() {
println!("{}", self.human);
}
}
/// The autopilot sannr gate (truth-verdict) (mirrors the UI `state_json`/atom check):
/// success, non-empty `data`, and no error sentinel leaked into `human`.
pub fn is_sannr(&self) -> bool {
self.ok && !data_is_empty(&self.data) && !has_error_marker(&self.human)
}
/// The **process-exit contract**: the outcome's semantic verdict as a Unix
/// exit code — `0` when the command succeeded (`ok`), `1` when it ran but
/// found a red/blocked state (`!ok`). This is the honest user-facing code:
/// an empty-but-`ok` "list" (0 rows) is still a successful run, so it exits
/// `0`. For the stricter autopilot gate (empty ⇒ RED) use
/// [`sannr_exit_code`](Self::sannr_exit_code).
pub fn exit_code(&self) -> i32 {
if self.ok {
0
} else {
1
}
}
/// The **strict autopilot exit contract**: `0` only when [`is_sannr`] holds,
/// else `1`. Unlike [`exit_code`](Self::exit_code) this treats a *falsely
/// green* outcome — `ok=true` but empty `data`, or an error atom leaked into
/// `human` — as a failure (RAGNARÖK: empty is RED, never silently green), so
/// the sannr check can drive a process exit directly.
///
/// [`is_sannr`]: Self::is_sannr
pub fn sannr_exit_code(&self) -> i32 {
if self.is_sannr() {
0
} else {
1
}
}
/// Render then hand back the exit code — the ONE line every CLI `main` wants:
/// `std::process::exit(outcome.finish(json))`. Prints the whole outcome as
/// pretty JSON when `json`, else the `human` rendering (via
/// [`print`](Self::print)), and returns [`exit_code`](Self::exit_code) so the
/// caller never re-derives the "print, then match `ok` for the exit status"
/// boilerplate. Does not itself call `process::exit` (leaves that to the
/// caller so it stays testable and destructor-safe).
#[must_use = "the returned code is the process exit status — pass it to std::process::exit"]
pub fn finish(&self, json: bool) -> i32 {
self.print(json);
self.exit_code()
}
}
/// Empty ⇒ a blank pane equivalent (RAGNARÖK: empty is RED, never silently green).
fn data_is_empty(v: &Value) -> bool {
match v {
Value::Null => true,
Value::Array(a) => a.is_empty(),
Value::Object(o) => o.is_empty(),
Value::String(s) => s.trim().is_empty(),
_ => false, // numbers/bools are real data
}
}
/// The same error atoms the UI check scans for (`nornir_robotui::ERROR_MARKERS`),
/// so a command that surfaced an error instead of data fails the sannr check.
fn has_error_marker(s: &str) -> bool {
// Mirrors nornir_robotui::ERROR_MARKERS (+ "panicked" for CLI surfaces). The
// pane-specific phrases (bug #22) catch lowercase "failed" renders without
// over-matching a "failed: <n>" count or a "✗ failed" glyph.
const MARKERS: &[&str] = &[
"is not served",
"Failed to load",
"unavailable",
"RPC: status:",
"panicked",
"scan failed",
"open warehouse failed",
"read failed",
"release_events read failed",
"failed to read registry",
];
MARKERS.iter().any(|m| s.contains(m))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn ok_outcome_with_real_data_is_sannr() {
let o = CommandOutcome::ok("bench history", json!([{"repo":"holger","ops":7.0}]), "1 run");
assert!(o.is_sannr());
// json print is a parseable round-trip carrying ok + data (the check reads these).
let mut buf = serde_json::to_value(&o).unwrap();
assert_eq!(buf["ok"], json!(true));
assert_eq!(buf["command"], json!("bench history"));
assert!(buf["data"].as_array().unwrap().len() == 1);
buf["ok"] = json!(false); // sanity: we actually mutated a parsed value
assert_eq!(buf["ok"], json!(false));
}
#[test]
fn empty_or_failed_or_errored_is_not_sane() {
// RAGNARÖK: empty data ⇒ RED.
assert!(!CommandOutcome::ok("x", json!([]), "no rows").is_sannr());
assert!(!CommandOutcome::ok("x", json!({}), "").is_sannr());
assert!(!CommandOutcome::ok("x", Value::Null, "").is_sannr());
// semantic failure ⇒ RED.
assert!(!CommandOutcome::fail("x", "workspace `` is not served").is_sannr());
// success but an error atom leaked into the human text ⇒ RED.
assert!(!CommandOutcome::ok("x", json!([1]), "RPC: status: unavailable").is_sannr());
// numbers/bools count as real data.
assert!(CommandOutcome::ok("x", json!(0), "zero is a value").is_sannr());
}
#[test]
fn exit_code_maps_semantic_verdict_to_process_status() {
// ok ⇒ 0 (success), even if empty (an empty "list" is still a good run).
assert_eq!(CommandOutcome::ok("x", json!([{"a":1}]), "1 row").exit_code(), 0);
assert_eq!(CommandOutcome::ok("x", json!([]), "no rows").exit_code(), 0);
// RED-when-broken: a semantic failure MUST be non-zero, never 0. If the
// mapping ever collapsed to a constant 0 this flips and the test fails.
assert_eq!(CommandOutcome::fail("x", "workspace is not served").exit_code(), 1);
assert_ne!(CommandOutcome::fail("x", "blocked").exit_code(), 0);
}
#[test]
fn sannr_exit_code_is_strict_empty_is_red() {
// Genuinely sane green ⇒ 0.
assert_eq!(CommandOutcome::ok("x", json!([1]), "one").sannr_exit_code(), 0);
// Falsely-green (ok=true but empty data) ⇒ non-zero under the strict gate,
// even though the lenient exit_code() would call it 0.
let empty_but_ok = CommandOutcome::ok("x", json!([]), "no rows");
assert_eq!(empty_but_ok.exit_code(), 0);
assert_eq!(empty_but_ok.sannr_exit_code(), 1);
// An error atom leaked into a "successful" render ⇒ strict non-zero.
assert_eq!(
CommandOutcome::ok("x", json!([1]), "RPC: status: unavailable").sannr_exit_code(),
1
);
}
#[test]
fn finish_returns_the_exit_code() {
// finish() renders (to stdout, not asserted here) and returns exit_code().
assert_eq!(CommandOutcome::ok("x", json!([1]), "ok").finish(true), 0);
assert_eq!(CommandOutcome::fail("x", "red").finish(false), 1);
}
}