crates/nornir-build-thing/src/tree.rs
//! **The settings TREE** — one recursive tree per variant, in the owner's
//! vocabulary: **variant → features on the variant → settings / sub-settings →
//! typ**.
//!
//! ```text
//! gunnar-installer (variant · typ installer)
//! ├─ wireguard (feature) └─ wireguard.hub_conf (setting, when on)
//! ├─ dhcp (feature) └─ dhcp.static_ip, dhcp.gateway (settings, when off)
//! └─ kernel (feature, edge → tunnr/tunnr-kernel : kernel)
//! ├─ kernel.preset (val: full | normal | slim | nuked)
//! └─ kernel.<symbol> (features, from the kernel feature model)
//! ```
//!
//! Exactly **three node kinds** and recursion — nothing else:
//!
//! | kind | what it is | today's flat spelling |
//! |---|---|---|
//! | [`FeatureNode`] | on/off with a default; decides a cargo feature (or a kconfig symbol) | `[[build.installer.options]]` row |
//! | [`SettingNode`] | a typed value ([`ParamKind`]), `@file` / `@fixture` staging, `boot_param` bake | `[[build.installer.params]]` row |
//! | [`ValNode`] | one-of with a default — `kernel.preset` is one | the `--opt kernel.preset=` special case |
//!
//! **Paths are the ids**: `dhcp.static_ip`, `kernel.preset`, `kernel.unix`. A
//! node may carry an [`Edge`] — a feature bound to a coordinate `repo/variant`
//! of a required typ ([`ArtifactKind`]); today's
//! [`InstallerBuild::kernel_donor`](crate::installer::InstallerBuild::kernel_donor)
//! is the first edge, kept as-is on the struct and PROJECTED into the tree.
//!
//! # Derived, not declared — no toml change
//!
//! The tree is derived from the flat declaration every published 0.1.x reader
//! already loads: each [`OptionSpec`] becomes a feature node, its
//! `params_when_on` / `params_when_off` become that feature's children (with
//! [`SettingsNode::when`] saying which state they belong to), the unconditional
//! [`ParamSpec`] rows become root settings, and a `kernel_donor` becomes the
//! `kernel` feature with its edge and its `preset` val. Nothing in the
//! declaration changes, so law A's round trip is untouched. The tree's own
//! serde is `default` + `skip_serializing_if` throughout, so the JSON a pane
//! or a plugin host reads stays stable as fields are added.
//!
//! # Flat spellings stay accepted — as unambiguous aliases
//!
//! `--param static_ip=` ≡ `--param dhcp.static_ip=`: [`SettingsTree::resolve`]
//! takes a full path first, then a bare last segment when exactly ONE node
//! carries it. Two nodes sharing a short name make that short name a **named
//! refusal** listing both full paths — never a silent first-match.
use std::collections::BTreeMap;
use anyhow::{Result, bail};
use serde::{Deserialize, Serialize};
use crate::installer::InstallerBuild;
use crate::iso::IsoBuild;
use crate::kernel::{HardeningProfile, KernelBuild, Preset};
use crate::kernel_features::KernelFeatureModel;
use crate::options::{OptionBake, OptionSpec};
use crate::params::{FixtureKind, ParamKind, ParamSpec};
use crate::variant::{ArtifactKind, BuildVariant, BuildVariantSet};
/// The path separator: `kernel.preset`.
pub const PATH_SEP: char = '.';
/// The path a variant's consumed kernel lives under (`kernel`, `kernel.preset`,
/// `kernel.<symbol>`) — the one spelling both doors already use
/// (`--opt kernel.preset=nuked`, `split_kernel_opts`).
pub const KERNEL_PATH: &str = "kernel";
/// The path of a variant's ROOT preset val (`preset = airgap`) — the
/// "preconfig": a named bundle of choices over the whole tree, declared as
/// `[build.<kind>.presets.<name>]` and chosen with `--opt preset=<name>`.
pub const PRESET_PATH: &str = "preset";
/// **The `data` group's paths on the tree** — `[build.installer.data]`'s two
/// arms as VAL nodes, so the group an installer declares is spellable
/// (`--opt data.fs=xfs`), listable (`flags_of` renders every choice) and
/// renders back (law A, [`Rows::data`]).
///
/// A declared group that the tree does not carry is a group nobody can reach:
/// lane N (2026-08-30) had to flip `fs = "fat"` in a scratch worktree to build
/// a FAT medium, because `--list-options` showed `dhcp`, `wireguard` and
/// `kernel.*` and nothing else — a whole lane's detour for a value the recipe
/// already named. These two are the general fix's first users, and the rule is
/// the one the root preset val already follows: a group is a val (or a set of
/// them) at a reserved path, ignored by the option-row loop and read back BY
/// PATH.
pub const DATA_FS_PATH: &str = "data.fs";
/// The writer arm of the `data` group — see [`DATA_FS_PATH`].
pub const DATA_IO_PATH: &str = "data.io";
/// A declared bundle: path → value (`wireguard = "off"`, `"kernel.preset" =
/// "nuked"`, `dhcp.static_ip = "10.0.2.15/24"`).
pub type Bundle = BTreeMap<String, String>;
/// Every bundle a variant declares, by name (`[build.<kind>.presets.<name>]`).
pub type Presets = BTreeMap<String, Bundle>;
/// **A feature: on/off with a default.** Off means the code behind it is NOT
/// COMPILED IN (a cargo feature) or the kconfig symbol is vetoed.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct FeatureNode {
/// The state when nobody says otherwise.
#[serde(default)]
pub default: bool,
/// What the toggle decides: the cargo feature of the app binary, or the
/// `CONFIG_` symbol of a kernel feature. Empty when the feature only
/// groups its children (a bare edge node).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub feature: String,
/// Symbol families that must be ABSENT from the artifact when this is off
/// ([`OptionSpec::forbid_symbols`]).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub forbid_symbols: Vec<String>,
/// The cmdline selector this feature bakes, per state ([`OptionSpec::bakes`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bakes: Option<OptionBake>,
/// **The lock**: turning this off would break these (boot-contract kernel
/// symbols, asked of the kernel model's own `try_toggle`). Empty ⇒ free.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub locked_by: Vec<String>,
/// **What this feature needs of the KERNEL behind the variant's kernel
/// edge** ([`OptionSpec::kernel_requires`]) — the requirement closure's
/// forward rows, carried on the node that OWNS them.
///
/// It is here because the tree is the MODEL and the rows are its spelling
/// (LAW A): without it `SettingsTree::rows()` rendered an option row with
/// an empty `kernel_requires`, so the first impl that declared one broke
/// law A by declaring it (measured on gunnar, lane AF 2026-08-30 — the
/// rows had never been through a law-checked impl before, because the one
/// consumer that wanted them could not spell them).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub kernel_requires: Vec<crate::kernel_requires::KconfigReq>,
/// The general form of the line above, addressed by edge
/// ([`OptionSpec::requires`]). Same reason for being here.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub requires: Vec<crate::requires::EdgeRequirement>,
}
/// **A setting: a typed value.** Where it lands is exactly one of `boot_param`
/// (a kernel-cmdline token) or `staged_file` (a file on the medium, `@path` /
/// `@fixture` staging).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct SettingNode {
/// The value's declared shape — the one validator
/// ([`ParamKind::validate_value`](crate::params::ParamSpec::validate_value)).
#[serde(default)]
pub param_kind: ParamKind,
/// Whether a build without a value must refuse.
#[serde(default)]
pub required: bool,
/// Landing: `<boot_param>=<value>` on the cmdline.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub boot_param: String,
/// Landing: a staging-tree file whose contents ARE the value (`@path`).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub staged_file: String,
/// Where the staged file lands ([`crate::params::Landing`]): `/data` or the release.
#[serde(default, skip_serializing_if = "crate::params::Landing::is_data")]
pub lands: crate::params::Landing,
/// The fixture the forge may mint for it (`@fixture`), when declared.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fixture: Option<FixtureKind>,
/// The kind the `[[…params]]` ROW spells, when the owning feature
/// sharpened it through `param_kinds` (`param_kind` is then the sharper
/// one). `None` = the row's kind IS `param_kind`. Carried so the tree
/// renders back to exactly the rows it came from (law A).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub declared_kind: Option<ParamKind>,
}
/// **A val: one-of with a default.** `kernel.preset = full | normal | slim |
/// nuked` is one; a variant-root `preset = airgap` (the "preconfig") is the
/// same node kind.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct ValNode {
/// The choices, in declaration order.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub choices: Vec<String>,
/// The choice when nobody says otherwise. Always one of `choices` — or
/// EMPTY for a root preset, whose honest default is "no bundle chosen".
#[serde(default, skip_serializing_if = "String::is_empty")]
pub default: String,
/// **What each choice EXPANDS to**, for a preset val: choice → (path →
/// value) over the rest of the tree. Empty for a plain val (`kernel.preset`
/// expands inside the kernel model, not here).
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub bundles: Presets,
}
/// **The three node kinds.** Serialized with a `kind` tag (`feature` /
/// `setting` / `val`) beside the node's own fields.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum NodeKind {
/// On/off, default.
Feature(FeatureNode),
/// A typed value.
Setting(SettingNode),
/// One-of with a default.
Val(ValNode),
}
impl NodeKind {
/// The tag word (`feature` / `setting` / `val`).
pub fn word(&self) -> &'static str {
match self {
NodeKind::Feature(_) => "feature",
NodeKind::Setting(_) => "setting",
NodeKind::Val(_) => "val",
}
}
}
/// **A consumption edge**: this feature is bound to another build thing's
/// artifact, named by coordinate and required to be of one typ. `kernel ↔
/// tunnr/tunnr-kernel : kernel` is the first.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Edge {
/// `repo/variant` — the donor checkout and the variant inside it.
pub coordinate: String,
/// The typ the consumed artifact must have. Checked at declaration time.
pub typ: ArtifactKind,
/// The donor checkout may be absent ([`EdgeDecl::lazy`]).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub lazy: bool,
}
impl Edge {
/// Build an edge from its two halves.
pub fn new(repo: &str, variant: &str, typ: ArtifactKind) -> Self {
Edge { coordinate: format!("{repo}/{variant}"), typ, lazy: false }
}
/// The edge a declaration row spells ([`EdgeDecl`]).
pub fn from_decl(d: &EdgeDecl) -> Self {
Edge::new(&d.repo, &d.variant, d.typ)
}
/// **The edge as a view spells it** — `<repo>/<variant> : <typ>`, the one
/// line a settings tree, a step console and a refusal all print.
pub fn spelled(&self) -> String {
format!("{} : {}", self.coordinate, self.typ.as_str())
}
/// **Read [`spelled`](Self::spelled) back.** A string without the ` : ` is
/// the coordinate alone and the typ is the kind's default — so a view
/// holding only the string never re-decides a kind itself.
pub fn from_spelled(s: &str) -> Self {
match s.rsplit_once(" : ") {
Some((c, t)) => Edge { coordinate: c.to_string(), typ: ArtifactKind::from(t.to_string()), lazy: false },
None => Edge { coordinate: s.to_string(), typ: ArtifactKind::default(), lazy: false },
}
}
/// `(repo, variant)` — the coordinate split at its one `/`.
pub fn split(&self) -> Option<(&str, &str)> {
self.coordinate.split_once('/')
}
/// The declaration row this edge renders back to.
pub fn to_decl(&self) -> Option<EdgeDecl> {
let (repo, variant) = self.split()?;
Some(EdgeDecl { repo: repo.to_string(), variant: variant.to_string(), typ: self.typ, lazy: self.lazy })
}
}
/// **A declared consumption edge** — the toml spelling of an [`Edge`] on ANY
/// variant kind: `[build.<kind>.edges.<feature>] repo = "tunnr" variant =
/// "tunnr-kernel" typ = "kernel"`. The feature name is the table key (the
/// node's path at the variant's root); `repo/variant` is the coordinate the
/// consumed artifact is built at, and `typ` is what it must BE — checked at
/// declaration time against the donor's own declaration
/// ([`BuildVariantSet::validate_edges_with`](crate::variant::BuildVariantSet::validate_edges_with)).
///
/// The kernel edge keeps its older spelling: `[build.<kind>.kernel_donor] repo /
/// kernel` ([`KernelDonor`](crate::installer::KernelDonor)) IS the `kernel`
/// edge of typ `kernel`, benign to every published 0.1.x reader. An
/// `edges.kernel` of typ kernel is refused by name — one spelling per edge.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EdgeDecl {
/// The donor CHECKOUT's name — its directory beside the consuming repo.
pub repo: String,
/// The variant inside that checkout whose artifact is consumed.
pub variant: String,
/// The typ that artifact must have.
pub typ: ArtifactKind,
/// **Lazy** (`lazy = true`): the donor checkout may be ABSENT. An open product
/// whose one sold row consumes a guest built by a commercial checkout must
/// load, list and build its other rows in a tree without that checkout
/// (holger, owner rule: the open build never needs monetize-impl). A lazy
/// edge is type-checked like any other when its donor is beside the consumer
/// and left unresolved at load when it is not; building the row that carries
/// it still resolves the donor, and refuses by name without it. Unspelled
/// when false, so every eager edge keeps its old toml.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub lazy: bool,
}
impl EdgeDecl {
/// `repo/variant`.
pub fn coordinate(&self) -> String {
format!("{}/{}", self.repo, self.variant)
}
/// The declaration's own shape: both halves named, a typ that is a
/// consumable artifact. `who` prefixes the refusal (`iso 'x'`),
/// `feature` is the table key.
pub fn validate(&self, who: &str, feature: &str) -> Result<()> {
if feature.trim().is_empty() || feature.contains(PATH_SEP) || feature.chars().any(char::is_whitespace) {
bail!("{who}: edge {feature:?}: an edge is a ROOT feature — one path segment, no '.', no whitespace");
}
if self.repo.trim().is_empty() {
bail!("{who}: edges.{feature}.repo is empty — the edge names WHICH checkout declares the consumed variant");
}
if self.variant.trim().is_empty() {
bail!(
"{who}: edges.{feature}.variant is empty — the edge names WHICH variant in '{}' is consumed",
self.repo
);
}
if feature == KERNEL_PATH && self.typ == ArtifactKind::Kernel {
bail!(
"{who}: edges.kernel of typ kernel is the kernel donor — spell it once, as \
[build.<kind>.kernel_donor] repo = \"{}\" kernel = \"{}\"",
self.repo,
self.variant
);
}
Ok(())
}
}
/// **A consumption edge, WHOLE** — the feature that owns the need together
/// with the coordinate and the typ it needs. [`EdgeDecl`] is the toml ROW, in
/// which the feature is the table key and therefore not on the struct; this is
/// the row and its key in one value, and it is the shape every reader of "what
/// does this variant consume" is handed ([`declared_edges`],
/// [`BuildVariantSet::consumption_edges`](crate::variant::BuildVariantSet::consumption_edges)).
///
/// The two spellings — `[build.<kind>.kernel_donor]` (the `kernel` edge of typ
/// kernel) and `[build.<kind>.edges.<feature>]` (any feature, any typ) — both
/// arrive here, so a consumer of this type never learns which one was written:
/// the general edge and the kernel donor are ONE mechanism with two spellings,
/// and `kernel_donor` is the older one kept because published readers know it.
///
/// **Types match at DECLARATION time.** A consumer naming a donor variant of
/// another typ is refused when the declaration is loaded
/// ([`wrong_typ`] is the one refusal, shared with the DAG's walker) — not at
/// compose time, not at run time, for the same reason
/// [`KernelDonor::validate`](crate::installer::KernelDonor::validate) refuses
/// an empty half at declaration time: a later error would be true words about
/// the wrong defect.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConsumptionEdge {
/// The feature path in the CONSUMER's tree that owns this need ("registry").
/// Next to the feature, per the model: a requirement is a property of the
/// feature that needs it, not of the repo.
pub feature: String,
/// The donor CHECKOUT's name — its directory beside the consuming repo.
pub repo: String,
/// The variant name inside that checkout ("mvp-server-musl").
pub variant: String,
/// The typ both sides must agree on ("kernel", "server", "container", …).
pub typ: String,
/// The donor checkout may be absent ([`EdgeDecl::lazy`]).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub lazy: bool,
}
impl ConsumptionEdge {
/// The edge a declaration row spells, with its table key.
pub fn from_decl(feature: impl Into<String>, d: &EdgeDecl) -> Self {
ConsumptionEdge {
feature: feature.into(),
repo: d.repo.clone(),
variant: d.variant.clone(),
typ: d.typ.as_str().to_string(),
lazy: d.lazy,
}
}
/// All four halves given, the typ as its canonical word.
pub fn new(feature: impl Into<String>, repo: impl Into<String>, variant: impl Into<String>, typ: ArtifactKind) -> Self {
ConsumptionEdge {
feature: feature.into(),
repo: repo.into(),
variant: variant.into(),
typ: typ.as_str().to_string(),
lazy: false,
}
}
/// The typ as the enum — the word parsed back. An unknown word reads as the
/// [`ArtifactKind`] default, exactly as a `kind = "…"` field does, so the
/// two roads into a declaration cannot disagree about what a word means.
pub fn kind(&self) -> ArtifactKind {
ArtifactKind::from(self.typ.clone())
}
/// `repo/variant`.
pub fn coordinate(&self) -> String {
format!("{}/{}", self.repo, self.variant)
}
/// The toml row this edge renders back to (the feature becomes the key).
pub fn decl(&self) -> EdgeDecl {
EdgeDecl { repo: self.repo.clone(), variant: self.variant.clone(), typ: self.kind(), lazy: self.lazy }
}
/// The tree-node form: a coordinate and the typ required of it.
pub fn edge(&self) -> Edge {
Edge { lazy: self.lazy, ..Edge::new(&self.repo, &self.variant, self.kind()) }
}
/// The declaration's own shape ([`EdgeDecl::validate`]), the feature
/// included — `who` prefixes the refusal (`installer 'gunnar-installer'`).
pub fn validate(&self, who: &str) -> Result<()> {
self.decl().validate(who, &self.feature)
}
/// `installer 'x': edge 'registry' → holger/mvp-server-musl : exe` — the
/// prefix every refusal about this edge carries.
pub fn who(&self, kind_word: &str, variant: &str) -> String {
format!("{kind_word} '{variant}': edge '{}' → {} : {}", self.feature, self.coordinate(), self.typ)
}
/// **The typ check**, against what the donor's own declaration says that
/// variant IS. `declared` is [`BuildVariantSet::kind_of`](crate::variant::BuildVariantSet::kind_of)
/// of the donor's set. Ok when they agree; [`wrong_typ`] otherwise.
pub fn check_typ(&self, who: &str, declared: ArtifactKind) -> Result<()> {
if declared == self.kind() {
Ok(())
} else {
Err(wrong_typ(who, &self.repo, &self.variant, declared, self.kind()))
}
}
}
/// **The ONE refusal for a typ that does not match** — consumer (in `who`),
/// feature (in `who`), the donor coordinate and BOTH typs. Shared by the
/// declaration-time check
/// ([`BuildVariantSet::validate_edges_with`](crate::variant::BuildVariantSet::validate_edges_with))
/// and the DAG's walker ([`crate::build_dag::BuildDag::derive`]), so the two
/// roads into a build cannot phrase the same defect two ways.
pub fn wrong_typ(who: &str, repo: &str, variant: &str, declared: ArtifactKind, required: ArtifactKind) -> anyhow::Error {
anyhow::anyhow!(
"{who}: '{repo}' declares '{variant}' as typ {}, not {} — the edge's typ must be what the \
donor builds",
declared.as_str(),
required.as_str()
)
}
/// **The preset a build was made with** — recorded on the variant
/// (`applied_preset`) and in the receipt: the bundle's name AND the flags it
/// expanded to, so the descriptor says both what was chosen and what that
/// meant on the day (law D's shape: decision ↔ its whole consequence).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct AppliedPreset {
/// The bundle's name (`airgap`).
pub bundle: String,
/// The rendered flags the bundle expanded to, in bundle order
/// (`--opt wireguard=off`, `--opt kernel.preset=nuked`, …).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub expanded: Vec<String>,
}
impl AppliedPreset {
/// One line for a human — the CLI's stderr and the pane's receipt.
pub fn describe(&self) -> String {
format!("preset = {} → {}", self.bundle, self.expanded.join(" · "))
}
}
/// **What expanding a variant's `--opt` / `--param` line produced** — the
/// bundle (when `preset=<name>` was typed) plus the flags with the bundle's
/// choices folded in: an explicit flag on the line WINS over the bundle's
/// (the bundle is the defaults the operator starts from, exactly as a kernel
/// preset is the defaults its symbol rows start from).
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Expanded {
/// `--opt` pairs, `name=value`, bundle choices first, explicit after.
pub opts: Vec<String>,
/// `--param` pairs, `name=value`, same order rule.
pub params: Vec<String>,
/// The bundle, when one was chosen.
pub preset: Option<AppliedPreset>,
}
/// **Declaration-time shape check over a variant's edge rows** — every
/// `[…edges.<feature>]` well-formed ([`EdgeDecl::validate`]), no feature
/// declared twice (as an edge AND an option), and no `edges.kernel` beside a
/// `kernel_donor`. The TYPE check against the donor's own declaration is the
/// set's ([`BuildVariantSet::validate_edges_with`](crate::variant::BuildVariantSet::validate_edges_with)),
/// because it needs the donor checkout.
pub fn validate_edge_rows(
who: &str,
kernel_donor: Option<&crate::installer::KernelDonor>,
edges: &BTreeMap<String, EdgeDecl>,
options: &[OptionSpec],
) -> Result<()> {
for (feature, e) in edges {
e.validate(who, feature)?;
if feature == KERNEL_PATH && kernel_donor.is_some() {
bail!(
"{who}: declares BOTH [kernel_donor] and [edges.kernel] — one kernel edge per \
variant; the donor row is its spelling"
);
}
if let Some(o) = options.iter().find(|o| &o.name == feature) {
bail!(
"{who}: '{feature}' is declared twice — as an edge → {} and as an option \
deciding cargo feature {:?}; a feature is one or the other",
e.coordinate(),
o.feature
);
}
}
if let Some(o) = kernel_donor.and(options.iter().find(|o| o.name == KERNEL_PATH)) {
bail!(
"{who}: '{}' is declared twice — as the kernel donor edge and as an option deciding \
cargo feature {:?}",
KERNEL_PATH,
o.feature
);
}
Ok(())
}
/// Every edge declaration a variant carries, in one shape: the `kernel_donor`
/// row (the `kernel` edge of typ kernel) first, then `[…edges.<feature>]` in
/// key order. The one place the two spellings meet.
pub fn declared_edges(
kernel_donor: Option<&crate::installer::KernelDonor>,
edges: &BTreeMap<String, EdgeDecl>,
) -> Vec<ConsumptionEdge> {
let mut out = Vec::with_capacity(edges.len() + 1);
if let Some(d) = kernel_donor {
out.push(ConsumptionEdge::new(KERNEL_PATH, &d.repo, &d.kernel, ArtifactKind::Kernel));
}
for (f, e) in edges {
out.push(ConsumptionEdge::from_decl(f.clone(), e));
}
out
}
/// **One node of the tree.** Its id is [`path`](Self::path).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SettingsNode {
/// The full dotted path — the id: `dhcp.static_ip`.
pub path: String,
/// What a human is shown. Falls back to the last path segment when empty.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub label: String,
/// Which of the three kinds, with that kind's data.
#[serde(flatten)]
pub kind: NodeKind,
/// For a child of a feature: the parent state this node is ACTIVE in
/// (`Some(true)` = `params_when_on`, `Some(false)` = `params_when_off`).
/// `None` = always active.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub when: Option<bool>,
/// The consumption edge this node is bound to, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub edge: Option<Edge>,
/// Sub-settings.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub children: Vec<SettingsNode>,
}
impl SettingsNode {
/// A node with no children, no edge, always active.
pub fn leaf(path: impl Into<String>, label: impl Into<String>, kind: NodeKind) -> Self {
SettingsNode {
path: path.into(),
label: label.into(),
kind,
when: None,
edge: None,
children: Vec::new(),
}
}
/// The last path segment — the short name (`static_ip` of `dhcp.static_ip`).
pub fn name(&self) -> &str {
self.path.rsplit(PATH_SEP).next().unwrap_or(&self.path)
}
/// What a human is shown — the label, or the short name.
pub fn display_label(&self) -> &str {
if self.label.trim().is_empty() { self.name() } else { &self.label }
}
/// Every node of this subtree, pre-order, self first.
pub fn walk(&self) -> Vec<&SettingsNode> {
let mut out = Vec::with_capacity(1 + self.children.len());
self.visit(&mut |n| out.push(n));
out
}
/// Pre-order visit without allocating — the walk the hot paths use.
pub fn visit<'a>(&'a self, f: &mut dyn FnMut(&'a SettingsNode)) {
f(self);
for c in &self.children {
c.visit(f);
}
}
/// The node at `path` inside this subtree (exact full path).
pub fn get(&self, path: &str) -> Option<&SettingsNode> {
if self.path == path {
return Some(self);
}
if !path.starts_with(&self.path) {
return None;
}
self.children.iter().find_map(|c| c.get(path))
}
/// Re-root: prefix every path in the subtree with `prefix.` (used to
/// project a donor kernel's own tree under `kernel.`).
pub fn prefixed(mut self, prefix: &str) -> Self {
fn go(n: &mut SettingsNode, prefix: &str) {
n.path = format!("{prefix}{PATH_SEP}{}", n.path);
for c in &mut n.children {
go(c, prefix);
}
}
go(&mut self, prefix);
self
}
}
/// **One variant's whole settings tree.**
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct SettingsTree {
/// The variant this tree belongs to.
pub variant: String,
/// The variant's typ — what the artifact IS, which decides its verb.
#[serde(default)]
pub typ: ArtifactKind,
/// The top-level nodes, in declaration order.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub roots: Vec<SettingsNode>,
}
/// What [`SettingsTree::resolve`] answered: the node, and whether the caller's
/// spelling was the full path or a short alias.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Resolved<'a> {
/// The node.
pub node: &'a SettingsNode,
/// `true` when the spelling was a bare short name that resolved uniquely.
pub via_alias: bool,
}
impl SettingsTree {
/// Every node, pre-order.
pub fn walk(&self) -> Vec<&SettingsNode> {
let mut out = Vec::new();
self.visit(&mut |n| out.push(n));
out
}
/// Pre-order visit of every node without allocating.
pub fn visit<'a>(&'a self, f: &mut dyn FnMut(&'a SettingsNode)) {
for r in &self.roots {
r.visit(f);
}
}
/// How many nodes the tree holds.
pub fn len(&self) -> usize {
let mut n = 0;
self.visit(&mut |_| n += 1);
n
}
/// Whether the tree holds no node at all.
pub fn is_empty(&self) -> bool {
self.roots.is_empty()
}
/// Every full path, pre-order.
pub fn paths(&self) -> Vec<&str> {
self.walk().into_iter().map(|n| n.path.as_str()).collect()
}
/// The node at an exact full path.
pub fn get(&self, path: &str) -> Option<&SettingsNode> {
self.roots.iter().find_map(|r| r.get(path))
}
/// The subtree rooted at `path` (`""` = the whole tree as-is).
pub fn subtree(&self, path: &str) -> Option<SettingsTree> {
if path.trim().is_empty() {
return Some(self.clone());
}
self.get(path).map(|n| SettingsTree {
variant: self.variant.clone(),
typ: self.typ,
roots: vec![n.clone()],
})
}
/// **Resolve a spelling to a node** — full path first, then the short
/// name as an unambiguous alias. A short name two nodes carry is REFUSED
/// by name, listing both paths; an unknown spelling is refused listing
/// what is declared.
pub fn resolve(&self, spelling: &str) -> Result<Resolved<'_>> {
let s = spelling.trim();
if s.is_empty() {
bail!("an empty settings path");
}
if let Some(node) = self.get(s) {
return Ok(Resolved { node, via_alias: false });
}
// The alias scan: one pass, no intermediate Vec of every node — only
// the hits are collected (normally zero or one).
let mut hits: Vec<&SettingsNode> = Vec::new();
self.visit(&mut |n| {
if n.name() == s {
hits.push(n);
}
});
match hits.len() {
1 => Ok(Resolved { node: hits[0], via_alias: true }),
0 => bail!(
"'{s}' is not a setting of variant '{}' (declared: {})",
self.variant,
self.declared_list()
),
_ => bail!(
"'{s}' is AMBIGUOUS in variant '{}' — {} nodes carry that name; spell the full \
path: {}",
self.variant,
hits.len(),
hits.iter().map(|n| n.path.as_str()).collect::<Vec<_>>().join(", ")
),
}
}
/// The full path a spelling resolves to (see [`resolve`](Self::resolve)).
pub fn canonical_path(&self, spelling: &str) -> Result<String> {
Ok(self.resolve(spelling)?.node.path.clone())
}
fn declared_list(&self) -> String {
let p = self.paths();
if p.is_empty() { "none".to_string() } else { p.join(", ") }
}
// ── derivation from today's flat declaration ─────────────────────────────
/// **The tree of an installer** — options become features with their
/// conditional params as children, unconditional params become root
/// settings, and the `kernel_donor` becomes the `kernel` feature with its
/// edge. `donor` is the donor's own `[[build.kernel]]` row when the caller
/// has it (same checkout, or resolved beside it); without it the kernel
/// subtree starts from the `normal` / `vanilla` pick.
pub fn of_installer(inst: &InstallerBuild, donor: Option<&KernelBuild>) -> Self {
let mut roots = option_and_param_nodes(&inst.options, &inst.params, &inst.fixtures, "");
roots.extend(edge_nodes(inst.kernel_donor.as_ref(), &inst.edges, donor));
roots.extend(data_nodes(&inst.data));
roots.extend(preset_node(&inst.presets));
SettingsTree { variant: inst.name.clone(), typ: ArtifactKind::Installer, roots }
}
/// **The tree of an ISO** — its options as features, its params as
/// settings, and its edges (`kernel_donor` → the `kernel` feature with the
/// donor's subtree, `[build.iso.edges.<f>]` → a bare edge feature). `donor`
/// as in [`of_installer`](Self::of_installer).
pub fn of_iso(iso: &IsoBuild, donor: Option<&KernelBuild>) -> Self {
let mut roots = option_and_param_nodes(&iso.options, &iso.params, &iso.fixtures, "");
roots.extend(edge_nodes(iso.kernel_donor.as_ref(), &iso.edges, donor));
roots.extend(preset_node(&iso.presets));
SettingsTree { variant: iso.name.clone(), typ: ArtifactKind::Iso, roots }
}
/// **The tree of bare rows** — what the option resolver builds so that every
/// decision about `--opt` / `--param` is answered by the tree and never by a
/// second walk over the rows.
pub fn of_rows(
variant: &str,
typ: ArtifactKind,
options: &[OptionSpec],
params: &[ParamSpec],
fixtures: &BTreeMap<String, FixtureKind>,
) -> Self {
SettingsTree {
variant: variant.to_string(),
typ,
roots: option_and_param_nodes(options, params, fixtures, ""),
}
}
/// **The tree of a kernel pick, BARE** — `preset` / `profile` vals and a
/// feature per symbol at the root, with no `kernel.` scope on the paths.
///
/// This is the INTERNAL JUDGE of the kernel grammar, and its names are the
/// ones [`split_kernel_opts`](crate::kernel_features::split_kernel_opts)
/// hands it — already stripped of the scope. It is NOT what a door renders:
/// the tree of a declared kernel row ([`of_kernel`](Self::of_kernel)) puts
/// the same nodes under `kernel`, because that is the spelling every door
/// ACCEPTS. Keeping the judge bare and the door scoped is what lets one
/// string, `--opt kernel.preset=slim`, work at the donor's door and at a
/// consumer's alike.
pub fn of_kernel_pick(variant: &str, preset: Preset, profile: HardeningProfile) -> Self {
SettingsTree { variant: variant.to_string(), typ: ArtifactKind::Kernel, roots: kernel_nodes(preset, profile, "") }
}
/// **The tree of a kernel** — `preset` and `profile` vals, then one feature
/// per symbol of the pick (from the shared kernel feature model, lock and
/// all). An unparseable preset/profile falls back to `normal` / `vanilla`
/// — [`validate`](KernelBuild::validate) refuses those by name; the tree
/// is a view, not a second gate.
pub fn of_kernel(k: &KernelBuild) -> Self {
let preset = k.preset().unwrap_or(Preset::Normal);
let profile = k.hardening_profile().unwrap_or(HardeningProfile::Vanilla);
// UNDER THE `kernel` PREFIX (lane AK, 2026-08-30), the same as the
// consumer-side nesting (`of_installer` gives the edge node exactly
// these children). It used to be bare, and that is what made
// `--list-options` print a spelling the CLI refuses: the listing renders
// the tree's own flags (law C), so it said `--opt preset=slim`, and the
// resolver answered `no preset 'slim' on 'tunnr-kernel'; declared
// bundles: full · normal · slim · nuked` — a refusal that lists `slim`
// while saying there is no `slim`. It cost lane AH a wrong turn. ONE
// spelling at both doors, `kernel.preset=slim`: what the CLI has always
// accepted, what a consumer routes down the edge unchanged
// (`build_steps::route_opts` passes `kernel.*` through), and now what
// the listing prints. The row's own EDGES keep their bare paths:
// `--build toolchain` is this door's spelling of the edge a consumer
// reaches as `kernel.toolchain`.
let mut roots = kernel_nodes(preset, profile, KERNEL_PATH);
roots.extend(edge_nodes(None, &k.edges, None));
SettingsTree { variant: k.name.clone(), typ: ArtifactKind::Kernel, roots }
}
/// **The tree of a cargo variant** — its switchable axis: every `open`
/// feature is a feature node (default off). The row's `features` are its
/// identity, not a setting (an installer option that defaults ON is what
/// makes one of them switchable — `OptionSpec::validate_against_variant`).
pub fn of_variant(v: &BuildVariant) -> Self {
let mut roots: Vec<SettingsNode> = v
.open
.iter()
.map(|f| {
SettingsNode::leaf(
f.clone(),
"",
NodeKind::Feature(FeatureNode { default: false, feature: f.clone(), ..Default::default() }),
)
})
.collect();
roots.extend(edge_nodes(None, &v.edges, None));
roots.extend(preset_node(&v.presets));
SettingsTree { variant: v.name.clone(), typ: v.kind, roots }
}
/// **The tree of any declared variant of a set**, by name, or `None` when
/// the set declares no such variant. An installer's donor kernel is looked
/// up in the SAME set when the donor repo is this one (a self-donor); a
/// donor in another checkout is projected from the default pick — the
/// impl's `options("kernel")` (stage 2) is where a fetched donor answers.
pub fn of_set(set: &BuildVariantSet, variant: &str) -> Option<Self> {
if let Some(inst) = set.installers.iter().find(|i| i.name == variant) {
let donor = inst
.kernel_donor
.as_ref()
.and_then(|d| set.kernels.iter().find(|k| k.name == d.kernel));
return Some(Self::of_installer(inst, donor));
}
if let Some(iso) = set.isos.iter().find(|i| i.name == variant) {
let donor = iso
.kernel_donor
.as_ref()
.and_then(|d| set.kernels.iter().find(|k| k.name == d.kernel));
return Some(Self::of_iso(iso, donor));
}
if let Some(k) = set.kernels.iter().find(|k| k.name == variant) {
return Some(Self::of_kernel(k));
}
// A container's / vm's tree is its edges alone — the same edge every
// kind carries (design f3), so `--build <edge>` resolves against them too.
//
// These two arms sit ABOVE the cargo-variant arm, and that is load-bearing:
// a `kind = "container"` variant is required to carry a `container_ref`
// (`validate_kind`), so the pair `[[build.variant]] name = "X"` +
// `[[build.container]] name = "X"` is the normal shape. With the variant arm
// first, `of_set("X")` answered `of_variant` — a cargo tree with no edges —
// and the container's declared edge was invisible to `--build`, to the DAG
// and to the step console. Not refused, not built, silently absent.
//
// ⚠ TWIN, deliberately named rather than left to be found: this dispatch
// order and `BuildVariantSet::edges_of`'s are the same rule written twice,
// and they were BOTH wrong in the same way. `delegating_and_tree_agree_on_a_containers_edges`
// in variant.rs fails if they drift apart again.
// The descriptor owns the EDGES; the paired variant row still owns its
// switchable axis (`open` features, presets). Dropping those made a
// container that leaves `gpu` open draw no gpu control at all whenever the
// tree was derived from the declaration (no `<repo>-build-thing` crate).
// A delegating variant declaring edges of its own is refused at load, so
// taking its non-edge nodes cannot double an edge.
if let Some(c) = set.containers.iter().find(|c| c.name == variant) {
let mut roots = paired_axis(set, &c.name);
roots.extend(edge_nodes(None, &c.edges, None));
return Some(SettingsTree { variant: c.name.clone(), typ: ArtifactKind::Container, roots });
}
if let Some(vm) = set.vms.iter().find(|v| v.name == variant) {
let mut roots = paired_axis(set, &vm.name);
roots.extend(edge_nodes(None, &vm.edges, None));
return Some(SettingsTree { variant: vm.name.clone(), typ: ArtifactKind::Vm, roots });
}
if let Some(v) = set.variants.iter().find(|v| v.name == variant) {
return Some(Self::of_variant(v));
}
None
}
/// Every declared variant's tree, in declaration order (cargo rows, then
/// containers, vms, isos, installers, kernels).
pub fn forest(set: &BuildVariantSet) -> Vec<Self> {
let names: Vec<String> = set
.variants
.iter()
.map(|v| v.name.clone())
.chain(set.containers.iter().map(|c| c.name.clone()))
.chain(set.vms.iter().map(|v| v.name.clone()))
.chain(set.isos.iter().map(|i| i.name.clone()))
.chain(set.installers.iter().map(|i| i.name.clone()))
.chain(set.kernels.iter().map(|k| k.name.clone()))
.collect();
names.iter().filter_map(|n| Self::of_set(set, n)).collect()
}
}
/// The switchable axis of the `[[build.variant]]` row a delegating descriptor
/// (`[[build.container]]` / `[[build.vm]]`) pairs with — by name, or by the row's
/// `container_ref` / `vm_ref` — WITHOUT its edges (the descriptor owns those).
fn paired_axis(set: &BuildVariantSet, descriptor: &str) -> Vec<SettingsNode> {
set.variants
.iter()
.find(|v| v.name == descriptor || v.container_ref.as_deref() == Some(descriptor) || v.vm_ref.as_deref() == Some(descriptor))
.map(|v| SettingsTree::of_variant(v).roots.into_iter().filter(|n| n.edge.is_none()).collect())
.unwrap_or_default()
}
/// A setting node from a param row, with the sharper kind an option declared.
fn setting_node(
p: &ParamSpec,
sharpen: Option<ParamKind>,
fixtures: &BTreeMap<String, FixtureKind>,
prefix: &str,
when: Option<bool>,
) -> SettingsNode {
let path = if prefix.is_empty() { p.name.clone() } else { format!("{prefix}{PATH_SEP}{}", p.name) };
let mut n = SettingsNode::leaf(
path,
p.label.clone(),
NodeKind::Setting(SettingNode {
param_kind: sharpen.unwrap_or(p.kind),
required: p.required,
boot_param: p.boot_param.clone(),
staged_file: p.staged_file.clone(),
lands: p.lands,
fixture: fixtures.get(&p.name).copied(),
declared_kind: sharpen.map(|_| p.kind),
}),
);
n.when = when;
n
}
/// Options → feature nodes with their conditional params as children;
/// unconditional params → root settings. Declaration order throughout.
fn option_and_param_nodes(
options: &[OptionSpec],
params: &[ParamSpec],
fixtures: &BTreeMap<String, FixtureKind>,
prefix: &str,
) -> Vec<SettingsNode> {
let mut out = Vec::with_capacity(options.len() + params.len());
let by_name: BTreeMap<&str, &ParamSpec> = params.iter().map(|p| (p.name.as_str(), p)).collect();
let mut owned: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
for o in options {
let path = if prefix.is_empty() { o.name.clone() } else { format!("{prefix}{PATH_SEP}{}", o.name) };
let mut node = SettingsNode::leaf(
path.clone(),
o.label.clone(),
NodeKind::Feature(FeatureNode {
default: o.default,
feature: o.feature.clone(),
forbid_symbols: o.forbid_symbols.clone(),
bakes: o.bakes.clone(),
locked_by: Vec::new(),
kernel_requires: o.kernel_requires.clone(),
requires: o.requires.clone(),
}),
);
for (state, names) in [(true, &o.params_when_on), (false, &o.params_when_off)] {
for name in names {
let Some(p) = by_name.get(name.as_str()) else { continue };
owned.insert(name.as_str());
node.children.push(setting_node(
p,
o.param_kinds.get(name).copied(),
fixtures,
&path,
Some(state),
));
}
}
out.push(node);
}
for p in params {
if owned.contains(p.name.as_str()) {
continue;
}
out.push(setting_node(p, None, fixtures, prefix, None));
}
out
}
/// **An index over a tree for hot loops** — `path → node` and `short name →
/// paths`, built once with one visit; [`get`](Self::get) and
/// [`resolve`](Self::resolve) are then O(1) / O(hits) instead of a visit per
/// call. The laws and any pane that resolves thousands of spellings use it;
/// a single `--opt` goes through [`SettingsTree::resolve`] directly.
pub struct SettingsIndex<'a> {
tree: &'a SettingsTree,
by_path: std::collections::HashMap<&'a str, &'a SettingsNode>,
by_name: std::collections::HashMap<&'a str, Vec<&'a SettingsNode>>,
}
impl<'a> SettingsIndex<'a> {
/// Build the index — one visit, two maps.
pub fn of(tree: &'a SettingsTree) -> Self {
let n = tree.len();
let mut by_path = std::collections::HashMap::with_capacity(n);
let mut by_name: std::collections::HashMap<&'a str, Vec<&'a SettingsNode>> =
std::collections::HashMap::with_capacity(n);
tree.visit(&mut |node| {
by_path.insert(node.path.as_str(), node);
by_name.entry(node.name()).or_default().push(node);
});
SettingsIndex { tree, by_path, by_name }
}
/// The node at an exact full path.
pub fn get(&self, path: &str) -> Option<&'a SettingsNode> {
self.by_path.get(path).copied()
}
/// The same answer as [`SettingsTree::resolve`], from the index.
pub fn resolve(&self, spelling: &str) -> Result<Resolved<'a>> {
let s = spelling.trim();
if s.is_empty() {
bail!("an empty settings path");
}
if let Some(node) = self.get(s) {
return Ok(Resolved { node, via_alias: false });
}
match self.by_name.get(s).map(Vec::as_slice) {
Some([one]) => Ok(Resolved { node: one, via_alias: true }),
Some(many) if many.len() > 1 => bail!(
"'{s}' is AMBIGUOUS in variant '{}' — {} nodes carry that name; spell the full path: {}",
self.tree.variant,
many.len(),
many.iter().map(|n| n.path.as_str()).collect::<Vec<_>>().join(", ")
),
_ => bail!(
"'{s}' is not a setting of variant '{}' (declared: {})",
self.tree.variant,
self.tree.declared_list()
),
}
}
}
/// **The edge nodes of a variant** — one root feature per declared edge
/// ([`declared_edges`]): the `kernel` edge carries the donor's own subtree
/// (`preset` / `profile` vals and the pick's symbols — from `donor` when the
/// caller has the donor's `[[build.kernel]]` row, else the `normal` /
/// `vanilla` pick); every other edge is a bare feature (default on, no cargo
/// feature of its own) whose subtree is DELEGATED — the consumed build thing's
/// `options(path)` is where it says more.
pub fn edge_nodes(
kernel_donor: Option<&crate::installer::KernelDonor>,
edges: &BTreeMap<String, EdgeDecl>,
donor: Option<&KernelBuild>,
) -> Vec<SettingsNode> {
declared_edges(kernel_donor, edges)
.into_iter()
.map(|e| {
let feature = e.feature.clone();
let mut node = SettingsNode::leaf(
feature.clone(),
if feature == KERNEL_PATH { "Kernel".to_string() } else { String::new() },
NodeKind::Feature(FeatureNode { default: true, ..Default::default() }),
);
node.edge = Some(e.edge());
if feature == KERNEL_PATH && e.kind() == ArtifactKind::Kernel {
let (preset, profile) = donor
.map(|k| {
(
k.preset().unwrap_or(Preset::Normal),
k.hardening_profile().unwrap_or(HardeningProfile::Vanilla),
)
})
.unwrap_or((Preset::Normal, HardeningProfile::Vanilla));
node.children = kernel_nodes(preset, profile, KERNEL_PATH);
}
node
})
.collect()
}
/// **The root preset val** of a variant that declares bundles: choices = the
/// bundle names in key order, default EMPTY (no bundle chosen), each bundle
/// carried on the node so a pane can show what a choice means. `None` when
/// the variant declares no presets — the node does not exist then.
pub fn preset_node(presets: &Presets) -> Option<SettingsNode> {
if presets.is_empty() {
return None;
}
Some(SettingsNode::leaf(
PRESET_PATH,
"Preset",
NodeKind::Val(ValNode { choices: presets.keys().cloned().collect(), default: String::new(), bundles: presets.clone() }),
))
}
/// **The `data` group as val nodes** — `data.fs` and `data.io`, each with its
/// own roster of choices and the DECLARED arm as the default. Always present on
/// an installer's tree, declared or not: an undeclared group is at its type
/// default and an operator must be able to SEE that it is (and move it), which
/// is the difference between "recipe-only" and "invisible".
pub fn data_nodes(data: &crate::data::Data) -> Vec<SettingsNode> {
vec![
SettingsNode::leaf(
DATA_FS_PATH,
"/data filesystem",
NodeKind::Val(ValNode {
choices: crate::data::DataFs::ALL.iter().map(|f| f.as_str().to_string()).collect(),
default: data.fs.as_str().to_string(),
bundles: Presets::new(),
}),
),
SettingsNode::leaf(
DATA_IO_PATH,
"/data writer arm",
NodeKind::Val(ValNode {
choices: crate::data::DataIo::ALL.iter().map(|a| a.as_str().to_string()).collect(),
default: data.io.as_str().to_string(),
bundles: Presets::new(),
}),
),
]
}
/// The kernel subtree: `preset` + `profile` vals, then a feature per symbol.
fn kernel_nodes(preset: Preset, profile: HardeningProfile, prefix: &str) -> Vec<SettingsNode> {
let at = |name: &str| if prefix.is_empty() { name.to_string() } else { format!("{prefix}{PATH_SEP}{name}") };
let mut out = vec![
SettingsNode::leaf(
at("preset"),
"Preset",
NodeKind::Val(ValNode {
choices: Preset::ALL.iter().map(|p| p.as_str().to_string()).collect(),
default: preset.as_str().to_string(),
bundles: Presets::new(),
}),
),
SettingsNode::leaf(
at("profile"),
"Hardening profile",
NodeKind::Val(ValNode {
choices: HardeningProfile::ALL.iter().map(|p| p.as_str().to_string()).collect(),
default: profile.as_str().to_string(),
bundles: Presets::new(),
}),
),
// The DEV probe kernel (`kernel.coverage=gcov`): the coverage half of
// the measured kernel trim. Default off; refused on a release medium.
SettingsNode::leaf(
at(crate::kernel_trim::coverage::COVERAGE_OPT),
"Coverage probe (DEV only)",
NodeKind::Val(ValNode {
choices: crate::kernel_trim::coverage::KernelProbe::CHOICES.iter().map(|c| c.to_string()).collect(),
default: "off".to_string(),
bundles: Presets::new(),
}),
),
];
let model = KernelFeatureModel::derive(preset, profile);
for row in model.option_rows() {
out.push(SettingsNode::leaf(
at(&row.option),
row.symbol.clone(),
NodeKind::Feature(FeatureNode {
default: row.default_on,
feature: row.symbol.clone(),
forbid_symbols: Vec::new(),
bakes: None,
locked_by: row.locked_by.clone(),
// A kernel symbol node is the DONOR's own axis, not a consumer
// feature: it requires nothing of anything behind an edge.
kernel_requires: Vec::new(),
requires: Vec::new(),
}),
));
}
out
}
// ── the rows: the serde spelling of the tree ─────────────────────────────────
/// **The toml rows a tree renders back to** — [`OptionSpec`] / [`ParamSpec`] /
/// fixtures / the kernel donor are the on-disk SPELLING of the tree, nothing
/// more; this is the render half of the bijection law A proves.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Rows {
/// `[[…options]]`, in root order.
pub options: Vec<OptionSpec>,
/// `[[…params]]`, sorted by name (the declaration's interleaving is not
/// a decision and is not kept).
pub params: Vec<ParamSpec>,
/// `[…fixtures]`.
pub fixtures: BTreeMap<String, FixtureKind>,
/// `[…kernel_donor]`, from the `kernel` edge.
pub kernel_donor: Option<crate::installer::KernelDonor>,
/// `[…edges.<feature>]`, from every other edge node at the root.
pub edges: BTreeMap<String, EdgeDecl>,
/// `[…presets.<name>]`, from the root `preset` val's bundles.
pub presets: Presets,
/// `[build.installer.data]`, from the `data.fs` / `data.io` vals.
pub data: crate::data::Data,
}
/// **What [`SettingsTree::split_opts`] made of an `--opt` line** — this
/// variant's own toggles, this variant's own vals, and the pairs that belong to
/// a donor behind an edge.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SplitOpts {
/// Canonical path → on/off, for the features of THIS variant.
pub features: BTreeMap<String, bool>,
/// Canonical path → the chosen value, checked against the val's roster.
pub choices: BTreeMap<String, String>,
/// `path=value`, verbatim, for everything under an edge — the donor's door.
pub behind_an_edge: Vec<String>,
}
impl SettingsTree {
/// **Render the rows** this tree spells (installer / iso shape). A feature
/// carrying an edge is the kernel donor, not an option; its subtree (the
/// donor's vals and symbols) belongs to the donor's own row.
pub fn rows(&self) -> Rows {
let mut out = Rows::default();
let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
let mut push_setting = |n: &SettingsNode, s: &SettingNode| {
if !seen.insert(n.name().to_string()) {
return;
}
out.params.push(ParamSpec {
name: n.name().to_string(),
label: n.label.clone(),
kind: s.declared_kind.unwrap_or(s.param_kind),
required: s.required,
boot_param: s.boot_param.clone(),
staged_file: s.staged_file.clone(),
lands: s.lands,
});
if let Some(f) = s.fixture {
out.fixtures.insert(n.name().to_string(), f);
}
};
for n in &self.roots {
match &n.kind {
NodeKind::Feature(_) if n.edge.is_some() => {}
// A kernel symbol is the kernel row's, spelled by its pick — not an option row.
NodeKind::Feature(f) if f.feature.starts_with("CONFIG_") => {}
NodeKind::Feature(f) => {
let mut o = OptionSpec {
name: n.name().to_string(),
label: n.label.clone(),
default: f.default,
feature: f.feature.clone(),
forbid_symbols: f.forbid_symbols.clone(),
bakes: f.bakes.clone(),
kernel_requires: f.kernel_requires.clone(),
requires: f.requires.clone(),
..Default::default()
};
for c in &n.children {
if let NodeKind::Setting(s) = &c.kind {
match c.when {
Some(true) => o.params_when_on.push(c.name().to_string()),
Some(false) => o.params_when_off.push(c.name().to_string()),
None => {}
}
if s.declared_kind.is_some() {
o.param_kinds.insert(c.name().to_string(), s.param_kind);
}
push_setting(c, s);
}
}
out.options.push(o);
}
NodeKind::Setting(s) => push_setting(n, s),
NodeKind::Val(_) => {}
}
}
out.params.sort_by(|a, b| a.name.cmp(&b.name));
// The edges: the `kernel` edge of typ kernel is the donor row; every
// other root edge is an `[…edges.<feature>]` row.
for n in &self.roots {
let Some(e) = &n.edge else { continue };
if n.path == KERNEL_PATH && e.typ == ArtifactKind::Kernel {
if let Some((repo, kernel)) = e.split() {
out.kernel_donor =
Some(crate::installer::KernelDonor { repo: repo.to_string(), kernel: kernel.to_string() });
}
} else if let Some(d) = e.to_decl() {
out.edges.insert(n.path.clone(), d);
}
}
if let Some(NodeKind::Val(v)) = self.get(PRESET_PATH).map(|n| &n.kind) {
out.presets = v.bundles.clone();
}
// The `data` group, read back BY PATH — the same road `presets` takes.
// An absent val is the type default, so a tree that carries no group
// (an iso's, a cargo variant's) renders the default row and law A is
// byte-identical to before the group was on the tree at all.
if let Some(NodeKind::Val(v)) = self.get(DATA_FS_PATH).map(|n| &n.kind) {
if let Some(fs) = crate::data::DataFs::parse(&v.default) {
out.data.fs = fs;
}
}
if let Some(NodeKind::Val(v)) = self.get(DATA_IO_PATH).map(|n| &n.kind) {
if let Some(io) = crate::data::DataIo::parse(&v.default) {
out.data.io = io;
}
}
out
}
// ── presets: a val at the root, a bundle of choices over the tree ────────
/// **Prove every declared bundle against this tree** — each path resolves
/// to a node (full path or unambiguous alias), each value fits the node
/// (`on|off` for a feature, one of the choices for a val, a `--param`
/// value for a setting), and no bundle names the preset itself. A bundle
/// that names a path the tree lacks is a NAMED refusal at declaration
/// time, listing what the tree declares.
pub fn validate_presets(&self) -> Result<()> {
let Some(NodeKind::Val(v)) = self.get(PRESET_PATH).map(|n| &n.kind) else { return Ok(()) };
for name in &v.choices {
if name.trim().is_empty() || name.contains('=') || name.chars().any(char::is_whitespace) {
bail!("variant '{}': preset {name:?}: a bundle's name carries no whitespace and no '='", self.variant);
}
self.expand_preset(name).map_err(|e| anyhow::anyhow!("variant '{}': preset '{name}': {e:#}", self.variant))?;
}
Ok(())
}
/// **The spelling a `--opt preset=<x>` on a KERNEL tree actually wanted.**
///
/// A kernel row's preset is a VAL under the `kernel` scope, not a
/// `[presets]` bundle — two different things that share the word. Before
/// lane AK the two disagreed out loud: `--list-options` rendered `--opt
/// preset=slim` (the tree's own flags, law C) and the door answered `no
/// preset 'slim' on 'tunnr-kernel'; declared bundles: full · normal · slim ·
/// nuked` — a refusal that lists `slim` while saying there is no `slim`. The
/// listing is fixed (`of_kernel` scopes its nodes), and the refusal now
/// names the spelling for anyone who typed the old one from memory or from
/// a stale doc.
fn kernel_preset_hint(&self, bundle: &str) -> String {
let scoped = format!("{KERNEL_PATH}{PATH_SEP}{PRESET_PATH}");
match self.get(&scoped).map(|n| &n.kind) {
Some(NodeKind::Val(v)) => format!(
" — but '{}' has a KERNEL preset, which is a val and not a bundle: spell it `--opt {scoped}={bundle}` (declared: {}). `--list-options` prints that spelling.",
self.variant,
v.choices.join(" · ")
),
_ => String::new(),
}
}
/// **Expand a bundle into flags** — each `(path, value)` of the named
/// bundle becomes the flag its node takes: `--opt <path>=on|off` for a
/// feature, `--opt <path>=<choice>` for a val, `--param <path>=<value>`
/// for a setting — with the path CANONICALISED through the tree (an alias
/// becomes the full path), so the expansion is a set of law-C lines the
/// tree itself renders. Unknown bundle, unknown path, a value the node
/// refuses — each a named refusal.
pub fn expand_preset(&self, bundle: &str) -> Result<Vec<crate::flags::Flag>> {
use crate::flags::Flag;
let Some(NodeKind::Val(v)) = self.get(PRESET_PATH).map(|n| &n.kind) else {
bail!("'{}' declares no presets (no `[…presets.<name>]` table){}", self.variant, self.kernel_preset_hint(bundle));
};
let Some(choices) = v.bundles.get(bundle) else {
bail!(
"no preset '{bundle}' on '{}'; declared bundles: {}{}",
self.variant,
if v.choices.is_empty() { "none".to_string() } else { v.choices.join(" · ") },
self.kernel_preset_hint(bundle)
);
};
let mut out = Vec::with_capacity(choices.len());
for (path, value) in choices {
if path == PRESET_PATH {
bail!("a bundle cannot choose the preset itself ('{path}')");
}
let node = self.resolve(path).map_err(|e| anyhow::anyhow!("{e:#}"))?.node;
let flag = match &node.kind {
NodeKind::Feature(_) => {
let probe = Flag::choice(&node.path, value.clone());
let Some(on) = probe.feature_state() else {
bail!("'{}' is a feature and takes on|off, not {value:?}", node.path);
};
Flag::opt(&node.path, on)
}
NodeKind::Val(val) => {
if !val.choices.iter().any(|c| c == value) {
bail!("'{}' is a val and takes one of {}, not {value:?}", node.path, val.choices.join(" | "));
}
Flag::choice(&node.path, value.clone())
}
NodeKind::Setting(_) => {
if value.trim().is_empty() {
bail!("'{}' is a setting and a bundle cannot give it an empty value", node.path);
}
Flag::param(&node.path, value.clone())
}
};
out.push(flag);
}
Ok(out)
}
/// **Fold a chosen preset into an `--opt` / `--param` line** — the door's
/// one expansion, shared by the CLI and the pane. `preset=<name>` is
/// looked for among `opts` (by the root path, exactly); when found it is
/// consumed, the bundle expands ([`expand_preset`](Self::expand_preset)),
/// and the bundle's flags go FIRST so every explicit flag on the line
/// overrides its bundle twin (a later `--opt` for the same path is what the
/// resolvers read as "supplied twice", so the bundle's twin is DROPPED, not
/// duplicated). Two `preset=` on one line is refused — one bundle per build.
/// **Split an `--opt` line by asking the TREE what each path is.**
///
/// This is the one parser a door needs, and it exists because
/// [`crate::options::parse_cli_options`] cannot be one: its return type is
/// `name → bool`, so it answers *every* `--opt` with "an option is on or
/// off". That sentence is what `--opt kernel.preset=nuked` got on the
/// ISO-compose path (`suite_cli::compose_installer_cli` handed it the
/// UNSPLIT line), and gunnar's `BUILD-KEYED-INSTALLER.md` carried a
/// "⚠ do NOT add `--opt kernel.preset=`" warning for it rather than a fix.
/// The tree already knew better: `kernel.preset` is a VAL with four
/// choices, `dhcp` is a FEATURE, and `data.fs` is a val too.
///
/// Three buckets, because a door has three things to do with them:
///
/// * `features` — this variant's own on/off toggles, for
/// [`crate::options::resolve_options`];
/// * `choices` — this variant's own vals (`data.fs`, `data.io`, a root
/// `preset`), each already checked against its roster;
/// * `behind_an_edge` — verbatim `path=value` for anything under a node
/// that carries an edge (`kernel.*`): the DONOR's door judges those
/// (`kernel_features::resolve_kernel_cli`), and a consumer's door that
/// tried would be a second opinion about someone else's declaration.
///
/// Every refusal is by name and comes from the tree: an unknown spelling
/// lists what IS declared ([`resolve`](Self::resolve)), a val lists its
/// roster, a feature says what an on/off value looks like, and a `--param`
/// path typed as an `--opt` is told which flag it is.
pub fn split_opts<S: AsRef<str>>(&self, opts: &[S]) -> Result<SplitOpts> {
let mut out = SplitOpts::default();
for pair in opts {
let pair = pair.as_ref().trim();
let Some((spelling, value)) = pair.split_once('=') else {
bail!("--opt {pair}: an option is `<path>=<value>` (`dhcp=off`, `data.fs=xfs`, `kernel.preset=nuked`)");
};
let (spelling, value) = (spelling.trim(), value.trim());
// The word an operator typed is OPTION, so the refusal opens with
// it and the tree's own "not a setting of variant X (declared: …)"
// follows — the roster is what makes a typo fixable.
let node = match self.resolve(spelling) {
Ok(r) => r.node,
Err(e) => {
// A path the tree does not draw, UNDER an edge, belongs to
// the donor's door (`kernel.toolchain=off` is the donor
// kernel's own edge toggle) — the rule
// `BuildLine::canonical_in` applies to the step line. Kept
// verbatim; anything else is refused by name.
let under_edge = spelling
.split_once(PATH_SEP)
.is_some_and(|(root, rest)| !rest.is_empty() && self.get(root).is_some_and(|n| n.edge.is_some()));
if under_edge {
out.behind_an_edge.push(format!("{spelling}={value}"));
continue;
}
bail!("--opt {pair}: '{spelling}' is not a declared option — {e:#}");
}
};
let path = node.path.clone();
// Behind an edge ⇒ the donor's door. Asked of the ROOT the path
// hangs from, so `kernel.preset` and `kernel.vt` route together.
let root = path.split(PATH_SEP).next().unwrap_or(&path);
if self.get(root).is_some_and(|n| n.edge.is_some()) {
out.behind_an_edge.push(format!("{path}={value}"));
continue;
}
match &node.kind {
NodeKind::Feature(_) => {
let Some(on) = crate::flags::Flag::choice(&path, value).feature_state() else {
bail!(
"--opt {path}={value}: '{path}' is a feature of variant '{}' — it is on or off \
(on|off, true|false, yes|no, 1|0)",
self.variant
);
};
if out.features.insert(path.clone(), on).is_some() {
bail!("--opt '{path}' was supplied twice");
}
}
NodeKind::Val(v) => {
if !v.choices.iter().any(|c| c == value) {
bail!(
"--opt {path}={value}: '{path}' takes one of {} (declared default: {})",
v.choices.join(" · "),
if v.default.is_empty() { "<none>" } else { v.default.as_str() }
);
}
if out.choices.insert(path.clone(), value.to_string()).is_some() {
bail!("--opt '{path}' was supplied twice");
}
}
NodeKind::Setting(_) => bail!(
"--opt {path}={value}: '{path}' is a VALUE, not a toggle — it is spelled `--param {path}={value}`"
),
}
}
Ok(out)
}
pub fn expand_opts<S: AsRef<str>>(&self, opts: &[S], params: &[S]) -> Result<Expanded> {
use crate::flags::Flag;
let mut chosen: Option<String> = None;
let mut explicit_opts: Vec<String> = Vec::with_capacity(opts.len());
for o in opts {
let o = o.as_ref();
match o.split_once('=') {
Some((k, v)) if k.trim() == PRESET_PATH => {
if chosen.is_some() {
bail!("--opt preset= given twice — one bundle per build");
}
chosen = Some(v.trim().to_string());
}
_ => explicit_opts.push(o.to_string()),
}
}
let explicit_params: Vec<String> = params.iter().map(|p| p.as_ref().to_string()).collect();
let Some(bundle) = chosen else {
return Ok(Expanded { opts: explicit_opts, params: explicit_params, preset: None });
};
let flags = self.expand_preset(&bundle)?;
let key = |pair: &str| -> String {
let name = pair.split_once('=').map(|(k, _)| k).unwrap_or(pair).trim();
self.canonical_path(name).unwrap_or_else(|_| name.to_string())
};
let explicit_opt_keys: Vec<String> = explicit_opts.iter().map(|o| key(o)).collect();
let explicit_param_keys: Vec<String> = explicit_params.iter().map(|p| key(p)).collect();
let mut out = Expanded { preset: Some(AppliedPreset { bundle: bundle.clone(), expanded: flags.iter().map(Flag::render).collect() }), ..Default::default() };
for f in &flags {
match f {
Flag::Opt { path, value } => {
if !explicit_opt_keys.iter().any(|k| k == path) {
out.opts.push(format!("{path}={value}"));
}
}
Flag::Param { path, value } => {
if !explicit_param_keys.iter().any(|k| k == path) {
out.params.push(format!("{path}={value}"));
}
}
// A bundle chooses SETTINGS; a step is asked for on the line
// (`--build X`, `build_dag`). `expand_preset` renders no such
// flag today — refused by name rather than dropped, so the day
// a bundle spells one the operator hears about it.
Flag::Build { target } => bail!(
"preset '{bundle}' would chain a build of '{target}' — a bundle chooses settings; \
`--build {target}` is typed on the line"
),
}
}
out.opts.extend(explicit_opts);
out.params.extend(explicit_params);
Ok(out)
}
/// **The kernel pick** the subtree under `prefix` (`""` for a kernel
/// variant, `kernel` under an installer) spells: its `preset` and `profile`
/// vals, parsed. `None` when there is no such subtree.
pub fn kernel_pick(&self, prefix: &str) -> Option<(Preset, HardeningProfile)> {
let at = |name: &str| if prefix.is_empty() { name.to_string() } else { format!("{prefix}{PATH_SEP}{name}") };
let val = |path: &str| match &self.get(path)?.kind {
NodeKind::Val(v) => Some(v.default.clone()),
_ => None,
};
let preset = Preset::parse(&val(&at("preset"))?)?;
let profile = HardeningProfile::parse(&val(&at("profile"))?)?;
Some((preset, profile))
}
/// The `open` axis a cargo variant's tree spells.
pub fn open_features(&self) -> std::collections::BTreeSet<String> {
self.roots
.iter()
.filter(|n| matches!(&n.kind, NodeKind::Feature(f) if !f.default && n.edge.is_none()))
.map(|n| n.name().to_string())
.collect()
}
/// **The kernel decision the tree's state spells** — preset from `vals`
/// (or the val's default), the click-off set from `state`, and the
/// fragment rendered by the one writer
/// ([`KernelFeatureModel::decision_fragment`]). Every clicked-off path is
/// re-judged through [`try_toggle`](Self::try_toggle) so a state a door
/// never accepted cannot become a fragment.
pub fn kernel_decision(
&self,
prefix: &str,
state: &BTreeMap<String, bool>,
vals: &BTreeMap<String, String>,
) -> Result<crate::kernel_features::KernelDecision> {
let at = |name: &str| if prefix.is_empty() { name.to_string() } else { format!("{prefix}{PATH_SEP}{name}") };
let (declared_preset, declared_profile) = self
.kernel_pick(prefix)
.ok_or_else(|| anyhow::anyhow!("'{}' has no kernel subtree at '{prefix}'", self.variant))?;
let preset = match vals.get(&at("preset")) {
None => declared_preset,
Some(word) => Preset::parse(word).ok_or_else(|| {
anyhow::anyhow!(
"{}={word}: unknown preset; declared bundles: {}",
at("preset"),
Preset::ALL.map(|p| p.as_str()).join(" · ")
)
})?,
};
// The OTHER val of the same subtree, read the same way. Until 2026-08-27
// this reader took the profile off the declaration and ignored a chosen
// one, so `kernel.profile = hardened` in the pane's vals rendered the
// vanilla fragment — the two vals sat side by side and only one of them
// was a choice.
let profile = match vals.get(&at("profile")) {
None => declared_profile,
Some(word) => HardeningProfile::parse(word).ok_or_else(|| {
anyhow::anyhow!("{}={word}: unknown profile; declared profiles: {}", at("profile"), HardeningProfile::names())
})?,
};
let model = KernelFeatureModel::derive(preset, profile);
let mut clicked_off: Vec<String> = Vec::new();
let mut req = ToggleRequest { variant: self.variant.clone(), path: String::new(), on: false, state: BTreeMap::new(), vals: vals.clone() };
for (path, on) in state {
if *on {
continue;
}
req.path = path.clone();
let d = self.try_toggle(&req);
if !d.verdict.ok {
anyhow::bail!("{}", d.verdict.line());
}
req.state.insert(d.path.clone(), false);
let name = d.path.strip_prefix(&at("")).unwrap_or(&d.path);
if let Some(sym) = model.symbol_for_option(name) {
clicked_off.push(sym.to_string());
}
}
let fragment = model.decision_fragment(&clicked_off, None);
Ok(crate::kernel_features::KernelDecision { preset, profile, clicked_off, fragment, probe: None })
}
}
// ── the toggle: the judge, cascade included ──────────────────────────────────
/// **Ask to toggle one feature** — what a click in ⚒ and `--opt <path>=on|off`
/// both send. Carries the operator's state so the answer is about THIS pick,
/// not a clean slate.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct ToggleRequest {
/// The variant whose tree is being toggled.
pub variant: String,
/// The feature path (or an unambiguous short alias).
pub path: String,
/// The state asked for.
pub on: bool,
/// Features the operator has ALREADY decided (path → state); every other
/// feature sits at its default. A kernel symbol clicked off earlier is here.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub state: BTreeMap<String, bool>,
/// Vals the operator has chosen (path → choice): `kernel.preset = nuked`.
/// The kernel model is derived from the chosen preset, not the declared one.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub vals: BTreeMap<String, String>,
}
impl ToggleRequest {
/// A request against the defaults (nothing decided yet).
pub fn new(variant: impl Into<String>, path: impl Into<String>, on: bool) -> Self {
ToggleRequest { variant: variant.into(), path: path.into(), on, ..Default::default() }
}
}
/// **The decision.** Accepted or refused by name, with the whole consequence:
/// which other features fall with this one (the cascade) and which settings
/// the new state refuses.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToggleDecision {
/// The canonical full path the request resolved to.
pub path: String,
/// The state asked for.
pub on: bool,
/// Accepted, or refused with every reason named.
pub verdict: crate::plugin::Verdict,
/// Feature paths that ALSO go off as a consequence (a dropped kernel
/// parent drops its children). Empty for a plain toggle.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cascade: Vec<String>,
/// Setting paths that have no meaning in the new state and are refused
/// if supplied (`dhcp=on` refuses `dhcp.static_ip`).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub refuses: Vec<String>,
/// Setting paths the new state makes active (and whose `required` applies).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub activates: Vec<String>,
}
impl ToggleDecision {
fn refused(path: &str, on: bool, why: String) -> Self {
ToggleDecision {
path: path.to_string(),
on,
verdict: crate::plugin::Verdict::refuse(why),
cascade: Vec::new(),
refuses: Vec::new(),
activates: Vec::new(),
}
}
}
impl SettingsTree {
/// **The plain toggle, cascade included where the tree can compute it** —
/// the default every build thing starts from
/// ([`BuildThing::try_toggle`](crate::plugin::BuildThing::try_toggle)).
///
/// * an unknown or ambiguous path — refused by name;
/// * a setting or a val — refused: a setting takes `--param`, a val takes
/// a choice, neither is on/off;
/// * a plain feature — accepted; `refuses` / `activates` list its children
/// by the `when` they belong to;
/// * a **kernel symbol** (a feature under a subtree that carries a
/// `preset` val) — judged by the shared kernel model
/// ([`KernelFeatureModel::try_toggle`]): off is refused when the drop
/// would reach the boot contract (naming the contract symbols), on is
/// refused for a symbol the chosen preset vetoes, and the cascade is
/// the model's own.
pub fn try_toggle(&self, req: &ToggleRequest) -> ToggleDecision {
let node = match self.resolve(&req.path) {
Ok(r) => r.node,
Err(e) => return ToggleDecision::refused(&req.path, req.on, format!("{e:#}")),
};
let path = node.path.clone();
let feature = match &node.kind {
NodeKind::Feature(f) => f,
NodeKind::Setting(_) => {
return ToggleDecision::refused(
&path,
req.on,
format!("'{path}' is a setting, not a feature — it takes `--param {path}=<value>`, not on|off"),
);
}
NodeKind::Val(v) => {
return ToggleDecision::refused(
&path,
req.on,
format!(
"'{path}' is a val, not a feature — it takes one of {}, not on|off",
v.choices.join(" | ")
),
);
}
};
// A kernel symbol? Its parent level carries a `preset` val.
let prefix = path.rsplit_once(PATH_SEP).map(|(p, _)| p.to_string());
let at = |name: &str| match &prefix {
Some(p) => format!("{p}{PATH_SEP}{name}"),
None => name.to_string(),
};
let preset_node = self.get(&at("preset"));
if feature.feature.starts_with("CONFIG_") && preset_node.is_some() {
return self.try_toggle_kernel(req, &path, &at("preset"), &at("profile"), prefix.as_deref());
}
let mut d = ToggleDecision {
path: path.clone(),
on: req.on,
verdict: crate::plugin::Verdict::ok(),
cascade: Vec::new(),
refuses: Vec::new(),
activates: Vec::new(),
};
for c in &node.children {
match c.when {
Some(w) if w == req.on => d.activates.push(c.path.clone()),
Some(_) => d.refuses.push(c.path.clone()),
None => {}
}
}
d
}
fn try_toggle_kernel(
&self,
req: &ToggleRequest,
path: &str,
preset_path: &str,
profile_path: &str,
prefix: Option<&str>,
) -> ToggleDecision {
let chosen = |val_path: &str| -> Option<String> {
req.vals.get(val_path).cloned().or_else(|| match &self.get(val_path)?.kind {
NodeKind::Val(v) => Some(v.default.clone()),
_ => None,
})
};
let preset_word = chosen(preset_path).unwrap_or_default();
let Some(preset) = Preset::parse(&preset_word) else {
return ToggleDecision::refused(
path,
req.on,
format!(
"{preset_path}={preset_word}: unknown preset; declared bundles: {}",
Preset::ALL.map(|p| p.as_str()).join(" · ")
),
);
};
let profile_word = chosen(profile_path).unwrap_or_else(|| "vanilla".to_string());
let Some(profile) = HardeningProfile::parse(&profile_word) else {
return ToggleDecision::refused(path, req.on, format!("{profile_path}={profile_word}: unknown profile"));
};
let model = KernelFeatureModel::derive(preset, profile);
let symbol_of = |p: &str| -> Option<String> {
let name = match prefix {
Some(pre) => p.strip_prefix(pre)?.strip_prefix(PATH_SEP)?,
None => p,
};
if name.contains(PATH_SEP) {
return None;
}
model.symbol_for_option(name).map(str::to_string)
};
let path_of = |symbol: &str| -> String {
let name = crate::kernel_features::option_name(symbol);
match prefix {
Some(pre) => format!("{pre}{PATH_SEP}{name}"),
None => name,
}
};
let Some(symbol) = symbol_of(path) else {
return ToggleDecision::refused(
path,
req.on,
format!(
"'{path}': no such symbol in the {} pick; declared rows: {}",
preset.as_str(),
model.option_rows().iter().map(|r| path_of(&r.symbol)).collect::<Vec<_>>().join(" · ")
),
);
};
// The operator's current click-off set, in the model's vocabulary.
let mut clicked: Vec<String> = req
.state
.iter()
.filter(|(_, on)| !**on)
.filter_map(|(p, _)| symbol_of(p))
.collect();
let row_default_on = model.symbols.iter().find(|s| s.name == symbol).map(|s| s.enabled).unwrap_or(false);
let mut d = ToggleDecision {
path: path.to_string(),
on: req.on,
verdict: crate::plugin::Verdict::ok(),
cascade: Vec::new(),
refuses: Vec::new(),
activates: Vec::new(),
};
if req.on {
if !row_default_on {
d.verdict = crate::plugin::Verdict::refuse(format!(
"'{path}': the {} preset vetoes {symbol} — the decision can only veto; \
to have it, pick a bundle that has it ({preset_path}=…)",
preset.as_str()
));
return d;
}
if let Some(i) = clicked.iter().position(|s| s == &symbol) {
clicked.remove(i);
}
} else {
if clicked.iter().any(|s| s == &symbol) {
// Already off — the door says so rather than toggling it back.
d.verdict = crate::plugin::Verdict::ok_with(format!("'{path}' is already off"));
} else {
// The model's own door: it refuses a contract-breaking drop.
let mut trial = clicked.clone();
match model.try_toggle(&mut trial, &symbol) {
Some(crate::kernel_features::ToggleOutcome::Off) => clicked = trial,
Some(crate::kernel_features::ToggleOutcome::Refused(broken)) => {
d.verdict = crate::plugin::Verdict::refuse(format!(
"'{path}': turning {symbol} off would break the boot contract: {}",
broken.iter().map(|b| format!("{} ({b})", path_of(b))).collect::<Vec<_>>().join(", ")
));
return d;
}
// `On` cannot happen (not in `trial`); `None` = not in the model.
_ => {
d.verdict = crate::plugin::Verdict::refuse(format!("'{path}': {symbol} is not in the {} pick", preset.as_str()));
return d;
}
}
}
}
// The consequence: everything the cascade drops beyond the clicks.
let dropped = model.cascade_for(&clicked);
d.cascade = model
.symbols
.iter()
.enumerate()
.filter(|&(i, s)| dropped[i] && !clicked.contains(&s.name) && s.enabled)
.map(|(_, s)| path_of(&s.name))
.collect();
d
}
}
#[cfg(test)]
mod tests {
use super::*;
/// gunnar's real shape, minus gunnar: two options with conditional params,
/// one unconditional param, a fixture, and a kernel donor.
fn gunnar_like() -> BuildVariantSet {
BuildVariantSet::from_toml_str(
r#"
[build]
offers = ["dhcp-client", "wg-client"]
[[build.variant]]
name = "gunnar-server"
features = ["dhcp-client", "wg-client"]
no_default_features = true
[[build.installer]]
name = "gunnar-installer"
variant = "gunnar-server"
[build.installer.kernel_donor]
repo = "tunnr"
kernel = "tunnr-kernel"
[build.installer.fixtures]
hub_conf = "wg-hub"
[[build.installer.params]]
name = "site"
kind = "text"
required = true
boot_param = "korp.site"
[[build.installer.params]]
name = "hub_conf"
staged_file = "hub.conf"
[[build.installer.params]]
name = "static_ip"
boot_param = "korp.ip"
[[build.installer.params]]
name = "gateway"
boot_param = "korp.gw"
[[build.installer.options]]
name = "wireguard"
label = "WireGuard-klient"
feature = "wg-client"
params_when_on = ["hub_conf"]
[[build.installer.options]]
name = "dhcp"
feature = "dhcp-client"
params_when_off = ["static_ip", "gateway"]
param_kinds = { static_ip = "ipv4-cidr", gateway = "ipv4" }
bakes = { boot_param = "korp.env.GUNNAR_NET", when_on = "eth0:dhcp", when_off = "eth0:static:{static_ip}:{gateway}" }
[[build.kernel]]
name = "tunnr-kernel"
version = "6.12.4"
tarball_sha256 = "0000000000000000000000000000000000000000000000000000000000000000"
preset = "nuked"
"#,
)
.expect("parses")
}
#[test]
fn a_flat_declaration_derives_the_tree_with_no_toml_change() {
let set = gunnar_like();
let t = SettingsTree::of_set(&set, "gunnar-installer").expect("declared");
assert_eq!(t.typ, ArtifactKind::Installer);
let roots: Vec<&str> = t.roots.iter().map(|n| n.path.as_str()).collect();
// The two `data` vals close the tree: an installer ALWAYS carries the
// group, declared or not, so `--opt data.fs=` is spellable and
// `--list-options` shows the arm the recipe is on.
assert_eq!(roots, vec!["wireguard", "dhcp", "site", "kernel", "data.fs", "data.io"]);
let wg = t.get("wireguard").unwrap();
assert!(matches!(&wg.kind, NodeKind::Feature(f) if f.default && f.feature == "wg-client"));
let hub = t.get("wireguard.hub_conf").expect("params_when_on became a child");
assert_eq!(hub.when, Some(true));
assert!(matches!(&hub.kind, NodeKind::Setting(s) if s.fixture == Some(FixtureKind::WgHub) && s.staged_file == "hub.conf"));
let ip = t.get("dhcp.static_ip").expect("params_when_off became a child");
assert_eq!(ip.when, Some(false));
assert!(matches!(&ip.kind, NodeKind::Setting(s) if s.param_kind == ParamKind::Ipv4Cidr), "param_kinds sharpened it");
assert!(matches!(&t.get("dhcp").unwrap().kind, NodeKind::Feature(f) if f.bakes.is_some()));
let site = t.get("site").unwrap();
assert_eq!(site.when, None, "an unconditional param is a root setting");
assert!(matches!(&site.kind, NodeKind::Setting(s) if s.required && s.boot_param == "korp.site"));
}
#[test]
fn the_kernel_donor_is_the_first_edge_and_projects_preset_and_symbols() {
let set = gunnar_like();
let t = SettingsTree::of_set(&set, "gunnar-installer").unwrap();
let k = t.get("kernel").expect("the donor became the kernel feature");
assert_eq!(
k.edge,
Some(Edge { coordinate: "tunnr/tunnr-kernel".into(), typ: ArtifactKind::Kernel, lazy: false })
);
assert_eq!(k.edge.as_ref().unwrap().split(), Some(("tunnr", "tunnr-kernel")));
let preset = t.get("kernel.preset").expect("kernel.preset is a val");
assert!(matches!(&preset.kind, NodeKind::Val(v) if v.choices == ["full","normal","slim","nuked"] && v.default == "nuked"),
"the self-donor's declared preset is the default: {preset:?}");
assert!(t.get("kernel.unix").is_some(), "kernel.<symbol> features: {:?}", t.paths());
assert!(matches!(&t.get("kernel.unix").unwrap().kind, NodeKind::Feature(f) if f.feature == "CONFIG_UNIX"));
// The donor struct is untouched: kept as-is for compatibility.
assert_eq!(set.installers[0].kernel_donor.as_ref().unwrap().repo, "tunnr");
}
#[test]
fn flat_spellings_are_unambiguous_aliases_and_a_clash_is_a_named_refusal() {
let set = gunnar_like();
let t = SettingsTree::of_set(&set, "gunnar-installer").unwrap();
let r = t.resolve("static_ip").unwrap();
assert_eq!(r.node.path, "dhcp.static_ip");
assert!(r.via_alias);
let r = t.resolve("dhcp.static_ip").unwrap();
assert!(!r.via_alias);
assert_eq!(t.canonical_path("hub_conf").unwrap(), "wireguard.hub_conf");
assert_eq!(t.canonical_path("kernel.preset").unwrap(), "kernel.preset");
// A short name two nodes carry is ambiguous. Build the clash by hand:
// `wireguard.preset` beside `kernel.preset`.
let mut t2 = t.clone();
t2.roots[0].children.push(SettingsNode::leaf(
"wireguard.preset",
"",
NodeKind::Val(ValNode { choices: vec!["hub".into()], default: "hub".into(), bundles: Presets::new() }),
));
let e = format!("{:#}", t2.resolve("preset").unwrap_err());
assert!(e.contains("AMBIGUOUS"), "{e}");
assert!(e.contains("kernel.preset") && e.contains("wireguard.preset"), "both paths are listed: {e}");
// The full paths still work, and an exact full path always wins over an alias.
assert_eq!(t2.resolve("kernel.preset").unwrap().node.path, "kernel.preset");
t2.roots.push(SettingsNode::leaf(
"preset",
"",
NodeKind::Val(ValNode { choices: vec!["airgap".into()], default: "airgap".into(), bundles: Presets::new() }),
));
assert_eq!(t2.resolve("preset").unwrap().node.path, "preset", "a root val named preset is the exact path");
let e = format!("{:#}", t.resolve("dchp").unwrap_err());
assert!(e.contains("not a setting") && e.contains("dhcp.static_ip"), "{e}");
}
/// **The listing and the refusal say the SAME spelling** (lane AK,
/// 2026-08-30). `--list-options` renders `flags_of(tree)` verbatim (law C),
/// so the tree IS the listing; before this lane the kernel row's tree spelt
/// its vals bare and the door accepted only the `kernel.` scope, so the
/// listing printed `--opt preset=slim` and the door answered `no preset
/// 'slim' on 'tunnr-kernel'; declared bundles: full · normal · slim · nuked`
/// — a refusal that lists `slim` while saying there is no `slim`. It cost
/// lane AH a wrong turn. RED without either half of the fix.
#[test]
fn the_kernel_listing_prints_the_spelling_the_door_accepts() {
let set = gunnar_like();
let k = SettingsTree::of_set(&set, "tunnr-kernel").unwrap();
let lines: Vec<String> = crate::flags::flags_of(&k).iter().map(|f| f.render()).collect();
assert!(lines.contains(&"--opt kernel.preset=slim".to_string()), "{lines:?}");
assert!(lines.contains(&"--opt kernel.profile=hardened".to_string()), "{lines:?}");
assert!(!lines.iter().any(|l| l == "--opt preset=slim"), "the bare spelling is not offered: {lines:?}");
// Every rendered `kernel.*` line is one `split_kernel_opts` accepts and
// the kernel grammar then judges — the listing is runnable, not decorative.
let (pairs, rest) = crate::kernel_features::split_kernel_opts(&["kernel.preset=slim".to_string()]).unwrap();
assert!(rest.is_empty());
assert_eq!(pairs, vec![("preset".to_string(), "slim".to_string())]);
// And the spelling the listing USED to print refuses by naming the one
// it prints now.
let e = format!("{:#}", k.expand_preset("slim").unwrap_err());
assert!(e.contains("--opt kernel.preset=slim"), "{e}");
assert!(e.contains("--list-options"), "the refusal points at the listing that agrees with it: {e}");
}
#[test]
fn a_kernel_variant_and_a_cargo_variant_have_trees_too() {
let set = gunnar_like();
let k = SettingsTree::of_set(&set, "tunnr-kernel").unwrap();
assert_eq!(k.typ, ArtifactKind::Kernel);
// The kernel row's own tree is scoped `kernel.*` — the ONE spelling
// both doors accept (lane AK); a bare `unix` still resolves by name.
assert_eq!(k.roots[0].path, "kernel.preset");
assert!(k.get("kernel.unix").is_some());
assert!(k.resolve("unix").is_ok(), "the bare name is still an unambiguous alias");
let v = SettingsTree::of_set(&set, "gunnar-server").unwrap();
assert_eq!(v.typ, ArtifactKind::Exe);
assert!(v.paths().is_empty(), "always-on features are identity, not settings: {:?}", v.paths());
let mut open = set.variants[0].clone();
open.open.insert("gui".into());
let v = SettingsTree::of_variant(&open);
assert_eq!(v.paths(), vec!["gui"]);
assert_eq!(v.open_features(), open.open);
assert!(SettingsTree::of_set(&set, "ghost").is_none());
assert_eq!(SettingsTree::forest(&set).len(), 3);
}
#[test]
fn subtree_and_prefixing_keep_paths_as_ids() {
let set = gunnar_like();
let t = SettingsTree::of_set(&set, "gunnar-installer").unwrap();
let sub = t.subtree("dhcp").unwrap();
assert_eq!(sub.roots.len(), 1);
assert_eq!(sub.paths(), vec!["dhcp", "dhcp.static_ip", "dhcp.gateway"]);
assert!(t.subtree("nope").is_none());
assert_eq!(t.subtree("").unwrap(), t);
let k = SettingsTree::of_set(&set, "tunnr-kernel").unwrap();
let projected = k.roots[0].clone().prefixed("donor");
assert_eq!(projected.path, "donor.kernel.preset");
}
/// The index gives the tree's own answers, including the ambiguity refusal.
#[test]
fn the_index_answers_exactly_what_the_tree_answers() {
let set = gunnar_like();
let mut t = SettingsTree::of_set(&set, "gunnar-installer").unwrap();
t.roots[0].children.push(SettingsNode::leaf(
"wireguard.preset",
"",
NodeKind::Val(ValNode { choices: vec!["hub".into()], default: "hub".into(), bundles: Presets::new() }),
));
let ix = SettingsIndex::of(&t);
for spelling in ["dhcp.static_ip", "static_ip", "hub_conf", "kernel.preset", "preset", "nope", ""] {
let a = t.resolve(spelling).map(|r| (r.node.path.clone(), r.via_alias)).map_err(|e| format!("{e:#}"));
let b = ix.resolve(spelling).map(|r| (r.node.path.clone(), r.via_alias)).map_err(|e| format!("{e:#}"));
assert_eq!(a, b, "{spelling}");
}
assert_eq!(ix.get("dhcp.gateway").map(|n| n.path.as_str()), Some("dhcp.gateway"));
}
/// The JSON shape the ⚒ pane and the plugin host read: tagged kinds, no
/// empty fields, and a round trip through serde.
#[test]
fn the_tree_serializes_tagged_and_lean_and_round_trips() {
let set = gunnar_like();
let t = SettingsTree::of_set(&set, "gunnar-installer").unwrap();
let json = serde_json::to_string(&t).unwrap();
assert!(json.contains(r#""kind":"feature""#), "{json}");
assert!(json.contains(r#""kind":"setting""#), "{json}");
assert!(json.contains(r#""kind":"val""#), "{json}");
assert!(json.contains(r#""typ":"installer""#), "{json}");
assert!(!json.contains(r#""forbid_symbols":[]"#), "empty fields are skipped: {json}");
let back: SettingsTree = serde_json::from_str(&json).unwrap();
assert_eq!(back, t);
// An older/leaner document with unknown keys still loads (benign).
let lean: SettingsNode = serde_json::from_str(
r#"{"path":"x","kind":"feature","default":true,"future_field":1}"#,
)
.unwrap();
assert!(matches!(lean.kind, NodeKind::Feature(FeatureNode { default: true, .. })));
}
// ── the toggle ───────────────────────────────────────────────────────────
#[test]
fn a_plain_feature_toggles_and_names_what_it_activates_and_refuses() {
let set = gunnar_like();
let t = SettingsTree::of_set(&set, "gunnar-installer").unwrap();
let d = t.try_toggle(&ToggleRequest::new("gunnar-installer", "dhcp", false));
assert!(d.verdict.ok, "{}", d.verdict.line());
assert_eq!(d.path, "dhcp");
assert_eq!(d.activates, vec!["dhcp.static_ip", "dhcp.gateway"]);
assert!(d.refuses.is_empty());
let d = t.try_toggle(&ToggleRequest::new("gunnar-installer", "dhcp", true));
assert_eq!(d.refuses, vec!["dhcp.static_ip", "dhcp.gateway"]);
// Aliases resolve; settings and vals are refused by name.
let d = t.try_toggle(&ToggleRequest::new("gunnar-installer", "static_ip", false));
assert!(!d.verdict.ok);
assert!(d.verdict.line().contains("is a setting") && d.verdict.line().contains("--param dhcp.static_ip="), "{}", d.verdict.line());
let d = t.try_toggle(&ToggleRequest::new("gunnar-installer", "kernel.preset", false));
assert!(!d.verdict.ok && d.verdict.line().contains("is a val") && d.verdict.line().contains("nuked"), "{}", d.verdict.line());
let d = t.try_toggle(&ToggleRequest::new("gunnar-installer", "dchp", false));
assert!(!d.verdict.ok && d.verdict.line().contains("not a setting"), "{}", d.verdict.line());
}
/// The kernel subtree answers with the shared model: a contract-breaking
/// drop is refused naming the contract, a veto cannot be switched on, and
/// the cascade is the model's own — under `kernel.` in an installer and at
/// the root of the kernel variant alike.
#[test]
fn the_kernel_subtree_is_judged_by_the_kernel_model_with_its_cascade() {
let set = gunnar_like();
let model = KernelFeatureModel::derive(Preset::Nuked, HardeningProfile::Vanilla);
// Find one locked row and one free row with children, from the model itself.
let rows = model.option_rows();
let locked = rows.iter().find(|r| !r.locked_by.is_empty()).expect("the nuked pick has a contract row");
let free_parent = rows
.iter()
.find(|r| r.default_on && r.locked_by.is_empty() && rows.iter().any(|c| c.requires.as_deref() == Some(&r.symbol) && c.default_on))
.expect("a free parent with an enabled child");
let vetoed = rows.iter().find(|r| !r.default_on).expect("nuked states absences");
for (variant, prefix) in [("gunnar-installer", "kernel."), ("tunnr-kernel", "")] {
let t = SettingsTree::of_set(&set, variant).unwrap();
let d = t.try_toggle(&ToggleRequest::new(variant, format!("{prefix}{}", locked.option), false));
assert!(!d.verdict.ok, "{variant}: {}", d.verdict.line());
assert!(d.verdict.line().contains("boot contract"), "{}", d.verdict.line());
let d = t.try_toggle(&ToggleRequest::new(variant, format!("{prefix}{}", free_parent.option), false));
assert!(d.verdict.ok, "{variant}: {}", d.verdict.line());
assert!(!d.cascade.is_empty(), "dropping a parent drops its children: {d:?}");
assert!(d.cascade.iter().all(|p| p.starts_with(prefix)), "cascade paths carry the prefix: {:?}", d.cascade);
let d = t.try_toggle(&ToggleRequest::new(variant, format!("{prefix}{}", vetoed.option), true));
assert!(!d.verdict.ok && d.verdict.line().contains("vetoes"), "{}", d.verdict.line());
}
// The chosen preset in `vals` changes the model: under `full` nothing is declared.
let t = SettingsTree::of_set(&set, "gunnar-installer").unwrap();
let mut req = ToggleRequest::new("gunnar-installer", format!("kernel.{}", locked.option), false);
req.vals.insert("kernel.preset".into(), "full".into());
let d = t.try_toggle(&req);
assert!(!d.verdict.ok && d.verdict.line().contains("full pick"), "{}", d.verdict.line());
req.vals.insert("kernel.preset".into(), "slin".into());
let d = t.try_toggle(&req);
assert!(d.verdict.line().contains("unknown preset"), "{}", d.verdict.line());
}
/// **The rows are the spelling of the tree**: rows → tree → rows is the
/// identity (options in order, params by name, fixtures, the donor), and
/// the kernel pick reads back.
#[test]
fn the_tree_renders_back_to_exactly_the_rows_it_came_from() {
let set = gunnar_like();
let inst = &set.installers[0];
let t = SettingsTree::of_set(&set, "gunnar-installer").unwrap();
let rows = t.rows();
assert_eq!(rows.options, inst.options);
let mut want = inst.params.clone();
want.sort_by(|a, b| a.name.cmp(&b.name));
assert_eq!(rows.params, want);
assert_eq!(rows.fixtures, inst.fixtures);
assert_eq!(rows.kernel_donor, inst.kernel_donor);
assert_eq!(t.kernel_pick("kernel"), Some((Preset::Nuked, HardeningProfile::Vanilla)));
let k = SettingsTree::of_set(&set, "tunnr-kernel").unwrap();
assert_eq!(k.kernel_pick("kernel"), Some((Preset::Nuked, HardeningProfile::Vanilla)), "the row's own tree is scoped too");
assert_eq!(k.kernel_pick(""), None, "and nothing sits bare at the root any more");
assert!(k.rows().options.is_empty() && k.rows().kernel_donor.is_none());
// A second derivation from the rendered rows spells the same rows again.
let again = SettingsTree::of_rows("gunnar-installer", ArtifactKind::Installer, &rows.options, &rows.params, &rows.fixtures);
let mut again_rows = again.rows();
assert!(again_rows.kernel_donor.is_none(), "bare rows carry no edge");
assert!(again_rows.data.is_default(), "bare rows carry no group either");
again_rows.kernel_donor = rows.kernel_donor.clone();
again_rows.data = rows.data.clone();
assert_eq!(again_rows, rows);
// The two `data` vals are the group's, not the option rows': they come
// with the DECLARED group and go with the kernel subtree in the count.
assert_eq!(again.paths().len() + t.subtree("kernel").unwrap().len() + 2, t.len());
}
/// The derivation does not touch the declaration: law A still holds over
/// the same set.
#[test]
fn deriving_the_tree_leaves_law_a_intact() {
let set = gunnar_like();
let _ = SettingsTree::forest(&set);
assert_eq!(crate::laws::law_declaration_struct(&set), Vec::<String>::new());
}
// ── the consumption edge, generalised (wave 2, gap #3) ──────────────────
/// gunnar's shape with the LIVE iso consuming the same donor kernel, a
/// cargo variant consuming a wasm build thing, and the installer consuming
/// a container image beside its kernel.
fn edgy() -> BuildVariantSet {
BuildVariantSet::from_toml_str(
r#"
[build]
[[build.variant]]
name = "gunnar-server"
role = "server"
open = ["telemetry"]
[build.variant.edges.recipe]
repo = "gunnar"
variant = "gunnar-build-thing"
typ = "wasm"
[[build.variant]]
name = "gunnar-build-thing"
kind = "wasm"
target = "wasm32-unknown-unknown"
[[build.iso]]
name = "gunnar-appliance-iso"
from_variant = "gunnar-server"
[build.iso.kernel_donor]
repo = "tunnr"
kernel = "tunnr-kernel"
[[build.installer]]
name = "gunnar-installer"
variant = "gunnar-server"
[build.installer.kernel_donor]
repo = "tunnr"
kernel = "tunnr-kernel"
[build.installer.edges.runtime]
repo = "tunnr"
variant = "tunnr-runtime"
typ = "container"
[[build.installer.options]]
name = "dhcp"
feature = "dhcp-client"
[[build.kernel]]
name = "tunnr-kernel"
version = "6.12.4"
tarball_sha256 = "0000000000000000000000000000000000000000000000000000000000000000"
preset = "nuked"
"#,
)
.expect("parses")
}
/// RED-FIRST: before wave 2 `of_iso` took no donor and an iso's
/// `[build.iso.kernel_donor]` was dropped by the loader — the live medium
/// could not name its kernel at all.
#[test]
fn an_iso_declares_its_kernel_edge_and_the_tree_projects_it_like_the_installers() {
let set = edgy();
let iso = SettingsTree::of_set(&set, "gunnar-appliance-iso").expect("declared");
let k = iso.get(KERNEL_PATH).expect("the kernel edge is a root feature of the iso");
assert_eq!(k.edge, Some(Edge::new("tunnr", "tunnr-kernel", ArtifactKind::Kernel)));
assert_eq!(iso.kernel_pick("kernel"), Some((Preset::Nuked, HardeningProfile::Vanilla)), "the self-donor's pick");
let inst = SettingsTree::of_set(&set, "gunnar-installer").expect("declared");
assert_eq!(inst.get(KERNEL_PATH).unwrap().edge, k.edge, "one edge, two consumers");
// The iso's rows render the donor back (law A's tree half).
assert_eq!(iso.rows().kernel_donor, set.isos[0].kernel_donor);
}
/// Any variant kind carries edges, each a root feature bound to
/// `repo/variant : typ`, and the rows render them back exactly.
#[test]
fn edges_are_first_class_on_any_variant_kind_and_round_trip_through_the_rows() {
let set = edgy();
let v = SettingsTree::of_set(&set, "gunnar-server").unwrap();
let recipe = v.get("recipe").expect("the cargo variant's edge");
assert_eq!(recipe.edge, Some(Edge::new("gunnar", "gunnar-build-thing", ArtifactKind::Wasm)));
assert!(matches!(&recipe.kind, NodeKind::Feature(f) if f.default && f.feature.is_empty()));
assert!(recipe.children.is_empty(), "a non-kernel edge's subtree is delegated");
assert_eq!(v.rows().edges, set.variants[0].edges);
assert_eq!(v.open_features(), set.variants[0].open, "an edge is not an open feature");
let inst = SettingsTree::of_set(&set, "gunnar-installer").unwrap();
let roots: Vec<&str> = inst.roots.iter().map(|n| n.path.as_str()).collect();
assert_eq!(roots, ["dhcp", "kernel", "runtime", "data.fs", "data.io"], "options, then the donor, then the other edges, then the data group");
let rows = inst.rows();
assert_eq!(rows.edges, set.installers[0].edges);
assert_eq!(rows.kernel_donor, set.installers[0].kernel_donor);
// `--build` renders for every edge, by path and by coordinate (law C).
let flags: Vec<String> = crate::flags::flags_of(&inst).iter().map(|f| f.render()).collect();
for want in ["--build kernel", "--build tunnr/tunnr-kernel", "--build runtime", "--build tunnr/tunnr-runtime"] {
assert!(flags.iter().any(|f| f == want), "{want} missing from {flags:?}");
}
assert_eq!(crate::laws::law_rows_tree(&set), Vec::<String>::new());
assert_eq!(crate::laws::law_declaration_struct(&set), Vec::<String>::new());
}
/// The two spellings of the kernel edge, the shape rules, and a feature
/// declared twice — each a named refusal at declaration time.
#[test]
fn a_malformed_edge_is_refused_by_name_at_declaration_time() {
let mut set = edgy();
let inst = &mut set.installers[0];
inst.edges.insert(
"kernel".into(),
EdgeDecl { repo: "tunnr".into(), variant: "tunnr-kernel".into(), typ: ArtifactKind::Kernel, lazy: false },
);
let e = format!("{:#}", inst.validate().unwrap_err());
assert!(e.contains("spell it once") && e.contains("kernel_donor"), "{e}");
inst.edges.remove("kernel");
inst.edges.insert("dhcp".into(), EdgeDecl { repo: "x".into(), variant: "y".into(), typ: ArtifactKind::Exe, lazy: false });
let e = format!("{:#}", inst.validate().unwrap_err());
assert!(e.contains("'dhcp' is declared twice"), "{e}");
inst.edges.remove("dhcp");
inst.edges.insert("a.b".into(), EdgeDecl { repo: "x".into(), variant: "y".into(), typ: ArtifactKind::Exe, lazy: false });
let e = format!("{:#}", inst.validate().unwrap_err());
assert!(e.contains("ROOT feature"), "{e}");
inst.edges.remove("a.b");
inst.edges.insert("half".into(), EdgeDecl { repo: String::new(), variant: "y".into(), typ: ArtifactKind::Exe, lazy: false });
let e = format!("{:#}", inst.validate().unwrap_err());
assert!(e.contains("edges.half.repo is empty"), "{e}");
// An iso with both a kernel FILE and a donor is two kernels.
let mut iso = set.isos[0].clone();
iso.kernel = "artifacts/bzImage".into();
let e = format!("{:#}", iso.validate().unwrap_err());
assert!(e.contains("one kernel per medium"), "{e}");
// A cargo variant whose edge is also one of its own features.
let mut v = set.variants[0].clone();
v.open.insert("recipe".into());
let e = format!("{:#}", set.validate(&v).unwrap_err());
assert!(e.contains("'recipe' is declared twice"), "{e}");
}
/// **The type check** — an edge is proven against the donor's own
/// declaration through the constellation root: missing checkout, missing
/// variant, wrong typ — each refused by name; a self-edge resolves in the
/// same set; a sound constellation passes.
#[test]
fn an_edge_is_type_checked_against_the_donor_through_the_constellation_root() {
let base = tempfile::tempdir().unwrap();
let gunnar = base.path().join("gunnar");
std::fs::create_dir_all(gunnar.join(".nornir")).unwrap();
let set = edgy();
let text = toml::to_string(&crate::variant::BuildConfig::emitting(&set)).unwrap();
std::fs::write(gunnar.join(".nornir/build-variants.toml"), &text).unwrap();
// No tunnr beside: the checkout is named.
let e = format!("{:#}", set.validate_edges_in(&gunnar).unwrap_err());
assert!(e.contains("no checkout 'tunnr' beside"), "{e}");
// tunnr beside, declaring the kernel but no runtime container.
let tunnr = base.path().join("tunnr");
std::fs::create_dir_all(tunnr.join(".nornir")).unwrap();
std::fs::write(
tunnr.join(".nornir/build-variants.toml"),
r#"
[build]
[[build.kernel]]
name = "tunnr-kernel"
version = "6.12.4"
tarball_sha256 = "0000000000000000000000000000000000000000000000000000000000000000"
preset = "nuked"
"#,
)
.unwrap();
let e = format!("{:#}", set.validate_edges_in(&gunnar).unwrap_err());
assert!(e.contains("installer 'gunnar-installer': edge 'runtime' → tunnr/tunnr-runtime : container"), "{e}");
assert!(e.contains("declares no variant 'tunnr-runtime'") && e.contains("tunnr-kernel"), "{e}");
// …declaring it with the WRONG typ.
std::fs::write(
tunnr.join(".nornir/build-variants.toml"),
r#"
[build]
[[build.kernel]]
name = "tunnr-kernel"
version = "6.12.4"
tarball_sha256 = "0000000000000000000000000000000000000000000000000000000000000000"
preset = "nuked"
[[build.variant]]
name = "tunnr-runtime"
kind = "exe"
"#,
)
.unwrap();
let e = format!("{:#}", set.validate_edges_in(&gunnar).unwrap_err());
assert!(e.contains("as typ exe, not container"), "{e}");
// …and right: every edge — self (wasm), kernel, container — checks out.
std::fs::write(
tunnr.join(".nornir/build-variants.toml"),
r#"
[build]
[[build.kernel]]
name = "tunnr-kernel"
version = "6.12.4"
tarball_sha256 = "0000000000000000000000000000000000000000000000000000000000000000"
preset = "nuked"
[[build.variant]]
name = "tunnr-runtime"
kind = "container"
container_ref = "tunnr-runtime"
[[build.container]]
name = "tunnr-runtime"
# `base`, NOT `base_image`. This fixture said `base_image` from the day it was
# written and `ContainerBuild` has never had such a field: the key was dropped
# in silence, so the fixture declared a scratch base it never had. Found
# 2026-08-30 by `schema`'s R3 the first time the loader was made to refuse what
# it cannot read — in this crate's own test data.
base = "scratch"
"#,
)
.unwrap();
set.validate_edges_in(&gunnar).expect("a sound constellation");
assert_eq!(set.kernel_donor_of("gunnar-appliance-iso").map(|d| d.kernel.as_str()), Some("tunnr-kernel"));
assert_eq!(set.kernel_donor_consumers().len(), 2);
assert_eq!(set.edges_of("gunnar-server").unwrap().len(), 1);
assert_eq!(set.edges_of("gunnar-installer").unwrap().len(), 2);
assert_eq!(set.edges_of("tunnr-kernel"), Some(Vec::new()));
assert_eq!(set.edges_of("nope"), None);
}
// ── presets: a val at the variant's root (wave 2, gap #5) ───────────────
/// gunnar's shape plus a declared bundle — a FIXTURE: gunnar's own
/// declaration has no honest use for one today (its "airgap" is the payload
/// shape, already the default; "no hub yet" is first-boot reality with
/// WireGuard on), so the bundle lives here and in the doors' tests only.
fn with_presets() -> BuildVariantSet {
let mut set = gunnar_like();
let inst = &mut set.installers[0];
inst.presets.insert(
"airgap".into(),
[("wireguard", "off"), ("dhcp", "off"), ("kernel.preset", "nuked"), ("static_ip", "10.0.2.15/24"), ("gateway", "10.0.2.2")]
.into_iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
);
inst.presets.insert("hub".into(), [("wireguard".to_string(), "on".to_string())].into_iter().collect());
set
}
/// RED-FIRST: a variant's root had no `val`; `preset = airgap` could not be
/// declared, projected or chosen. Now it is a root val whose choices are
/// the bundle names, carrying each bundle, rendering back to the rows.
#[test]
fn a_root_preset_is_a_val_over_the_tree_and_round_trips_through_the_rows() {
let set = with_presets();
let inst = &set.installers[0];
inst.validate().expect("every bundle names paths the tree has");
let t = SettingsTree::of_set(&set, "gunnar-installer").unwrap();
let p = t.get(PRESET_PATH).expect("the root preset val");
let NodeKind::Val(v) = &p.kind else { panic!("a val") };
assert_eq!(v.choices, ["airgap", "hub"]);
assert!(v.default.is_empty(), "no bundle is chosen until somebody says so");
assert_eq!(v.bundles, inst.presets);
assert_eq!(t.rows().presets, inst.presets);
// Law A and law C hold over it: the flags render each choice.
assert_eq!(crate::laws::law_rows_tree(&set), Vec::<String>::new());
assert_eq!(crate::laws::law_declaration_struct(&set), Vec::<String>::new());
let flags: Vec<String> = crate::flags::flags_of(&t).iter().map(|f| f.render()).collect();
assert!(flags.contains(&"--opt preset=airgap".to_string()), "{flags:?}");
// A variant with no presets has no such node at all.
let plain = SettingsTree::of_set(&gunnar_like(), "gunnar-installer").unwrap();
assert!(plain.get(PRESET_PATH).is_none());
// Serde: the bundles ride the node, lean when empty.
let json = serde_json::to_string(&t).unwrap();
assert!(json.contains("\"bundles\""));
assert!(!serde_json::to_string(&plain).unwrap().contains("\"bundles\""));
assert_eq!(serde_json::from_str::<SettingsTree>(&json).unwrap(), t);
}
/// `--opt preset=airgap` EXPANDS into the tree's own flags — features to
/// `--opt path=on|off`, vals to `--opt path=<choice>`, settings to
/// `--param path=<value>` — with paths canonicalised; the record carries
/// the bundle AND the expansion; an explicit flag on the line wins.
#[test]
fn choosing_a_preset_expands_into_law_c_lines_and_explicit_flags_win() {
let set = with_presets();
let t = SettingsTree::of_set(&set, "gunnar-installer").unwrap();
let flags: Vec<String> = t.expand_preset("airgap").unwrap().iter().map(|f| f.render()).collect();
assert_eq!(
flags,
[
"--opt dhcp=off",
"--param dhcp.gateway=10.0.2.2",
"--opt kernel.preset=nuked",
"--param dhcp.static_ip=10.0.2.15/24",
"--opt wireguard=off",
],
"bundle order, aliases canonicalised to full paths"
);
let x = t.expand_opts(&["preset=airgap", "dhcp=on"], &["static_ip=10.0.0.9/24"]).unwrap();
let ap = x.preset.as_ref().expect("the bundle is recorded");
assert_eq!(ap.bundle, "airgap");
assert_eq!(ap.expanded, flags, "…with the whole expansion");
assert_eq!(x.opts, ["kernel.preset=nuked", "wireguard=off", "dhcp=on"], "the explicit dhcp=on replaced the bundle's dhcp=off");
assert_eq!(x.params, ["dhcp.gateway=10.0.2.2", "static_ip=10.0.0.9/24"], "the explicit static_ip replaced the bundle's (alias matched by canonical path)");
assert!(ap.describe().starts_with("preset = airgap → --opt dhcp=off"));
// No preset on the line: nothing changes, nothing is recorded.
let plain = t.expand_opts(&["dhcp=off"], &[] as &[&str]).unwrap();
assert_eq!(plain, Expanded { opts: vec!["dhcp=off".into()], params: vec![], preset: None });
}
/// Every refusal by name: a bundle naming a path the tree lacks (at
/// declaration time), a value the node refuses, the preset choosing
/// itself, an unknown bundle, two bundles on one line, no presets at all.
#[test]
fn a_bundle_that_names_what_the_tree_lacks_is_refused_by_name() {
let mut set = with_presets();
let t = SettingsTree::of_set(&set, "gunnar-installer").unwrap();
let e = format!("{:#}", t.expand_preset("nope").unwrap_err());
assert!(e.contains("no preset 'nope'") && e.contains("airgap · hub"), "{e}");
let e = format!("{:#}", t.expand_opts(&["preset=airgap", "preset=hub"], &[] as &[&str]).unwrap_err());
assert!(e.contains("given twice"), "{e}");
let plain = SettingsTree::of_set(&gunnar_like(), "gunnar-installer").unwrap();
let e = format!("{:#}", plain.expand_opts(&["preset=airgap"], &[] as &[&str]).unwrap_err());
assert!(e.contains("declares no presets"), "{e}");
let inst = &mut set.installers[0];
inst.presets.get_mut("hub").unwrap().insert("wifi".into(), "on".into());
let e = format!("{:#}", inst.validate().unwrap_err());
assert!(e.contains("preset 'hub'") && e.contains("'wifi' is not a setting of variant 'gunnar-installer'"), "{e}");
inst.presets.get_mut("hub").unwrap().remove("wifi");
inst.presets.get_mut("hub").unwrap().insert("wireguard".into(), "maybe".into());
let e = format!("{:#}", inst.validate().unwrap_err());
assert!(e.contains("takes on|off, not \"maybe\""), "{e}");
inst.presets.get_mut("hub").unwrap().insert("wireguard".into(), "on".into());
inst.presets.get_mut("hub").unwrap().insert("kernel.preset".into(), "huge".into());
let e = format!("{:#}", inst.validate().unwrap_err());
assert!(e.contains("takes one of full | normal | slim | nuked"), "{e}");
inst.presets.get_mut("hub").unwrap().remove("kernel.preset");
inst.presets.get_mut("hub").unwrap().insert("preset".into(), "airgap".into());
let e = format!("{:#}", inst.validate().unwrap_err());
assert!(e.contains("cannot choose the preset itself"), "{e}");
inst.presets.get_mut("hub").unwrap().remove("preset");
inst.presets.insert("two words".into(), Bundle::new());
let e = format!("{:#}", inst.validate().unwrap_err());
assert!(e.contains("no whitespace"), "{e}");
}
/// **LAW A over a requirement-carrying option** — the rows derive the tree
/// and the tree renders the rows BACK, `kernel_requires` and all.
///
/// Seen red 2026-08-30 (lane AF) on the first impl that ever declared the
/// key: gunnar's three option rows each came back with an EMPTY
/// `kernel_requires`, so `assert_bijective` reported
/// `LAW A (rows ↔ tree): installer 'gunnar-installer': the tree renders
/// different option rows than declared` — declaring a requirement broke the
/// law by itself. Drop either `kernel_requires` carry in `feature_nodes` /
/// `rows` and this test fails the same way.
#[test]
fn an_options_kernel_requires_survives_the_round_trip_through_the_tree() {
use crate::kernel_requires::KconfigReq;
let mut inst = InstallerBuild { name: "inst".into(), ..Default::default() };
inst.options.push(crate::options::OptionSpec {
name: "wireguard".into(),
feature: "wg-client".into(),
default: true,
kernel_requires: vec![KconfigReq::y("CONFIG_TUN", "boringtun opens /dev/net/tun")],
..Default::default()
});
let tree = SettingsTree::of_installer(&inst, None);
let node = tree.walk().into_iter().find(|n| n.path == "wireguard").expect("the option is a node");
let NodeKind::Feature(f) = &node.kind else { panic!("a feature node") };
assert_eq!(f.kernel_requires.len(), 1, "the requirement rides the NODE, not just the row");
assert_eq!(f.kernel_requires[0].symbol, "CONFIG_TUN");
assert_eq!(tree.rows().options, inst.options, "the tree renders the row back, requirement and all");
assert!(crate::laws::law_rows_tree(&{
let mut set = crate::variant::BuildVariantSet::default();
set.installers.push(inst.clone());
set
})
.is_empty());
}
/// **The tree splits an `--opt` line by asking what each path IS** — the
/// fix for two measured defects at once.
///
/// Seen red, both with the same sentence out of
/// `options::parse_cli_options`, whose return type is `name → bool`:
///
/// * `--opt kernel.preset=nuked` on the ISO-compose path — "an option is on
/// or off". gunnar's `BUILD-KEYED-INSTALLER.md` told operators not to type
/// the flag rather than the forge learning to read it;
/// * `--opt data.fs=fat` — the same, and before that the path was not even
/// on the tree, so `--list-options` never showed it and lane N flipped a
/// row in a scratch worktree to build a FAT medium.
///
/// Three buckets, and every refusal names the path and what it takes.
#[test]
fn the_tree_reads_a_choice_a_toggle_and_a_donors_pair_out_of_one_opt_line() {
let set = gunnar_like();
let t = SettingsTree::of_set(&set, "gunnar-installer").expect("declared");
let split = t
.split_opts(&["dhcp=off".to_string(), "data.fs=xfs".to_string(), "kernel.preset=nuked".to_string()])
.expect("one line, three kinds");
assert_eq!(split.features["dhcp"], false);
assert_eq!(split.choices["data.fs"], "xfs");
assert_eq!(split.behind_an_edge, ["kernel.preset=nuked"], "the donor's door judges its own declaration");
// The old parser is exactly what could not do this.
for pair in ["data.fs=xfs", "kernel.preset=nuked"] {
let e = format!("{:#}", crate::options::parse_cli_options(&[pair]).unwrap_err());
assert!(e.contains("an option is on or off"), "{pair}: {e}");
}
// RED 1: a val's value that is not on its roster, listing the roster.
let e = format!("{:#}", t.split_opts(&["data.fs=ext4".to_string()]).unwrap_err());
assert!(e.contains("data.fs") && e.contains("fat") && e.contains("xfs"), "{e}");
// RED 2: a feature given a choice-shaped value.
let e = format!("{:#}", t.split_opts(&["dhcp=nuked".to_string()]).unwrap_err());
assert!(e.contains("is a feature") && e.contains("on or off"), "{e}");
// RED 3: a --param path typed as an --opt says which flag it is.
let e = format!("{:#}", t.split_opts(&["site=oden".to_string()]).unwrap_err());
assert!(e.contains("--param site=oden"), "{e}");
// RED 4: a path the variant does not declare, listing what it does.
let e = format!("{:#}", t.split_opts(&["data.mount=noatime".to_string()]).unwrap_err());
assert!(e.contains("not a setting of variant"), "{e}");
// The short-name alias works here as everywhere else.
assert_eq!(t.split_opts(&["fs=fat".to_string()]).unwrap().choices["data.fs"], "fat");
}
/// **A path under an edge that the consumer's tree does not draw is the
/// donor's, not a typo** — the same rule [`crate::build_dag::BuildLine::canonical_in`]
/// applies to the step line. `kernel.toolchain=off` is the donor kernel's
/// OWN edge toggle: the consumer's tree draws the kernel's kconfig rows, not
/// the donor's edges, so the compose door refused the very flag the chained
/// kernel step had just consumed. MEASURED 2026-10-29 (tunnr-etcd NVMe and
/// XFS media): the kernel step BUILT, then the installer compose said
/// "'kernel.toolchain' is not a declared option".
#[test]
fn an_undrawn_path_under_an_edge_goes_to_the_donor_and_an_undrawn_root_is_still_refused() {
let set = gunnar_like();
let t = SettingsTree::of_set(&set, "gunnar-installer").expect("declared");
let split = t
.split_opts(&["kernel.toolchain=off".to_string(), "dhcp=off".to_string()])
.expect("an edge-scoped flag is the donor's to judge");
assert_eq!(split.behind_an_edge, ["kernel.toolchain=off"]);
assert_eq!(split.features["dhcp"], false);
// Not a hole: a root that is no edge is still refused by name.
let e = format!("{:#}", t.split_opts(&["nonsense.toolchain=off".to_string()]).unwrap_err());
assert!(e.contains("not a declared option"), "{e}");
}
/// **`--opt data.fs=` reaches the ROW, through the one writer both doors
/// call** — and a val the tree offers that no door applies is refused
/// rather than dropped.
#[test]
fn a_group_choice_lands_on_the_installer_row_and_an_unwired_val_is_refused() {
let mut inst = gunnar_like().installers[0].clone();
assert_eq!(inst.data.fs, crate::data::DataFs::Fat, "the fixture is at the default");
let t = SettingsTree::of_installer(&inst, None);
let split = t.split_opts(&["data.fs=xfs".to_string(), "data.io=uring".to_string()]).unwrap();
inst.apply_group_choices(&split.choices).unwrap();
assert_eq!(inst.data.fs, crate::data::DataFs::Xfs);
assert_eq!(inst.data.io, crate::data::DataIo::Uring);
// …and the kernel closure MOVES with it: the flag is not cosmetic.
let mut set = gunnar_like();
set.installers[0] = inst.clone();
let c = crate::requires::closure_of_consumer(&set, "gunnar", &inst.name, &std::collections::BTreeMap::new())
.unwrap()
.unwrap();
let rows = &c.on_edge("kernel").unwrap().as_kconfig().unwrap().rows;
assert!(rows.contains_key("CONFIG_XFS_FS") && rows.contains_key("CONFIG_IO_URING"), "{:?}", rows.keys());
// RED: a val nobody wired.
let mut stray: BTreeMap<String, String> = BTreeMap::new();
stray.insert("preset".into(), "x".into());
let e = format!("{:#}", inst.apply_group_choices(&stray).unwrap_err());
assert!(e.contains("no door applies"), "{e}");
}
#[test]
fn an_edge_spelled_by_a_view_reads_back_to_the_same_edge() {
let e = Edge::new("tunnr", "tunnr-kernel", ArtifactKind::Kernel);
assert_eq!(e.spelled(), "tunnr/tunnr-kernel : kernel");
assert_eq!(Edge::from_spelled(&e.spelled()), e);
let bare = Edge::from_spelled("tunnr/tunnr-kernel");
assert_eq!((bare.coordinate.as_str(), bare.typ), ("tunnr/tunnr-kernel", ArtifactKind::default()));
assert!(ArtifactKind::Iso.takes_kernel_donor() && ArtifactKind::Installer.takes_kernel_donor());
assert!(!ArtifactKind::Vm.takes_kernel_donor() && !ArtifactKind::Exe.takes_kernel_donor());
}
}