znippy-build-thing/tests/siblings.rs
//! **Every sibling checkout a build of znippy reaches is declared in
//! `[build.toolchain] siblings`** — or ⚒ Build refuses every cell before it compiles.
//!
//! MEASURED 2026-10-16 on loki (the build-thing sweep): both cells refused, "path
//! dependencies escape this repo into sibling repo(s) the declaration does not list
//! … Add `siblings = ["openzl-sys-rs"]`" — znippy-common's default `openzl` feature
//! path-deps `../../openzl-sys-rs`. The recipe's own comment already said the list is
//! TRANSITIVE (xtask → nornir's root → …) and that the first-order scan cannot see
//! it; this walks it: the door's reader for this workspace, then the path deps cargo
//! loads for every crate those land in.
use std::collections::BTreeSet;
use std::path::{Component, Path, PathBuf};
use nornir_build_thing::cargo_container::workspace_escaping_path_deps;
use nornir_build_thing::plugin::BuildThing;
use znippy_build_thing::emit::repo_root;
use znippy_build_thing::ZnippyBuildThing;
/// `a/b/../c` → `a/c`, lexically (the dep's directory may not exist on this box).
fn normalize(p: &Path) -> PathBuf {
let mut out = PathBuf::new();
for c in p.components() {
match c {
Component::ParentDir => {
out.pop();
}
Component::CurDir => {}
other => out.push(other),
}
}
out
}
/// The `[workspace.dependencies]` path of `dep` for the crate at `dir`: the nearest
/// ancestor manifest that declares a `[workspace]`.
fn workspace_dep_path(dir: &Path, dep: &str) -> Option<PathBuf> {
let mut cur = Some(dir);
while let Some(d) = cur {
if let Some(doc) = std::fs::read_to_string(d.join("Cargo.toml"))
.ok()
.and_then(|t| t.parse::<toml::Table>().ok())
{
if let Some(ws) = doc.get("workspace") {
let rel = ws.get("dependencies")?.get(dep)?.get("path")?.as_str()?;
return Some(normalize(&d.join(rel)));
}
}
cur = d.parent();
}
None
}
/// The path dependencies cargo LOADS for a non-member crate at `dir`: its normal and
/// build deps (plain or per-target), never its dev-deps (cargo does not resolve a
/// dependency's dev-dependencies) and never its optional ones, `workspace = true`
/// entries resolved through that crate's own workspace root.
fn loaded_path_deps(dir: &Path) -> Vec<PathBuf> {
let Some(doc) = std::fs::read_to_string(dir.join("Cargo.toml"))
.ok()
.and_then(|t| t.parse::<toml::Table>().ok())
else {
return Vec::new();
};
let mut tables: Vec<&toml::Value> = Vec::new();
for key in ["dependencies", "build-dependencies"] {
tables.extend(doc.get(key));
}
if let Some(t) = doc.get("target").and_then(toml::Value::as_table) {
for v in t.values() {
for key in ["dependencies", "build-dependencies"] {
tables.extend(v.get(key));
}
}
}
let on_by_default = default_enabled(&doc);
let mut out = Vec::new();
for t in tables.into_iter().filter_map(toml::Value::as_table) {
for (dep, spec) in t {
// An OPTIONAL dep of a non-member is resolved only when a feature turns it
// on: counted when the crate's own `default` turns it on (znippy-common →
// openzl-sys-rs), skipped otherwise (draupnir's `tunnr-vm`: not in the
// Cargo.lock, so no tunnr checkout is needed).
if spec.get("optional").and_then(toml::Value::as_bool) == Some(true)
&& !on_by_default.contains(dep.as_str())
{
continue;
}
if let Some(rel) = spec.get("path").and_then(toml::Value::as_str) {
out.push(normalize(&dir.join(rel)));
} else if spec.get("workspace").and_then(toml::Value::as_bool) == Some(true) {
let name = spec.get("package").and_then(toml::Value::as_str).unwrap_or(dep);
out.extend(workspace_dep_path(dir, name));
}
}
}
out
}
/// Every feature and dep name the crate's `default` feature turns on, following its
/// own `[features]` table (`default = ["openzl"]`, `openzl = ["dep:openzl-sys-rs"]`).
fn default_enabled(doc: &toml::Table) -> BTreeSet<String> {
let features = doc.get("features").and_then(toml::Value::as_table);
let mut on = BTreeSet::new();
let mut queue = vec!["default".to_string()];
while let Some(f) = queue.pop() {
if !on.insert(f.clone()) {
continue;
}
let Some(list) = features.and_then(|t| t.get(&f)).and_then(toml::Value::as_array) else {
continue;
};
for item in list.iter().filter_map(toml::Value::as_str) {
// `dep:x` and `x?/feat` / `x/feat` all name the dependency `x`.
let name = item.trim_start_matches("dep:");
let name = name.split('/').next().unwrap_or(name).trim_end_matches('?');
queue.push(name.to_string());
}
}
on
}
/// The sibling checkouts a build of this workspace reaches, by name: every path dep
/// that leaves the repo (the door's own reader, members' dev-deps included), and then
/// TRANSITIVELY the path deps cargo loads for each crate those land in. cargo reads
/// every one of those manifests, so the container must carry every one of those
/// checkouts (2026-10-16: znippy-common → `../openzl-sys-rs`). A sibling not checked out on this box ends its branch of
/// the walk; the anchor in the test keeps it from going vacuous.
fn reached_siblings(root: &Path) -> BTreeSet<String> {
let parent = root.parent().expect("the repo has a parent").to_path_buf();
let mut names = BTreeSet::new();
let mut seen: BTreeSet<PathBuf> = BTreeSet::new();
let mut queue: Vec<PathBuf> =
workspace_escaping_path_deps(root).into_iter().map(|(_, _, r)| r).collect();
// The ROOT's `[patch.<registry>]` paths are loaded too when this repo is the build
// root — a patch is not a dependency table, but cargo reads every one of them.
if let Some(patch) = std::fs::read_to_string(root.join("Cargo.toml"))
.ok()
.and_then(|t| t.parse::<toml::Table>().ok())
.and_then(|d| d.get("patch").cloned())
{
for reg in patch.as_table().into_iter().flat_map(|t| t.values()) {
for spec in reg.as_table().into_iter().flat_map(|t| t.values()) {
if let Some(rel) = spec.get("path").and_then(toml::Value::as_str) {
queue.push(normalize(&root.join(rel)));
}
}
}
}
while let Some(dir) = queue.pop() {
if dir.starts_with(root) || !seen.insert(dir.clone()) {
continue;
}
if let Some(c) = dir.strip_prefix(&parent).ok().and_then(|r| r.components().next()) {
names.insert(c.as_os_str().to_string_lossy().into_owned());
}
queue.extend(loaded_path_deps(&dir));
}
names
}
#[test]
fn every_reached_sibling_is_declared() {
let root = repo_root();
let reached = reached_siblings(&root);
// NON-VACUOUS: znippy path-deps edda and znippy-zoomies directly.
assert!(reached.contains("edda") && reached.contains("znippy-zoomies"), "the walk found too little: {reached:?}");
let tc = ZnippyBuildThing.variants().toolchain.expect("znippy declares [build.toolchain]");
let declared: BTreeSet<String> = tc.siblings.iter().cloned().collect();
let missing: Vec<&String> = reached.difference(&declared).collect();
assert!(
missing.is_empty(),
"path deps reach sibling repo(s) [build.toolchain] `siblings` does not list — the \
build container would not have them and ⚒ refuses every cell: {missing:?}"
);
}