crates/nornir-build-thing/src/feature_axis.rs
//! **The FOURTH axis: features.**
//!
//! build-thing's variant model already spans three axes —
//! [`ArtifactKind`](crate::variant::ArtifactKind) × [`ServiceShape`](crate::variant::ServiceShape)
//! × target. Features were present as *data on a variant* but were never an axis
//! anyone could look at, and that gap is not theoretical. Three times in one day:
//!
//! * **korp** declared `spark41` in its `offers` and no variant named it. build-thing
//! therefore never built that combination, and **three separate compile breaks
//! accumulated invisibly** while the default build stayed green.
//! * **nornir**'s `scip` was in no default set, so `nornir-server` compiled no writer
//! and `scip_occurrences` sat at 0 rows in every repo.
//! * **dwarves-server** is `required-features = ["server"]`, which
//! `cargo build --all-targets` **silently skips** — so a stale binary stayed
//! installed.
//!
//! One rule explains all three: **a feature no variant declares is a feature nobody
//! builds, and it rots.** This module makes that visible, in the two directions it
//! can rot in:
//!
//! | direction | question | field |
//! |---|---|---|
//! | offered → built | the repo says it offers `spark41`; does any variant turn it on? | [`FeatureAxis::unbuilt`] |
//! | manifest → offered | `Cargo.toml` declares `scip`; does the build space even know about it? | [`FeatureAxis::unoffered`] |
//! | bin → variant | a `[[bin]]` has `required-features`; does any variant satisfy them? | [`FeatureAxis::unbuildable_bins`] |
//!
//! The report is pure data (a sibling of [`crate::surface::BuildSurface`]) so a UI
//! pane, a CLI `--json` and a gate all read the same thing.
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::variant::BuildVariantSet;
/// The feature axis of one repo's build space: which declared features an actual
/// variant builds, and which are declared-but-never-built.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct FeatureAxis {
/// The repo this axis belongs to.
pub repo: String,
/// The feature universe the repo `offers` (its `[build] offers`).
pub offers: BTreeSet<String>,
/// Every feature at least one variant enables → the variants that enable it.
/// This is the *covered* half of the axis.
pub built: BTreeMap<String, Vec<String>>,
/// **The gap.** Features the repo `offers` that NO declared variant enables —
/// korp's `spark41` before it was noticed. Sorted.
pub unbuilt: Vec<String>,
/// Features declared in the repo's `Cargo.toml` `[features]` tables that the
/// build space does not even `offers` — nornir's `scip`. Empty when no manifest
/// was supplied, and empty when the space is
/// [`unconstrained`](Self::unconstrained) (an empty `offers` excludes nothing, so
/// flagging every manifest feature there would be noise). Sorted.
pub unoffered: Vec<String>,
/// **The widest form of the rule, and the one that always applies**: features
/// declared in `Cargo.toml` that NO variant enables — measurable whether or not
/// the repo declares an `offers` space, so an unconstrained repo (nornir, dwarves)
/// is not silently exempt from the axis. Empty when no manifest was read. Sorted.
pub manifest_unbuilt: Vec<String>,
/// How many features the manifest declared (the denominator for
/// [`manifest_unbuilt`](Self::manifest_unbuilt)).
pub manifest_total: usize,
/// How many of the repo's `offers` were actually FOUND in the scanned manifest.
/// Short of `offers.len()` means the scan did not see the whole repo — see
/// [`manifest_incomplete`](Self::manifest_incomplete).
pub offers_in_manifest: usize,
/// `[[bin]] required-features` entries (as mirrored into `[build.required]`)
/// that no declared variant satisfies — the `dwarves-server` shape, where
/// `cargo build --all-targets` silently skips the target. `bin → missing`.
pub unbuildable_bins: BTreeMap<String, Vec<String>>,
/// Whether a `Cargo.toml` feature list was supplied. `false` ⇒ `unoffered` is
/// "not measured", NOT "nothing missing" — the pane must say so rather than
/// render an unmeasured axis as clean.
pub manifest_read: bool,
/// The repo's HONEST allowlist (`[build.allow_unbuilt]`): offered-but-unbuilt
/// features that are accepted debt, each with a reason. The [`gate`](Self::gate)
/// subtracts these from the rot verdict. Carried onto the axis so a pane / gate
/// reads the excuse next to the gap.
pub allow_unbuilt: BTreeMap<String, String>,
}
impl FeatureAxis {
/// The axis of a build space, from the declaration alone (no manifest read).
/// `unoffered` is left empty and [`manifest_read`](Self::manifest_read) `false`.
pub fn of(repo: impl Into<String>, set: &BuildVariantSet) -> Self {
Self::with_manifest_features(repo, set, None)
}
/// The axis including the manifest direction: `manifest` is every feature name
/// declared in the repo's `Cargo.toml` `[features]` tables (see
/// [`manifest_features`]).
pub fn with_manifest_features(
repo: impl Into<String>,
set: &BuildVariantSet,
manifest: Option<&BTreeSet<String>>,
) -> Self {
let mut built: BTreeMap<String, Vec<String>> = BTreeMap::new();
for v in &set.variants {
for f in &v.features {
built.entry(f.clone()).or_default().push(v.name.clone());
}
}
// offered but never enabled by any variant.
let unbuilt: Vec<String> =
set.offers.iter().filter(|f| !built.contains_key(*f)).cloned().collect();
// declared in Cargo.toml but outside the build space's `offers`. Only
// meaningful when the repo actually constrains its space: an empty `offers`
// means "unconstrained", and flagging every manifest feature there would be
// noise, not a finding.
let (unoffered, manifest_read) = match manifest {
Some(m) if !set.offers.is_empty() => (
m.iter().filter(|f| !set.offers.contains(*f)).cloned().collect::<Vec<_>>(),
true,
),
Some(_) => (Vec::new(), true),
None => (Vec::new(), false),
};
// The widest form: declared in Cargo.toml, built by nobody. This is the one
// measure an UNCONSTRAINED repo cannot escape — without it, declaring no
// `offers` would exempt a repo from the axis entirely, which is precisely how
// nornir's `scip` went unbuilt in every repo.
let (manifest_unbuilt, manifest_total) = match manifest {
Some(m) => (
m.iter().filter(|f| !built.contains_key(*f)).cloned().collect::<Vec<_>>(),
m.len(),
),
None => (Vec::new(), 0),
};
// How much of the repo the scan actually saw. The repo's own `offers` is the
// control: every offered feature is by definition declared SOMEWHERE in the
// repo, so an offered feature the scan never encountered means the scan missed
// a manifest (a detached sub-workspace, an unlisted member). Without this the
// Cargo column would silently understate — skade offers 8 and its root manifest
// is a shell around three vendored forks, so it would have read `0/2`, which is
// not a small number, it is the wrong denominator.
let offers_in_manifest = match manifest {
Some(m) => set.offers.iter().filter(|f| m.contains(*f)).count(),
None => 0,
};
// A `required-features` bin no variant can build. `[build.required]` mirrors
// Cargo's `[[bin]] required-features`, so this is the same silent-skip that
// `cargo build --all-targets` performs.
let mut unbuildable_bins: BTreeMap<String, Vec<String>> = BTreeMap::new();
for (bin, req) in &set.required {
let satisfied = set
.variants
.iter()
.any(|v| req.iter().all(|r| v.features.contains(r)));
if !satisfied {
let missing: Vec<String> = req
.iter()
.filter(|r| !built.contains_key(*r))
.cloned()
.collect();
unbuildable_bins.insert(bin.clone(), if missing.is_empty() { req.clone() } else { missing });
}
}
FeatureAxis {
repo: repo.into(),
offers: set.offers.clone(),
built,
unbuilt,
unoffered,
manifest_unbuilt,
manifest_total,
offers_in_manifest,
unbuildable_bins,
manifest_read,
allow_unbuilt: set.allow_unbuilt.clone(),
}
}
/// The manifest scan did not see the whole repo: at least one feature the repo
/// `offers` was never found in any scanned `Cargo.toml`. The Cargo column is then
/// a floor, not a measurement, and must be reported as such rather than as a
/// (small, wrong) number.
pub fn manifest_incomplete(&self) -> bool {
self.manifest_read && !self.offers.is_empty() && self.offers_in_manifest < self.offers.len()
}
/// True when nothing on this axis rots: every offered feature is built by some
/// variant, every `required-features` bin is buildable, and (when measured) the
/// manifest declares nothing the space does not offer.
///
/// Note what this deliberately does NOT fold in:
/// [`manifest_unbuilt`](Self::manifest_unbuilt). A repo can legitimately declare
/// a Cargo feature it does not ship a variant for; the offered space is the
/// verdict, and the manifest count is the wider *number* reported beside it. What
/// this DOES refuse to do is call an [`unconstrained`](Self::unconstrained) repo
/// clean without saying so — see [`headline`](Self::headline).
pub fn is_clean(&self) -> bool {
self.unbuilt.is_empty() && self.unoffered.is_empty() && self.unbuildable_bins.is_empty()
}
/// **The rotting features**: the offered-but-unbuilt ([`unbuilt`](Self::unbuilt))
/// and declared-but-unoffered ([`unoffered`](Self::unoffered)) features that are
/// NOT excused by the [`allow_unbuilt`](Self::allow_unbuilt) allowlist. Sorted,
/// de-duplicated. This is the set the gate fails on — the live rot, after the
/// honest debt has been subtracted.
pub fn rotting(&self) -> Vec<String> {
let mut r: BTreeSet<String> = BTreeSet::new();
for f in self.unbuilt.iter().chain(self.unoffered.iter()) {
if !self.allow_unbuilt.contains_key(f) {
r.insert(f.clone());
}
}
r.into_iter().collect()
}
/// **Stale allowlist entries**: features excused as unbuilt in `allow_unbuilt`
/// that some variant NOW builds. A dead excuse is itself rot — it lets the next
/// real gap hide behind a line nobody re-reads — so the gate fails on these too
/// and asks for the stale line to be removed. `feature → the variants that build it`.
pub fn stale_allowlist(&self) -> BTreeMap<String, Vec<String>> {
self.allow_unbuilt
.keys()
.filter_map(|f| self.built.get(f).map(|v| (f.clone(), v.clone())))
.collect()
}
/// **The gate verdict, allowlist-aware.** `Ok(())` when nothing rots that has
/// not been honestly excused; otherwise a [`FeatureRot`] naming every rotting
/// feature, every `required-features` bin no variant can build, and every stale
/// excuse. Unlike [`is_clean`](Self::is_clean) this consults
/// [`allow_unbuilt`](Self::allow_unbuilt), so "known, documented debt" passes and
/// only silent rot fails.
///
/// This is the reusable check the task asks for: call it from a `cargo test`, a
/// CI step, or a release gate, and it fails LOUD, naming the feature, the moment
/// an offered capability stops being built by any variant.
pub fn gate(&self) -> Result<(), FeatureRot> {
let rotting = self.rotting();
let stale = self.stale_allowlist();
if rotting.is_empty() && self.unbuildable_bins.is_empty() && stale.is_empty() {
return Ok(());
}
Err(FeatureRot {
repo: self.repo.clone(),
rotting,
unbuildable_bins: self.unbuildable_bins.clone(),
stale_allowlist: stale,
unknown_allowlist: Vec::new(),
unmeasured: Vec::new(),
})
}
/// **The MANIFEST direction of the same rule** — the half [`gate`](Self::gate)
/// deliberately leaves out, and the ONLY half a repo without a `[build] offers`
/// space has.
///
/// [`gate`](Self::gate) is a verdict on the `offers` space: `unbuilt` ∪
/// `unoffered`, both of which are EMPTY BY CONSTRUCTION when `offers` is absent.
/// So a repo that declares no offers passes that gate no matter what its
/// `Cargo.toml` says — it is green because there was nothing to ask, which is
/// exactly the shape of a check that can never report red. Measured on
/// znippy-zoomies 2026-08-22: nine declared features, **zero** built by any
/// declared variant, `gate()` `Ok(())`.
///
/// This gate asks the question that still has an answer there: **every feature
/// the repo's `Cargo.toml` declares is either enabled by some declared
/// `[[build.variant]]`, or carries a line in `[build.allow_unbuilt]` saying in
/// words why nothing compiles it.** Same allowlist, same stale-excuse rule.
///
/// It refuses to be vacuous, and that is not decoration: without the refusal a
/// manifest scan that found nothing (a moved root, a member glob that stopped
/// expanding) reads exactly like a repo whose every feature is built. So a gate
/// that never read a manifest, or read one declaring no features at all, is an
/// `Err` naming that fact — never an `Ok`.
///
/// [`manifest_unbuilt`](Self::manifest_unbuilt) is unchanged and so is
/// [`gate`](Self::gate): this is a second question over the same data, not a
/// change to the first one.
pub fn manifest_gate(&self) -> Result<(), FeatureRot> {
let mut unmeasured = Vec::new();
if !self.manifest_read {
unmeasured.push(
"no Cargo.toml feature set was supplied — build the axis with \
`FeatureAxis::with_manifest_features(.., Some(&manifest_features(root)))`"
.to_string(),
);
} else if self.manifest_total == 0 {
unmeasured.push(
"the manifest scan found ZERO declared features — either the repo really \
declares none (then this gate has nothing to say and should not be called) \
or the scan missed the manifests (a moved root, a member glob that no \
longer expands). An empty oracle is not a pass."
.to_string(),
);
}
if self.manifest_incomplete() {
unmeasured.push(format!(
"the scan saw only {} of the {} features this repo `offers` — the Cargo \
column is a floor, not a measurement",
self.offers_in_manifest,
self.offers.len()
));
}
let rotting = self.manifest_rotting();
let stale = self.stale_allowlist();
let unknown = self.unknown_allowlist();
if rotting.is_empty() && stale.is_empty() && unknown.is_empty() && unmeasured.is_empty() {
return Ok(());
}
Err(FeatureRot {
repo: self.repo.clone(),
rotting,
unbuildable_bins: self.unbuildable_bins.clone(),
stale_allowlist: stale,
unknown_allowlist: unknown,
unmeasured,
})
}
/// The features [`manifest_unbuilt`](Self::manifest_unbuilt) names that no
/// `[build.allow_unbuilt]` line excuses — what [`manifest_gate`](Self::manifest_gate)
/// fails on. Sorted.
pub fn manifest_rotting(&self) -> Vec<String> {
self.manifest_unbuilt
.iter()
.filter(|f| !self.allow_unbuilt.contains_key(*f))
.cloned()
.collect()
}
/// **Excuses for features that do not exist.** An `[build.allow_unbuilt]` entry
/// naming a feature the manifest scan never saw and no variant builds: a renamed
/// or deleted feature whose excuse outlived it. Same disease as
/// [`stale_allowlist`](Self::stale_allowlist) — a line nobody re-reads, standing
/// between the next real gap and the reader — so
/// [`manifest_gate`](Self::manifest_gate) fails on it too. Empty when no manifest
/// was read (not measured is not the same as nothing wrong). Sorted.
pub fn unknown_allowlist(&self) -> Vec<String> {
if !self.manifest_read {
return Vec::new();
}
let unbuilt: BTreeSet<&String> = self.manifest_unbuilt.iter().collect();
self.allow_unbuilt
.keys()
.filter(|f| !unbuilt.contains(*f) && !self.built.contains_key(*f))
.cloned()
.collect()
}
/// The allowlist-aware sibling of [`is_clean`](Self::is_clean): nothing rots that
/// has not been documented. `true` ⇔ [`gate`](Self::gate) is `Ok`.
pub fn gate_clean(&self) -> bool {
self.gate().is_ok()
}
}
/// The feature-rot gate's failure: the ways a repo's feature space is rotting after
/// the honest [`FeatureAxis::allow_unbuilt`] allowlist has been subtracted. Carries
/// enough to be actioned without re-reading the manifest — the feature name, the bin,
/// and the fix.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FeatureRot {
pub repo: String,
/// Offered / declared features no variant builds and no allowlist excuses.
pub rotting: Vec<String>,
/// `required-features` bins no variant can build (the silent `--all-targets` skip).
pub unbuildable_bins: BTreeMap<String, Vec<String>>,
/// Allowlist entries a variant now builds — dead excuses to delete.
pub stale_allowlist: BTreeMap<String, Vec<String>>,
/// Allowlist entries naming a feature that is neither declared in the manifest
/// nor built by anything — an excuse that outlived the feature it excused. Only
/// [`FeatureAxis::manifest_gate`] fills this; [`FeatureAxis::gate`] leaves it empty.
pub unknown_allowlist: Vec<String>,
/// **Why the verdict could not be trusted as a measurement.** Non-empty ⇒ the
/// gate failed because it could not SEE, not because it saw rot: no manifest was
/// read, the scan found no features at all, or it saw less of the repo than the
/// repo's own `offers` proves is there. An unmeasured axis must never be reported
/// as a clean one. Only [`FeatureAxis::manifest_gate`] fills this.
pub unmeasured: Vec<String>,
}
impl std::fmt::Display for FeatureRot {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(f, "feature-rot gate FAILED for `{}`:", self.repo)?;
if !self.rotting.is_empty() {
writeln!(
f,
" · OFFERED BUT NEVER BUILT (a feature no variant declares is a feature nobody \
builds, and it rots): {}",
self.rotting.join(", ")
)?;
writeln!(
f,
" fix: add a `[[build.variant]]` that enables it, OR document it in \
`[build.allow_unbuilt]` with a reason."
)?;
}
for (bin, missing) in &self.unbuildable_bins {
writeln!(
f,
" · BIN `{bin}` has required-features {:?} that NO variant satisfies — \
`cargo build --all-targets` silently skips it. fix: declare a variant that \
builds it.",
missing
)?;
}
for (feat, by) in &self.stale_allowlist {
writeln!(
f,
" · STALE EXCUSE: `{feat}` is allowlisted as unbuilt but variant(s) {:?} now \
build it — delete the `[build.allow_unbuilt]` line.",
by
)?;
}
for feat in &self.unknown_allowlist {
writeln!(
f,
" · EXCUSE FOR A FEATURE THAT IS NOT THERE: `{feat}` is allowlisted as \
unbuilt, but no `Cargo.toml` in this repo declares it and nothing builds \
it — delete the `[build.allow_unbuilt]` line, or restore the feature."
)?;
}
for why in &self.unmeasured {
writeln!(f, " · NOT MEASURED (so NOT a pass): {why}")?;
}
Ok(())
}
}
impl std::error::Error for FeatureRot {}
/// **The constellation-wide gate.** Run [`FeatureAxis::gate`] over every repo's axis
/// and fold the failures into one report, so a single `cargo test` / CI step covers
/// the whole suite and names every rotting repo at once. `Ok(())` when the whole
/// constellation is rot-free (allowlist-aware).
pub fn gate_all(axes: &[FeatureAxis]) -> Result<(), ConstellationRot> {
let failures: Vec<FeatureRot> = axes.iter().filter_map(|a| a.gate().err()).collect();
if failures.is_empty() {
Ok(())
} else {
Err(ConstellationRot { failures })
}
}
/// Every repo whose feature-rot gate failed, folded into one error.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConstellationRot {
pub failures: Vec<FeatureRot>,
}
impl std::fmt::Display for ConstellationRot {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(
f,
"feature-rot gate FAILED across {} repo(s) — offered capabilities that no build \
variant compiles:",
self.failures.len()
)?;
for r in &self.failures {
write!(f, "{r}")?;
}
Ok(())
}
}
impl std::error::Error for ConstellationRot {}
impl FeatureAxis {
/// The repo declares no `offers`, so its feature SPACE is undeclared and nothing
/// can be found "missing" from it. Not a clean bill of health — an unstated space.
pub fn unconstrained(&self) -> bool {
self.offers.is_empty()
}
/// How many Cargo-manifest features at least one variant builds, over how many
/// the manifest declares. `(built, declared)`; `(0, 0)` when no manifest was read.
/// This is the measure an unconstrained repo cannot escape.
pub fn manifest_ratio(&self) -> (usize, usize) {
(self.manifest_total.saturating_sub(self.manifest_unbuilt.len()), self.manifest_total)
}
/// How many offered features an actual variant builds, over how many are offered.
/// `(built, offered)`. A repo with an empty `offers` reports `(n, n)` over the
/// features its variants use — unconstrained, so nothing can be *missing*.
pub fn ratio(&self) -> (usize, usize) {
if self.offers.is_empty() {
let n = self.built.len();
(n, n)
} else {
(self.offers.len() - self.unbuilt.len(), self.offers.len())
}
}
/// The one honest line a report leads with — it names the unmeasured direction
/// rather than letting an unread manifest look like a clean bill of health.
pub fn headline(&self) -> String {
let (built, offered) = self.ratio();
let mut s = if self.unconstrained() {
format!(
"{}: feature space UNDECLARED (no `offers`) — nothing can be found missing from it, \
which is not the same as nothing missing",
self.repo
)
} else {
format!("{}: {built}/{offered} offered features are built by a variant", self.repo)
};
if self.manifest_incomplete() {
s.push_str(&format!(
" · manifest scan INCOMPLETE ({} of {} offered features found on disk) — the Cargo \
count below is a floor, not a measurement",
self.offers_in_manifest,
self.offers.len()
));
} else if self.manifest_read && self.manifest_total > 0 {
let (mb, mt) = self.manifest_ratio();
s.push_str(&format!(" · {mb}/{mt} Cargo features built by some variant"));
}
if !self.unbuilt.is_empty() {
s.push_str(&format!(" · NEVER BUILT: {}", self.unbuilt.join(", ")));
}
if !self.unbuildable_bins.is_empty() {
let bins: Vec<&str> = self.unbuildable_bins.keys().map(|s| s.as_str()).collect();
s.push_str(&format!(" · bin(s) no variant can build: {}", bins.join(", ")));
}
if !self.manifest_read {
s.push_str(" · manifest features NOT MEASURED");
} else if !self.unoffered.is_empty() {
s.push_str(&format!(" · in Cargo.toml but not offered: {}", self.unoffered.join(", ")));
}
s
}
/// The `state_json` shape a viz pane / CLI `--json` reads.
pub fn state_json(&self) -> serde_json::Value {
let (built, offered) = self.ratio();
let (mbuilt, mtotal) = self.manifest_ratio();
serde_json::json!({
"repo": self.repo,
"offered": offered,
"built": built,
"unbuilt": self.unbuilt,
"unoffered": self.unoffered,
"unbuildable_bins": self.unbuildable_bins,
"manifest_read": self.manifest_read,
"manifest_incomplete": self.manifest_incomplete(),
"manifest_total": mtotal,
"manifest_built": mbuilt,
"manifest_unbuilt": self.manifest_unbuilt,
"unconstrained": self.unconstrained(),
"clean": self.is_clean(),
"gate_clean": self.gate_clean(),
"rotting": self.rotting(),
"allow_unbuilt": self.allow_unbuilt,
"stale_allowlist": self.stale_allowlist(),
"headline": self.headline(),
})
}
}
/// Every feature name declared in a repo's `Cargo.toml` `[features]` tables.
///
/// Walks the manifest at `repo_root/Cargo.toml` and, when it is a workspace root,
/// each `[workspace] members` manifest one level down (globs are expanded by a
/// single-level directory scan — enough for the constellation's `crates/*` layout
/// and dep-light: this crate parses TOML, it does not shell out to `cargo metadata`).
///
/// `default` is excluded: it is not a capability, it is a selection of them.
pub fn manifest_features(repo_root: &Path) -> BTreeSet<String> {
let mut out = BTreeSet::new();
let root = repo_root.join("Cargo.toml");
let Some(doc) = read_toml(&root) else { return out };
collect_features(&doc, &mut out);
// Workspace members, one level of glob expansion.
if let Some(members) = doc
.get("workspace")
.and_then(|w| w.get("members"))
.and_then(|m| m.as_array())
{
for m in members.iter().filter_map(|m| m.as_str()) {
for dir in expand_member(repo_root, m) {
if let Some(d) = read_toml(&dir.join("Cargo.toml")) {
collect_features(&d, &mut out);
}
}
}
}
out.remove("default");
out
}
fn read_toml(path: &Path) -> Option<toml::Value> {
let text = std::fs::read_to_string(path).ok()?;
toml::from_str::<toml::Value>(&text).ok()
}
fn collect_features(doc: &toml::Value, out: &mut BTreeSet<String>) {
if let Some(t) = doc.get("features").and_then(|f| f.as_table()) {
for k in t.keys() {
out.insert(k.clone());
}
}
}
/// Expand ONE workspace-member entry into concrete directories. Handles the plain
/// path (`crates/dwarves`) and a single trailing `*` (`crates/*`); anything more
/// exotic resolves to nothing rather than to a wrong answer.
fn expand_member(repo_root: &Path, member: &str) -> Vec<std::path::PathBuf> {
if let Some(prefix) = member.strip_suffix("/*") {
let base = repo_root.join(prefix);
let Ok(rd) = std::fs::read_dir(&base) else { return Vec::new() };
rd.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.is_dir())
.collect()
} else if member.contains('*') {
Vec::new()
} else {
vec![repo_root.join(member)]
}
}
#[cfg(test)]
mod tests {
use super::*;
/// korp's real shape at the moment the breaks accumulated: `spark41` offered,
/// no variant naming it.
const KORP_BEFORE: &str = r#"
[build]
offers = ["iceberg", "sweden-clip", "server", "client", "spark41", "testmatrix"]
[build.required]
"korp-server" = ["server"]
[[build.variant]]
name = "korp"
package = "korp"
bin = "korp"
features = ["iceberg", "sweden-clip"]
[[build.variant]]
name = "korp-server"
package = "korp"
bin = "korp-server"
features = ["server", "iceberg"]
[[build.variant]]
name = "korp-thin"
package = "korp"
bin = "korp"
features = ["client"]
"#;
#[test]
fn an_offered_feature_no_variant_names_is_the_gap() {
let set = BuildVariantSet::from_toml_str(KORP_BEFORE).unwrap();
let axis = FeatureAxis::of("korp", &set);
assert!(
axis.unbuilt.contains(&"spark41".to_string()),
"spark41 is offered and built by nothing — the korp break, made visible: {:?}",
axis.unbuilt
);
assert!(axis.unbuilt.contains(&"testmatrix".to_string()));
assert!(!axis.is_clean());
assert_eq!(axis.ratio(), (4, 6), "iceberg/sweden-clip/server/client are built");
assert!(axis.headline().contains("NEVER BUILT"), "{}", axis.headline());
// And the covered half names WHICH variant covers it.
assert_eq!(axis.built["iceberg"], vec!["korp", "korp-server"]);
}
#[test]
fn declaring_the_variant_closes_the_gap() {
let mut set = BuildVariantSet::from_toml_str(KORP_BEFORE).unwrap();
set.variants.push(crate::variant::BuildVariant {
name: "korp-spark41".into(),
package: Some("korp".into()),
bin: Some("korp".into()),
features: vec!["iceberg".into(), "sweden-clip".into(), "spark41".into()],
..Default::default()
});
let axis = FeatureAxis::of("korp", &set);
assert!(!axis.unbuilt.contains(&"spark41".to_string()), "the fix is a DECLARED variant");
assert_eq!(axis.built["spark41"], vec!["korp-spark41"]);
}
/// The `dwarves-server` shape: a bin gated behind a feature that no variant
/// enables. `cargo build --all-targets` skips it SILENTLY; this does not.
#[test]
fn a_required_features_bin_no_variant_satisfies_is_named() {
let toml = r#"
[build]
offers = ["cli", "server"]
[build.required]
"dwarves-server" = ["server"]
[[build.variant]]
name = "dwarves-default"
package = "dwarves"
bin = "dwarves"
features = ["cli"]
"#;
let set = BuildVariantSet::from_toml_str(toml).unwrap();
let axis = FeatureAxis::of("dwarves", &set);
assert_eq!(
axis.unbuildable_bins.get("dwarves-server").map(|v| v.as_slice()),
Some(["server".to_string()].as_slice()),
"the silently-skipped target is named, with the feature it needs"
);
assert!(!axis.is_clean());
assert!(axis.headline().contains("dwarves-server"), "{}", axis.headline());
}
#[test]
fn an_unread_manifest_is_reported_as_unmeasured_not_as_clean() {
let set = BuildVariantSet::from_toml_str(
r#"
[build]
offers = ["a"]
[[build.variant]]
name = "v"
features = ["a"]
"#,
)
.unwrap();
let axis = FeatureAxis::of("x", &set);
assert!(axis.is_clean(), "nothing measured is missing");
assert!(!axis.manifest_read);
assert!(
axis.headline().contains("NOT MEASURED"),
"an unmeasured direction must never read as clean: {}",
axis.headline()
);
// With the manifest, nornir's `scip` shape surfaces.
let manifest: BTreeSet<String> = ["a", "scip"].iter().map(|s| s.to_string()).collect();
let axis = FeatureAxis::with_manifest_features("x", &set, Some(&manifest));
assert_eq!(axis.unoffered, vec!["scip".to_string()]);
assert_eq!(axis.manifest_unbuilt, vec!["scip".to_string()]);
assert_eq!(axis.manifest_ratio(), (1, 2));
assert!(!axis.is_clean());
assert!(axis.manifest_read);
}
/// **An undeclared feature space is not a clean one.** A repo with no `offers`
/// (nornir, dwarves) has nothing that can be "missing" from its space, so the
/// offered ratio is vacuously full — and that is exactly how a repo could exempt
/// itself from the axis. The manifest measure is the one it cannot escape, and
/// the headline refuses to read as green.
#[test]
fn an_unconstrained_repo_is_still_measured_against_its_manifest() {
let set = BuildVariantSet::from_toml_str(
r#"
[build]
[[build.variant]]
name = "nornir"
package = "nornir"
bin = "nornir"
profile = "release"
"#,
)
.unwrap();
let manifest: BTreeSet<String> =
["scip", "server", "warehouse"].iter().map(|s| s.to_string()).collect();
let axis = FeatureAxis::with_manifest_features("nornir", &set, Some(&manifest));
assert!(axis.unconstrained(), "no `offers` ⇒ the space is undeclared");
assert!(axis.is_clean(), "vacuously — nothing is offered, so nothing is missing");
assert!(
axis.headline().contains("UNDECLARED"),
"and the headline says so instead of reading green: {}",
axis.headline()
);
// The measure it cannot escape: the variant enables NO feature, so every
// Cargo feature — `scip` included — is built by nobody.
assert_eq!(axis.manifest_ratio(), (0, 3));
assert!(
axis.manifest_unbuilt.contains(&"scip".to_string()),
"nornir's `scip`: declared, in no variant, so no writer was ever compiled"
);
assert!(axis.headline().contains("0/3 Cargo features"), "{}", axis.headline());
}
/// **A scan that missed half the repo must not report a small number as if it
/// were the measurement.** skade's root `Cargo.toml` is a shell around three
/// vendored forks; its own crates live in a detached sub-workspace, so a
/// root-anchored scan sees 2 features while the repo offers 8. The repo's own
/// `offers` is the control that catches it — an offered feature is declared
/// SOMEWHERE, so one the scan never met means the scan missed a manifest.
#[test]
fn a_manifest_scan_that_missed_a_sub_workspace_says_so() {
let set = BuildVariantSet::from_toml_str(
r#"
[build]
offers = ["sql", "s3", "aws-s3", "rustfs", "lineage", "stream", "lineage-http", "testmatrix"]
[[build.variant]]
name = "skade"
package = "skade"
features = ["sql", "s3"]
"#,
)
.unwrap();
// What a root-anchored scan of skade actually finds: the root shell only.
let seen: BTreeSet<String> = ["bench", "testmatrix"].iter().map(|s| s.to_string()).collect();
let axis = FeatureAxis::with_manifest_features("skade", &set, Some(&seen));
assert!(axis.manifest_incomplete(), "6 of 8 offered features were never seen on disk");
assert_eq!(axis.offers_in_manifest, 1, "only `testmatrix` was found in the scanned manifest");
assert!(
axis.headline().contains("INCOMPLETE"),
"the headline reports a floor, not a measurement: {}",
axis.headline()
);
assert!(
!axis.headline().contains("0/2 Cargo features"),
"and it does NOT print the wrong denominator as if it were the answer: {}",
axis.headline()
);
// A repo the scan DID see whole reports the real number, with no caveat.
let whole: BTreeSet<String> = set.offers.iter().cloned().collect();
let axis = FeatureAxis::with_manifest_features("skade", &set, Some(&whole));
assert!(!axis.manifest_incomplete());
assert_eq!(axis.manifest_ratio(), (2, 8));
assert!(axis.headline().contains("2/8 Cargo features"), "{}", axis.headline());
}
#[test]
fn manifest_features_reads_a_workspace_and_drops_default() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/*\"]\n[features]\ndefault = [\"cli\"]\ncli = []\n",
)
.unwrap();
std::fs::create_dir_all(root.join("crates/leaf")).unwrap();
std::fs::write(
root.join("crates/leaf/Cargo.toml"),
"[package]\nname=\"leaf\"\n[features]\nscip = []\nserver = []\n",
)
.unwrap();
let f = manifest_features(root);
assert!(f.contains("cli") && f.contains("scip") && f.contains("server"), "{f:?}");
assert!(!f.contains("default"), "`default` is a selection, not a capability");
}
#[test]
fn state_json_carries_the_gap_for_a_pane() {
let set = BuildVariantSet::from_toml_str(KORP_BEFORE).unwrap();
let j = FeatureAxis::of("korp", &set).state_json();
assert_eq!(j["repo"], "korp");
assert_eq!(j["offered"], 6);
assert_eq!(j["built"], 4);
assert_eq!(j["clean"], false);
assert!(j["unbuilt"].as_array().unwrap().iter().any(|v| v == "spark41"));
}
// ── the ROT GATE (allowlist-aware) ───────────────────────────────────────
//
// korp's REAL shape today: `spark41` is now built (the `korp-spark41` variant),
// but SEVEN offered features are enabled by no variant — the debt korp's own
// build-variants.toml documents in a comment. The gate makes that comment an
// enforced check.
const KORP_TODAY: &str = r#"
[build]
offers = [
"iceberg", "sweden-clip", "planet-download", "server", "client",
"collectors", "collectors-osm", "live-pipelines", "spark41",
"containers", "testmatrix", "demo-watch",
]
[build.required]
"korp-server" = ["server"]
[[build.variant]]
name = "korp"
features = ["iceberg", "sweden-clip"]
[[build.variant]]
name = "korp-server"
bin = "korp-server"
features = ["server", "iceberg"]
[[build.variant]]
name = "korp-thin"
features = ["client"]
[[build.variant]]
name = "korp-spark41"
features = ["iceberg", "sweden-clip", "spark41"]
"#;
/// RED: run against korp's real declared shape and the gate FAILS, naming the
/// seven offered-but-never-built features — the live rot, spark41 no longer among
/// them because a variant now builds it.
#[test]
fn the_gate_catches_korps_real_orphaned_features() {
let set = BuildVariantSet::from_toml_str(KORP_TODAY).unwrap();
let axis = FeatureAxis::of("korp", &set);
let err = axis.gate().expect_err("korp's offered-but-unbuilt features are live rot");
let msg = err.to_string();
// spark41 is BUILT now → not in the rot; the seven documented-debt features are.
assert!(!err.rotting.contains(&"spark41".to_string()), "spark41 is built: {msg}");
for feat in
["collectors", "collectors-osm", "containers", "demo-watch", "live-pipelines", "planet-download", "testmatrix"]
{
assert!(err.rotting.contains(&feat.to_string()), "`{feat}` must be named rot: {msg}");
}
assert!(msg.contains("a feature no variant declares is a feature nobody builds"), "{msg}");
}
/// GREEN: the SAME shape, with the seven documented HONESTLY in
/// `[build.allow_unbuilt]` (each with a reason), passes the gate — known,
/// accepted debt is not rot.
#[test]
fn documenting_the_debt_in_allow_unbuilt_passes_the_gate() {
let with_allowlist = format!(
"{KORP_TODAY}\n[build.allow_unbuilt]\n\
collectors = \"open-data collectors: no shipped variant yet, tracked debt\"\n\
\"collectors-osm\" = \"OSM collector: needs the osmium leg, not yet a variant\"\n\
containers = \"container bring-up: exercised by korp-image, not a cargo variant\"\n\
\"demo-watch\" = \"demo file-watch: dev convenience, no deployed variant\"\n\
\"live-pipelines\" = \"live Spark path: folded into spark41 work, no own variant\"\n\
\"planet-download\" = \"planet-scale OSM download: opt-in, never a default build\"\n\
testmatrix = \"testmatrix emitter: turned on by the test harness, not a build variant\"\n"
);
let set = BuildVariantSet::from_toml_str(&with_allowlist).unwrap();
let axis = FeatureAxis::of("korp", &set);
assert!(axis.gate().is_ok(), "documented debt is not rot: {:?}", axis.gate().err());
assert!(axis.rotting().is_empty());
// is_clean() is unchanged — it still sees the raw (un-excused) gap.
assert!(!axis.is_clean(), "the raw axis still reports the gap; the GATE is what forgives it");
}
/// An UNDOCUMENTED orphan next to documented ones still fails — the allowlist
/// forgives exactly what it names and nothing more.
#[test]
fn an_undocumented_orphan_beside_documented_debt_still_fails() {
let toml = r#"
[build]
offers = ["a", "b", "c"]
[build.allow_unbuilt]
b = "known debt, tracked"
[[build.variant]]
name = "v"
features = ["a"]
"#;
let set = BuildVariantSet::from_toml_str(toml).unwrap();
let axis = FeatureAxis::of("x", &set);
let err = axis.gate().expect_err("`c` is undocumented rot");
assert_eq!(err.rotting, vec!["c".to_string()], "only the undocumented one: {err}");
}
/// A STALE excuse — a feature allowlisted as unbuilt that a variant now builds —
/// fails the gate so the allowlist itself cannot rot.
#[test]
fn a_stale_allowlist_entry_fails_the_gate() {
let toml = r#"
[build]
offers = ["a"]
[build.allow_unbuilt]
a = "was debt — but a variant below now builds it, so this line is stale"
[[build.variant]]
name = "v"
features = ["a"]
"#;
let set = BuildVariantSet::from_toml_str(toml).unwrap();
let axis = FeatureAxis::of("x", &set);
let err = axis.gate().expect_err("a dead excuse is itself rot");
assert!(err.stale_allowlist.contains_key("a"), "{err}");
assert!(err.to_string().contains("STALE EXCUSE"), "{err}");
}
/// **The real-constellation gate**, run against every sibling checkout's ACTUAL
/// `.nornir/build-variants.toml`. Ignored by default (siblings are a dev
/// convenience, not a dependency); run it on a full checkout to see the live rot:
///
/// ```text
/// cargo test -p nornir-build-thing --lib \
/// feature_axis::tests::the_real_constellation_feature_rot_gate -- --ignored --nocapture
/// ```
///
/// This is the reusable gate the release/CI path calls: point it at the checkouts
/// and it fails LOUD, naming every offered capability no variant builds.
#[test]
#[ignore = "reads sibling checkouts; run on a full constellation tree"]
fn the_real_constellation_feature_rot_gate() {
let git_root = Path::new(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(3)
.expect("edda is nested at least 3 deep");
let members = [
"dwarves", "nornir", "korp", "holger", "knut", "modgunn", "tunnr", "skade",
];
let mut axes = Vec::new();
for m in members {
let root = git_root.join(m);
let vf = BuildVariantSet::conventional_path(&root);
if !vf.is_file() {
eprintln!("SKIP {m}: no .nornir/build-variants.toml");
continue;
}
let set = match BuildVariantSet::load_file(&vf) {
Ok(s) => s,
Err(e) => {
eprintln!("SKIP {m}: parse error {e:#}");
continue;
}
};
let manifest = root
.join("Cargo.toml")
.is_file()
.then(|| manifest_features(&root));
let axis = FeatureAxis::with_manifest_features(m, &set, manifest.as_ref());
eprintln!("── {m} ──\n{}", axis.headline());
match axis.gate() {
Ok(()) => eprintln!(" GATE: OK (no un-excused rot)\n"),
Err(e) => eprintln!(" GATE: {e}"),
}
axes.push(axis);
}
if axes.is_empty() {
eprintln!("no sibling checkouts found — nothing to gate");
return;
}
// Print the folded verdict. This assertion is what a release gate would trip
// on; left as an eprintln + soft check so the ignored run is a REPORT, and the
// per-repo `gate()` above is the hard check a CI wires per repo once its own
// allowlist is honest.
match gate_all(&axes) {
Ok(()) => eprintln!("CONSTELLATION FEATURE-ROT GATE: CLEAN"),
Err(e) => eprintln!("CONSTELLATION FEATURE-ROT GATE:\n{e}"),
}
}
/// The constellation gate folds every repo's failure into one report.
#[test]
fn gate_all_names_every_rotting_repo_at_once() {
let korp = FeatureAxis::of("korp", &BuildVariantSet::from_toml_str(KORP_TODAY).unwrap());
let clean = FeatureAxis::of(
"clean-repo",
&BuildVariantSet::from_toml_str(
"[build]\noffers=[\"a\"]\n[[build.variant]]\nname=\"v\"\nfeatures=[\"a\"]\n",
)
.unwrap(),
);
assert!(clean.gate().is_ok());
let err = gate_all(&[korp, clean]).expect_err("one repo rots");
assert_eq!(err.failures.len(), 1, "only korp fails");
assert_eq!(err.failures[0].repo, "korp");
assert!(err.to_string().contains("across 1 repo"), "{err}");
}
// ── manifest_gate: the half a repo without `offers` cannot escape ──────────
/// znippy-zoomies' real shape: five atoms, every one of them the lean default
/// build (`features = []`), and NO `[build] offers` at all.
const NO_OFFERS_SPACE: &str = r#"
[build]
base_suite = "ORIGINAL"
[[build.variant]]
name = "osm-katana"
package = "osm-katana"
bin = "osm-katana"
features = []
[[build.variant]]
name = "ljar"
package = "ljar"
bin = "ljar"
features = []
"#;
fn manifest(names: &[&str]) -> BTreeSet<String> {
names.iter().map(|s| s.to_string()).collect()
}
/// **RED FIRST, and it is the whole reason this method exists.** `gate()` is
/// `Ok(())` on a space with no `offers` no matter how many features rot, because
/// both of its inputs are empty by construction there. `manifest_gate()` is the
/// one that can still say red.
#[test]
fn the_offers_gate_is_structurally_blind_without_an_offers_space() {
let set = BuildVariantSet::from_toml_str(NO_OFFERS_SPACE).unwrap();
let m = manifest(&["timing", "zlib-ng", "gen", "search-index", "testmatrix"]);
let axis = FeatureAxis::with_manifest_features("znippy-zoomies", &set, Some(&m));
assert!(axis.offers.is_empty(), "the precondition: this repo offers nothing");
assert!(
axis.gate().is_ok(),
"THE BLIND SPOT: the offers gate passes a repo where nothing at all is built"
);
assert_eq!(axis.manifest_total, 5);
assert_eq!(axis.manifest_unbuilt.len(), 5, "not one of the five is built");
let rot = axis.manifest_gate().expect_err("the manifest gate must see it");
assert_eq!(rot.rotting, vec!["gen", "search-index", "testmatrix", "timing", "zlib-ng"]);
assert!(rot.unmeasured.is_empty(), "it MEASURED — this is real rot, not blindness");
assert!(rot.to_string().contains("NEVER BUILT"), "{rot}");
}
/// A written reason passes; the same feature without one does not. Both
/// directions, so the allowlist cannot be what makes the gate green everywhere.
#[test]
fn a_written_reason_excuses_and_an_unwritten_one_does_not() {
let with_reasons = format!(
"{NO_OFFERS_SPACE}\n[build.allow_unbuilt]\n\
timing = \"stderr timing prints, dev-only\"\n\
\"zlib-ng\" = \"links the C zlib-ng; the shipped decoder is pure Rust\"\n\
gen = \"pulls the third-party rivals this repo exists to beat\"\n\
\"search-index\" = \"the katana search index, not in any shipped artifact\"\n\
testmatrix = \"nornir test-matrix emit; on only under the matrix run\"\n"
);
let set = BuildVariantSet::from_toml_str(&with_reasons).unwrap();
let m = manifest(&["timing", "zlib-ng", "gen", "search-index", "testmatrix"]);
let axis = FeatureAxis::with_manifest_features("znippy-zoomies", &set, Some(&m));
assert_eq!(axis.allow_unbuilt.len(), 5, "the five reasons parsed");
axis.manifest_gate().expect("every gap has a written reason");
// RED: one more feature appears in the manifest and nobody wrote it down.
let m2 = manifest(&["timing", "zlib-ng", "gen", "search-index", "testmatrix", "pmtiles"]);
let axis2 = FeatureAxis::with_manifest_features("znippy-zoomies", &set, Some(&m2));
let rot = axis2.manifest_gate().expect_err("the new one is not written down");
assert_eq!(rot.rotting, vec!["pmtiles"], "and it is named");
}
/// A dead excuse is rot too, in both of its shapes: the feature is built now, or
/// the feature does not exist any more.
#[test]
fn an_excuse_that_outlived_its_feature_fails_too() {
let toml = format!(
"{NO_OFFERS_SPACE}\n[build.allow_unbuilt]\n\
timing = \"dev-only\"\n\
\"vann-mmap\" = \"deleted in the sweep, but the excuse stayed\"\n"
);
let set = BuildVariantSet::from_toml_str(&toml).unwrap();
let m = manifest(&["timing"]);
let axis = FeatureAxis::with_manifest_features("zz", &set, Some(&m));
let rot = axis.manifest_gate().expect_err("the excuse names nothing");
assert_eq!(rot.unknown_allowlist, vec!["vann-mmap"]);
assert!(rot.rotting.is_empty(), "timing itself is honestly excused");
assert!(rot.to_string().contains("NOT THERE"), "{rot}");
// The other shape: a variant now builds it, so the excuse is stale.
let mut set2 = BuildVariantSet::from_toml_str(&toml).unwrap();
set2.variants[0].features.push("timing".into());
let axis2 = FeatureAxis::with_manifest_features("zz", &set2, Some(&m));
let rot2 = axis2.manifest_gate().expect_err("built AND excused");
assert_eq!(rot2.stale_allowlist["timing"], vec!["osm-katana"]);
}
/// **An empty oracle is not a pass.** The failure mode that would make this whole
/// gate ornamental: a manifest scan that found nothing looks exactly like a repo
/// whose every feature is built.
#[test]
fn a_gate_that_measured_nothing_is_red_not_green() {
let set = BuildVariantSet::from_toml_str(NO_OFFERS_SPACE).unwrap();
let never_read = FeatureAxis::of("zz", &set);
let rot = never_read.manifest_gate().expect_err("no manifest was supplied");
assert!(rot.rotting.is_empty(), "it found no rot — because it could not look");
assert_eq!(rot.unmeasured.len(), 1);
assert!(rot.to_string().contains("NOT MEASURED"), "{rot}");
let empty = FeatureAxis::with_manifest_features("zz", &set, Some(&BTreeSet::new()));
let rot = empty.manifest_gate().expect_err("the scan found zero features");
assert!(rot.unmeasured[0].contains("ZERO declared features"), "{rot}");
// And the control: the SAME construction with one real feature and one real
// reason is green, so "always red" is not how this passes its own tests.
let with_reason =
format!("{NO_OFFERS_SPACE}\n[build.allow_unbuilt]\ntiming = \"dev-only\"\n");
let set2 = BuildVariantSet::from_toml_str(&with_reason).unwrap();
FeatureAxis::with_manifest_features("zz", &set2, Some(&manifest(&["timing"])))
.manifest_gate()
.expect("measured, and every gap written down");
}
}