nordisk/edda
crates/nornir-build-thing/src/variant.rs
//! Build-variant descriptor + validator + `cargo build` materializer.
//!
//! # The model
//!
//! * [`BuildVariant`] — ONE concrete build point: an optional workspace
//! `-p <package>`, an optional `--bin <bin>`, a set of `--features`, a
//! `--no-default-features` toggle, a [`Profile`] (`--profile`), an optional
//! `--target <triple>`, and an optional `--cfg` (passed via `RUSTFLAGS`).
//! * [`BuildVariantSet`] — the SPACE of choices a repo offers: the named
//! variants plus the rules that make a variant *valid* — the feature space
//! the repo `offers`, `exclusive` (mutually-exclusive) feature groups, and
//! per-bin `required` features (Cargo's `required-features`, mirrored so we
//! fail fast with a clear message instead of a raw cargo error).
//!
//! # Declarative source
//!
//! The set is data. The same `[build]` / `[[build.variant]]` schema loads
//! either from the workspace `nornir.toml` (as [`crate::config::Nornir::build`])
//! or from a standalone `.nornir/build-variants.toml` next to the repo
//! ([`BuildVariantSet::discover`]). Worked example: holger's three executables
//! plus its GUI, in `tests/fixtures/holger-build-variants.toml`.
//!
//! # Materialize + run
//!
//! [`BuildVariant::cargo_args`] renders the exact `cargo` argv;
//! [`BuildVariant::build`] validates against the owning set and then runs it.
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{anyhow, bail, Context, Result};
use serde::{Deserialize, Serialize};
/// A cargo build **profile** — `--profile <name>`. `Dev` is the cargo default
/// and emits no flag; `Release` emits `--profile release`; `Custom` carries any
/// user-defined profile name (e.g. a `[profile.dist]` in `Cargo.toml`).
///
/// Deserializes from a bare TOML string (`profile = "release"`).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(from = "String", into = "String")]
pub enum Profile {
/// The default cargo profile — no `--profile` flag is emitted.
Dev,
/// `--profile release`.
Release,
/// A user-defined profile — `--profile <name>`.
Custom(String),
}
impl Default for Profile {
fn default() -> Self {
Profile::Dev
}
}
impl From<String> for Profile {
fn from(s: String) -> Self {
match s.trim() {
"" | "dev" | "debug" => Profile::Dev,
"release" => Profile::Release,
other => Profile::Custom(other.to_string()),
}
}
}
impl Profile {
/// The canonical label — the exact word cargo's `--profile` takes and the
/// directory name under `target/` the artifact lands in. Borrowed rather than
/// owned so a hot key-building loop ([`BuildVariantSet::check_bins`]) does not
/// allocate a `String` per variant just to name a profile.
pub fn label(&self) -> &str {
match self {
Profile::Dev => "dev",
Profile::Release => "release",
Profile::Custom(s) => s.as_str(),
}
}
}
impl From<Profile> for String {
fn from(p: Profile) -> Self {
match p {
Profile::Dev => "dev".to_string(),
Profile::Release => "release".to_string(),
Profile::Custom(s) => s,
}
}
}
/// The **fat/thin service shape** of a build variant — the suite's second build
/// axis, orthogonal to `(package × bin × features × profile × target)`.
///
/// A nordisk suite member (holger · korp · dwarves · nornir · modgunn · skade)
/// ships as either a self-contained *default service* with its siblings compiled
/// IN, or a *standalone/thin component* that reaches remote owners over gRPC — or
/// a configurable mix. This enum names that choice so a variant can carry
/// [`BuildVariant::embeds`] (siblings compiled-in) and [`BuildVariant::reaches`]
/// (siblings reached over gRPC).
///
/// Deserializes from a bare TOML string. The canonical key is **`linkage`**
/// (`linkage = "fat"`); `shape = "fat"` is accepted as the historical spelling of
/// the SAME field — there is no second field to keep in sync.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(from = "String", into = "String")]
pub enum ServiceShape {
/// The **default service (fat)** — the member plus the sibling functionality
/// it needs compiled IN and running embedded. Self-contained: shut every
/// other server down and it still runs. Carries a non-empty `embeds`.
FatService,
/// The **standalone / thin component** — a leaner build that compiles in NO
/// siblings; it links gRPC clients and `reaches` remote owners. Smaller
/// binary; needs the owners reachable. `embeds` MUST be empty.
ThinStandalone,
/// A configurable middle: embed SOME siblings, reach OTHERS over gRPC.
Mixed,
/// **The axis does not apply to this artifact.** A `lib` is linked by whoever
/// consumes it and composes nothing; a browser demo with no wire at all
/// reaches nobody. Saying `linkage = "not-applicable"` is NOT the same as
/// saying nothing: an omitted axis silently reads as the [`FatService`]
/// default, i.e. as a positive claim of self-containment that nobody checked.
/// This value makes "inapplicable" a declaration a reader can tell apart from
/// "undeclared", and [`BuildVariantSet::validate`] holds it to `embeds` and
/// `reaches` both being empty.
NotApplicable,
}
impl Default for ServiceShape {
fn default() -> Self {
ServiceShape::FatService
}
}
impl From<String> for ServiceShape {
fn from(s: String) -> Self {
match s.trim().to_ascii_lowercase().as_str() {
"thin" | "thin-standalone" | "standalone" | "component" => ServiceShape::ThinStandalone,
"mixed" | "mix" | "configurable" => ServiceShape::Mixed,
"not-applicable" | "not_applicable" | "inapplicable" | "n/a" | "na" | "none" => {
ServiceShape::NotApplicable
}
// "fat" | "fat-service" | "service" | "default" | "" → the default.
_ => ServiceShape::FatService,
}
}
}
impl From<ServiceShape> for String {
fn from(s: ServiceShape) -> Self {
s.as_str().to_string()
}
}
impl ServiceShape {
/// Stable label (matches the canonical string form).
pub fn as_str(&self) -> &'static str {
match self {
ServiceShape::FatService => "fat",
ServiceShape::ThinStandalone => "thin",
ServiceShape::Mixed => "mixed",
ServiceShape::NotApplicable => "not-applicable",
}
}
/// True when the fat/thin axis says something about this artifact at all —
/// i.e. everything except [`NotApplicable`](Self::NotApplicable).
pub fn is_applicable(&self) -> bool {
!matches!(self, ServiceShape::NotApplicable)
}
}
/// **The kind table, written ONCE.** Generates the enum, the canonical string,
/// every accepted spelling, and [`ArtifactKind::ALL`] — from one list.
///
/// # Why a macro and not four hand-written lists
///
/// It was four lists, and on 2026-09-06 adding a tenth kind (`Package`, for a pip
/// wheel) showed what that costs. The enum, `as_str`, `From<String>` and the kind
/// tables in [`crate::verbs`] / [`crate::contract`] each named every kind
/// separately, and only the first two were held by the compiler. `From<String>`
/// ends in `_ => Exe`, so a kind missing from it does not fail to compile — a
/// `kind = "package"` row would have read as `exe` and built a cargo binary,
/// silently. `verbs::VERBS` ends in `unwrap_or_default()`, so a kind with no row
/// answers "no verbs" by accident and indistinguishably from a kind that has
/// none. Both of their tests enumerated kinds BY HAND, so both would have stayed
/// green with the new kind absent from everything they check: the hollow-guard
/// shape LAW 2 exists for, twice, in the one place a new kind arrives.
///
/// So the spellings live beside the variant that owns them, and `ALL` is derived
/// rather than typed. A new kind is now a compile error in exactly one place —
/// this list — and the tables that key on a kind are driven off `ALL`, which
/// makes a missing decision a RED TEST instead of a default answer.
///
/// The `default` is the fallback of `From<String>`: an unrecognised spelling (and
/// the empty string) reads as that kind, which is why `Exe`'s own spellings need
/// no entry to work and are listed anyway — a reader should not have to know
/// which arm is the fallback to find out what `"bin"` means.
macro_rules! artifact_kinds {
(
default = $default:ident;
$(
$(#[$meta:meta])*
$variant:ident => $canonical:literal $(| $alias:literal)* ,
)+
) => {
/// The **artifact kind** a build variant produces — the suite's THIRD build
/// axis, ORTHOGONAL to [`ServiceShape`] (fat/thin) and to
/// `(package × bin × features × profile × target)`.
///
/// A build-thing is one `(kind × shape × target)` point:
/// e.g. `Service`×thin×x86_64 (a gRPC-client daemon), `Service`×fat×x86_64 (an
/// embedded server), `Wasm`×—×wasm32 (a web demo), `Exe`×fat (a CLI),
/// `Container`×thin (a deploy image).
///
/// `Exe`/`Lib`/`Wasm`/`Service` are all produced by `cargo build`
/// ([`BuildVariant::cargo_args`]); `Container`/`Vm` delegate to the rich
/// [`ContainerBuild`](crate::container::ContainerBuild) /
/// [`VmBuild`](crate::vm::VmBuild) descriptors. Deserializes from a bare TOML
/// string (`kind = "wasm"`), identical style to [`Profile`] / [`ServiceShape`].
///
/// Every variant, its canonical spelling and its aliases are declared in one
/// place — see [`artifact_kinds`] for why that is a macro.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(from = "String", into = "String")]
pub enum ArtifactKind {
$( $(#[$meta])* $variant, )+
}
impl ArtifactKind {
/// **Every kind, in declaration order** — the list every table keyed by
/// kind is driven off, so a new kind cannot be added to the enum without
/// appearing here (they are the same list) and a table that forgot it
/// fails RED rather than answering a default.
pub const ALL: &'static [ArtifactKind] = &[ $(ArtifactKind::$variant),+ ];
/// Stable label (matches the canonical string form).
pub fn as_str(&self) -> &'static str {
match self { $(ArtifactKind::$variant => $canonical,)+ }
}
/// **Every spelling this kind answers to**, canonical first — the inverse
/// of [`From<String>`], and the reason a recipe author can be told what a
/// refused `kind = "…"` COULD have said.
pub fn spellings(&self) -> &'static [&'static str] {
match self { $(ArtifactKind::$variant => &[$canonical $(, $alias)*],)+ }
}
}
impl From<String> for ArtifactKind {
fn from(s: String) -> Self {
match s.trim().to_ascii_lowercase().as_str() {
$( $canonical $(| $alias)* => ArtifactKind::$variant, )+
// Anything unrecognised, and the empty string, is the default.
_ => ArtifactKind::$default,
}
}
}
};
}
artifact_kinds! {
default = Exe;
/// A native executable (default) — a plain binary/tool.
Exe => "exe" | "bin" | "binary",
/// A cargo library artifact (`--lib`); no `--bin`.
Lib => "lib" | "library" | "rlib" | "cdylib" | "staticlib",
/// A `wasm32-*` module (requires a wasm `target`).
Wasm => "wasm" | "wasm32" | "web",
/// An OCI / appliance image — delegates to a
/// [`ContainerBuild`](crate::container::ContainerBuild).
Container => "container" | "image" | "oci" | "docker",
/// A bootable VM / appliance — delegates to a [`VmBuild`](crate::vm::VmBuild).
Vm => "vm" | "appliance" | "boot",
/// A bootable ISO-9660 CD/DVD image — delegates to an
/// [`IsoBuild`](crate::iso::IsoBuild).
Iso => "iso" | "cdrom" | "bootable-iso",
/// A per-site **installer** ISO — the medium that LAYS A PRODUCT ONTO A
/// DISK, minted from an [`InstallerBuild`](crate::installer::InstallerBuild)
/// variant. A separate kind from [`Iso`](Self::Iso) because the two artifacts
/// answer different questions in a build record: a live ISO *is* the
/// appliance, an installer ISO *produces* one — and until this variant
/// existed the attestation spelled both `"iso"`, so an auditor could not
/// tell a bootable product from the tool that installs it.
///
/// A variant may declare `kind = "installer"` directly; `[[build.installer]]`
/// is permanent sugar for the same thing. (Until 2026-08-15 `validate()`
/// REFUSED the field spelling and pointed at the section — reversed by the
/// [vocabulary ruling](crate#the-vocabulary).)
Installer => "installer" | "installer-iso",
/// A nordisk suite-member daemon: an executable that ALSO wraps as a
/// skidbladnir `ServiceSpec` (dedicated system user + unit). Requires a
/// `member`.
Service => "service" | "daemon" | "unit",
/// A **built Linux kernel** — a `bzImage` + its `.config` + a pin file of
/// sha256s, compiled from a PINNED kernel.org tarball inside a toolchain
/// container under a named hardening profile. Delegates to a
/// [`KernelBuild`](crate::kernel::KernelBuild); `[[build.kernel]]` is the
/// sugar section. A separate kind from [`Vm`](Self::Vm)/[`Iso`](Self::Iso)
/// because those CONSUME a kernel the constellation already has — this is
/// where that kernel comes from, with its provenance attached.
Kernel => "kernel" | "bzimage" | "linux-kernel",
/// A **distribution package** — a produced FILE SET, minted from a
/// [`PackageBuild`](crate::package::PackageBuild); `[[build.package]]` is the
/// sugar section.
///
/// The set, not the file, is the point. facett's pip release is a wheel AND
/// an sdist AND the `SHA256SUMS` a downloader runs `sha256sum -c` against AND
/// a provenance record over the produced bytes — four files that are one
/// artifact, because any three of them without the fourth is not a release
/// anybody can check. Every other kind here produces ONE thing and names it;
/// this is the kind whose output is a set, and
/// [`PackageBuild::required_members`](crate::package::PackageBuild::required_members)
/// is where the set is declared.
///
/// A separate kind from [`Wasm`](Self::Wasm), which was the near-miss: facett's
/// wheel carries a wasm module, so `kind = "wasm"` looks right and is not — it
/// would make the forge build a bare `.wasm` and stop, with no wasm-bindgen
/// step, no wheel, no read-back of the produced zip and no release set. And a
/// separate kind from [`Exe`](Self::Exe)/[`Lib`](Self::Lib) because `cargo
/// build` does not build a wheel at all: a package is assembled by its
/// format's own tool inside a pinned container ([`crate::package`]), and the
/// only thing cargo contributes is inputs.
///
/// Added 2026-09-06 (HO-T5). Before it, facett's release existed and had no
/// door: `cargo xtask publish-pip` produced a checkable release set that
/// `dwarves buildthing facett --variant facet-wrapped-web` could not ask for,
/// because the forge had no kind to route on. facett's own
/// `facett-ops/tests/release_manifest.rs` recorded the gap in its module docs
/// rather than working around it.
Package => "package" | "wheel" | "pip" | "sdist" | "dist",
}
impl Default for ArtifactKind {
fn default() -> Self {
ArtifactKind::Exe
}
}
impl From<ArtifactKind> for String {
fn from(k: ArtifactKind) -> Self {
k.as_str().to_string()
}
}
impl ArtifactKind {
/// True for the kinds produced by `cargo build` (Exe/Lib/Wasm/Service) — the
/// dispatcher routes these through `build_variant`, the rest through the
/// image/vm builders.
pub fn is_cargo(&self) -> bool {
matches!(
self,
ArtifactKind::Exe | ArtifactKind::Lib | ArtifactKind::Wasm | ArtifactKind::Service
)
}
/// **True for the kinds that may declare a kernel donor** — the kinds with a
/// `[build.<kind>.kernel_donor]` table (`[[build.iso]]`, `[[build.installer]]`),
/// the same two [`BuildVariantSet::kernel_donor_of`] and
/// [`BuildVariantSet::kernel_donor_consumers`] read. A medium of one of these
/// kinds with no consumption edge boots the host's kernel, and a UI saying so
/// asks HERE rather than matching the kind itself (the scene renders what a
/// declaration says; the kind→capability table lives in one place).
pub fn takes_kernel_donor(&self) -> bool {
matches!(self, ArtifactKind::Iso | ArtifactKind::Installer)
}
/// True for kinds whose composition the fat/thin `shape` + `embeds`/`reaches`
/// machinery applies to (Service, Container).
pub fn is_service_shaped(&self) -> bool {
matches!(self, ArtifactKind::Service | ArtifactKind::Container)
}
/// **True for the cargo kinds that legitimately produce NO `executable`** in
/// cargo's artifact stream.
///
/// [`build_variant_observed_with_info`](crate::build_variant_observed_with_info)
/// only emits an [`Artifact`](crate::progress::BuildEvent::Artifact) event when
/// cargo's `CompilerArtifact` carries an `executable` path — an rlib and a wasm
/// module do not. So "exited 0 and produced no artifact", which is the classic
/// false success for an `exe`, is the CORRECT and only possible outcome for a
/// `lib`, and any consumer that keys greenness on the artifact list needs to be
/// able to tell the two apart. draupnir declares three `kind = "lib"` variants; with
/// no way to ask, every one of them reads as a build that produced nothing.
///
/// This is the ask, not a second copy of the kind list: it is the discriminant
/// itself, next to [`is_cargo`](Self::is_cargo), so there is one place to change
/// when cargo starts reporting an executable for a wasm target.
///
/// **[`Package`](Self::Package) is deliberately NOT here**, and the reason is
/// the same one that narrowed [`product_is_not_a_file`](Self::product_is_not_a_file):
/// dwarves' `produced_something()` is
/// `!artifacts.is_empty() || (produced_ref && product_is_not_a_file) ||
/// produces_no_executable()`, so a kind that answers `true` here is a kind whose
/// build reads GREEN having written nothing at all. That is right for an rlib,
/// whose artifact genuinely never appears in cargo's stream. A package's whole
/// product is files on disk — if none arrived, the build failed, and the record
/// must say so.
pub fn produces_no_executable(&self) -> bool {
matches!(self, ArtifactKind::Lib | ArtifactKind::Wasm)
}
/// **True when this kind's product does NOT land as a file on disk**, so a
/// named handle is the only evidence it exists.
///
/// A [`Container`](ArtifactKind::Container) image lives in the container
/// store, not in the working tree: there is no path to stat, and the image
/// reference (`gunnar-scratch:0.1.0`) is the whole of the proof. A consumer
/// deciding "did anything come out?" therefore has to accept that reference
/// in place of an artifact list.
///
/// # Why this is a question worth asking, measured 2026-08-05
///
/// `dwarves`' `produced_something()` accepted a produced-reference for EVERY
/// kind, with a comment naming the container case as the reason. But an
/// [`Iso`](ArtifactKind::Iso) **is** a file — the ISO is the artifact — and
/// the blanket exemption let `gunnar-appliance-iso` report
///
/// ```text
/// 'gunnar-appliance-iso' → iso gunnar-appliance-iso (digest 377b42609bc15eff…)
/// BUILT 'gunnar-appliance-iso' in 38.5s — 0 artifact(s)
/// ```
///
/// and exit 0, with no `.iso` anywhere on disk. Run twice it took 38.5 s and
/// then 0.9 s and printed a DIFFERENT digest each time, for two runs that
/// wrote nothing — a digest over something that is not the ISO, presented as
/// the ISO's identity. `dwarves-buildthing-6` ("exited 0 but produced NO
/// artifact") is precisely the code for that, and it could never fire.
///
/// So the exemption is narrowed to the kinds it was actually written for.
/// This is the discriminant answering for itself, next to
/// [`produces_no_executable`](Self::produces_no_executable), rather than a
/// second copy of the kind list at the call site.
pub fn product_is_not_a_file(&self) -> bool {
matches!(self, ArtifactKind::Container)
}
}
#[cfg(test)]
mod artifact_kind_evidence {
use super::ArtifactKind;
/// The narrowing itself, stated as the two cases that differ.
///
/// Seen RED before it was trusted: with `product_is_not_a_file` returning
/// `true` for every kind (the behaviour this replaces) the `Iso` assertion
/// below fails, which is the `gunnar-appliance-iso` false success reproduced
/// in one line and without a forge.
#[test]
fn an_iso_is_a_file_and_a_container_image_is_not() {
assert!(
ArtifactKind::Container.product_is_not_a_file(),
"a container image lives in the container store, so its reference is \
the only evidence there is"
);
assert!(
!ArtifactKind::Iso.product_is_not_a_file(),
"an ISO IS a file; accepting a bare digest for one is how a build that \
wrote no media reported BUILT and exited 0"
);
for k in [
ArtifactKind::Exe,
ArtifactKind::Lib,
ArtifactKind::Wasm,
ArtifactKind::Service,
// A built kernel is a `bzImage` ON DISK (+ .config + pin file) — the
// exemption stays narrowed to the container store.
ArtifactKind::Kernel,
] {
assert!(
!k.product_is_not_a_file(),
"{k:?} produces a file; only the container store is exempt"
);
}
}
}
/// One concrete build point: a fully-resolved `cargo build` target.
///
/// Serializes as a `[[build.variant]]` table:
/// ```toml
/// [[build.variant]]
/// name = "gui" # the handle you pass to `nornir build --variant`
/// package = "holger-ui" # cargo `-p`
/// bin = "holger-ui" # cargo `--bin`
/// features = ["gui"] # cargo `--features`
/// # open = ["gpu"] # features THIS row leaves switchable (its own axis)
/// no_default_features = false # cargo `--no-default-features`
/// profile = "release" # cargo `--profile`
/// # target = "x86_64-unknown-linux-musl" # cargo `--target`
/// # cfg = "holger_dist" # RUSTFLAGS --cfg
/// # member = "nornir" # the suite member this artifact IS
/// # shape = "fat" # fat-service / thin-standalone / mixed
/// # embeds = ["holger", "skade"] # siblings compiled IN (fat)
/// # reaches = ["dwarves"] # siblings reached over gRPC (never embedded)
/// # boot-logo = "brand/acme.png" # this ISO's own boot mark (⇒ KORP_BOOT_LOGO)
/// ```
/// **A key this struct does not know is a REFUSAL, not a shrug.** MEASURED
/// 2026-08-26 across all 21 `.nornir/build-variants.toml` in the constellation:
/// zero unknown keys on a variant row, in zero repos — so this costs nothing
/// today and buys the thing `manifest_dir` cost. Published `nornir-build-thing`
/// 0.1.8 has no `manifest_dir` field and no `deny_unknown_fields`, so skade's
/// declaration parsed and was THROWN AWAY SILENTLY; a guard that asked the struct
/// would have read `None` and gone green. With this on, the same skew fails to
/// load the file instead — the right failure, and a loud one.
///
/// Serde honours `alias`, so `test-packages`/`test_packages`, `linkage`/`shape`
/// and `default`→`is_default` all keep working. Precedent in this crate:
/// `options.rs:85`, `options.rs:168`, `params.rs:254`.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct BuildVariant {
/// Stable handle for the variant (`nornir build --variant <name>`).
pub name: String,
/// Workspace package for `-p` (omit for a single-crate repo).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub package: Option<String>,
/// `[[bin]]` target for `--bin` (omit to build the package's default bin).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bin: Option<String>,
/// **A cargo EXAMPLE target** for `--example` — the artifact is
/// `<triple>/<profile>/examples/<example>.wasm` (or the native example binary).
/// Mutually exclusive with [`bin`](Self::bin) and refused on `kind = "lib"`: an
/// example is its own target, so a row that names one builds exactly it. Exists
/// so a fixture that lives as an example (facett's `panel` WebGPU guard) is a
/// declared row instead of an argv spelled in an xtask.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub example: Option<String>,
/// Features to enable (`--features a,b`).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub features: Vec<String>,
/// **The SIBLING packages whose tests this variant's test run also covers** —
/// `test-packages = ["holger-server-lib"]`, extra `-p` selections that
/// [`test_args`](Self::test_args) adds and [`cargo_args`](Self::cargo_args)
/// ignores.
///
/// ## Why the test surface needed a key of its own
///
/// A brokkr `tests` step runs the VARIANT'S argv with `build` swapped for
/// `test`, so it inherits `-p <package>` and covers exactly one package.
/// MEASURED 2026-08-25: gunnar reaches 265 of 1114 tests (24 %), holger 210 of
/// 827 (25 %) — and `holger-server-lib` alone is **297 tests, 36 % of holger's
/// suite**, the crate its three tiers are tiers *of*. The three tier variants
/// build it, link it, and ship it, and no tier step has ever run one of its
/// tests.
///
/// Inheriting the variant's *feature* resolution is right and stays: a test run
/// whose features drift from the build's tests a configuration the forge does
/// not ship. But **scoping features and scoping packages are different
/// questions**, and the original design conflated them. This key separates
/// them: the features stay the variant's, the package selection widens.
///
/// ## Why this is not a second configuration
///
/// The extra packages are compiled BY THE BUILD ALREADY — they are in the
/// variant package's own dependency closure, with feature values cargo derived
/// from the variant's `--features`. `test_args` therefore renders the selection
/// as `-p <package> -p <sibling>` with the features **qualified to the variant's
/// package** (`--features holger-server-cli/full`), so unification hands the
/// sibling exactly the feature set the shipped artifact links. Verified against
/// cargo 2026-08-25: `cargo test -p a -p b --features a/full` is accepted and
/// runs `b`'s tests under the features `a/full` propagates.
///
/// What the *declaration* cannot check is that the sibling really is in that
/// closure — that needs `cargo metadata`, which [`BuildVariantSet::validate`]
/// deliberately does not run. Naming a package OUTSIDE the closure makes the
/// test run a configuration the build does not ship (the outsider drags its own
/// default features into the unified graph), which is the exact drift this
/// design exists to prevent. `nornir_testmatrix::testsurface` computes the
/// closure and reports both halves: an outsider named here, and a closure
/// member named nowhere.
///
/// Empty ⇒ today's behaviour, unchanged: the test step covers the variant's own
/// package and nothing else. The default is deliberately NOT "the whole
/// closure" — a default that silently widens when somebody adds a dependency
/// makes a new member's broken tests fail a variant step that has nothing to do
/// with it, and blames the wrong row.
#[serde(
rename = "test-packages",
alias = "test_packages",
default,
skip_serializing_if = "Vec::is_empty"
)]
pub test_packages: Vec<String>,
/// **The features THIS variant leaves open to whoever builds it** — the row's
/// own switchable axis, declared as `open = ["gpu"]`.
///
/// [`BuildVariantSet::offers`] is the repo's whole feature SPACE: the set a
/// declaration may draw from, and the thing `validate` rejects a stray feature
/// against. It is emphatically NOT a list of switches every variant has. A
/// build surface that reads it as one draws a switch on every row for every
/// feature any row could use — a GPU toggle on a server that has only ever had
/// a CPU path — and a switch that cannot honestly be flipped is the same defect
/// as a column nobody declared.
///
/// So the switchable axis belongs to the ROW: one assembly *can* have a GPU and
/// says so here; its sibling only ever has a CPU and stays silent, and silence
/// means no widget at all rather than a greyed-out one.
///
/// Empty ⇒ nothing to flip: everything about this row is settled by its
/// declaration. That is the correct reading for every repo today, and it is why
/// the default is empty rather than "whatever the repo offers".
///
/// Held by [`BuildVariantSet::validate`] to two rules: an entry must be in the
/// repo's `offers` (when the repo declares one), and it must not also sit in
/// [`features`](Self::features) — a feature cannot be both decided and open, and
/// a declaration that says it is would render as a fixed value and a switch for
/// one key.
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub open: BTreeSet<String>,
/// **Consumption edges** (`[build.variant.edges.<feature>]`) — a root
/// feature of this variant bound to another build thing's artifact,
/// `repo/variant : typ`, type-checked at declaration time against the
/// donor's own declaration. The general form of an installer's / iso's
/// `kernel_donor`. See [`EdgeDecl`](crate::tree::EdgeDecl).
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub edges: BTreeMap<String, crate::tree::EdgeDecl>,
/// **Declared PRESETS** (`[build.variant.presets.<name>]`) — named bundles
/// over this variant's `open` features (and edges), the root `preset` val;
/// see [`InstallerBuild::presets`](crate::installer::InstallerBuild::presets).
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub presets: crate::tree::Presets,
/// **The bundle this build was made with** — set by the door when it
/// expands `--opt preset=<name>` through the tree (`SettingsTree::
/// expand_opts`), never by the declaration; rides the descriptor so the
/// receipt says which bundle AND what it meant, exactly as
/// [`InstallerBuild::applied_preset`](crate::installer::InstallerBuild::applied_preset)
/// does for a medium.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub applied_preset: Option<crate::tree::AppliedPreset>,
/// Emit `--no-default-features`.
#[serde(default, skip_serializing_if = "is_false")]
pub no_default_features: bool,
/// Emit `--all-features` — set by a door from the operator's choice
/// ([`CargoFeatures`]), never declared.
#[serde(default, skip_serializing_if = "is_false")]
pub all_features: bool,
/// Build profile (`--profile`).
#[serde(default, skip_serializing_if = "is_dev_profile")]
pub profile: Profile,
/// Cross-compile target triple (`--target`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
/// **Where this variant's package lives, when it is NOT a member of the
/// repo-root workspace** — the directory whose `Cargo.toml` owns it, relative to
/// the repo root, rendered as `--manifest-path <dir>/Cargo.toml`. Omit for the
/// normal case.
///
/// This exists because the suite has a deliberate DETACHED-WORKSPACE pattern:
/// skade keeps `skade/`, `rest/`, `grpc/` and `serve/` out of the core library's
/// workspace (and out of its published tarball) precisely to keep that manifest
/// and lockfile clean, and knut `exclude`s its wasm guest because it only builds
/// for `wasm32-unknown-unknown`. A `-p <package>` rooted at the repo root can
/// never reach any of them, whatever features it carries, so a consumer that
/// compiles declared variants (`nornir-testmatrix`'s target-compile aspect) had
/// no way to run those configurations and reported them as artifacts that do not
/// build.
///
/// **Not `member`, and not derivable from it.** [`member`](Self::member) is the
/// SUITE member an artifact IS — a node in the suite dep graph and its
/// system-user / service name — a logical handle, not a path. skade declares
/// `member = "skade"` on both its variants while `skade-remote`'s package
/// `skade-katalog-serve` lives in `serve/`, so reading `member` as a directory
/// would be right by accident for one and wrong for the other.
///
/// ⚠ **IT WAS RENDERED NOWHERE UNTIL 2026-08-26.** The field parsed, validated,
/// and neither [`cargo_args`](Self::cargo_args) nor [`test_args`](Self::test_args)
/// ever emitted `--manifest-path` — so every word above described an intent the
/// argv did not carry. Fixed in [`push_manifest_path`](Self::push_manifest_path),
/// which both writers now call.
///
/// ⚠ **AND THE PUBLISHED CRATE STILL CANNOT SEE IT.** skade is the only
/// crates.io consumer (`skade/skade/Cargo.toml:206`, `version = "0.1.4"`), and
/// published `nornir-build-thing` 0.1.8 has zero occurrences of `manifest_dir` —
/// so skade's two declared lines resolve against a struct with no such field and
/// are dropped. skade's guard therefore reads raw TOML, which is CORRECT as
/// written and for the reason it states: asking a struct that cannot represent
/// the key guarantees `None` and guarantees a green.
///
/// That reader is a BRIDGE and should carry an expiry: **delete it when a
/// release ≥ the one carrying this field is published and skade's requirement
/// reaches it.** It already shows the scar of being a second parser — it must
/// skip `#` lines because skade's own prose quotes `manifest_dir = "serve"`.
/// (`buildgate::read_allowlist` was the counter-example: same shape, no expiry,
/// and it quietly became a permanent second door. It is gone now.)
///
/// One correction to how that hand-off was described to me: it will NOT make
/// skade's old version "fail to load its own file". `deny_unknown_fields` and
/// this field land in the SAME release, so there is no published version that
/// has the closure without the field. Old versions keep silently dropping it;
/// new ones read it. Strictly better than the failure that was predicted, and
/// worth stating so nobody plans a migration around a break that cannot happen.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub manifest_dir: Option<String>,
/// **The sibling checkouts THIS row's build reaches** — directory names under
/// the repo's parent, like `[build.toolchain] siblings`, but per row.
///
/// Empty (the default, and every row written before 2026-09-28) ⇒ the row
/// builds with the toolchain's repo-wide list, unchanged. Non-empty ⇒ it
/// REPLACES that list for this row: only these are mounted, only these must
/// be checked out, and the escaping-path-dep check scans the ONE workspace
/// rooted at the row's manifest ([`manifest_dir`](Self::manifest_dir), else
/// the repo), skipping nested workspaces cargo does not load. A
/// `manifest_dir` that is a member of an enclosing workspace must list that
/// workspace's reach too.
///
/// Why a row needs its own: monetize-impl builds holger's plugin from
/// `../holger` and gunnar's from `../gunnar`. One repo-wide list is the UNION
/// of both closures, so building holger's row demanded a gunnar checkout —
/// and holger may have no dependency on the gunnar repo at all (owner rule
/// 2026-09-27). The row's closure is what `cargo metadata` of ITS manifest
/// loads ([`crate::cargo_container::metadata_sibling_closure`]), not the
/// repo's.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub siblings: Vec<String>,
/// An extra `--cfg <cfg>`, threaded through `RUSTFLAGS` at build time.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cfg: Option<String>,
/// The **suite member** this artifact IS (e.g. `"nornir"`). Names the node in
/// the suite dep graph for the fat/thin asymmetry rule, and is the dedicated
/// system-user / service name the [`crate::service`] seam wraps it under.
/// Omit for a plain (non-suite) cargo variant.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub member: Option<String>,
/// **How this variant talks to its siblings** — the fat/thin LINKAGE axis.
///
/// Declared as `linkage = "fat" | "thin" | "mixed" | "not-applicable"`;
/// `shape = …` is the historical spelling of the same key and still parses.
/// FAT is the default and the normal case — thin is the supported exception —
/// so an omitted key reads as fat, which is why an artifact the axis does not
/// apply to must say [`ServiceShape::NotApplicable`] out loud rather than stay
/// silent. `embeds` says WHICH siblings; this says HOW.
#[serde(
rename = "linkage",
alias = "shape",
default,
skip_serializing_if = "is_fat_shape"
)]
pub shape: ServiceShape,
/// **What this variant IS to the product** — the role axis a menu groups by
/// (`role = "server" | "client" | "solo" | "library" | "tool"`).
///
/// Optional: a single-role repo may omit it. When any variant declares one,
/// [`BuildVariantSet::check_defaults`] holds every declared role to exactly one
/// [`is_default`](Self::is_default).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role: Option<crate::plugin::Role>,
/// **Which DELIVERIES this variant ships in** — the SUITE axis (Swedish
/// *utgåva*), orthogonal to [`role`](Self::role) and to
/// [`member`](Self::member).
///
/// ⚠ Not the same "suite" as [`member`](Self::member) /
/// [`BuildVariantSet::downstream`], which name the nordisk *constellation* a
/// binary belongs to. This axis is about DELIVERIES: a repo can ship more than
/// one product out of one crate graph. holger ships its full artifact registry
/// AND, from the same crates, a co-maintainer's Secure Update Gateway MVP — an intake
/// agent, a bundle tool, a verifier and a zero-egress protected node. Those are
/// not "some of holger's variants"; they are a separate deliverable with its own
/// normal case.
///
/// **A LIST, not a name**, because a variant can ship in more than one delivery:
/// holger's `agent-online` and `holger-mikro` are in both the MVP and the full
/// product. A scalar would force a copy of the variant per delivery, and two copies
/// of a build point is exactly the drift this crate exists to remove. A variant
/// in N suites is a member of N `(suite, role)` groups
/// ([`BuildVariantSet::groups`]) and is held to the default rule in every one.
///
/// Without this key the only thing separating two deliveries is a naming
/// convention, and a convention is not checkable: `features(MVP) ⊆ features(full)`
/// cannot be stated, and — worse — [`check_defaults`](BuildVariantSet::check_defaults)
/// used to group by role ALONE, so two deliveries could not each name their own
/// default server. The file still validated and handed back the OTHER suite's
/// default, leaving one delivery silently defaultless. Grouping on `(suite, role)`
/// closes that.
///
/// Empty ⇒ the variant belongs to the repo's single unnamed delivery
/// ([`UNSUITED`]), which is the correct and unchanged reading for every repo that
/// ships one product (korp, nornir, dwarves, modgunn, znippy today).
///
/// ```toml
/// [[build.variant]]
/// name = "agent-online"
/// suites = ["full", "mvp"] # ships in both deliveries
/// ```
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub suites: Vec<String>,
/// **The normal case for this variant's role** — `default = true`.
///
/// Exactly one per role. A menu of nine variants with nothing marking which is
/// normal makes every caller guess, and fat-vs-thin is not a preference: fat is
/// the normal case and thin the deliberate exception.
#[serde(rename = "default", default, skip_serializing_if = "is_false")]
pub is_default: bool,
/// The **artifact kind** this variant produces (default [`ArtifactKind::Exe`]).
/// Orthogonal to [`shape`](Self::shape) and to
/// `(package × bin × features × profile × target)`.
#[serde(default, skip_serializing_if = "is_exe_kind")]
pub kind: ArtifactKind,
/// For `kind = "container"`: the `[[build.container]]` this variant delegates
/// to (a thin unifying node so a UI can list every kind in one build list).
/// Ignored for other kinds.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub container_ref: Option<String>,
/// For `kind = "vm"`: the `[[build.vm]]` this variant delegates to. Ignored
/// for other kinds.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vm_ref: Option<String>,
/// Sibling members **compiled IN** (embedded) by a fat variant. The asymmetry
/// rule ([`BuildVariantSet::validate`]) allows a member to embed only what is
/// DOWNSTREAM of it in the dep graph.
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub embeds: BTreeSet<String>,
/// Sibling members **reached over gRPC** (never compiled in). Unconstrained by
/// the dep graph — a member may reach any peer. Must be disjoint from `embeds`.
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub reaches: BTreeSet<String>,
/// **The boot mark this variant burns into its ISO** — `boot-logo =
/// "brand/acme-mark.png"`, relative to the repo root (an absolute path is taken
/// as-is).
///
/// This is the per-variant half of korp's customer boot-logo override. A
/// customer shipping an own-branded appliance declares the mark ON THE VARIANT
/// that builds their ISO, next to its features and profile, instead of exporting
/// an environment variable by hand at every call site or — as before — editing
/// three `build.rs` files. [`BuildVariant::build`] exports it to the child cargo
/// as `KORP_BOOT_LOGO`, which is the ONE thing
/// [`korp_installer::splash`](https://git.gunnar.rs/nordisk/edda) reads; the
/// declaration is the home, the variable is only the transport, so there is no
/// second resolution path to keep in sync.
///
/// Omitted ⇒ nothing is exported and korp's own `assets/korp-logo.webp` is
/// baked, which is the correct default and stays the default. A path that does
/// not exist or does not decode never fails the build: korp's splash step falls
/// back to an empty blob and warns, because a bad logo must not stop an
/// appliance from booting.
#[serde(
rename = "boot-logo",
alias = "boot_logo",
default,
skip_serializing_if = "Option::is_none"
)]
pub boot_logo: Option<String>,
/// **This variant's artifact is signed** — `sign = { purpose = "plugin" }`.
///
/// The forge's signing step writes `<artifact>.p7s` (detached CMS over the
/// blake3 digest) and `<artifact>.blake3` beside every artifact the build
/// produced, on the host side of the container boundary. See
/// [`crate::sign`]. Absent ⇒ unsigned, which is what every variant written
/// before 2026-09-25 is.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sign: Option<crate::sign::SignSpec>,
}
/// **A delegating variant does not get to declare edges of its own.**
///
/// `kind = "container"` / `kind = "vm"` rows exist to POINT at a descriptor
/// (`container_ref` / `vm_ref`), and the artifact's consumption edges belong to
/// that descriptor — which is where [`BuildVariantSet::edges_of`] now reads
/// them. Letting the pointer row carry a second set would give one artifact two
/// edge lists that nothing reconciles, and whichever lookup ran first would win
/// silently. Same rule as `edges.kernel` against `kernel_donor`: one spelling.
fn refuse_delegated_edges(
variant: &str,
field: &str,
edges: &BTreeMap<String, crate::tree::EdgeDecl>,
) -> Result<()> {
if edges.is_empty() {
return Ok(());
}
Err(anyhow!(
"variant '{variant}': a delegating row (`{field}`) declares edges {} — put them on the \
descriptor it points at, which is where the artifact's edges are read from; one spelling \
per edge",
edges.keys().cloned().collect::<Vec<_>>().join(", ")
))
}
fn is_dev_profile(p: &Profile) -> bool {
*p == Profile::Dev
}
fn is_fat_shape(s: &ServiceShape) -> bool {
*s == ServiceShape::FatService
}
fn is_exe_kind(k: &ArtifactKind) -> bool {
*k == ArtifactKind::Exe
}
fn is_false(b: &bool) -> bool {
!*b
}
/// The label an EMPTY [`BuildVariant::suites`] groups under — the repo's single
/// unnamed delivery. Kept as one constant so the grouping key, the guards and the
/// menu all spell it the same way. Angle-bracketed like `<unroled>` so it cannot
/// collide with a name a repo would plausibly declare.
pub const UNSUITED: &str = "<the delivery>";
/// The label a variant with no [`BuildVariant::role`] groups under.
const UNROLED: &str = "<unroled>";
/// **The operator's cargo feature choice for ONE build** — what `dwarves buildthing
/// --features a,b --no-default-features --all-features`, the ⚒ button and the
/// forge's `BuildThing` RPC carry. Applied on top of the declared variant
/// ([`BuildVariant::with_cargo_features`]): the declared features stay, the chosen
/// ones are added, and the flags reach cargo exactly as given — so the provenance
/// (`inputs.features`, `inputs.cargo_args`) names what was built.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CargoFeatures {
/// `--features a,b` (each entry one feature; a comma list is split).
#[serde(default)]
pub features: Vec<String>,
/// `--no-default-features`.
#[serde(default)]
pub no_default_features: bool,
/// `--all-features`.
#[serde(default)]
pub all_features: bool,
}
impl CargoFeatures {
/// Nothing chosen: the declared variant builds as declared.
#[must_use]
pub fn is_empty(&self) -> bool {
self.features.is_empty() && !self.no_default_features && !self.all_features
}
/// Add `--features` values, splitting comma lists, trimming, dropping empties
/// and repeats (order kept).
pub fn add_features<S: AsRef<str>>(&mut self, values: impl IntoIterator<Item = S>) {
for v in values {
for f in v.as_ref().split(',').map(str::trim).filter(|f| !f.is_empty()) {
if !self.features.iter().any(|x| x == f) {
self.features.push(f.to_string());
}
}
}
}
/// The flags as cargo spells them (`--features a,b --no-default-features
/// --all-features`), for a log line or a receipt.
#[must_use]
pub fn argv(&self) -> Vec<String> {
let mut a = Vec::new();
if !self.features.is_empty() {
a.push("--features".to_string());
a.push(self.features.join(","));
}
if self.no_default_features {
a.push("--no-default-features".to_string());
}
if self.all_features {
a.push("--all-features".to_string());
}
a
}
}
impl BuildVariant {
/// This variant with the operator's [`CargoFeatures`] on top: chosen features
/// appended to the declared ones (no repeats) and taken out of `open` (a picked
/// switch is a decided value), the two flags OR-ed in.
#[must_use]
pub fn with_cargo_features(&self, cf: &CargoFeatures) -> BuildVariant {
let mut v = self.clone();
for f in &cf.features {
if !v.features.iter().any(|x| x == f) {
v.features.push(f.clone());
}
// chosen = decided: an OPEN feature the operator picked is no longer a
// switch (validation refuses a feature that is both)
v.open.remove(f);
}
v.no_default_features |= cf.no_default_features;
v.all_features |= cf.all_features;
v
}
/// The deliveries this variant ships in, in declaration order — or a single
/// [`UNSUITED`] when it declares none.
///
/// Blank and duplicate entries are dropped HERE rather than silently tolerated
/// downstream; [`BuildVariantSet::check_suites`] reports a blank entry as a
/// declaration problem, so dropping it does not hide it.
pub fn suite_keys(&self) -> Vec<&str> {
let mut out: Vec<&str> = Vec::new();
for s in &self.suites {
let s = s.trim();
if !s.is_empty() && !out.contains(&s) {
out.push(s);
}
}
if out.is_empty() {
out.push(UNSUITED);
}
out
}
/// The role label this variant groups under ([`UNROLED`] when it declares none).
pub fn role_key(&self) -> &'static str {
self.role.map(|r| r.as_str()).unwrap_or(UNROLED)
}
/// **The grouping keys this variant belongs to**: one `(suite, role)` per
/// delivery it ships in.
///
/// THE one writer of that key. [`BuildVariantSet::check_defaults`], the suite
/// guards and the menu builder ([`crate::plugin::BuildThing::roles`]) all route
/// through [`BuildVariantSet::groups`], which routes through here — so "which
/// group is this in" cannot drift between the checker and the thing it checks.
/// Two copies that have to agree is what let a delivery go silently defaultless
/// in the first place.
///
/// A variant in N suites returns N keys and is counted in N groups. That is the
/// whole point of the list: `agent-online` is the MVP's default client AND the
/// full product's, and a one-group reading would let the MVP's server group be
/// empty of defaults without anything saying so.
pub fn group_keys(&self) -> Vec<(&str, &'static str)> {
let role = self.role_key();
self.suite_keys().into_iter().map(|s| (s, role)).collect()
}
/// Materialize the exact `cargo` argv for this variant (starting with the
/// `build` subcommand). Deterministic order: `build -p --bin --features
/// --no-default-features --profile --target`.
pub fn cargo_args(&self) -> Vec<String> {
let mut a = vec!["build".to_string()];
if let Some(p) = &self.package {
a.push("-p".to_string());
a.push(p.clone());
}
if let Some(b) = &self.bin {
a.push("--bin".to_string());
a.push(b.clone());
}
if let Some(e) = &self.example {
a.push("--example".to_string());
a.push(e.clone());
}
// A `Lib` kind builds the crate library (`--lib`), never a bin. `Wasm`
// needs no extra arg here — its mandatory `--target wasm32-*` rides through
// the `target` block below (validation guarantees it is present).
if self.kind == ArtifactKind::Lib {
a.push("--lib".to_string());
}
if !self.features.is_empty() {
a.push("--features".to_string());
a.push(self.features.join(","));
}
if self.no_default_features {
a.push("--no-default-features".to_string());
}
if self.all_features {
a.push("--all-features".to_string());
}
match &self.profile {
Profile::Dev => {}
Profile::Release => {
a.push("--profile".to_string());
a.push("release".to_string());
}
Profile::Custom(name) => {
a.push("--profile".to_string());
a.push(name.clone());
}
}
if let Some(t) = &self.target {
a.push("--target".to_string());
a.push(t.clone());
}
self.push_manifest_path(&mut a);
a
}
/// Append `--manifest-path <manifest_dir>/Cargo.toml` when the variant declares
/// a [`manifest_dir`](Self::manifest_dir).
///
/// ⚠ **THE FIELD WAS DECLARED, VALIDATED, AND RENDERED NOWHERE.** It has been
/// on this struct since the detached-workspace work and `nornir-testmatrix`'s
/// `VariantSpec` already renders it (`targetcheck.rs:249`) — but the two argv
/// writers HERE, the one authority on what the forge actually runs, never
/// emitted it. A repo declaring `manifest_dir` therefore got a build of
/// whatever workspace `repo_root` happened to be, and the declaration changed
/// nothing about the command.
///
/// It rides BOTH writers deliberately. A test run that resolves a different
/// workspace than the build it is meant to cover is the same defect
/// `test_args` was created to end, in a new spelling.
fn push_manifest_path(&self, a: &mut Vec<String>) {
if let Some(d) = self.manifest_dir.as_deref().map(str::trim).filter(|d| !d.is_empty()) {
a.push("--manifest-path".to_string());
// Joined, not formatted: a declared `foo/` and a declared `foo` must
// resolve to the same manifest.
a.push(std::path::Path::new(d).join("Cargo.toml").to_string_lossy().into_owned());
}
}
/// Human-readable one-liner, e.g. `cargo build -p holger-ui --bin holger-ui
/// --features gui --profile release`.
pub fn command_line(&self) -> String {
format!("cargo {}", self.cargo_args().join(" "))
}
/// Materialize the exact `cargo test` argv for this variant — **the one
/// writer** of what a forge test step runs.
///
/// It existed nowhere. `dwarves`' `RealRunner::test_one` built it inline by
/// taking [`cargo_args`](Self::cargo_args), overwriting `argv[0]` with `test`
/// and filtering `--bin`/`--lib` back out of the vector it had just asked for
/// (`dwarves/crates/dwarves/src/runner.rs:181`). That is the second copy of the
/// variant→argv rule, living in the consumer, and it is where the
/// one-package-per-test-step defect is spelled: the `-p` came along with
/// everything else because nothing had ever asked whether it should.
///
/// Differences from [`cargo_args`](Self::cargo_args), each for a reason:
///
/// * verb `test`, not `build`;
/// * **no `--bin` / `--lib`** — those select ONE build target, and a test run
/// wants the package's test targets (a `--lib` run compiles the lib's unit
/// tests but skips every `tests/*.rs`);
/// * **extra `-p` for each [`test_packages`](Self::test_packages) entry**, the
/// sibling test surface the variant declares;
/// * features **qualified** as `<package>/<feature>` as soon as more than one
/// package is selected, because an unqualified `--features` across a
/// multi-package selection is resolved against whichever selected package
/// happens to declare the name — right by accident for a unique feature name
/// and silently wrong for a name two members share.
///
/// Profile, target and `--no-default-features` ride through unchanged, so the
/// tests run in the configuration the artifact ships.
pub fn test_args(&self) -> Vec<String> {
let mut a = vec!["test".to_string()];
if let Some(p) = &self.package {
a.push("-p".to_string());
a.push(p.clone());
}
for extra in &self.test_packages {
a.push("-p".to_string());
a.push(extra.clone());
}
if !self.features.is_empty() {
a.push("--features".to_string());
// Qualify only when the selection is wider than one package AND we have
// a package name to qualify WITH. A single-crate repo declares no
// `package`, so there is nothing to prefix and nothing ambiguous.
let qualify = !self.test_packages.is_empty() && self.package.is_some();
a.push(if qualify {
let pkg = self.package.as_deref().unwrap_or_default();
self.features
.iter()
.map(|f| format!("{pkg}/{f}"))
.collect::<Vec<_>>()
.join(",")
} else {
self.features.join(",")
});
}
if self.no_default_features {
a.push("--no-default-features".to_string());
}
if self.all_features {
a.push("--all-features".to_string());
}
match &self.profile {
Profile::Dev => {}
Profile::Release => {
a.push("--profile".to_string());
a.push("release".to_string());
}
Profile::Custom(name) => {
a.push("--profile".to_string());
a.push(name.clone());
}
}
if let Some(t) = &self.target {
a.push("--target".to_string());
a.push(t.clone());
}
self.push_manifest_path(&mut a);
a
}
/// Human-readable one-liner for the test run, e.g.
/// `cargo test -p holger-server-cli -p holger-server-lib --features holger-server-cli/full`.
pub fn test_command_line(&self) -> String {
format!("cargo {}", self.test_args().join(" "))
}
/// The environment variable a [`BuildVariant::boot_logo`] declaration travels
/// to the build scripts in. Mirrors `korp_installer::splash::LOGO_ENV` — the
/// two constants are the same wire and must not drift.
pub const BOOT_LOGO_ENV: &'static str = "KORP_BOOT_LOGO";
/// The absolute boot-logo path this variant declares, resolved against
/// `repo_root`. `None` when the variant declares no mark (⇒ korp's default).
pub fn boot_logo_path(&self, repo_root: &Path) -> Option<PathBuf> {
let declared = self.boot_logo.as_deref()?.trim();
if declared.is_empty() {
return None;
}
let p = Path::new(declared);
Some(if p.is_absolute() {
p.to_path_buf()
} else {
repo_root.join(p)
})
}
/// Validate against `set`, then run `cargo build …` in `repo_root`.
///
/// An optional [`BuildVariant::cfg`] is passed through by appending
/// `--cfg <cfg>` to any inherited `RUSTFLAGS`. An optional
/// [`BuildVariant::boot_logo`] is exported as [`Self::BOOT_LOGO_ENV`] so korp's
/// splash build step bakes the customer's mark instead of korp's raven. Fails if
/// the variant is invalid for the set or if cargo exits non-zero.
/// Where this variant's artifact lands, relative to the repo root.
///
/// `target/[<triple>/]<profile-dir>/<bin>`, with `dev` spelled `debug`
/// because that is what cargo calls the directory and not what it calls the
/// profile. `None` for a variant with no `bin`: it builds the package's
/// default target, which cannot be resolved without `cargo metadata`, and
/// guessing a path that then does not exist would turn a build into a
/// "missing artifact" for the wrong reason.
///
/// The **one** writer of this convention. `plugin::BuildThing::plan` used to
/// spell it out a second time for `expected_artifacts`, and two copies of a
/// path rule is how a plan starts expecting a file the build never writes.
#[must_use]
pub fn artifact_rel_path(&self) -> Option<PathBuf> {
Some(PathBuf::from("target").join(self.artifact_under_target()?))
}
/// The artifact's path **below the target directory** —
/// `[<triple>/]<profile-dir>/<bin>`.
///
/// Split from [`artifact_rel_path`](Self::artifact_rel_path) because the
/// target directory is not always `<repo>/target`: `CARGO_TARGET_DIR` moves
/// it, and a caller that has to go and *find* the artifact has to honour
/// that. Everything under it is the same, so there is still one place that
/// knows the convention.
#[must_use]
pub fn artifact_under_target(&self) -> Option<PathBuf> {
let bin = self.bin.as_ref()?;
let profile_dir = match String::from(self.profile.clone()).as_str() {
"dev" => "debug".to_string(),
other => other.to_string(),
};
Some(match &self.target {
Some(t) => PathBuf::from(t).join(profile_dir).join(bin),
None => PathBuf::from(profile_dir).join(bin),
})
}
/// [`build`](Self::build) with extra environment for the cargo invocation.
///
/// The one caller that needs it is the attesting delivery, which sets
/// `CARGO_PROFILE_<PROFILE>_STRIP=none` so the compilation keeps the symbol
/// table it is about to be audited on. Kept as a parameter on the real
/// builder rather than a second builder: a delivery that compiled through a
/// different function from the one every other path uses would be a
/// different build, which is the whole thing being guarded against.
///
/// # Errors
///
/// As [`build`](Self::build).
pub fn build_with_env(
&self,
set: &BuildVariantSet,
repo_root: &Path,
env: &[(String, String)],
) -> Result<()> {
self.build_inner(set, repo_root, env)
}
pub fn build(&self, set: &BuildVariantSet, repo_root: &Path) -> Result<()> {
self.build_inner(set, repo_root, &[])
}
fn build_inner(
&self,
set: &BuildVariantSet,
repo_root: &Path,
extra_env: &[(String, String)],
) -> Result<()> {
set.validate(self)?;
let args = self.cargo_args();
let mut cmd = Command::new("cargo");
cmd.current_dir(repo_root).args(&args);
if let Some(cfg) = &self.cfg {
let inherited = std::env::var("RUSTFLAGS").unwrap_or_default();
let flags = format!("{inherited} --cfg {cfg}");
cmd.env("RUSTFLAGS", flags.trim());
}
if let Some(logo) = self.boot_logo_path(repo_root) {
cmd.env(Self::BOOT_LOGO_ENV, logo);
}
for (k, v) in extra_env {
cmd.env(k, v);
}
let status = cmd
.status()
.with_context(|| format!("spawn cargo for variant '{}'", self.name))?;
if !status.success() {
return Err(anyhow!(
"variant '{}' failed: `{}` exited with {}",
self.name,
self.command_line(),
status
));
}
Ok(())
}
}
/// **One package's cargo `[features]` table**, as declared — the manifest side of
/// the subset rule.
///
/// Read by [`BuildVariantSet::cargo_feature_tables`] and consumed by
/// [`BuildVariantSet::check_suites_resolved`]. Deliberately NOT a resolved graph: it
/// is the declaration verbatim, so the closure walk is a pure function over it and
/// can be tested without a repo on disk.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FeatureTable {
/// `feature name → the list it enables`, including the `default` entry.
pub features: BTreeMap<String, Vec<String>>,
/// The crate's `default` list, hoisted out for the `no_default_features = false`
/// case. Empty when the manifest declares no default.
pub default: Vec<String>,
}
impl FeatureTable {
/// **The transitive closure of a feature selection.**
///
/// Expands any token that is a key in [`features`](Self::features); keeps every
/// other token opaque — `dep:serde` and `egui/serde` never expand and are
/// perfectly comparable across two suites as-is. `with_defaults` adds the crate
/// `default` list first, which is what makes a variant carrying NO explicit
/// features (holger's `server-local`) resolve to the four features cargo actually
/// builds instead of to the empty set. Cycles terminate: a token is expanded at
/// most once.
/// **The CAPABILITIES a selection resolves to** — [`closure`](Self::closure) with
/// every composite ALIAS dropped.
///
/// A composite (`mvp-server = ["mini", "secure_update"]`) is a NAME for a tier,
/// not a capability: it compiles nothing by itself. Two deliveries spell their
/// tiers with different composite names on purpose, so comparing those names asks
/// "did you pick the same label?" when the question is "did you enable more than
/// the product does?".
///
/// Measured 2026-08-02: with composites kept, holger's MVP reported `mvp-server`
/// and `gui-mini` as capability escapes — the two tier labels themselves, and
/// nothing else. Their expansions were already inside ORIGINAL's closure. Dropping
/// the aliases leaves exactly the leaves and the opaque `dep:` / `crate/feat`
/// tokens, which is what actually reaches cargo.
///
/// A key whose list is EMPTY (`bench = []`) is a leaf, not an alias — it is a
/// real marker a `#[cfg]` reads — so it is kept.
pub fn leaf_closure(&self, selected: &[String], with_defaults: bool) -> BTreeSet<String> {
self.closure(selected, with_defaults)
.into_iter()
.filter(|f| !self.features.get(f).is_some_and(|deps| !deps.is_empty()))
.collect()
}
pub fn closure(&self, selected: &[String], with_defaults: bool) -> BTreeSet<String> {
let mut out: BTreeSet<String> = BTreeSet::new();
let mut queue: Vec<String> = selected.to_vec();
if with_defaults {
queue.extend(self.default.iter().cloned());
}
while let Some(f) = queue.pop() {
if !out.insert(f.clone()) {
continue;
}
if let Some(deps) = self.features.get(&f) {
queue.extend(deps.iter().cloned());
}
}
out
}
}
/// One **declared buildable**, whatever its kind — the unified row.
///
/// See the [crate-root vocabulary](crate#the-vocabulary): a *variant* is the NOUN
/// (one declared buildable, one row in the list, it has a name) and *kind* is a
/// FIELD on it naming the shape of artifact it produces. This type is that pair,
/// plus a borrow of the concrete per-kind payload so nothing is flattened into a
/// god-struct.
///
/// Produced only by [`BuildVariantSet::declarations`] — the ONE enumeration of a
/// set's five storage collections. Every unified question
/// ([`declared`](BuildVariantSet::declared),
/// [`declared_names`](BuildVariantSet::declared_names),
/// [`declared_count`](BuildVariantSet::declared_count),
/// [`kind_of`](BuildVariantSet::kind_of), [`artifacts`](BuildVariantSet::artifacts))
/// is answered from it, so no call site has to remember that there are five places
/// to look.
///
/// # The POINTER VARIANT is one row, not two
///
/// The declared idiom for a non-cargo artifact is a PAIR: a `[[build.variant]]`
/// row carrying the `suites` tag and the `kind`, plus the sugar cell carrying the
/// descriptor. holger writes exactly that for `holger-installer` and
/// `holger-mvp-appliance`; korp writes it for `korp-appliance-iso`. Both halves
/// describe ONE buildable, so [`declarations`](BuildVariantSet::declarations)
/// emits ONE row for them, with [`variant`](Self::variant) borrowing the cargo row
/// and [`payload`](Self::payload) borrowing the descriptor cell.
///
/// Before 2026-08-23 the two halves were enumerated independently. MEASURED on the
/// real files that day: holger's `declared_count()` answered **18** for sixteen
/// buildables and `declared_names()` listed `holger-installer` and
/// `holger-mvp-appliance` TWICE (korp: 10 for nine, `korp-appliance-iso` twice) —
/// numbers that reached `dwarves buildthing --list`, its `variant_count` JSON and
/// the GUI picker. Worse, `declared()` returned the FIRST match, which is the cargo
/// row, so `as_installer()` / `as_container()` / `as_iso()` answered `None` on
/// every paired declaration: the descriptor was unreachable through the one lookup
/// that exists to reach it.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Declared<'a> {
/// The declared name — the handle a human types.
pub name: &'a str,
/// The shape of artifact this row produces.
pub kind: ArtifactKind,
/// The `[[build.variant]]` row behind this declaration, when there is one — a
/// plain cargo row, or the POINTER VARIANT of a paired declaration. `None` for
/// a sugar cell no variant row claims.
pub variant: Option<&'a BuildVariant>,
/// The concrete descriptor this row carries: the sugar cell when one is
/// resolved (see [`BuildVariantSet::payload_of`]), else the variant row itself.
pub payload: DeclaredPayload<'a>,
}
/// The concrete descriptor behind a [`Declared`] row.
///
/// Deliberately an enum of BORROWS of the existing per-kind types rather than a
/// merged struct: an [`IsoBuild`](crate::iso::IsoBuild)'s boot payload and a
/// [`ContainerBuild`](crate::container::ContainerBuild)'s layer list are not the
/// same fields wearing different names, and flattening them would lose exactly the
/// detail the builders need.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum DeclaredPayload<'a> {
/// Declared as a `[[build.variant]]` row (any `kind`).
Variant(&'a BuildVariant),
/// Declared as a `[[build.container]]` row (sugar for `kind = "container"`).
Container(&'a crate::container::ContainerBuild),
/// Declared as a `[[build.vm]]` row (sugar for `kind = "vm"`).
Vm(&'a crate::vm::VmBuild),
/// Declared as a `[[build.iso]]` row (sugar for `kind = "iso"`).
Iso(&'a crate::iso::IsoBuild),
/// Declared as a `[[build.installer]]` row (sugar for `kind = "installer"`).
Installer(&'a crate::installer::InstallerBuild),
/// Declared as a `[[build.kernel]]` row (sugar for `kind = "kernel"`).
Kernel(&'a crate::kernel::KernelBuild),
/// Declared as a `[[build.package]]` row (sugar for `kind = "package"`).
Package(&'a crate::package::PackageBuild),
}
impl<'a> Declared<'a> {
/// The `[[build.variant]]` row behind this declaration, when there is one.
/// `None` only for a sugar cell that no variant row claims.
///
/// A POINTER VARIANT paired with its sugar cell answers `Some` here AND `Some`
/// from the per-kind accessor below — one row, both halves reachable.
pub fn as_variant(&self) -> Option<&'a BuildVariant> {
self.variant
}
/// The container descriptor, when this row is a `[[build.container]]`.
pub fn as_container(&self) -> Option<&'a crate::container::ContainerBuild> {
match self.payload {
DeclaredPayload::Container(c) => Some(c),
_ => None,
}
}
/// The vm descriptor, when this row is a `[[build.vm]]`.
pub fn as_vm(&self) -> Option<&'a crate::vm::VmBuild> {
match self.payload {
DeclaredPayload::Vm(v) => Some(v),
_ => None,
}
}
/// The iso descriptor, when this row is a `[[build.iso]]`.
pub fn as_iso(&self) -> Option<&'a crate::iso::IsoBuild> {
match self.payload {
DeclaredPayload::Iso(i) => Some(i),
_ => None,
}
}
/// The installer descriptor, when this row is a `[[build.installer]]`.
pub fn as_installer(&self) -> Option<&'a crate::installer::InstallerBuild> {
match self.payload {
DeclaredPayload::Installer(i) => Some(i),
_ => None,
}
}
/// The kernel descriptor, when this row is a `[[build.kernel]]`.
pub fn as_kernel(&self) -> Option<&'a crate::kernel::KernelBuild> {
match self.payload {
DeclaredPayload::Kernel(k) => Some(k),
_ => None,
}
}
/// The package descriptor, when this row is a `[[build.package]]`.
pub fn as_package(&self) -> Option<&'a crate::package::PackageBuild> {
match self.payload {
DeclaredPayload::Package(p) => Some(p),
_ => None,
}
}
/// **The row as a [`BuildVariant`] node** — borrowed when it already is one,
/// synthesized (name + kind, nothing else) when it was declared through a sugar
/// section.
///
/// The synthesized node is the unifying handle the rest of the crate already
/// understands: [`output_of`](crate::output::output_of) and
/// [`assemble`](crate::assemble::assemble) resolve a `kind = "iso"` /
/// `kind = "installer"` variant to the declared variant OF THE SAME NAME, so a
/// `[[build.installer]]` and a `[[build.variant]] kind = "installer"` land on
/// exactly the same builder. One writer for "what does a picker put in its
/// candidate slot", instead of one per UI.
pub fn as_variant_node(&self) -> std::borrow::Cow<'a, BuildVariant> {
match self.variant {
Some(v) => std::borrow::Cow::Borrowed(v),
None => std::borrow::Cow::Owned(BuildVariant {
name: self.name.to_string(),
kind: self.kind,
..BuildVariant::default()
}),
}
}
}
/// The space of valid build choices a repo offers.
///
/// **Vocabulary** — see the [crate root](crate#the-vocabulary). Every row this set
/// holds is a *variant*: one declared buildable with a name and a `kind`. The six
/// storage collections below (`variants`, `containers`, `vms`, `isos`,
/// `installers`, `kernels`) are STORAGE, not six different nouns; ask them anything unified
/// through [`declarations`](Self::declarations) and its derivatives
/// ([`declared`](Self::declared), [`declared_names`](Self::declared_names),
/// [`declared_count`](Self::declared_count), [`kind_of`](Self::kind_of),
/// [`artifacts`](Self::artifacts)), never by searching one collection and hoping.
///
/// Loads from a `[build]` table (see the module docs). Validation catches the
/// three classes of "you can't build that here":
/// * a feature the repo never `offers`;
/// * two features from the same `exclusive` group at once;
/// * a `--bin` whose `required` features aren't all enabled.
/// **One entry in an allowlist table** — the reason, or the wreckage of a key
/// that was written without quotes.
///
/// `allow_unbuilt_bins` and `allow_dark_test_files` are keyed by FILE NAME, and a
/// file name contains a dot. `foo.rs = "why"` is not one key: TOML reads it as a
/// dotted key and builds `foo = { rs = "why" }`. Typing the map's value as a
/// plain `String` cannot represent that, so serde would reject the whole file
/// with "invalid type: map, expected a string" — turning a precise, fixable
/// declaration mistake into a parse failure for every consumer of the file.
///
/// This enum is what lets the STRUCT hold the malformed shape, which is what
/// lets the struct be the only reader. See
/// [`BuildVariantSet::allow_table`] for the split back into reasons and
/// problems, and [`AllowEntry::problem`] for the message.
/// `Eq` is deliberately ABSENT: the malformed arm holds a [`toml::Value`], which
/// can carry a float, and a float is not reflexively equal to itself. Claiming
/// `Eq` here would be a lie the compiler happens not to check. `PartialEq` is
/// what this type honestly has, and it is all any caller needs.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum AllowEntry {
/// The normal case: a non-empty reason.
Reason(String),
/// Anything else the file put there. Held rather than rejected so the gate
/// can NAME it; see [`problem`](Self::problem).
Malformed(toml::Value),
}
impl AllowEntry {
/// The reason, when this entry is one.
pub fn reason(&self) -> Option<&str> {
match self {
AllowEntry::Reason(r) => Some(r),
AllowEntry::Malformed(_) => None,
}
}
/// The operator-facing problem, when this entry is not a reason. The dotted-key
/// case reconstructs the name the author meant and shows the quoting that fixes
/// it — the whole point of keeping the malformed shape instead of refusing it.
pub fn problem(&self, key: &str) -> Option<String> {
let AllowEntry::Malformed(v) = self else { return None };
Some(match v.as_table() {
Some(sub) => format!(
"`{key}` is a TABLE, not a reason — an unquoted file name is a TOML \
dotted key. Write `\"{key}.{}\" = \"…\"` with the quotes.",
sub.keys().next().map(String::as_str).unwrap_or("…"),
),
None => format!("`{key}`'s reason is not a string"),
})
}
}
/// **Closed too** — but only once the two allowlist tables above became fields.
/// Turning this on before that would have failed edda, gunnar and korp on
/// `allow_unbuilt_bins`, a key THIS CRATE was reading through
/// `buildgate::read_allowlist`'s raw side door. That ordering is the whole
/// lesson: a schema cannot be closed while something else is still answering for
/// it.
#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct BuildVariantSet {
/// The feature space this repo offers. When non-empty, any variant feature
/// outside this set is rejected. Empty means "unconstrained" (accept any).
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub offers: BTreeSet<String>,
/// Mutually-exclusive feature groups: at most one feature per group may be
/// enabled by a single variant (e.g. `["gui", "headless"]`).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub exclusive: Vec<Vec<String>>,
/// Per-bin required features — mirrors Cargo `[[bin]] required-features`
/// so a bad combo is rejected up front with a clear message.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub required: BTreeMap<String, Vec<String>>,
/// **The honest allowlist for the feature-rot gate.** Features the repo
/// `offers` (or its `Cargo.toml` declares) that NO variant builds ON PURPOSE,
/// each mapped to a one-line REASON. A feature offered-but-unbuilt is ROT — it
/// is the exact shape that let korp's `spark41` accumulate three invisible
/// compile breaks — UNLESS it appears here with a reason, so the gate
/// ([`crate::feature_axis::FeatureAxis::gate`]) can tell "known, accepted debt"
/// from "silently rotting". The reason must be non-empty: an excuse with no
/// words is not an excuse. An entry for a feature a variant now DOES build is a
/// stale excuse and the gate fails on it too (keeping the allowlist itself from
/// rotting).
///
/// Loads from a `[build.allow_unbuilt]` table (`feature = "why nobody builds it"`).
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub allow_unbuilt: BTreeMap<String, String>,
/// **Declared `[[bin]]` targets NO variant builds, on purpose** — keyed by bin
/// name, valued with the reason. The bin-side twin of
/// [`allow_unbuilt`](Self::allow_unbuilt), consumed by
/// [`buildgate::tally_bins`](crate::buildgate::tally_bins).
///
/// ⚠ **THIS FIELD DID NOT EXIST UNTIL 2026-08-26, AND THE TABLE WORKED
/// ANYWAY** — which is the defect worth recording. Three repos declare it
/// (edda 2 entries, gunnar 4, korp 2) and the schema had never heard of it.
/// It worked because THIS CRATE read it through a second door:
/// `buildgate::read_allowlist` re-opened the file as raw `toml::Value` and
/// bypassed the struct entirely. A declaration honoured by a second parser
/// the schema knows nothing about — inside the crate that DEFINES the
/// schema, and the reason `deny_unknown_fields` could not be turned on here
/// without breaking three working repos.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub allow_unbuilt_bins: BTreeMap<String, AllowEntry>,
/// **Test files deliberately left dark** — keyed by file name, valued with the
/// reason. Read by the dark-test gate
/// ([`buildgate`](crate::buildgate)); declared by NO repo today, which is
/// exactly why it belongs in the struct: an undeclared reader is how the
/// field above went five months without a schema.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub allow_dark_test_files: BTreeMap<String, AllowEntry>,
/// The suite **dependency graph**, as an embed-allowlist: for each member,
/// the set of siblings strictly DOWNSTREAM of it — the only siblings a fat
/// variant of that member may compile IN (`embeds`). Encodes the suite
/// asymmetry: `dwarves = ["nornir", ...]` lets dwarves embed nornir, while
/// nornir's row omits `dwarves` so a fat nornir that embeds dwarves (an
/// upstream member — a dep-graph inversion) is rejected. Empty ⇒
/// unconstrained (embeds accepted as-is), mirroring an empty `offers`.
///
/// Loads from a `[build.downstream]` table (`member = ["sibling", …]`).
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub downstream: BTreeMap<String, BTreeSet<String>>,
/// **The delivery the others are carved OUT of** — `base_suite = "full"`.
///
/// When a repo ships more than one delivery out of one crate graph (see
/// [`BuildVariant::suites`]), one of them is the whole product and the rest are
/// subsets of it. Naming it gives [`check_suites`](Self::check_suites) a subject
/// for `features(other) ⊆ features(base)` — "the MVP must not turn on anything
/// the full product does not have".
///
/// Deliberately **NOT inferred**. Guessing the base from variant counts, or from
/// a name like `"full"`, would make the guard's subject depend on how the file
/// happens to be written — and a guard whose subject you cannot see is the
/// silent-green shape this axis exists to remove. Required as soon as a second
/// suite appears; meaningless (and ignored) for a single-delivery repo.
///
/// Loads from `[build] base_suite = "full"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_suite: Option<String>,
/// **The deliveries are SIBLINGS, not carve-outs.** Turns the subset rule off,
/// out loud.
///
/// Without this a repo has only two states: a base the others are carved out
/// of, or a missing base, which is REPORTED as a defect. A repo whose
/// deliveries are deliberately independent had no way to say so, and its only
/// exits were to keep a `base_suite` nothing checks — a declaration that lies —
/// or to bend the product until the rule held again.
///
/// holger, 2026-09-20, is the case that made this exist. `mvp-server-metered`
/// turns on `metered`, a commercial-only capability no ORIGINAL row declares,
/// so MVP stopped being a subset of ORIGINAL. The owner ruled that the
/// containment was never a requirement: the MVP has to WORK, not to be
/// contained. That is a real shape — a paid edition is not a smaller edition —
/// and the model had no word for it.
///
/// Opt-IN, and a whole word somebody has to write, because the subset rule
/// catches a real class of defect: a carved-out delivery quietly growing
/// capability the product does not have. Turning it off must be a decision
/// somebody made, not a field somebody forgot. With it set, `base_suite` is
/// meaningless and declaring one anyway is REFUSED rather than ignored — two
/// answers to "which is the base" is how a stale declaration survives.
///
/// Loads from `[build] independent_suites = true`.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub independent_suites: bool,
/// The named, declared variants.
#[serde(default, rename = "variant", skip_serializing_if = "Vec::is_empty")]
pub variants: Vec<BuildVariant>,
/// The declared **image / appliance** builds — the container target kind,
/// a sibling of the cargo `variants` above (`[[build.container]]` tables). A
/// repo can declare zero of these (a pure cargo repo) or chain a cargo
/// variant's artifact into an image layer (`from_variant`).
#[serde(default, rename = "container", skip_serializing_if = "Vec::is_empty")]
pub containers: Vec<crate::container::ContainerBuild>,
/// The declared **VM / appliance boot** builds — the bootable target kind, a
/// sibling of `variants` and `containers` (`[[build.vm]]` tables). A repo can
/// declare zero of these, or chain a cargo variant's artifact into the VM's
/// boot image (`from_variant`) — the "build holger, then boot it as a VM" chain.
#[serde(default, rename = "vm", skip_serializing_if = "Vec::is_empty")]
pub vms: Vec<crate::vm::VmBuild>,
/// The declared **bootable-ISO** builds — the ISO target kind, a sibling of
/// `variants`, `containers` and `vms` (`[[build.iso]]` tables). A repo can
/// declare zero of these, or chain a cargo variant's artifact onto the ISO's
/// boot payload (`from_variant`) — the "build it, then master an installer ISO"
/// chain.
#[serde(default, rename = "iso", skip_serializing_if = "Vec::is_empty")]
pub isos: Vec<crate::iso::IsoBuild>,
/// The declared **disk-installer** builds — the installer target kind, a
/// sibling of `variants`, `containers`, `vms` and `isos` (`[[build.installer]]`
/// tables). Each masters an *installer* ISO (via
/// [`InstallerBuild::installer_iso`](crate::installer::InstallerBuild::installer_iso))
/// that boots the `korp-installer` PID-1, lays a GPT + ESP, streams the app
/// `.znippy` onto disk and reboots — the `nornir build --installer <name>` path.
/// A repo can declare zero of these, or chain the cargo variant that produces the
/// PID-1 payload (`installer_variant` → the ISO's `from_variant`).
#[serde(default, rename = "installer", skip_serializing_if = "Vec::is_empty")]
pub installers: Vec<crate::installer::InstallerBuild>,
/// The declared **built-kernel** builds — the kernel target kind, a sibling
/// of the five collections above (`[[build.kernel]]` tables). Each compiles
/// a PINNED vanilla Linux tarball inside a toolchain container under a named
/// hardening profile, producing `bzImage` + `.config` + a pin file
/// ([`KernelBuild`](crate::kernel::KernelBuild)).
#[serde(default, rename = "kernel", skip_serializing_if = "Vec::is_empty")]
pub kernels: Vec<crate::kernel::KernelBuild>,
/// The declared **distribution package** builds — a produced FILE SET rather
/// than a single artifact (`[[build.package]]` tables). A pip release is a
/// wheel AND an sdist AND the `SHA256SUMS` a downloader checks them with AND a
/// provenance record over the produced bytes; any three of those without the
/// fourth is not a release anybody can verify, which is why the SET is what
/// [`PackageBuild`](crate::package::PackageBuild) declares.
#[serde(default, rename = "package", skip_serializing_if = "Vec::is_empty")]
pub packages: Vec<crate::package::PackageBuild>,
/// The declared **wasm cells** — a `kind = "wasm"` variant's TYPED post-build
/// stages (`[[build.wasm]]` tables). A wasm cell is what turns cargo's raw
/// `<stem>.wasm` into a bundle a consumer can load: wasm-bindgen's `_bg` split
/// and JS glue, and the component manifest the crate prints about itself.
///
/// OPTIONAL, and the absence is meaningful rather than a gap: every
/// `kind = "wasm"` variant that predates this collection declares no cell, and
/// for those the kind means what it always meant — a plain cargo wasm build
/// ([`WasmBuild`](crate::wasm::WasmBuild)).
#[serde(default, rename = "wasm", skip_serializing_if = "Vec::is_empty")]
pub wasms: Vec<crate::wasm::WasmBuild>,
/// The declared **browser web apps** — a `kind = "wasm"` variant built into a
/// directory a browser loads (`[[build.web_app]]` tables): the page, the
/// wasm-bindgen glue and the assets its `<link data-trunk …>` tags name.
/// Claimed by the variant of the SAME NAME
/// ([`WebAppBuild`](crate::web_app::WebAppBuild)).
#[serde(default, rename = "web_app", skip_serializing_if = "Vec::is_empty")]
pub web_apps: Vec<crate::web_app::WebAppBuild>,
/// **The toolchain container the repo's CARGO variants compile in**
/// (`[build.toolchain]`).
///
/// Absent — the historical state of every repo — means the cargo variants
/// spawn `cargo` in the host's environment, and the build's outcome is
/// decided by whichever box the forge landed on. That is not hypothetical:
/// `dwarves buildthing gunnar --variant gunnar-server` FAILED in 1.8 s on
/// oden with `error[E0463]: can't find crate for core`, because every gunnar
/// variant targets musl and that box's rustup had no musl std.
///
/// Present ⇒ every cargo variant in the repo goes through the pinned image
/// ([`CargoToolchain`](crate::cargo_container::CargoToolchain)). It is
/// repo-level and NOT per-variant on purpose: "which box can build this" is
/// a property of the whole delivery, and a per-variant spelling would let
/// one variant quietly keep the host habit while the repo claimed to be
/// hermetic.
///
/// Sits beside the six build-kind collections rather than inside them
/// because it is not a kind — it is the ENVIRONMENT the `variants`
/// collection runs in, the way `offers` is the feature space they draw from.
#[serde(default, rename = "toolchain", skip_serializing_if = "Option::is_none")]
pub toolchain: Option<crate::cargo_container::CargoToolchain>,
}
/// Newtype wrapper so the SAME `[build]` schema parses whether it's embedded in
/// `nornir.toml` or lives in a standalone `.nornir/build-variants.toml`.
///
/// **Deliberately NOT `deny_unknown_fields`, and that is now load-bearing.** A
/// `nornir.toml` carries a dozen root tables this struct knows nothing about, so
/// the root has to be open — and because it is open, the root is the only place
/// in this format where a version marker can be added without every published
/// reader back to 0.1.3 refusing the whole file over it. That is why
/// [`schema_header`](Self::schema_header) lives here rather than inside
/// `[build]`, which IS closed. See [`crate::schema`], R1.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct BuildConfig {
/// **`[nornir-build-thing] schema = N`** — the schema this recipe needs a
/// reader to understand ([`crate::schema`], R1/R2).
///
/// A real field rather than a key the loader peeks at and then leaves lying
/// around, because R3 refuses on any ignored `build.*` path and the version
/// marker may not be the first thing the drop detector trips over. Absent ⇒
/// [`RECIPE_SCHEMA_FLOOR`](crate::schema::RECIPE_SCHEMA_FLOOR), which is
/// every recipe written before 2026-08-30.
#[serde(
default,
rename = "nornir-build-thing",
skip_serializing_if = "Option::is_none"
)]
pub schema_header: Option<crate::schema::SchemaHeader>,
#[serde(default)]
pub build: BuildVariantSet,
}
impl BuildConfig {
/// **The ONE way to build a `BuildConfig` for EMISSION**, and the reason R1
/// cannot be forgotten by a writer.
///
/// The header is computed from the set
/// ([`schema::required_by`](crate::schema::required_by)), never chosen: a
/// recipe that declares nothing past the floor emits no header at all and
/// stays readable by every generation back to 0.1.3, and a recipe that DOES
/// declare something new says so without anyone remembering to.
///
/// Deliberately not `Default` + struct literal: an emitter that spelled
/// `BuildConfig { build, ..Default::default() }` would compile, emit no
/// header, and hand an old reader a file it then swallows in silence —
/// which is the exact 2026-08-30 defect, arriving by a new road.
pub fn emitting(set: &BuildVariantSet) -> Self {
let (schema, _why) = crate::schema::required_by(set);
BuildConfig {
schema_header: (schema > crate::schema::RECIPE_SCHEMA_FLOOR)
.then_some(crate::schema::SchemaHeader { schema }),
build: set.clone(),
}
}
}
impl BuildVariantSet {
/// This set with the cargo variant `name` built with `cf` on top
/// ([`BuildVariant::with_cargo_features`]). An empty choice is the set as it is;
/// a choice for a name that is no CARGO variant of the set is refused BY NAME —
/// a feature has nowhere to go on an image / iso / installer row.
pub fn with_cargo_features(&self, name: &str, cf: &CargoFeatures) -> Result<BuildVariantSet> {
if cf.is_empty() {
return Ok(self.clone());
}
let mut set = self.clone();
match set.variants.iter_mut().find(|v| v.name == name) {
Some(v) if v.kind.is_cargo() => {
*v = v.with_cargo_features(cf);
Ok(set)
}
Some(v) => bail!(
"'{name}' is a {} build, not a cargo one: `{}` has nowhere to go",
v.kind.as_str(),
cf.argv().join(" ")
),
None => bail!("'{name}' is no cargo variant of this declaration: `{}` has nowhere to go", cf.argv().join(" ")),
}
}
/// **The one reader of an allowlist table**, split into the reasons the gate
/// honours and the problems it must report.
///
/// `table` is the declared table name (`allow_unbuilt_bins` /
/// `allow_dark_test_files`). An unknown name yields two empties rather than a
/// panic: the caller is naming a table, and a typo there should not take the
/// build down — but it also cannot silently pass, because an unknown table
/// has no entries and the gate has nothing to allow.
pub fn allow_table(&self, table: &str) -> (BTreeMap<String, String>, Vec<String>) {
let src = match table {
"allow_unbuilt_bins" => &self.allow_unbuilt_bins,
"allow_dark_test_files" => &self.allow_dark_test_files,
_ => return (BTreeMap::new(), Vec::new()),
};
let mut ok = BTreeMap::new();
let mut bad = Vec::new();
for (k, v) in src {
match v.reason() {
Some(r) => {
ok.insert(k.clone(), r.to_string());
}
None => bad.extend(v.problem(k)),
}
}
(ok, bad)
}
/// Parse a `[build]` document (the standalone-file / `nornir.toml` schema).
///
/// **THE reader, and it now refuses to read PART of a recipe.** Three gates,
/// in the order that makes each refusal name its own fix
/// ([`crate::schema`]):
///
/// 1. **R2** — a recipe declaring a schema past this reader's is refused BY
/// NAME, with the version to upgrade to, before serde sees a byte. The
/// alternative — measured on gunnar's committed recipe against published
/// 0.1.10 — is `unknown field kernel_requires, expected one of name,
/// label, …`, which names a field the operator never wrote and a line
/// number in a file the code emitted.
/// 2. **serde** — an unknown key on a CLOSED type is a typo or a hand-edit
/// of an emitted file. Unchanged, and still loud.
/// 3. **R3** — an unknown key on an OPEN type (`InstallerBuild`, `IsoBuild`,
/// `ContainerBuild`) used to be dropped in silence. It is now a refusal
/// listing every dropped path. This is the gate that would have caught
/// the four paths published 0.1.10 swallowed out of gunnar's recipe
/// (`build.installer.{0,1}.{target,data}`) while `tests/toml_parity.rs`
/// was green: a mirror compared against a loader that discards what it
/// cannot parse agrees about nothing.
///
/// One deserialize, not two: `serde_ignored` wraps the same pass that builds
/// the set. There is no second parser to drift from this one.
pub fn from_toml_str(text: &str) -> Result<Self> {
crate::schema::check_readable(text)?;
let mut ignored: Vec<String> = Vec::new();
// toml >= 0.9: building the deserializer parses, so it can fail here.
let de = toml::Deserializer::parse(text).map_err(|e| anyhow::anyhow!("{e}"))?;
let cfg: BuildConfig = serde_ignored::deserialize(de, |path| {
let p = path.to_string();
if p == "build" || p.starts_with("build.") {
ignored.push(p);
}
})
.context("parse [build] variants")?;
ignored.sort();
ignored.dedup();
crate::schema::refuse_dropped("this recipe", &ignored)?;
Ok(cfg.build)
}
/// Load a standalone build-variants file (schema rooted at `[build]`).
pub fn load_file(path: &Path) -> Result<Self> {
let text =
std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
Self::from_toml_str(&text).with_context(|| format!("parse {}", path.display()))
}
/// The conventional standalone location: `<repo_root>/.nornir/build-variants.toml`.
pub fn conventional_path(repo_root: &Path) -> PathBuf {
repo_root.join(".nornir").join("build-variants.toml")
}
/// Discover + load `<repo_root>/.nornir/build-variants.toml` if present.
/// `Ok(None)` when the repo declares no standalone variant file.
pub fn discover(repo_root: &Path) -> Result<Option<Self>> {
let path = Self::conventional_path(repo_root);
if path.is_file() {
Ok(Some(Self::load_file(&path)?))
} else {
Ok(None)
}
}
/// **THE enumeration of everything this set declares** — one [`Declared`] row
/// per declared buildable, across all six storage collections, in declaration
/// order (`[[build.variant]]`, then the sugar sections `container`, `vm`,
/// `iso`, `installer`, `kernel`).
///
/// This is the ONE writer for "what does this repo declare". It exists because
/// the alternative — five parallel searches at every call site — is the twin
/// this suite forbids, and it had already cost real behaviour: a declared
/// `[[build.installer]]` could not be found by name at all, because
/// [`get`](Self::get) searches `self.variants` and nothing else. Add a sixth
/// storage collection and this function is the only place that has to learn
/// about it.
///
/// Note the sugar sections carry their kind IMPLICITLY (an `[[build.iso]]` row
/// is `kind = "iso"`, always), while a `[[build.variant]]` row carries whatever
/// its `kind` field says — including `container` / `vm` / `iso` / `installer`.
/// Both spellings land here identically; see the
/// [crate-root vocabulary](crate#the-vocabulary).
///
/// **A PAIRED declaration is ONE row.** When a variant row resolves a sugar
/// cell through [`payload_of`](Self::payload_of) — the pointer-variant idiom
/// holger and korp both write — the cell is that row's payload and is NOT
/// enumerated again. See [`Declared`] for the measured cost of the two-row
/// version this replaced.
pub fn declarations(&self) -> Vec<Declared<'_>> {
let mut out: Vec<Declared<'_>> = Vec::with_capacity(
self.variants.len()
+ self.containers.len()
+ self.vms.len()
+ self.isos.len()
+ self.installers.len()
+ self.kernels.len()
+ self.packages.len(),
);
out.extend(self.variants.iter().map(|v| Declared {
name: v.name.as_str(),
kind: v.kind,
variant: Some(v),
payload: self
.payload_of(v)
.unwrap_or(DeclaredPayload::Variant(v)),
}));
// Only the sugar cells NO pointer variant claims get a row of their own.
out.extend(self.unclaimed_containers().map(|c| Declared {
name: c.name.as_str(),
kind: ArtifactKind::Container,
variant: None,
payload: DeclaredPayload::Container(c),
}));
out.extend(self.unclaimed_vms().map(|v| Declared {
name: v.name.as_str(),
kind: ArtifactKind::Vm,
variant: None,
payload: DeclaredPayload::Vm(v),
}));
out.extend(self.unclaimed_isos().map(|i| Declared {
name: i.name.as_str(),
kind: ArtifactKind::Iso,
variant: None,
payload: DeclaredPayload::Iso(i),
}));
out.extend(self.unclaimed_installers().map(|i| Declared {
name: i.name.as_str(),
kind: ArtifactKind::Installer,
variant: None,
payload: DeclaredPayload::Installer(i),
}));
out.extend(self.unclaimed_kernels().map(|k| Declared {
name: k.name.as_str(),
kind: ArtifactKind::Kernel,
variant: None,
payload: DeclaredPayload::Kernel(k),
}));
out.extend(self.unclaimed_packages().map(|p| Declared {
name: p.name.as_str(),
kind: ArtifactKind::Package,
variant: None,
payload: DeclaredPayload::Package(p),
}));
out
}
/// **The sugar cell a `[[build.variant]]` row IS the pointer for** — the ONE
/// writer of the pointer-variant resolution table.
///
/// It is the same table [`validate_kind`](Self::validate_kind) validates and
/// [`output_of`](crate::output::output_of) resolves, spelled once:
///
/// | variant `kind` | the cell it points at |
/// |---|---|
/// | `Container` | the `[[build.container]]` named by `container_ref` |
/// | `Vm` | the `[[build.vm]]` named by `vm_ref` |
/// | `Iso` / `Installer` / `Kernel` | the cell of the SAME NAME (no ref field) |
/// | `Exe` / `Lib` / `Wasm` / `Service` | none — a cargo row is its own payload |
///
/// `None` also when the pointer DANGLES (a `container_ref` naming nothing, an
/// `installer` variant with no cell). That is a declaration defect
/// `validate_kind` reports for the ref kinds; here it just means the row has no
/// descriptor to carry, and the row survives so the defect stays visible in the
/// build list instead of vanishing from it.
pub fn payload_of(&self, v: &BuildVariant) -> Option<DeclaredPayload<'_>> {
match v.kind {
ArtifactKind::Container => self
.containers
.iter()
.find(|c| Some(c.name.as_str()) == v.container_ref.as_deref())
.map(DeclaredPayload::Container),
ArtifactKind::Vm => self
.vms
.iter()
.find(|x| Some(x.name.as_str()) == v.vm_ref.as_deref())
.map(DeclaredPayload::Vm),
ArtifactKind::Iso => self
.isos
.iter()
.find(|i| i.name == v.name)
.map(DeclaredPayload::Iso),
ArtifactKind::Installer => self
.installers
.iter()
.find(|i| i.name == v.name)
.map(DeclaredPayload::Installer),
ArtifactKind::Kernel => self
.kernels
.iter()
.find(|k| k.name == v.name)
.map(DeclaredPayload::Kernel),
ArtifactKind::Package => self
.packages
.iter()
.find(|p| p.name == v.name)
.map(DeclaredPayload::Package),
ArtifactKind::Exe | ArtifactKind::Lib | ArtifactKind::Wasm | ArtifactKind::Service => {
None
}
}
}
/// True when some `[[build.variant]]` row is the pointer for the sugar cell
/// `name` of `kind` — i.e. the cell is already somebody's payload.
fn cell_is_claimed(&self, kind: ArtifactKind, name: &str) -> bool {
self.variants.iter().any(|v| {
v.kind == kind
&& match kind {
ArtifactKind::Container => v.container_ref.as_deref() == Some(name),
ArtifactKind::Vm => v.vm_ref.as_deref() == Some(name),
_ => v.name == name,
}
})
}
fn unclaimed_containers(&self) -> impl Iterator<Item = &crate::container::ContainerBuild> {
self.containers
.iter()
.filter(|c| !self.cell_is_claimed(ArtifactKind::Container, &c.name))
}
fn unclaimed_vms(&self) -> impl Iterator<Item = &crate::vm::VmBuild> {
self.vms
.iter()
.filter(|v| !self.cell_is_claimed(ArtifactKind::Vm, &v.name))
}
fn unclaimed_isos(&self) -> impl Iterator<Item = &crate::iso::IsoBuild> {
self.isos
.iter()
.filter(|i| !self.cell_is_claimed(ArtifactKind::Iso, &i.name))
}
fn unclaimed_installers(&self) -> impl Iterator<Item = &crate::installer::InstallerBuild> {
self.installers
.iter()
.filter(|i| !self.cell_is_claimed(ArtifactKind::Installer, &i.name))
}
fn unclaimed_kernels(&self) -> impl Iterator<Item = &crate::kernel::KernelBuild> {
self.kernels
.iter()
.filter(|k| !self.cell_is_claimed(ArtifactKind::Kernel, &k.name))
}
fn unclaimed_packages(&self) -> impl Iterator<Item = &crate::package::PackageBuild> {
self.packages
.iter()
.filter(|p| !self.cell_is_claimed(ArtifactKind::Package, &p.name))
}
/// **Give me the declared variant named `name`, whatever its kind.** The one
/// lookup a CLI / UI / forge should reach for; [`get`](Self::get) is the
/// narrower "…and it must be a `[[build.variant]]` row" question.
pub fn declared(&self, name: &str) -> Option<Declared<'_>> {
self.declarations().into_iter().find(|d| d.name == name)
}
/// [`declared`](Self::declared) or a helpful error listing every declared name
/// (not just the cargo ones — the list that used to omit the very row the
/// caller asked for).
pub fn require_declared(&self, name: &str) -> Result<Declared<'_>> {
self.declared(name).ok_or_else(|| {
let known = self.declared_names();
anyhow!(
"no build variant '{}'; declared: {}",
name,
if known.is_empty() {
"<none declared>".to_string()
} else {
known.join(", ")
}
)
})
}
/// Every declared name, in declaration order — the honest build list.
pub fn declared_names(&self) -> Vec<&str> {
self.declarations().into_iter().map(|d| d.name).collect()
}
/// **How many buildables this repo declares** — the number a "N variant(s)
/// declared" line must print. Summing `self.variants` alone under-reports every
/// container, vm, iso and installer in the file.
///
/// A PROJECTION of [`declarations`](Self::declarations), not a second sum: the
/// six-collection sum it used to be over-reported every PAIRED declaration
/// (holger 18 for sixteen buildables, korp 10 for nine — measured 2026-08-23),
/// and a count that disagrees with the list beside it is the drift this crate
/// exists to prevent.
pub fn declared_count(&self) -> usize {
self.declarations().len()
}
/// The `[[build.variant]]`-row names, in declaration order.
///
/// ⚠ This is the CARGO-ROW collection, not the repo's build list — a declared
/// `[[build.container]]` / `[[build.iso]]` / `[[build.installer]]` is a variant
/// too and does not appear here. Use [`declared_names`](Self::declared_names)
/// for "what can I build in this repo".
pub fn names(&self) -> Vec<&str> {
self.variants.iter().map(|v| v.name.as_str()).collect()
}
/// Look up a `[[build.variant]]` row by name.
///
/// ⚠ Searches only the cargo rows. A declared installer / container / iso / vm
/// is invisible here BY DESIGN — this function's job is to hand back a
/// [`BuildVariant`]. For "the declared thing called `name`, whatever it is",
/// use [`declared`](Self::declared).
pub fn get(&self, name: &str) -> Option<&BuildVariant> {
self.variants.iter().find(|v| v.name == name)
}
/// Look up a variant by name, or a helpful error listing the valid names.
pub fn require(&self, name: &str) -> Result<&BuildVariant> {
self.get(name).ok_or_else(|| {
anyhow!(
"no build variant '{}'; known variants: {}",
name,
if self.variants.is_empty() {
"<none declared>".to_string()
} else {
self.names().join(", ")
}
)
})
}
/// Validate a single variant against this set's rules.
///
/// Rejects: a feature not in `offers` (when `offers` is declared), an
/// [`open`](BuildVariant::open) feature that is not offered or that the variant
/// already enables, two features from one `exclusive` group, and a `--bin`
/// missing any of its `required` features.
pub fn validate(&self, v: &BuildVariant) -> Result<()> {
// Per-row `siblings` are directory names under the repo's parent — one
// component each, never a path — and a cargo row's business only.
if !v.siblings.is_empty() {
if !v.kind.is_cargo() {
return Err(anyhow!(
"variant '{}': `siblings` on a kind '{}' row — per-row sibling checkouts \
are mounted for a cargo build; an assembled kind mounts none",
v.name,
v.kind.as_str()
));
}
let mut seen = BTreeSet::new();
for s in &v.siblings {
let one_component = !s.is_empty()
&& s != "."
&& s != ".."
&& !s.contains('/')
&& !s.contains('\\');
if !one_component {
return Err(anyhow!(
"variant '{}': sibling `{s}` is not a directory name beside the repo — \
a sibling is ONE path component (`edda`, not `../edda`)",
v.name
));
}
if !seen.insert(s.as_str()) {
return Err(anyhow!("variant '{}': sibling `{s}` is declared twice", v.name));
}
}
}
// `sign` is honoured by the forge on the CARGO kinds (and a web app, which
// is a wasm variant). An assembled kind declaring it would be a signature
// promised and never written, so it is refused here, by name.
if let Some(s) = &v.sign {
if !v.kind.is_cargo() {
return Err(anyhow!(
"variant '{}': `sign = {{ purpose = \"{}\" }}` on a kind '{}' row — the signing \
step signs what a cargo build (exe/lib/wasm/service) or a web app produces; an \
assembled kind is not signed by it, and declaring it would promise a \
signature nothing writes",
v.name,
s.purpose.as_str(),
v.kind.as_str()
));
}
}
// 0. the edge rows are well-formed, and no edge is also an `open` feature
// (a feature cannot be both switchable here and consumed from elsewhere).
crate::tree::validate_edge_rows(&format!("variant '{}'", v.name), None, &v.edges, &[])?;
for f in v.edges.keys() {
if v.open.contains(f) || v.features.contains(f) {
return Err(anyhow!(
"variant '{}': '{f}' is declared twice — as a consumption edge and as a \
cargo feature of this variant; a feature is one or the other",
v.name
));
}
}
crate::tree::SettingsTree::of_variant(v).validate_presets()?;
// 1. every enabled feature must be offered (skip when unconstrained).
if !self.offers.is_empty() {
for f in &v.features {
if !self.offers.contains(f) {
return Err(anyhow!(
"variant '{}': feature '{}' is not offered by this repo (offered: {})",
v.name,
f,
self.offers.iter().cloned().collect::<Vec<_>>().join(", ")
));
}
}
}
// 1b. the row's own OPEN axis: drawn from the same space, and disjoint from
// the features the row already decided.
for f in &v.open {
if !self.offers.is_empty() && !self.offers.contains(f) {
return Err(anyhow!(
"variant '{}': open feature '{}' is not offered by this repo (offered: {})",
v.name,
f,
self.offers.iter().cloned().collect::<Vec<_>>().join(", ")
));
}
if v.features.contains(f) {
return Err(anyhow!(
"variant '{}': feature '{}' is both enabled and open — it cannot be a \
decided value and a switch at the same time",
v.name,
f
));
}
}
// 1c. the row's declared SIBLING TEST SURFACE.
//
// Three rejections, each a way the widened selection would stop being the
// configuration the build ships — which is the whole reason the test step
// inherits the variant in the first place.
if !v.test_packages.is_empty() {
if v.package.is_none() {
return Err(anyhow!(
"variant '{}': `test-packages` names sibling packages but the variant \
declares no `package` of its own, so there is no package to qualify its \
`--features` against and cargo would resolve them against whichever \
selected member happens to declare the name",
v.name
));
}
if v.no_default_features {
return Err(anyhow!(
"variant '{}': `test-packages` cannot be combined with \
`no_default_features`. `--no-default-features` applies to EVERY selected \
package, so the siblings would be tested with their defaults off — but \
the build links them with whatever their dependency EDGE says, which is \
defaults-on unless that edge sets `default-features = false`. The test \
run would be a configuration this repo does not ship, which is the drift \
inheriting the variant exists to prevent.",
v.name
));
}
if !v.kind.is_cargo() {
return Err(anyhow!(
"variant '{}': `test-packages` is a cargo test selection, but this row is \
kind `{}`, which cargo does not build at all",
v.name,
v.kind.as_str()
));
}
let own = v.package.as_deref().unwrap_or_default();
let mut seen = BTreeSet::new();
for p in &v.test_packages {
if p.trim().is_empty() {
return Err(anyhow!("variant '{}': empty `test-packages` entry", v.name));
}
if p == own {
return Err(anyhow!(
"variant '{}': `test-packages` repeats the variant's own package \
'{p}' — it is already selected",
v.name
));
}
if !seen.insert(p.clone()) {
return Err(anyhow!(
"variant '{}': `test-packages` names '{p}' twice",
v.name
));
}
}
}
// 2. mutually-exclusive groups.
for group in &self.exclusive {
let hits: Vec<&String> = group.iter().filter(|g| v.features.contains(g)).collect();
if hits.len() > 1 {
return Err(anyhow!(
"variant '{}': features {:?} are mutually exclusive",
v.name,
hits
));
}
}
// 3. Cargo required-features for the selected bin.
if let Some(bin) = &v.bin {
if let Some(req) = self.required.get(bin) {
for r in req {
if !v.features.contains(r) {
return Err(anyhow!(
"variant '{}': bin '{}' requires feature '{}' but it is not enabled \
(Cargo required-features)",
v.name,
bin,
r
));
}
}
}
}
// 4. fat/thin shape coherence + the suite ASYMMETRY rule.
self.validate_shape(v)?;
// 5. artifact-kind coherence (kind × target / service / bin / composition).
self.validate_kind(v)?;
Ok(())
}
/// The **artifact-kind** half of [`validate`](Self::validate): enforce that a
/// variant's [`kind`](BuildVariant::kind) agrees with its target, service
/// identity, bin, delegation, and composition fields. Orthogonal to
/// [`validate_shape`](Self::validate_shape) — the two axes have independent
/// validators.
///
/// The coherence matrix:
///
/// | kind | requires | forbids |
/// |---|---|---|
/// | `Exe` | — | `embeds` (composes nothing) |
/// | `Lib` | — | `bin`, `embeds` |
/// | `Wasm` | `target = wasm32-*` | `embeds` |
/// | `Service` | `member` | a wasm target |
/// | `Container` | `container_ref` → declared | — |
/// | `Vm` | `vm_ref` → declared | — |
///
/// `embeds` (compiled-in siblings) is only meaningful for the service-shaped
/// kinds (`Service`, `Container`); a non-service that `embeds` is incoherent.
/// `reaches` (a remote gRPC dependency) stays allowed for any kind — an `Exe`
/// UI/tool may be a thin client of a remote owner without composing anything in.
pub fn validate_kind(&self, v: &BuildVariant) -> Result<()> {
// An example is its own cargo target: it cannot ALSO be a `--bin`, and a
// library row builds the lib and nothing else.
if v.example.is_some() && v.bin.is_some() {
return Err(anyhow!(
"variant '{}': `example` and `bin` both name the target to build — pick one",
v.name
));
}
if v.example.is_some() && v.kind == ArtifactKind::Lib {
return Err(anyhow!(
"variant '{}': kind 'lib' builds the package library and may not select an `example`",
v.name
));
}
match v.kind {
// **A DELIBERATE REVERSAL (2026-08-15).** This arm used to REFUSE
// `kind = "installer"` on a variant. Its words, verbatim, were
// "kind 'installer' is not an atom kind — declare an
// [[build.installer]] cell instead". Under the vocabulary ruling
// (see the crate root) `kind` is a FIELD on a variant, and
// `installer` is one of the six legal values; `[[build.installer]]`
// is permanent SUGAR for it, not a rival noun. So the refusal is
// gone.
//
// Like `Iso`, an installer variant carries its handle in its own
// NAME (there is no delegating `installer_ref` field):
// `output_of` and `assemble::dispatch` resolve it to the declared
// `[[build.installer]]` of the same name. That makes it a leaf here,
// with no extra constraint — exactly the `Iso` and `Exe` shape.
ArtifactKind::Installer => {}
// Wasm ⇒ a wasm32 target is mandatory.
ArtifactKind::Wasm => {
let t = v.target.as_deref().unwrap_or_default();
if !t.starts_with("wasm32") {
return Err(anyhow!(
"variant '{}': kind 'wasm' requires a wasm32 `target` \
(e.g. wasm32-unknown-unknown), got '{}'",
v.name,
if t.is_empty() { "<none>" } else { t }
));
}
}
// Service ⇒ must resolve a ServiceSpec identity: needs a `member` (the
// dedicated system-user name skidbladnir installs it under).
ArtifactKind::Service => {
if v.member.as_deref().unwrap_or("").trim().is_empty() {
return Err(anyhow!(
"variant '{}': kind 'service' requires a `member` \
(the dedicated system-user / ServiceSpec identity)",
v.name
));
}
// A service is a native daemon, never a wasm module.
if v.target
.as_deref()
.map_or(false, |t| t.starts_with("wasm32"))
{
return Err(anyhow!(
"variant '{}': a 'service' is a native daemon, not a wasm target",
v.name
));
}
}
// Lib ⇒ builds the crate library, so no `--bin` may be selected.
ArtifactKind::Lib => {
if v.bin.is_some() {
return Err(anyhow!(
"variant '{}': kind 'lib' builds the package library and \
may not select a `bin`",
v.name
));
}
// A library composes nothing and runs nowhere, so the fat/thin axis
// has no answer for it — and the DEFAULT answer is 'fat', a positive
// claim of self-containment. Silence would therefore read as a claim
// nobody made. Say it does not apply.
if v.shape.is_applicable() {
return Err(anyhow!(
"variant '{}': kind 'lib' has no fat/thin linkage — it is linked by \
whoever consumes it — but declares linkage '{}'. Say \
`linkage = \"not-applicable\"`, which is NOT the same as omitting \
the key (an omitted linkage reads as 'fat').",
v.name,
v.shape.as_str()
));
}
}
// Delegating nodes must point at a declared descriptor.
ArtifactKind::Container => {
let name = v.container_ref.as_deref().ok_or_else(|| {
anyhow!(
"variant '{}': kind 'container' needs `container_ref = \"…\"` \
(the [[build.container]] it delegates to)",
v.name
)
})?;
// Name the CONSUMER, not just the dangling pointer: `require_container`
// is shared by every caller and cannot know who asked, and a refusal
// that says only "no container build 'nowhere'" leaves the reader to
// grep for who names it.
// ONE string, not a context chain: callers read this through
// `to_string()` as often as through `{:#}`, and a chain hides the
// inner half from the first of those.
self.require_container(name)
.map_err(|e| anyhow!("variant '{}': container_ref: {e}", v.name))?;
refuse_delegated_edges(&v.name, "container_ref", &v.edges)?;
}
ArtifactKind::Vm => {
let name = v.vm_ref.as_deref().ok_or_else(|| {
anyhow!(
"variant '{}': kind 'vm' needs `vm_ref = \"…\"` \
(the [[build.vm]] it delegates to)",
v.name
)
})?;
self.require_vm(name)?;
refuse_delegated_edges(&v.name, "vm_ref", &v.edges)?;
}
// A bootable ISO is declared directly as a `[[build.iso]]` table
// (validated via [`validate_iso`](Self::validate_iso)), not as a cargo
// variant delegating to one — so a `kind = "iso"` variant carries no
// extra constraint here (the analogue of the leaf `Exe`).
ArtifactKind::Iso => {}
// Same shape as `Iso`/`Installer`: a `kind = "kernel"` variant carries
// its handle in its own NAME and resolves to the declared
// `[[build.kernel]]` of the same name (validated via
// [`validate_kernel`](Self::validate_kernel)) — a leaf here.
ArtifactKind::Kernel => {}
// **A package is the one same-name kind that REFUSES a missing cell**,
// and the difference from `Iso`/`Installer`/`Kernel` above is worth
// stating rather than being read as an inconsistency.
//
// Those three can be leaves because a missing cell still leaves them
// something true to say: `output_of` falls back to naming the variant
// itself, and the row survives in the build list carrying a handle. A
// package's OUTPUT IS THE MEMBER SET — the distribution name, the
// directory, and which of wheel/sdist/sums/provenance must be there.
// None of that can be inferred from a variant name, so a `kind =
// "package"` row without its cell is not a row with a dangling
// pointer; it is a release nobody described, and describing it anyway
// would mean inventing a distribution name and asserting a set.
ArtifactKind::Package => {
if !self.packages.iter().any(|p| p.name == v.name) {
return Err(anyhow!(
"variant '{}': kind 'package' needs a [[build.package]] cell of the SAME NAME (the iso/installer/kernel rule — there is no delegating package_ref field). The cell is where the produced set is declared: the distribution name, the out_dir, and whether an sdist is part of the release. Without it there is nothing to say what this build produces",
v.name
));
}
}
ArtifactKind::Exe => {}
}
// Composition (embeds) is meaningful for the service-shaped kinds AND for
// a plain `exe`. The old rule refused it on `exe` ("composes no service"),
// and two repos hit that wall with artifacts that genuinely compile a
// sibling in: dwarves-solo wrote `embeds = []` under a comment calling the
// refusal "a SCHEMA limit and not a claim" and put the truth in prose, and
// korp's window-having atoms (korp / korp-solo) were mis-declared
// `kind = "service"` partly because service was the only kind that could
// carry their real `embeds = ["skade"]`. The ?2 UI-drive ruling
// (2026-08-17) moves those atoms to `exe`, so `embeds` follows: it names
// what the LINKER KEPT, which is a fact about any executable artifact, not
// about service management. The real coherence rules are kind-independent
// and live in `validate_shape` (thin never embeds, embeds ∩ reaches empty,
// downstream direction only). Still refused where it is genuinely
// incoherent: a lib / wasm module / delegating pointer kind links no
// sibling binary in.
if !v.embeds.is_empty()
&& !v.kind.is_service_shaped()
&& v.kind != ArtifactKind::Exe
{
return Err(anyhow!(
"variant '{}': `embeds` requires kind 'service', 'container' or 'exe'; \
kind '{}' composes nothing in",
v.name,
v.kind.as_str()
));
}
Ok(())
}
/// The fat/thin half of [`validate`](Self::validate): shape coherence and the
/// suite **asymmetry** rule — a member may embed only what is DOWNSTREAM of it.
///
/// Rejects:
/// * `embeds` ∩ `reaches` non-empty (a sibling can't be both compiled-in and
/// reached over gRPC);
/// * a [`ServiceShape::ThinStandalone`] variant that nonetheless `embeds`
/// anything (thin compiles in NO siblings);
/// * a fat/mixed variant that embeds a sibling NOT downstream of its `member`
/// in [`downstream`](Self::downstream) — the dep-graph inversion (e.g. nornir
/// embedding dwarves). Skipped when the graph is empty (unconstrained).
pub fn validate_shape(&self, v: &BuildVariant) -> Result<()> {
// embeds and reaches must be disjoint.
let both: Vec<&String> = v.embeds.intersection(&v.reaches).collect();
if !both.is_empty() {
return Err(anyhow!(
"variant '{}': sibling(s) {:?} are BOTH embedded and reached over gRPC \
(embeds and reaches must be disjoint)",
v.name,
both
));
}
// 'not-applicable' is a claim that this artifact has no sibling relationship
// at all. If it embeds or reaches one, the axis plainly DOES apply and the
// declaration is hiding a real runtime requirement behind an opt-out.
if v.shape == ServiceShape::NotApplicable && !(v.embeds.is_empty() && v.reaches.is_empty())
{
return Err(anyhow!(
"variant '{}': linkage 'not-applicable' but it embeds {:?} and reaches {:?} — \
the axis applies to anything with a sibling edge; declare 'fat', 'thin' or \
'mixed'",
v.name,
v.embeds.iter().collect::<Vec<_>>(),
v.reaches.iter().collect::<Vec<_>>()
));
}
// A thin standalone compiles in nothing.
if v.shape == ServiceShape::ThinStandalone && !v.embeds.is_empty() {
return Err(anyhow!(
"variant '{}': shape 'thin' embeds {:?}, but a thin/standalone build compiles \
in NO siblings (move them to `reaches`, or use shape 'fat'/'mixed')",
v.name,
v.embeds.iter().collect::<Vec<_>>()
));
}
// The asymmetry rule: embed only downstream. Only enforced when the suite
// graph is declared AND this variant embeds something.
if !self.downstream.is_empty() && !v.embeds.is_empty() {
let member = v.member.as_deref().ok_or_else(|| {
anyhow!(
"variant '{}': embeds {:?} but declares no `member`, so its position in the \
suite dep graph is unknown (set `member = \"…\"`)",
v.name,
v.embeds.iter().collect::<Vec<_>>()
)
})?;
// Empty allowlist when the member is absent from the graph → any embed
// is an inversion.
let allowed = self.downstream.get(member);
for e in &v.embeds {
let ok = allowed.map(|d| d.contains(e)).unwrap_or(false);
if !ok {
return Err(anyhow!(
"variant '{}': member '{}' may not embed '{}' — it is not DOWNSTREAM of \
'{}' in the suite dep graph (embedding an upstream/peer member inverts \
the graph; reach it over gRPC instead). Downstream of '{}': {}",
v.name,
member,
e,
member,
member,
allowed
.map(|d| if d.is_empty() {
"<none>".to_string()
} else {
d.iter().cloned().collect::<Vec<_>>().join(", ")
})
.unwrap_or_else(|| "<member not in graph>".to_string())
));
}
}
}
Ok(())
}
/// The declared image/appliance build names, in declaration order.
pub fn container_names(&self) -> Vec<&str> {
self.containers.iter().map(|c| c.name.as_str()).collect()
}
/// Look up an image/appliance build by name.
pub fn container(&self, name: &str) -> Option<&crate::container::ContainerBuild> {
self.containers.iter().find(|c| c.name == name)
}
/// Look up an image build by name, or a helpful error listing the known ones.
pub fn require_container(&self, name: &str) -> Result<&crate::container::ContainerBuild> {
self.container(name).ok_or_else(|| {
anyhow!(
"no container build '{}'; known images: {}",
name,
if self.containers.is_empty() {
"<none declared>".to_string()
} else {
self.container_names().join(", ")
}
)
})
}
/// The declared VM/appliance build names, in declaration order.
pub fn vm_names(&self) -> Vec<&str> {
self.vms.iter().map(|vm| vm.name.as_str()).collect()
}
/// Look up a VM/appliance build by name.
pub fn vm(&self, name: &str) -> Option<&crate::vm::VmBuild> {
self.vms.iter().find(|vm| vm.name == name)
}
/// Look up a VM build by name, or a helpful error listing the known ones.
/// Mirrors [`require_container`](Self::require_container) so a CLI/UI can
/// resolve a [`VmBuild`](crate::vm::VmBuild) by name the same way.
pub fn require_vm(&self, name: &str) -> Result<&crate::vm::VmBuild> {
self.vm(name).ok_or_else(|| {
anyhow!(
"no vm build '{}'; known vms: {}",
name,
if self.vms.is_empty() {
"<none declared>".to_string()
} else {
self.vm_names().join(", ")
}
)
})
}
/// The declared bootable-ISO build names, in declaration order.
pub fn iso_names(&self) -> Vec<&str> {
self.isos.iter().map(|iso| iso.name.as_str()).collect()
}
/// Look up a bootable-ISO build by name.
pub fn iso(&self, name: &str) -> Option<&crate::iso::IsoBuild> {
self.isos.iter().find(|iso| iso.name == name)
}
/// Look up an ISO build by name, or a helpful error listing the known ones.
/// Mirrors [`require_vm`](Self::require_vm).
pub fn require_iso(&self, name: &str) -> Result<&crate::iso::IsoBuild> {
self.iso(name).ok_or_else(|| {
anyhow!(
"no iso build '{}'; known isos: {}",
name,
if self.isos.is_empty() {
"<none declared>".to_string()
} else {
self.iso_names().join(", ")
}
)
})
}
/// The declared disk-installer build names, in declaration order.
pub fn installer_names(&self) -> Vec<&str> {
self.installers.iter().map(|i| i.name.as_str()).collect()
}
/// Look up a disk-installer build by name — the `nornir build --installer <name>`
/// resolver. The returned [`InstallerBuild`](crate::installer::InstallerBuild)
/// materializes the installer ISO
/// ([`installer_iso`](crate::installer::InstallerBuild::installer_iso)) and the
/// KVM boot seam.
pub fn installer(&self, name: &str) -> Option<&crate::installer::InstallerBuild> {
self.installers.iter().find(|i| i.name == name)
}
/// Look up an installer build by name, or a helpful error listing the known ones.
/// Mirrors [`require_iso`](Self::require_iso) — the resolver a CLI/UI calls to pick
/// an installer variant and drive its [`installer_iso`](crate::installer::InstallerBuild::installer_iso).
pub fn require_installer(&self, name: &str) -> Result<&crate::installer::InstallerBuild> {
self.installer(name).ok_or_else(|| {
anyhow!(
"no installer build '{}'; known installers: {}",
name,
if self.installers.is_empty() {
"<none declared>".to_string()
} else {
self.installer_names().join(", ")
}
)
})
}
/// The declared built-kernel build names, in declaration order.
pub fn kernel_names(&self) -> Vec<&str> {
self.kernels.iter().map(|k| k.name.as_str()).collect()
}
/// Look up a built-kernel build by name.
pub fn kernel(&self, name: &str) -> Option<&crate::kernel::KernelBuild> {
self.kernels.iter().find(|k| k.name == name)
}
/// The declared wasm cell names.
pub fn wasm_names(&self) -> Vec<&str> {
self.wasms.iter().map(|w| w.name.as_str()).collect()
}
/// Look up a wasm cell by name. `None` is the ORDINARY case for a
/// `kind = "wasm"` variant — it means a plain cargo wasm build, not a defect.
pub fn wasm(&self, name: &str) -> Option<&crate::wasm::WasmBuild> {
self.wasms.iter().find(|w| w.name == name)
}
/// Look up a browser web app by name. `None` for every wasm variant that is
/// not one.
pub fn web_app(&self, name: &str) -> Option<&crate::web_app::WebAppBuild> {
self.web_apps.iter().find(|w| w.name == name)
}
/// Validate a declared web app: its own shape, that the variant of the same
/// name exists and is `kind = "wasm"`, and that no `[[build.wasm]]` cell
/// claims the same variant (two post-build roads for one cargo build).
pub fn validate_web_app(&self, w: &crate::web_app::WebAppBuild) -> Result<()> {
w.validate()?;
match self.get(&w.name) {
Some(v) if v.kind == ArtifactKind::Wasm => {}
Some(v) => {
return Err(anyhow!(
"web app '{}': the variant of that name is kind '{}', not 'wasm' — a web \
app is built from a wasm32 cargo build and there is none",
w.name,
v.kind.as_str()
))
}
None => {
return Err(anyhow!(
"web app '{}': no [[build.variant]] of that name (the web app is claimed by \
the wasm variant of the SAME NAME) — a web app nobody claims is never built",
w.name
))
}
}
if self.wasm(&w.name).is_some() {
return Err(anyhow!(
"web app '{}': a [[build.wasm]] cell of the same name exists too — one cargo \
build, two post-build roads writing bundles; declare one of them",
w.name
));
}
Ok(())
}
/// Look up a wasm cell by name, or a helpful error listing the known ones.
pub fn require_wasm(&self, name: &str) -> Result<&crate::wasm::WasmBuild> {
self.wasm(name).ok_or_else(|| {
anyhow!(
"no wasm cell '{}'; known wasm cells: {}",
name,
if self.wasms.is_empty() {
"<none declared>".to_string()
} else {
self.wasm_names().join(", ")
}
)
})
}
/// The declared package cell names.
pub fn package_names(&self) -> Vec<&str> {
self.packages.iter().map(|p| p.name.as_str()).collect()
}
/// Look up a package cell by name.
pub fn package(&self, name: &str) -> Option<&crate::package::PackageBuild> {
self.packages.iter().find(|p| p.name == name)
}
/// Look up a package cell by name, or a helpful error listing the known ones.
/// Mirrors [`require_kernel`](Self::require_kernel) — the resolver `dispatch`
/// calls once it knows the variant is `kind = "package"`.
pub fn require_package(&self, name: &str) -> Result<&crate::package::PackageBuild> {
self.package(name).ok_or_else(|| {
anyhow!(
"no package build '{}'; known packages: {}",
name,
if self.packages.is_empty() {
"<none declared>".to_string()
} else {
self.package_names().join(", ")
}
)
})
}
/// Look up a kernel build by name, or a helpful error listing the known ones.
/// Mirrors [`require_iso`](Self::require_iso) — the resolver `dispatch` calls
/// for a `kind = "kernel"` variant (the handle rides in the variant name).
pub fn require_kernel(&self, name: &str) -> Result<&crate::kernel::KernelBuild> {
self.kernel(name).ok_or_else(|| {
anyhow!(
"no kernel build '{}'; known kernels: {}",
name,
if self.kernels.is_empty() {
"<none declared>".to_string()
} else {
self.kernel_names().join(", ")
}
)
})
}
/// Validate a declared kernel build: the descriptor itself — a pinned
/// tarball sha256 (an unpinned kernel is refused BY NAME), a known
/// hardening profile, a wired arch/target and a repo-relative fragment.
/// It chains no cargo variant ([`variant_deps`](crate::kernel::KernelBuild::variant_deps)
/// is empty by construction), so there is no cross-reference half. Mirrors
/// [`validate_iso`](Self::validate_iso).
pub fn validate_kernel(&self, kernel: &crate::kernel::KernelBuild) -> Result<()> {
kernel.validate()
}
/// Validate a declared wasm cell: the descriptor itself, and that a variant of
/// the SAME NAME is declared and is `kind = "wasm"`.
///
/// The name check is the one that matters. A cell nobody claims is a set of
/// stages that never run — and because the cell is optional, nothing else would
/// ever notice: no variant would fail, no build would refuse, and the bundle
/// would simply not be built while the file says it is.
pub fn validate_wasm_cell(&self, cell: &crate::wasm::WasmBuild) -> Result<()> {
cell.validate()?;
match self.get(&cell.name) {
Some(v) if v.kind == ArtifactKind::Wasm => Ok(()),
Some(v) => Err(anyhow!(
"wasm cell '{}': the variant of that name is kind '{}', not 'wasm' — \
the cell's stages run after a wasm cargo build and there is none",
cell.name,
v.kind.as_str()
)),
None => Err(anyhow!(
"wasm cell '{}': no [[build.variant]] of that name (the \
iso/installer/kernel/package rule — the cell is claimed by the \
variant of the SAME NAME). A cell nobody claims is a set of stages \
that never run, and nothing else would ever say so",
cell.name
)),
}
}
/// Validate a declared package cell: the descriptor itself, and that every
/// cargo variant it chains for its inputs (`from_variant`) names a declared
/// variant. Mirrors [`validate_installer`](Self::validate_installer).
pub fn validate_package(&self, pkg: &crate::package::PackageBuild) -> Result<()> {
pkg.validate()?;
for dep in &pkg.from_variant {
if self.get(dep).is_none() {
return Err(anyhow!(
"package '{}': from_variant '{dep}' names no declared variant — the inputs it stages would never be built",
pkg.name
));
}
}
// ── THE WASM EDGE, AND IT IS CHECKED ────────────────────────────────
// Naming a producer is worth nothing on its own: `from_variant` named one
// too, and the whole 2026-09-06 defect was that it produced something
// ELSE. So every input this package stages must be a file the named cell
// SAYS it writes, and a mismatch is refused here rather than discovered by
// a build that has already reported success.
if let Some(w) = &pkg.from_wasm {
let cell = self.wasm(w).ok_or_else(|| {
anyhow!(
"package '{}': from_wasm '{w}' names no declared [[build.wasm]] cell; known wasm cells: {}",
pkg.name,
if self.wasms.is_empty() {
"<none declared>".to_string()
} else {
self.wasm_names().join(", ")
}
)
})?;
let variant = self.get(&cell.name).ok_or_else(|| {
anyhow!(
"package '{}': from_wasm '{w}' names a cell with no variant of that name",
pkg.name
)
})?;
let produced = cell.outputs(variant);
for row in &pkg.inputs {
let (from, _) = pkg.input_pair(row)?;
if !produced.contains(&from) {
return Err(anyhow!(
"package '{}': it stages `{from}`, and the wasm cell '{w}' it is chained to does not produce that file. '{w}' produces: {}. A chain that promises a product its producer does not make is the defect this edge exists to end — it would build cleanly and then refuse for a missing input, one step later, with a success in between",
pkg.name,
if produced.is_empty() { "<nothing>".to_string() } else { produced.join(", ") }
));
}
}
}
Ok(())
}
/// Validate a declared disk-installer: the descriptor itself (name + volume_id +
/// a plausible target disk + payload, and the mastered ISO), and that any cargo
/// variant it chains for its PID-1 payload (`installer_variant` →
/// [`variant_deps`](crate::installer::InstallerBuild::variant_deps)) names a
/// declared variant. Mirrors [`validate_iso`](Self::validate_iso).
pub fn validate_installer(&self, installer: &crate::installer::InstallerBuild) -> Result<()> {
installer.validate()?;
for dep in installer.variant_deps() {
if self.get(dep).is_none() {
return Err(anyhow!(
"installer '{}': chains cargo variant '{}' (its PID-1 payload) which is not \
declared (known variants: {})",
installer.name,
dep,
if self.variants.is_empty() {
"<none>".to_string()
} else {
self.names().join(", ")
}
));
}
}
Ok(())
}
/// Validate a container build against this set: the descriptor must be
/// well-formed AND every cargo variant it chains (`from_variant`) must exist.
pub fn validate_container(&self, c: &crate::container::ContainerBuild) -> Result<()> {
c.validate()?;
for dep in c.variant_deps() {
if self.get(dep).is_none() {
return Err(anyhow!(
"container '{}': layer chains cargo variant '{}' which is not declared \
(known variants: {})",
c.name,
dep,
if self.variants.is_empty() {
"<none>".to_string()
} else {
self.names().join(", ")
}
));
}
}
Ok(())
}
/// [`validate_container`](Self::validate_container) plus the **on-disk** check
/// a `containerfile = "…"` variant needs: the real Containerfile and its build
/// context exist under `repo_root`.
///
/// Split from `validate_container` rather than folded into it because the
/// shape rules must stay callable with no filesystem at all — a build-thing
/// wasm PLUGIN validates a set it can only see as data. Any caller that HAS a
/// repo root (the forge, `--list`, the image builder) should prefer this one:
/// it is the difference between `--list` reporting a variant as buildable and
/// discovering at build time that the Containerfile was never there.
pub fn validate_container_in(
&self,
c: &crate::container::ContainerBuild,
repo_root: &Path,
) -> Result<()> {
self.validate_container(c)?;
c.validate_in(repo_root)
}
/// Validate a declared VM boot: the descriptor itself, and that any
/// `from_variant` it chains names a declared cargo variant (so "build it then
/// boot it" can't reference a phantom target). Mirrors [`validate_container`].
pub fn validate_vm(&self, vm: &crate::vm::VmBuild) -> Result<()> {
vm.validate()?;
for dep in vm.variant_deps() {
if self.get(dep).is_none() {
return Err(anyhow!(
"vm '{}': boots cargo variant '{}' which is not declared \
(known variants: {})",
vm.name,
dep,
if self.variants.is_empty() {
"<none>".to_string()
} else {
self.names().join(", ")
}
));
}
}
Ok(())
}
/// Validate a declared bootable ISO: the descriptor itself, and that any
/// `from_variant` it chains names a declared cargo variant (so "build it then
/// master an ISO around it" can't reference a phantom target). Mirrors
/// [`validate_vm`](Self::validate_vm).
pub fn validate_iso(&self, iso: &crate::iso::IsoBuild) -> Result<()> {
iso.validate()?;
for dep in iso.variant_deps() {
if self.get(dep).is_none() {
return Err(anyhow!(
"iso '{}': masters cargo variant '{}' which is not declared \
(known variants: {})",
iso.name,
dep,
if self.variants.is_empty() {
"<none>".to_string()
} else {
self.names().join(", ")
}
));
}
}
Ok(())
}
/// **Exactly one `default = true` per declared role**, or the reasons it is not
/// so. An empty result means well-formed.
///
/// The role axis is optional (a single-role repo may omit it), but the moment a
/// repo declares roles it must also say which variant in each is the normal
/// case. `Vec<String>` rather than `Result` because a declaration can be wrong
/// in several ways at once and a reader wants all of them.
///
/// Variants with no `role` are grouped under the pseudo-role `<unroled>` and
/// held to the same rule, so a repo cannot escape the check by declining to
/// name a role — the one escape this whole axis exists to close.
///
/// **Grouped on `(suite, role)`, not on role alone.** A repo shipping two
/// deliveries out of one crate graph (see [`BuildVariant::suites`]) has two
/// server groups, each with its own normal case. Grouping by role alone accepted
/// such a file and handed back the first-declared suite's default, leaving the
/// other delivery silently defaultless — a green that could never go red. The
/// grouping comes from [`groups`](Self::groups), the same writer the menu uses.
///
/// A repo that declares no suite yields exactly the old role grouping and, to
/// the byte, the old messages: the suite is named only when the repo actually
/// declares one.
pub fn check_defaults(&self) -> Vec<String> {
let mut problems = Vec::new();
let names_suites = self.declares_suites();
for (suite, role, members) in self.groups() {
let marked: Vec<&str> = members
.iter()
.filter(|v| v.is_default)
.map(|v| v.name.as_str())
.collect();
// Only name the suite when the repo declares one — a single-delivery repo
// must not start reading "suite '<the delivery>' role 'server'".
let label = if names_suites {
format!("suite '{suite}' role '{role}'")
} else {
format!("role '{role}'")
};
match marked.len() {
1 => {}
0 => problems.push(format!(
"{} has {} variant(s) and NO `default = true` — the caller has to \
guess which is the normal case (fat is normally the answer)",
label,
members.len()
)),
n => problems.push(format!(
"{label} marks {n} defaults ({}) — exactly one is the normal case",
marked.join(", ")
)),
}
}
problems
}
/// True when any variant declares a [`suite`](BuildVariant::suites) — i.e. this
/// repo ships more than one delivery and the suite axis is LIVE.
///
/// The way to tell [`check_suites`](Self::check_suites)'s "no problems" from its
/// "not applicable": both are an empty `Vec`, and only this says which.
pub fn declares_suites(&self) -> bool {
self.variants
.iter()
.any(|v| v.suites.iter().any(|s| !s.trim().is_empty()))
}
/// The declared delivery names, in declaration order. Empty when the repo ships
/// a single unnamed delivery.
pub fn suites(&self) -> Vec<&str> {
let mut out: Vec<&str> = Vec::new();
for v in &self.variants {
for s in v.suites.iter().map(|s| s.trim()).filter(|s| !s.is_empty()) {
if !out.contains(&s) {
out.push(s);
}
}
}
out
}
/// The variants of one delivery, in declaration order. `suite` is a declared
/// name or [`UNSUITED`]. A variant in N suites is returned by N of these calls.
pub fn in_suite(&self, suite: &str) -> Vec<&BuildVariant> {
self.variants
.iter()
.filter(|v| v.suite_keys().contains(&suite))
.collect()
}
/// **The `(suite, role)` grouping, in declaration order** — the shared spine
/// under [`check_defaults`](Self::check_defaults), the suite guards and the
/// menu's [`RoleGroup`](crate::plugin::RoleGroup)s.
///
/// ONE writer, so the checker and the thing it checks cannot disagree about what
/// a group is. A variant declaring N suites appears in N groups — that is the
/// difference between this and a scalar suite key, and it is the difference
/// between catching a defaultless delivery and not.
///
/// Order is first-appearance while scanning variants in declaration order, and
/// within a variant, its suites in declaration order. A repo that declares no
/// suite yields exactly the old role grouping, unchanged.
pub fn groups(&self) -> Vec<(&str, &str, Vec<&BuildVariant>)> {
let mut order: Vec<(&str, &str)> = Vec::new();
for v in &self.variants {
for k in v.group_keys() {
if !order.contains(&k) {
order.push(k);
}
}
}
order
.into_iter()
.map(|(suite, role)| {
let members: Vec<&BuildVariant> = self
.variants
.iter()
.filter(|v| v.group_keys().contains(&(suite, role)))
.collect();
(suite, role, members)
})
.collect()
}
/// **The suite axis' own guard** — the reasons the delivery declaration is not
/// sound. Empty result = well-formed.
///
/// ⚠ **Empty is also what "not applicable" looks like.** A repo declaring fewer
/// than two suites gets a VACUOUSLY empty answer: there is no second delivery to
/// be a subset of anything. Ask [`declares_suites`](Self::declares_suites) to
/// tell the two apart — "we checked and it is fine" and "there was nothing to
/// check" must never be read as the same sentence.
///
/// Three rules, each because a second delivery is otherwise expressible but not
/// checkable:
///
/// 1. **No blank names.** `suites = [""]` reads as a delivery whose name is
/// nothing; it groups as [`UNSUITED`] and would quietly join the unnamed
/// delivery.
/// 2. **A named base.** As soon as two suites exist, `base_suite` must name one
/// of them. Without it rule 3 has no subject — and an unstated subject is how
/// a guard ends up passing on nothing.
/// 3. **`features(other) ⊆ features(base)`.** A carved-out delivery may enable
/// fewer features than the whole product, never a feature the whole product
/// does not build. A feature only the MVP turns on is a build point nothing
/// else compiles — precisely the rot [`crate::feature_axis`] exists to catch,
/// reappearing one level up. A variant shipping in BOTH suites is part of the
/// base by definition and so can never violate this, which is right.
///
/// ## What this deliberately does NOT claim
///
/// It compares **declared feature sets**. It cannot see an *absence*.
/// `holger-mvp`'s defining property is ZERO EGRESS, and no feature anywhere
/// means "and definitely not `reqwest`" — a subset of features says nothing
/// about which crates the resolver drags in. That needs a MEASURED guard over
/// the resolved dependency tree; holger already has one
/// (`holger/build-space/tests/auth_ship_small.rs`). Saying so here keeps this
/// guard from being read as the stronger claim it is not.
pub fn check_suites(&self) -> Vec<String> {
let mut problems = Vec::new();
for v in &self.variants {
if v.suites.iter().any(|s| s.trim().is_empty()) {
problems.push(format!(
"variant '{}' declares a BLANK suite name — a delivery with no name is \
indistinguishable from declaring none, and would silently join the \
repo's unnamed delivery",
v.name
));
}
}
let suites = self.suites();
if suites.len() < 2 {
// 0 suites: the axis is not in use. 1 suite: nothing to be a subset of,
// and declaring a base for it would assert a containment nobody can
// violate. Either way this is NOT APPLICABLE, not "checked and clean".
return problems;
}
if self.independent_suites {
// Declared siblings: there is no base and the subset rule does not
// apply. Refuse the contradiction rather than picking one, because a
// `base_suite` left behind beside this flag still names a subject
// nothing checks.
if self.base_suite.as_deref().map(str::trim).is_some_and(|b| !b.is_empty()) {
problems.push(format!(
"`independent_suites` says the {} deliveries ({}) are siblings, but \
`base_suite` still names one as the base they are carved OUT of — two \
answers to which is the base. Drop one.",
suites.len(),
suites.join(", ")
));
}
return problems;
}
let Some(base) = self
.base_suite
.as_deref()
.map(str::trim)
.filter(|b| !b.is_empty())
else {
problems.push(format!(
"{} suites are declared ({}) but no `base_suite` names which one the others \
are carved OUT of — so `features(other) ⊆ features(base)` has no subject and \
nothing checks that a smaller delivery stays a subset of the product",
suites.len(),
suites.join(", ")
));
return problems;
};
if !suites.contains(&base) {
problems.push(format!(
"`base_suite = \"{base}\"` names no declared suite (declared: {})",
suites.join(", ")
));
return problems;
}
// ⚠ THE SUBSET RULE LIVES IN `check_suites_resolved`, NOT HERE — moved
// 2026-08-02, because comparing DECLARED feature strings is unsound and was
// provably wrong on the reference repo. holger names one composite feature per
// tier (`mvp-server` = mini + secure_update, `gui-mini` = gui-core +
// about-card + mini), so no MVP variant could ever be a string subset of
// ORIGINAL's declared names even though it IS a capability subset. This
// function reported two confident false reds and blocked holger from declaring
// the suites it had already settled. A subset rule needs the cargo
// `[features]` closure, which needs the manifests, which this `&self`-only
// method cannot read — so the check moved to where the input exists rather
// than staying here in a shape that answered the wrong question.
let _ = base;
problems
}
/// **No two variants of one delivery may write the SAME file.** Empty result =
/// well-formed.
///
/// # The defect this exists to catch
///
/// A `[[build.variant]]` is a *build point*, and a cargo build point lands at
/// exactly one path: `target/<triple>/<profile>/<bin>`. When two variants of the
/// same delivery declare the same `bin` at the same `profile` and `target`, they
/// are not two artifacts — they are one file, written twice. Whichever built
/// last is what is on disk, and NOTHING on disk says which one it is.
///
/// Measured 2026-08-01, before this guard existed: korp declared bin `korp`
/// three times (`korp`, `korp-thin`, `korp-spark41`), nornir declared bin
/// `nornir` in ALL THREE of its variants, and dwarves declared bin `dwarves`
/// three times. So "build the fat one and the thin one and compare" was not
/// something those repos could do at all — a fat build and a thin build could
/// not coexist on one machine, and a deployer holding the binary could not tell
/// them apart. The declaration described a matrix the filesystem could not hold.
///
/// holger solved this long before the axis was named: `holger-embedded` /
/// `holger-mikro` / `holger-mvp` / `holger-full` are distinct `[[bin]]` names
/// off ONE `main.rs`, precisely so the shapes can sit side by side.
///
/// # The collision key, and why it is not just the bin name
///
/// `(suite, target, profile, package, bin)`.
///
/// * **suite** — two DELIVERIES may reuse a name on purpose. holger ships
/// `holger-server` in both MVP and ORIGINAL from different feature sets, and
/// that is correct: you build one delivery at a time and the binary reports
/// which one it came from in `--version`. Keying on the bin name alone would
/// red on the one repo that already had this right.
/// * **profile** and **target** — `target/release/x` and `target/debug/x` are
/// different files, as are two cross-compile triples. dwarves' `dwarves-default`
/// variant exists precisely to build the `dev`-profile default surface of the
/// same bin, and it is not a collision.
/// * **package** — belt and braces: two packages cannot emit the same bin name
/// into one target dir anyway (cargo rejects it), but keying on it keeps this
/// guard's notion of "same file" honest rather than approximate.
///
/// Variants with no `bin` are skipped: they build the package's default bin (or
/// a lib), which this cannot resolve without cargo metadata. That is a real
/// limit and it is named here rather than papered over — a repo that leaves
/// `bin` off two `Exe` variants of one package still has the defect and this
/// guard will not see it. Declare the `bin`.
pub fn check_bins(&self) -> Vec<String> {
// (suite, target, profile, package, bin) -> the variants that claim it, in
// declaration order.
let mut order: Vec<(String, String)> = Vec::new();
let mut claims: std::collections::BTreeMap<String, Vec<&str>> =
std::collections::BTreeMap::new();
for v in &self.variants {
let Some(bin) = v.bin.as_deref().map(str::trim).filter(|b| !b.is_empty()) else {
continue;
};
let target = v.target.as_deref().unwrap_or("<host>");
let package = v.package.as_deref().unwrap_or("<root>");
for suite in v.suite_keys() {
let key = format!(
"{suite}\u{1}{target}\u{1}{}\u{1}{package}\u{1}{bin}",
v.profile.label()
);
let human = format!(
"bin '{bin}' (package {package}, profile {}, target {target})",
v.profile.label()
);
let entry = claims.entry(key.clone()).or_default();
if entry.is_empty() {
order.push((key.clone(), human));
}
entry.push(v.name.as_str());
}
}
let names_suites = self.declares_suites();
let mut problems = Vec::new();
for (key, human) in order {
let who = &claims[&key];
if who.len() < 2 {
continue;
}
let suite = key.split('\u{1}').next().unwrap_or(UNSUITED);
let where_ = if names_suites && suite != UNSUITED {
format!("delivery '{suite}': ")
} else {
String::new()
};
problems.push(format!(
"{where_}{} variants emit the SAME artifact — {human} — so the last build \
silently OVERWRITES the others and nothing on disk says which one you have: {}. \
Give each its own `bin` name off the same source (holger: holger-mikro / \
holger-full), or fold them into one variant.",
who.len(),
who.join(", ")
));
}
problems
}
/// **Every declared `bin` must be a `[[bin]]` cargo can actually build.** Empty
/// result = well-formed.
///
/// The sibling of [`check_bins`](Self::check_bins), and it caught a defect that
/// had been live for months: korp renamed its GUI target `korp` → `korp-ui` in
/// `Cargo.toml` and left three variants declaring `bin = "korp"`. Every one of
/// them materialized `cargo build -p korp --bin korp …`, which cannot succeed —
/// and korp's own `tests/build_variants.rs` asserted that exact argv STRING, so
/// the test was green on an argv that could only ever fail. Nothing compared the
/// declaration to the manifest, so the drift was invisible from both sides.
///
/// `real` is the set of `[[bin]] name = …` targets the repo's manifests declare.
/// It is passed IN rather than read here on purpose: this crate is dep-light and
/// has no cargo-metadata, and the caller (a repo's own build-variants test) is
/// the one that knows its workspace layout. What must NOT happen is each repo
/// hand-rolling the comparison — that is the twin this method removes.
pub fn check_bins_declared<S: AsRef<str>>(&self, real: &[S]) -> Vec<String> {
let real: BTreeSet<&str> = real.iter().map(|s| s.as_ref()).collect();
if real.is_empty() {
return vec![
"no `[[bin]]` targets were passed in — this check would pass VACUOUSLY \
on every variant, which is worse than not running it"
.to_string(),
];
}
let mut problems = Vec::new();
for v in &self.variants {
let Some(bin) = v.bin.as_deref().map(str::trim).filter(|b| !b.is_empty()) else {
continue;
};
if !real.contains(bin) {
problems.push(format!(
"variant '{}' declares bin '{}', which no `[[bin]]` target exists for — \
`cargo build --bin {}` cannot succeed. Declared targets: {}",
v.name,
bin,
bin,
real.iter().copied().collect::<Vec<_>>().join(", ")
));
}
}
problems
}
/// **Every `[[bin]]` target the repo's manifests declare** — the input
/// [`check_bins_declared`](Self::check_bins_declared) needs, read once, here.
///
/// Without this every repo's build-variants test hand-rolls the same
/// Cargo.toml walk, and four copies of a parser is the twin this crate exists
/// to remove (LAW 5). Worse, the obvious shortcut — hardcoding the bin names in
/// the test — recreates exactly the second declaration the guard is meant to
/// catch: korp's test already hardcoded a variant roster and stayed green while
/// the manifest renamed a bin out from under it.
///
/// Walks `repo_root` for `Cargo.toml` files, skipping `target/`, `.git/`,
/// `vendor/` and any `.claude/worktrees` — build output and vendored sources are
/// not this repo's declaration. Collects, per manifest, every explicit
/// `[[bin]] name = …` **and every bin cargo AUTO-DISCOVERS from the source
/// layout**, which the manifest text never mentions.
///
/// # Why it parses instead of asking cargo
///
/// `cargo metadata --format-version 1` answers this exactly, and the
/// `cargo_metadata` crate is the idiomatic way in. It is not used here for two
/// reasons that both had to hold: the LAW forbids shelling out and
/// `cargo_metadata` is a subprocess wrapper around the `cargo` binary; and the
/// call sites are guards — `check_bins_declared`, `tally_bins`,
/// `coverage_only_failures` — that run over a repo checkout in contexts where
/// invoking cargo means a lock wait, a network resolve of the whole dependency
/// graph, and a failure mode ("no cargo on PATH", "offline") that would turn a
/// membership question into a build. So the rule is spelled out here instead,
/// in full, once. **If a third half-correct special case is ever tempting, that
/// is the signal to pay the cargo-metadata cost rather than guess again** — a
/// partial rule is what made this function lie.
///
/// # Cargo's documented rule, implemented whole
///
/// Per the Cargo Book, *Target auto-discovery*:
///
/// * `src/main.rs` ⇒ a bin named after the **package**;
/// * `src/bin/<name>.rs` ⇒ a bin named `<name>`;
/// * `src/bin/<name>/main.rs` ⇒ a bin named `<name>`;
/// * an explicit `[[bin]]` **does not** switch discovery off — the two sets
/// union. A `[[bin]]` only suppresses the auto-discovered target that would
/// share its `name` or its `path` (this is how `[[bin]] name = "korp-ui"
/// path = "src/main.rs"` stops a package-named `korp` bin existing);
/// * `autobins = false` in `[package]` is the one real opt-out;
/// * edition 2015 (the default when `edition` is absent) turns discovery off as
/// soon as any `[[bin]]` is declared. Every manifest in this constellation is
/// 2018+, but the rule is cheap and its absence is a silent over-report.
///
/// # The defect this replaces
///
/// The old body handled exactly one auto-discovery case — `src/main.rs`, and
/// only when the manifest declared **no** `[[bin]]` at all — and was blind to
/// `src/bin/` entirely. Measured 2026-08-25 by running both bodies over every
/// sibling checkout, that hid SIX binaries in two repos: edda's
/// `nornir-bench-redprobe` and `fleet_mock_server`, and facett's
/// `errcode_registry_json`, `errcode_registry_md`, `osm2d_demo` and its
/// package-named `facett-demo` bin. korp's `korp-probe` was the seventh until
/// korp worked around it. The failure is one-sided in two ways, and both are
/// bad: `check_bins_declared` prints "`cargo build --bin X` cannot succeed"
/// about a bin cargo builds (korp: five of nine `tests/build_variants.rs`
/// cases red on main since `8023cb1`), while `tally_bins` and
/// `coverage_only_failures` just never count the target — a hole that goes
/// unreported is a guard reporting success it did not measure.
///
/// Returns names in sorted order with duplicates removed: this is a membership
/// oracle, not a per-package map. Two packages cannot emit one bin name into one
/// target dir anyway (cargo rejects it), so flattening loses nothing the caller
/// can act on.
pub fn cargo_bin_targets(repo_root: &Path) -> Result<Vec<String>> {
/// Every bin one manifest yields: explicit `[[bin]]` names ∪ the targets
/// cargo auto-discovers from `pkg_dir`'s source layout.
fn bins_of_manifest(doc: &toml::Value, pkg_dir: &Path, out: &mut BTreeSet<String>) {
// A virtual workspace manifest has no `[package]` and therefore no
// targets of its own — its members are separate manifests this walk
// visits in their own right.
let Some(pkg) = doc.get("package") else {
return;
};
let pkg_name = pkg.get("name").and_then(|n| n.as_str());
// Explicit `[[bin]]`s, remembering the NAMES and PATHS they claim so an
// auto-discovered target with the same identity is not counted twice.
let mut claimed_names: BTreeSet<String> = BTreeSet::new();
let mut claimed_paths: BTreeSet<String> = BTreeSet::new();
let mut declared_any = false;
if let Some(bins) = doc.get("bin").and_then(|b| b.as_array()) {
for b in bins {
declared_any = true;
if let Some(n) = b.get("name").and_then(|n| n.as_str()) {
out.insert(n.to_string());
claimed_names.insert(n.to_string());
}
if let Some(path) = b.get("path").and_then(|p| p.as_str()) {
// Manifest paths are always `/`-separated and relative to
// the package dir; normalise a leading `./` so
// `./src/main.rs` and `src/main.rs` claim the same file.
claimed_paths
.insert(path.trim_start_matches("./").replace('\\', "/").to_string());
}
}
}
// `autobins = false` — the explicit opt-out.
if pkg.get("autobins").and_then(|a| a.as_bool()) == Some(false) {
return;
}
// Edition 2015 (also the default when `edition` is absent) disables
// auto-discovery once any target section is declared. `edition.workspace
// = true` is not a string, and a workspace-inheriting manifest is 2018+
// by construction, so an unreadable edition means "modern".
let edition_2015 = match pkg.get("edition") {
Some(toml::Value::String(e)) => e == "2015",
Some(_) => false,
None => true,
};
if edition_2015 && declared_any {
return;
}
let discovered = |name: String, rel: &str, out: &mut BTreeSet<String>| {
if !claimed_names.contains(&name) && !claimed_paths.contains(rel) {
out.insert(name);
}
};
// src/main.rs => a bin named after the package.
if let Some(pkg_name) = pkg_name {
if pkg_dir.join("src").join("main.rs").is_file() {
discovered(pkg_name.to_string(), "src/main.rs", out);
}
}
// src/bin/<name>.rs and src/bin/<name>/main.rs.
let bin_dir = pkg_dir.join("src").join("bin");
if let Ok(rd) = std::fs::read_dir(&bin_dir) {
for e in rd.flatten() {
let p = e.path();
let file = e.file_name();
let file = file.to_string_lossy();
if p.is_dir() {
if p.join("main.rs").is_file() {
let rel = format!("src/bin/{file}/main.rs");
discovered(file.to_string(), &rel, out);
}
} else if let Some(stem) = file.strip_suffix(".rs") {
let rel = format!("src/bin/{file}");
discovered(stem.to_string(), &rel, out);
}
}
}
}
fn walk(dir: &Path, out: &mut BTreeSet<String>, depth: usize) -> Result<()> {
if depth > 8 {
return Ok(());
}
let rd = match std::fs::read_dir(dir) {
Ok(rd) => rd,
Err(_) => return Ok(()), // unreadable subtree is not a declaration
};
for e in rd.flatten() {
let p = e.path();
let name = e.file_name();
let name = name.to_string_lossy();
if p.is_dir() {
if matches!(name.as_ref(), "target" | ".git" | "vendor" | "node_modules")
|| name.starts_with('.')
{
continue;
}
walk(&p, out, depth + 1)?;
} else if name == "Cargo.toml" {
let text = std::fs::read_to_string(&p)
.with_context(|| format!("read {}", p.display()))?;
let doc: toml::Value = match toml::from_str(&text) {
Ok(d) => d,
// A malformed manifest is somebody else's error; skipping it
// silently would shrink the oracle, so say so and go on.
Err(e) => {
eprintln!("cargo_bin_targets: skipping {} ({e})", p.display());
continue;
}
};
if let Some(pkg_dir) = p.parent() {
bins_of_manifest(&doc, pkg_dir, out);
}
}
}
Ok(())
}
let mut out = BTreeSet::new();
walk(repo_root, &mut out, 0)?;
Ok(out.into_iter().collect())
}
/// **Every package's `[features]` table** — the input
/// [`check_suites_resolved`](Self::check_suites_resolved) needs, read once, here.
///
/// Same walk and same exclusions as
/// [`cargo_bin_targets`](Self::cargo_bin_targets) (one writer of the "what counts
/// as this repo's manifests" rule), keyed by `[package] name` so a variant's
/// `package` field selects its table.
pub fn cargo_feature_tables(repo_root: &Path) -> Result<BTreeMap<String, FeatureTable>> {
fn walk(dir: &Path, out: &mut BTreeMap<String, FeatureTable>, depth: usize) -> Result<()> {
if depth > 8 {
return Ok(());
}
let rd = match std::fs::read_dir(dir) {
Ok(rd) => rd,
Err(_) => return Ok(()),
};
for e in rd.flatten() {
let p = e.path();
let name = e.file_name();
let name = name.to_string_lossy();
if p.is_dir() {
if matches!(name.as_ref(), "target" | ".git" | "vendor" | "node_modules")
|| name.starts_with('.')
{
continue;
}
walk(&p, out, depth + 1)?;
} else if name == "Cargo.toml" {
let Ok(text) = std::fs::read_to_string(&p) else {
continue;
};
let Ok(doc) = toml::from_str::<toml::Value>(&text) else {
eprintln!("cargo_feature_tables: skipping {}", p.display());
continue;
};
let Some(pkg) = doc
.get("package")
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str())
else {
continue;
};
let mut features = BTreeMap::new();
let mut default = Vec::new();
if let Some(tbl) = doc.get("features").and_then(|f| f.as_table()) {
for (k, v) in tbl {
let deps: Vec<String> = v
.as_array()
.map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
if k == "default" {
default = deps.clone();
}
features.insert(k.clone(), deps);
}
}
out.insert(pkg.to_string(), FeatureTable { features, default });
}
}
Ok(())
}
let mut out = BTreeMap::new();
walk(repo_root, &mut out, 0)?;
Ok(out)
}
/// **`features(carved-out suite) ⊆ features(base suite)`, over RESOLVED cargo
/// features.** Empty result = well-formed.
///
/// # Why this exists and [`check_suites`](Self::check_suites) could not do it
///
/// `check_suites` compares the feature strings a variant DECLARES. That is
/// unsound the moment a repo names one composite feature per tier, and it made
/// the subset rule unsatisfiable for the exact repo the suite model was designed
/// from. Measured 2026-08-02, holger's first declaration of its two settled
/// suites:
///
/// ```text
/// suite 'MVP' variant 'mvp-server' enables feature 'mvp-server', which the base
/// suite 'ORIGINAL' never builds (base builds: embedded, full, gui, …)
/// suite 'MVP' variant 'ui-mini' enables feature 'gui-mini', which the base suite
/// 'ORIGINAL' never builds
/// ```
///
/// Both reds are FALSE. `mvp-server = ["mini", "secure_update"]` and ORIGINAL's
/// `server-local` inherits the crate default `["mini", "online", "bench",
/// "secure_update"]`, so MVP's server is a capability subset of ORIGINAL's — the
/// two deliveries simply spell their tiers with different composite NAMES, which
/// is what a composite is for. holger's suites went undeclared for a day because
/// declaring them correctly reddened a guard that was comparing the wrong thing.
///
/// So: expand each variant's selection through its package's `[features]` table
/// (transitively, plus the crate `default` set when `no_default_features` is
/// false) and compare the CLOSURES. `dep:x` and `crate/feat` tokens are kept
/// opaque — they never expand, and they compare across suites exactly as well as
/// a plain name does.
///
/// A variant naming no `package` cannot be resolved, and that is REPORTED rather
/// than skipped: silently dropping the unresolvable case is how a subset rule
/// goes vacuous.
pub fn check_suites_resolved(&self, tables: &BTreeMap<String, FeatureTable>) -> Vec<String> {
let mut problems = Vec::new();
let suites = self.suites();
if suites.len() < 2 {
return problems;
}
if self.independent_suites {
// Declared siblings — no containment is claimed, so there is nothing
// here to violate. `check_suites` has already refused a `base_suite`
// left beside the flag.
return problems;
}
let Some(base) = self
.base_suite
.as_deref()
.map(str::trim)
.filter(|b| !b.is_empty())
else {
// `check_suites` already reports the missing base; do not say it twice.
return problems;
};
let resolve = |v: &BuildVariant| -> Option<BTreeSet<String>> {
let t = tables.get(v.package.as_deref()?)?;
Some(t.leaf_closure(&v.features, !v.no_default_features))
};
// A DELEGATING variant compiles nothing of its own: a tagged pointer of
// kind container / vm / iso / installer carries no cargo argv (no
// `package`, no `features`), so it HAS no feature closure — its payload
// cell chains `from_variant` / `variant` rows that are themselves
// declared and pass through this very check under their own suites.
// Skipping these is not a silent cap: every cargo-shaped kind (exe,
// service, lib, wasm) with an unreadable package is still reported.
// Without the skip, tagging a payload cell's pointer variant into a
// carved-out suite (holger-mvp-appliance / holger-installer, 2026-08-17)
// reads as "cannot check" — a red about a closure that does not exist.
let delegates = |v: &BuildVariant| {
matches!(
v.kind,
ArtifactKind::Container
| ArtifactKind::Vm
| ArtifactKind::Iso
| ArtifactKind::Installer
| ArtifactKind::Kernel
)
};
let mut base_closure: BTreeSet<String> = BTreeSet::new();
for v in self.in_suite(base) {
if delegates(v) {
continue;
}
match resolve(v) {
Some(c) => base_closure.extend(c),
None => problems.push(format!(
"base suite '{base}' variant '{}' names package {:?}, which has no \
readable [features] table — the base closure is INCOMPLETE, so a \
subset check against it would pass things it should not",
v.name, v.package
)),
}
}
for suite in suites {
if suite == base {
continue;
}
for v in self.in_suite(suite) {
if delegates(v) {
continue;
}
let Some(c) = resolve(v) else {
problems.push(format!(
"suite '{suite}' variant '{}' names package {:?}, which has no \
readable [features] table — cannot check it is a subset of '{base}'",
v.name, v.package
));
continue;
};
let missing: Vec<&str> = c
.iter()
.filter(|f| !base_closure.contains(*f))
.map(|s| s.as_str())
.collect();
if !missing.is_empty() {
problems.push(format!(
"suite '{suite}' variant '{}' resolves to feature(s) {} that the base \
suite '{base}' never builds — a carved-out delivery must be a SUBSET \
of the product",
v.name,
missing.join(", ")
));
}
}
}
problems
}
/// The variant marked `default = true` in `role` (`None` for the unroled group).
///
/// Ambiguous once a repo declares more than one delivery — two suites can each
/// hold a default server and this returns whichever was declared first. Use
/// [`default_in`](Self::default_in) there; this stays for single-delivery repos,
/// which is every caller that predates the suite axis.
pub fn default_for_role(&self, role: Option<crate::plugin::Role>) -> Option<&BuildVariant> {
self.variants
.iter()
.find(|v| v.is_default && v.role == role)
}
/// The variant marked `default = true` in one `(suite, role)` group — the
/// suite-aware resolver. `suite` is a declared name or [`UNSUITED`].
pub fn default_in(
&self,
suite: &str,
role: Option<crate::plugin::Role>,
) -> Option<&BuildVariant> {
self.variants
.iter()
.find(|v| v.is_default && v.role == role && v.suite_keys().contains(&suite))
}
/// Validate every declared variant, container, vm, iso AND installer — a
/// config-time sanity gate.
pub fn validate_all(&self) -> Result<()> {
for v in &self.variants {
self.validate(v)?;
}
for c in &self.containers {
self.validate_container(c)?;
}
for vm in &self.vms {
self.validate_vm(vm)?;
}
for iso in &self.isos {
self.validate_iso(iso)?;
}
for installer in &self.installers {
self.validate_installer(installer)?;
}
for kernel in &self.kernels {
self.validate_kernel(kernel)?;
}
for pkg in &self.packages {
self.validate_package(pkg)?;
}
for w in &self.wasms {
self.validate_wasm_cell(w)?;
}
for w in &self.web_apps {
self.validate_web_app(w)?;
}
Ok(())
}
/// [`validate_all`](Self::validate_all) against a real checkout: every shape
/// rule, plus the on-disk existence of every container variant's declared
/// `containerfile` + `context` ([`validate_container_in`](Self::validate_container_in)).
///
/// The one to call from anything that enumerates buildable targets for a
/// human, so `--list` cannot advertise a variant whose Containerfile is missing.
pub fn validate_all_in(&self, repo_root: &Path) -> Result<()> {
self.validate_all()?;
for c in &self.containers {
c.validate_in(repo_root)?;
}
// A kernel cell's declared `kconfig_fragment` must be in this checkout —
// the same "refuse before `--list` advertises it" rule as a Containerfile.
for k in &self.kernels {
k.validate_in(repo_root)?;
}
// Every consumption edge is TYPE-CHECKED against the donor beside this
// checkout — the constellation layout rule every cross-repo seam uses.
self.validate_edges_in(repo_root)
}
// ── consumption edges ────────────────────────────────────────────────────
/// **Every edge a declared variant carries**, as whole
/// [`ConsumptionEdge`](crate::tree::ConsumptionEdge)s in root order — the
/// `kernel_donor` row as the `kernel` edge first, then
/// `[…edges.<feature>]`. Empty for a variant that consumes nothing, `None`
/// for an undeclared name. One walk for every kind, so the CLI, the pane
/// and the type check read the same list.
///
/// This is the general shape; [`edges_of`](Self::edges_of) is the same list
/// as `(feature, row)` pairs for the readers written before the feature sat
/// on the value.
pub fn consumption_edges(&self, variant: &str) -> Option<Vec<crate::tree::ConsumptionEdge>> {
self.edges_decls_of(variant)
}
/// [`consumption_edges`](Self::consumption_edges) as `(feature, row)` pairs
/// — the shape the forge's `buildthing` listing and every other 0.1.x
/// reader already destructures.
The file goes on; the console shows all of it.