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

nordisk/nornir

tests/cli_parity.rs

//! CLI-parity smoke tests for the dependency-graph (`mimir`) and viz-diagram
//! (`diagram`) subcommands added to bring the `nornir` CLI to parity with the
//! server's `Mimir`/`Viz` RPCs and the egui viz's `diagram.rs` renderers.
//!
//! These drive the real `nornir` binary's argument parser (`--help`), so a
//! regression that drops a subcommand or mis-wires clap fails here without
//! needing a warehouse fixture. `clap` also debug-asserts the whole command
//! tree while building `--help`, catching duplicate/ambiguous args.

use std::process::Command;

fn nornir() -> Command {
    Command::new(env!("CARGO_BIN_EXE_nornir"))
}

fn stdout_of(args: &[&str]) -> String {
    let out = nornir().args(args).output().expect("run nornir");
    assert!(
        out.status.success(),
        "`nornir {}` failed: {}",
        args.join(" "),
        String::from_utf8_lossy(&out.stderr)
    );
    String::from_utf8_lossy(&out.stdout).into_owned()
}

/// The mechanical leaf list — `nornir commands --json`'s `CommandOutcome.data`. This
/// is the single source of truth the autopilot (`tests/cli_autopilot.rs`) discovers
/// the whole CLI surface from. The retired hand lists below now derive from it instead
/// of duplicating subcommand names by hand (which drifted whenever a verb was added).
fn leaf_commands() -> Vec<String> {
    let out = stdout_of(&["commands", "--json"]);
    let v: serde_json::Value =
        serde_json::from_str(&out).expect("commands --json is a CommandOutcome");
    v["data"]
        .as_array()
        .expect("CommandOutcome.data is the leaf array")
        .iter()
        .map(|s| s.as_str().expect("each leaf is a string").to_string())
        .collect()
}

// NOTE — the per-command subcommand-name hand lists that used to live here
// (`mimir_is_registered_with_every_op`, `diagram_is_registered_with_every_renderer`,
// `arch_is_registered_with_open_and_friends`) were drift-prone: they restated the clap
// tree by hand, so a renamed/added verb silently diverged from reality. They are now
// SUBSUMED by `tests/cli_autopilot.rs`, which enumerates EVERY leaf from
// `nornir commands --json` and proves each is real (`--help` parses). The single
// mechanical check below replaces all three: it asserts every leaf the autopilot
// enumerates under these subcommand groups actually parses — completeness comes from
// the leaf list, never a hand list. Genuinely-unique parity assertions (specific flags,
// renamed tokens, behavioral output) are kept as their own tests further down.

/// The `mimir`, `diagram`, and `arch` subcommand groups (CLI parity with the server's
/// `Mimir`/`Viz` RPCs and the egui diagram renderers / 🏛 Architecture tab) are each
/// registered with their full set of leaves — derived from the mechanical leaf list,
/// not a hand-maintained name list. Every enumerated leaf must also parse.
#[test]
fn parity_subcommand_groups_are_fully_registered_from_leaf_list() {
    let leaves = leaf_commands();
    // The floor per group. `arch` has four leaves since Mímir v2: `arch generate`
    // went with the table it filled (the board is a view of the code graph now),
    // which leaves `svg`, `trace`, `history` and `open`.
    for (group, floor) in [("mimir", 5), ("diagram", 5), ("arch", 4)] {
        let group_leaves: Vec<&String> = leaves
            .iter()
            .filter(|l| l.starts_with(&format!("{group} ")))
            .collect();
        // The group exists and recursed into real leaves (no collapsed-to-root regression).
        assert!(
            group_leaves.len() >= floor,
            "`{group}` should expose its full op set as leaves, got: {group_leaves:?}"
        );
        // Each enumerated leaf actually wires up (`--help` parses past required args).
        for leaf in group_leaves {
            let mut args: Vec<&str> = leaf.split(' ').collect();
            args.push("--help");
            let st = nornir().args(&args).output().expect("run leaf --help");
            assert!(
                st.status.success(),
                "enumerated `{leaf}` does not parse: {}",
                String::from_utf8_lossy(&st.stderr)
            );
        }
    }
}

