nornir 0.5.0

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
//! 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)
    }
}

/// 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 {
    const MARKERS: &[&str] =
        &["is not served", "Failed to load", "unavailable", "RPC: status:", "panicked"];
    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());
    }
}