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

nordisk/monetize

xtask/tests/detached_locks.rs

//! **monetize has seven locks: the root's and six detached workspaces, each with a reason
//! (A28 M1, TASKS-4-v2 T14; the fuzz crate since MON-OSSF, 2026-10-31).**
//!
//! Until T14 there were ten. `crates/monetize-front`, `crates/monetize-shop-pairing` and
//! `crates/monetize-cli` were detached because they path-depend on the sibling Skidbladnir
//! (`nordisk-pairing`, `plugin-door-wire`), "which monetize's workspace resolves nothing of".
//! That reason is gone: the root's own members (monetize-estate-side, monetize-mail,
//! lake-product) path-depend on the same `nordisk-pairing`, and on edda (`guest-host`,
//! `nornir-auth-sshsig`), so a clone of monetize already needs both siblings. `crates/fast-edge`
//! (P1's TLS door for the fast test) had no reason at all. The four are root members now.
//!
//! The six that stay are listed in [`STAY_DETACHED`] with why. A seventh detached workspace,
//! or a folded crate that grows its own `[workspace]` again, fails here by name.
//!
//! The detached locks also follow the sibling checkouts. The Build Thing builds the box with
//! `--locked`, so a lock that still names the version a sibling carried BEFORE a release bump
//! refuses the build: MEASURED 2026-09-29 on release-3 (Skidbladnir nordisk-pairing 0.5.9, the
//! locks still 0.5.8), `cargo xtask install --where kvm` of the monetize-admin box failed
//! "cannot update the lock file … because --locked was passed". That check needs the siblings
//! beside this checkout (the constellation's layout, the one the Build Thing bind-mounts);
//! without them it says so and passes, since there is nothing to follow.

use std::path::{Path, PathBuf};
use std::process::Command;

/// The workspaces that keep their own `[workspace]` and `Cargo.lock`, and why (beside the root).
pub const STAY_DETACHED: &[(&str, &str)] = &[
    (
        "xtask",
        "nornir-xtask-support reaches rustls on aws-lc-rs (nornir-git: gix, reqwest). As a member it unified with the server's ring in \
         `cargo test --workspace`, and rustls refused to pick a provider (monetize-ui-server tests/pinned_control, 2026-10-23)",
    ),
    (
        "crates/monetize-fast",
        "the same dependency as xtask: its kit build runs cargo in the toolchain container through nornir-xtask-support's \
         `ci::Container`, so aws-lc-rs is in its lock; as a member it would bring xtask's ring/aws-lc-rs clash back into the root",
    ),
    (
        "crates/monetize-build-thing",
        "nornir-build-thing's closure (plugin-host, aws-lc-rs) must not grow the root lock for a crate that only DESCRIBES the build; \
         it is a cdylib by contract with dwarves (`<repo>-build-thing`)",
    ),
    (
        "crates/mock-upcloud/kvm-guest",
        "a different target: the real KVM behind mock-upcloud, on a sibling draupnir by path (agent/faithful-guest). As a member, \
         every clone of monetize would need that draupnir checkout and the root lock a draupnir row",
    ),
    (
        "fuzz",
        "the cargo-fuzz crate (MON-OSSF): its build takes SanitizerCoverage flags the product never does, and its \
         [[bin]]s are libFuzzer targets; as a member every `cargo test --workspace` would link libfuzzer-sys. Its lock starts \
         from the root's, so the fuzzed code is the code monetize ships",
    ),
    (
        "crates/monetize-admin",
        "the ONE box's musl build: the Build Thing builds its row static musl by `manifest_dir` (ADMIN_MANIFEST_DIR) for the \
         release, and TASKS-2's DO-NOT-TOUCH row on monetize-admin/tests has not been lifted",
    ),
];

/// The crates T14 folded into the root workspace.
pub const FOLDED: &[&str] = &[
    "crates/fast-edge",
    "crates/monetize-cli",
    "crates/monetize-front",
    "crates/monetize-shop-pairing",
];

fn repo() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .expect("the xtask sits in the repository")
        .to_path_buf()
}

fn rel(root: &Path, p: &Path) -> String {
    p.strip_prefix(root)
        .unwrap_or(p)
        .to_string_lossy()
        .into_owned()
}

