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