#[test]
fn arch_open_accepts_symbol_and_flags() {
    // Parse-only: `--help` on the leaf op asserts the positional + flags exist.
    let help = stdout_of(&["arch", "open", "--help"]);
    assert!(help.contains("--open"), "arch open missing --open:\n{help}");
    assert!(help.contains("--all"), "arch open missing --all:\n{help}");
    assert!(
        help.contains("symbol") || help.contains("SYMBOL"),
        "arch open missing symbol arg:\n{help}"
    );
}

#[test]
fn top_level_lists_new_commands() {
    let help = stdout_of(&["--help"]);
    assert!(
        help.contains("mimir"),
        "top-level help missing `mimir`:\n{help}"
    );
    assert!(
        help.contains("diagram"),
        "top-level help missing `diagram`:\n{help}"
    );
}

#[test]
fn mimir_deps_of_accepts_transitive_flag() {
    // Parse-only: `--help` on the leaf op asserts the flag exists.
    let help = stdout_of(&["mimir", "deps-of", "--help"]);
    assert!(
        help.contains("--transitive"),
        "deps-of missing --transitive:\n{help}"
    );
}

#[test]
fn server_and_token_are_global_thin_client_flags() {
    // Advertised at the top level...
    let top = stdout_of(&["--help"]);
    assert!(
        top.contains("--server"),
        "top-level --help must advertise --server:\n{top}"
    );
    assert!(
        top.contains("--token"),
        "top-level --help must advertise --token:\n{top}"
    );
    // ...and reachable on a subcommand (global), so `nornir test run … --server …`
    // routes that command to the server (the thin-client UX).
    let sub = stdout_of(&["test", "run", "--help"]);
    assert!(
        sub.contains("--server"),
        "`test run --help` must show the global --server:\n{sub}"
    );
    assert!(
        sub.contains("--token"),
        "`test run --help` must show the global --token:\n{sub}"
    );
}

// `mirror_to_holger_exposes_renamed_holger_token` lived here. The verb it drove
// (`nornir release mirror-to-holger`) was DELETED by the release-orchestration
// move to `dwarves release` (move=delete, 2026-07-18 — see
// .nornir/cli-command-map.md). clap exits 2 on the unknown subcommand, so the
// test asserted on a hard failure. The `--holger-token` / global-`--token`
// collision property it guarded belongs to the dwarves CLI now.

#[test]
fn workspace_populate_verb_is_registered() {
    // The `populate` verb (force fetch + republish) must be on `workspace`.
    let help = stdout_of(&["workspace", "--help"]);
    assert!(
        help.contains("populate"),
        "`workspace --help` must list `populate`:\n{help}"
    );
    // And it parses with a name (clap debug-asserts the leaf command tree).
    let leaf = stdout_of(&["workspace", "populate", "--help"]);
    assert!(
        leaf.contains("name") || leaf.contains("NAME"),
        "`workspace populate` takes a name:\n{leaf}"
    );
}

/// `nornir install` is the bespoke nornir-server lifecycle — exactly the three
/// verbs add | update | remove, and NOT the generic component verbs (those moved
/// to `airgap component`). clap debug-asserts the whole tree while rendering help.
#[test]
fn install_exposes_server_lifecycle_verbs() {
    let help = stdout_of(&["install", "--help"]);
    for sub in ["add", "update", "remove"] {
        assert!(
            help.contains(sub),
            "`install --help` missing subcommand `{sub}`:\n{help}"
        );
    }
    // The generic component verbs are NOT under `install` anymore.
    for moved in ["systemd", "uninstall", "upgrade", "status"] {
        assert!(
            !help.contains(&format!("  {moved} ")) && !help.contains(&format!("  {moved}\n")),
            "`install --help` should NOT list moved verb `{moved}`:\n{help}"
        );
    }
}

