tests/cli_autopilot.rs
//! AUT9 — the autopilot CLI check (the CLI twin of the viz atom-walk).
//!
//! `nornir commands` self-describes the CLI as a uniform [`CommandOutcome`] whose
//! `data` is the recursive leaf-command list (the autopilot's surface source — the
//! CLI twin of MCP `tools/list`). From that one source we assert:
//! 1. the outcome is SANE (the `CommandOutcome` sane-data contract), and
//! 2. EVERY enumerated leaf is REAL — it parses (`--help` short-circuits required
//! args), so a dropped/mis-wired command fails here mechanically, no hand list.
//!
//! This is the reachability floor; per-command sane-DATA checks land as each handler
//! migrates to `CommandOutcome` (see `.nornir/cli-command-contract.md`).
use nornir::cli_outcome::CommandOutcome;
use std::process::Command;
use std::sync::OnceLock;
/// A dedicated, empty HOME for the CLI invocations so they are hermetic: they read
/// their own fresh `~/.nornir` (empty index / no funnel) instead of the caller's
/// real one. Without this, `index stats` opens the *real* `~/.nornir` index redb —
/// which fails with "Database already open" whenever a `nornir-server` (or any other
/// nornir process) is running against that same home. The read-only commands we run
/// all emit a well-formed `CommandOutcome` against an empty home (`index stats` ->
/// ok:true empty counts, `funnel show` -> the ok:false empty=RED path), so an
/// isolated home preserves exactly what these tests assert while removing the shared
/// -lock flake.
fn test_home() -> &'static std::path::Path {
static HOME: OnceLock<tempfile::TempDir> = OnceLock::new();
HOME.get_or_init(|| tempfile::tempdir().expect("create isolated test HOME"))
.path()
}
fn nornir() -> Command {
let mut c = Command::new(env!("CARGO_BIN_EXE_nornir"));
c.env("HOME", test_home());
c
}
fn commands_outcome() -> CommandOutcome {
let out = nornir()
.args(["commands", "--json"])
.output()
.expect("run `nornir commands --json`");
assert!(
out.status.success(),
"`nornir commands --json` failed: {}",
String::from_utf8_lossy(&out.stderr)
);
serde_json::from_slice(&out.stdout).unwrap_or_else(|e| {
panic!(
"stdout is not a CommandOutcome: {e}\n{}",
String::from_utf8_lossy(&out.stdout)
)
})
}
#[test]
fn commands_self_description_is_a_sane_command_outcome() {
let o = commands_outcome();
assert_eq!(o.command, "commands");
assert!(
o.is_sannr(),
"the `commands` outcome must pass the sannr check (truth-verdict): {o:?}"
);
let leaves: Vec<String> = o
.data
.as_array()
.expect("data is the leaf-command array")
.iter()
.map(|v| v.as_str().expect("each leaf is a string").to_string())
.collect();
// recursion happened: far more leaves than the ~25 top-level roots.
assert!(
leaves.len() > 25,
"expected recursive leaves (>25), got {}",
leaves.len()
);
// confirmed nested leaves are present (self + parity with cli_parity.rs).
for leaf in ["commands", "mimir deps-of", "diagram timeline"] {
assert!(
leaves.contains(&leaf.to_string()),
"missing leaf `{leaf}` in {leaves:?}"
);
}
}
#[test]
fn every_enumerated_command_is_real() {
let leaves: Vec<String> = commands_outcome()
.data
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect();
// Each leaf must parse — `--help` exits 0 even past required args. A command the
// autopilot enumerates but that no longer wires up fails here (no silent rot).
let mut broken = Vec::new();
for leaf in &leaves {
let mut args: Vec<&str> = leaf.split(' ').collect();
args.push("--help");
let st = nornir().args(&args).output().expect("run leaf --help");
if !st.status.success() {
broken.push(format!(
"{leaf} ({})",
String::from_utf8_lossy(&st.stderr).trim()
));
}
}
assert!(
broken.is_empty(),
"enumerated commands that don't parse: {broken:#?}"
);
}
/// AUT9 step 4 — the generic sannr smoke test.
///
/// Beyond "every leaf parses", this RUNS a curated set of read-only commands with
/// `--json` and asserts each emits a WELL-FORMED [`CommandOutcome`] (parses, with
/// `command`/`ok`/`data` present and `command` == the leaf we asked for). This is
/// the CLI twin of the viz `state_json` check: it proves migrated handlers actually
/// route through `CommandOutcome::print` and that the empty=RED path renders as a
/// proper outcome (`ok:false` + a clean human reason) rather than a crash or garbage.
///
/// ## SAFETY LIST (read this before adding to it)
/// `SAFE_READ_COMMANDS` is an explicit *safety* allow list — it is NOT a completeness
/// list. Completeness comes from the autopilot gate + `every_enumerated_command_is_real`
/// above, which already cover EVERY leaf. This list exists only to pick the subset that
/// is safe to actually execute with no arguments and no populated warehouse:
/// * READ-only, no required positional args, supports the global `--json`, and
/// * emits a `CommandOutcome` even when the warehouse/funnel is empty (empty ⇒ a
/// well-formed `ok:false` outcome, NOT a panic and NOT free-form text).
/// We DELIBERATELY never run side-effecting / arg-hungry commands here — `serve`, `viz`,
/// anything `*run*`, `publish`, `stage`, `pack`, `install`, `key`, `register`, `fetch`,
/// `populate`, `update-db`, etc. Those mutate state, hit the network, or block; their
/// reachability is still proven by the `--help` parse check, not by execution.
const SAFE_READ_COMMANDS: &[&str] = &[
// The reference AUT9 migration — self-describing, no config, always has real data.
"commands",
// Reads the forbidden-path table from policy alone; no warehouse fixture needed.
"guard status",
// Per-corpus index counts; well-formed CommandOutcome whether or not the index is built.
"index stats",
// Funnel DAG read. With no plan it returns the empty=RED path: ok:false + a clean
// human reason ("funnel is empty …"), exactly the well-formed empty outcome we assert.
"funnel show",
];
#[test]
fn safe_read_commands_emit_conformant_outcomes() {
for &leaf in SAFE_READ_COMMANDS {
let mut args: Vec<&str> = leaf.split(' ').collect();
args.push("--json");
let out = nornir().args(&args).output().expect("run safe leaf --json");
assert!(
out.status.success(),
"`nornir {leaf} --json` exited non-zero: {}",
String::from_utf8_lossy(&out.stderr)
);
// stdout must be a CommandOutcome (the CLI's state_json) — parses with the
// contract fields present. A handler that printed ad-hoc text fails here.
let o: CommandOutcome = serde_json::from_slice(&out.stdout).unwrap_or_else(|e| {
panic!(
"`nornir {leaf} --json` stdout is not a CommandOutcome: {e}\n{}",
String::from_utf8_lossy(&out.stdout)
)
});
// The outcome self-identifies as the leaf we asked for (no cross-wired arm).
assert_eq!(
o.command, leaf,
"outcome.command mismatch for `{leaf}`: {o:?}"
);
// `data` is always present on the wire (serde): null is a legitimate empty value.
let parsed: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
for key in ["command", "ok", "data"] {
assert!(
parsed.get(key).is_some(),
"`{leaf}` outcome missing `{key}`: {parsed}"
);
}
if leaf == "commands" {
// The reference migration must pass the FULL sannr check (truth-verdict):
// ok + non-empty data + no error atom. This is the one we run as a real
// autopilot assertion, not merely "well-formed".
assert!(o.is_sannr(), "`commands` outcome must be sannr: {o:?}");
} else {
// Data commands have no fixture here, so we assert WELL-FORMEDNESS, not
// is_sannr(): the contract holds (parses + command set) and, crucially, the
// empty/no-data path renders as a proper outcome — a failed outcome carries a
// clean human reason (no panic atom leaked), an ok outcome carries real data.
assert!(
!o.command.is_empty(),
"`{leaf}` outcome has empty command: {o:?}"
);
if !o.ok {
assert!(
!o.human.trim().is_empty(),
"`{leaf}` failed outcome must explain itself (empty=RED with a reason): {o:?}"
);
} else {
assert!(
!matches!(o.data, serde_json::Value::Null),
"`{leaf}` claims ok but carries null data: {o:?}"
);
}
}
}
}