#[test]
fn monetize_has_seven_locks_the_root_and_the_six_detached() {
    let root = repo();
    let mut on_disk: Vec<String> = nornir_xtask_support::lockcheck::locks_of(&root)
        .iter()
        .map(|l| rel(&root, l))
        .collect();
    on_disk.sort();
    let mut want: Vec<String> = std::iter::once("Cargo.lock".to_string())
        .chain(STAY_DETACHED.iter().map(|(d, _)| format!("{d}/Cargo.lock")))
        .collect();
    want.sort();
    assert_eq!(on_disk, want, "monetize's locks are the root's and the six detached workspaces' (A28 M1: 10 -> 6; + fuzz, MON-OSSF)");
    for (dir, why) in STAY_DETACHED {
        let m = std::fs::read_to_string(root.join(dir).join("Cargo.toml"))
            .unwrap_or_else(|e| panic!("{dir}/Cargo.toml: {e}"));
        assert!(
            m.lines().any(|l| l.trim() == "[workspace]"),
            "{dir} stays detached ({why}) but has no [workspace] table"
        );
    }
}

/// The root's `[workspace] members` entries (quoted strings up to the closing `]`).
fn root_members(root: &Path) -> Vec<String> {
    let m = std::fs::read_to_string(root.join("Cargo.toml")).expect("the root manifest");
    let start = m
        .find("members = [")
        .expect("the root workspace lists its members");
    let body = &m[start + "members = [".len()..];
    let mut out = Vec::new();
    for line in body.lines() {
        let code = line.split('#').next().unwrap_or("").trim();
        if code.starts_with(']') {
            break;
        }
        if let Some(name) = code.strip_prefix('"').and_then(|s| s.split('"').next()) {
            out.push(name.to_string());
        }
    }
    out
}

#[test]
fn the_folded_crates_are_root_members_with_no_workspace_or_lock_of_their_own() {
    let root = repo();
    let members = root_members(&root);
    for dir in FOLDED {
        assert!(members.iter().any(|m| m == dir), "{dir} is folded into the root workspace (T14) but is not one of its members: {members:?}");
        let m = std::fs::read_to_string(root.join(dir).join("Cargo.toml"))
            .unwrap_or_else(|e| panic!("{dir}/Cargo.toml: {e}"));
        assert!(
            !m.lines().any(|l| l.trim() == "[workspace]"),
            "{dir} is a root member; its own [workspace] table must go"
        );
        assert!(
            !root.join(dir).join("Cargo.lock").exists(),
            "{dir} is a root member; its own Cargo.lock must go"
        );
    }
    for (dir, _) in STAY_DETACHED {
        assert!(
            !members.iter().any(|m| m == dir),
            "{dir} is detached and cannot also be a root member"
        );
    }
}

#[test]
fn the_ci_plan_has_a_step_for_each_detached_workspace_and_none_for_a_folded_one() {
    // F4: one step per detached workspace (the root's `cargo test --workspace` reaches none of
    // them); a folded crate is in that root step, so its own step goes.
    let mut planned: Vec<String> = xtask::ci::DETACHED
        .iter()
        .map(|(_, d)| format!("crates/{d}"))
        .collect();
    planned.push("crates/mock-upcloud/kvm-guest".into());
    planned.push("fuzz".into());
    planned.sort();
    let mut want: Vec<String> = STAY_DETACHED
        .iter()
        .map(|(d, _)| d.to_string())
        .filter(|d| d != "xtask")
        .collect();
    want.sort();
    assert_eq!(planned, want);
}

fn resolves_locked(dir: &str) {
    let root = repo();
    let Some(parent) = root.parent() else { return };
    if !parent
        .join("Skidbladnir/crates/installer/nordisk-pairing/Cargo.toml")
        .is_file()
        || !parent.join("edda/crates/estate-front/Cargo.toml").is_file()
    {
        eprintln!(
            "{dir}: no Skidbladnir/edda sibling beside {} — nothing to follow here",
            root.display()
        );
        return;
    }
    let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
    let out = Command::new(cargo)
        .args([
            "metadata",
            "--locked",
            "--offline",
            "--format-version",
            "1",
            "--manifest-path",
        ])
        .arg(root.join(dir).join("Cargo.toml"))
        .output()
        .expect("cargo metadata runs");
    let err = String::from_utf8_lossy(&out.stderr);
    assert!(
        !err.contains("cannot update the lock file"),
        "{}/Cargo.lock does not follow the sibling checkouts — the Build Thing's `--locked` build refuses it:\n{err}",
        if dir.is_empty() { "." } else { dir }
    );
}

#[test]
fn the_root_lock_follows_the_siblings() {
    // monetize-front, monetize-shop-pairing and monetize-cli are in it since T14.
    resolves_locked("");
}

#[test]
fn the_monetize_admin_lock_follows_the_siblings() {
    resolves_locked("crates/monetize-admin");
}

#[test]
fn the_monetize_fast_lock_follows_the_siblings() {
    resolves_locked("crates/monetize-fast");
}