/// INSTALL-3/4/5 — `airgap component status` lists the registry with the exact
/// spec facts the CLI resolves from `nornir_airgap::product::registry()`.
/// Inject-assert: real command → real asserted output (not just "runs").
#[test]
fn component_status_lists_registry_with_real_facts() {
    let out = stdout_of(&["airgap", "component", "status"]);
    // Every registered component appears with its user/ports.
    assert!(
        out.contains("holger") && out.contains("_holger") && out.contains("7070"),
        "{out}"
    );
    assert!(
        out.contains("tunnr") && out.contains("_tunnr") && out.contains("8443"),
        "{out}"
    );
    assert!(
        out.contains("nornir-server") && out.contains("7878"),
        "{out}"
    );
}

/// INSTALL-4 — `airgap component add holger --dry-run` renders the EXACT holger
/// unit the installer would write (the "render unit → would-install" demo), with
/// no root and no side effects. Asserts the load-bearing lines, not just non-empty.
#[test]
fn component_add_holger_dry_run_renders_exact_unit() {
    let out = stdout_of(&[
        "airgap",
        "component",
        "add",
        "holger",
        "--bundle",
        "/opt/bundles/lake.tar.zst",
        "--software-dir",
        "/opt/lake",
        "--data-dir",
        "/var/lib",
        "--dry-run",
    ]);
    assert!(out.contains("Description=holger service"), "{out}");
    assert!(out.contains("User=_holger"), "{out}");
    assert!(
        out.contains(
            "ExecStart=/opt/lake/current/holger/holger serve --config /etc/holger/holger.toml"
        ),
        "{out}"
    );
    assert!(
        out.contains("After=network-online.target nornir-server.service"),
        "{out}"
    );
    assert!(out.contains("# ports : [7070]"), "{out}");
}

/// INSTALL-5 — `airgap component upgrade --delta` honestly notes the znippy delta
/// seam is not built in and falls back to a full re-install (no silent pretend).
#[test]
fn component_upgrade_delta_falls_back_with_note() {
    let out = stdout_of(&[
        "airgap",
        "component",
        "upgrade",
        "tunnr",
        "--bundle",
        "/opt/bundles/lake.tar.zst",
        "--delta",
        "--dry-run",
    ]);
    assert!(out.contains("--delta requested"), "{out}");
    assert!(out.contains("falling back to a full re-install"), "{out}");
    // Still renders the tunnr appliance unit it would install.
    assert!(
        out.contains("ExecStart=/opt/lake/current/tunnr/tunnr run --appliance"),
        "{out}"
    );
}

/// Unknown component → a clear error listing the registered names (no panic).
#[test]
fn component_add_unknown_component_errors_with_registry() {
    let out = nornir()
        .args([
            "airgap",
            "component",
            "add",
            "bogus",
            "--bundle",
            "/x",
            "--dry-run",
        ])
        .output()
        .expect("run nornir");
    assert!(!out.status.success(), "unknown component must fail");
    let err = String::from_utf8_lossy(&out.stderr);
    assert!(err.contains("unknown component `bogus`"), "{err}");
    assert!(err.contains("holger") && err.contains("tunnr"), "{err}");
}

#[test]
fn workspace_is_a_global_flag_on_subcommands() {
    // Global `--workspace` reaches subcommands (incl. ones that used to own a
    // local `--workspace`), so a thin client can target a workspace by name.
    for cmd in [
        &["test", "run", "--help"][..],
        // `release run` was here — deleted by the dwarves release-orchestration
        // move; `release events` is the surviving `release` leaf to prove the
        // global flag still reaches this subcommand tree.
        &["release", "events", "--help"][..],
        &["diagram", "timeline", "--help"][..],
    ] {
        let help = stdout_of(cmd);
        assert!(
            help.contains("--workspace"),
            "`{}` must show the global --workspace:\n{help}",
            cmd.join(" ")
        );
    }
}