Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

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);
    }
}