crates/nornir-build-thing/src/build_dag.rs
//! **`--build <X>` and the edge DAG** — the model half of a CHAINED build
//! (design f3 "Consumption edges and `--build`").
//!
//! A variant's typed consumption edges ([`Edge`]: a feature bound to
//! `repo/variant : typ`) say what it CONSUMES. `--build <X>` says: build that
//! consumed build thing as a STEP of this build, with its own `--opt` line. The
//! order of the steps falls out of the edges — a DAG over build things,
//! donors first, the target last — and that DAG is plain data
//! ([`BuildDag`]) the runner (dwarves, through runa) executes line by line.
//!
//! # The grammar
//!
//! `X` is a coordinate `repo/variant`; the short forms resolve through the
//! variant's declared edges when UNAMBIGUOUS and are refused by name otherwise
//! ([`resolve_build_target`]):
//!
//! | spelling | resolves through |
//! |---|---|
//! | `--build kernel` | the edge's feature PATH in the consumer's tree |
//! | `--build tunnr/tunnr-kernel` | the edge's coordinate |
//! | `--build tunnr` | the donor REPO — when exactly one edge points into it |
//! | `--build tunnr-kernel` | the donor VARIANT — when exactly one edge names it |
//!
//! **The path prefix is the brace.** These two lines are the SAME statement,
//! and both parse to one canonical form ([`BuildLine`]):
//!
//! ```text
//! --build kernel { --opt unix=off } (scoped: paths inside the brace are
//! relative to the edge)
//! --build kernel --opt kernel.unix=off (flat: the full path in the
//! consumer's tree)
//! ```
//!
//! The canonical form is the FLAT one — every flag with its full consumer
//! path — because that is the form law C already holds over
//! ([`crate::flags::flags_of`] renders `--opt kernel.unix=…` and `--build
//! kernel`, [`Flag::parse`] reads each back). [`BuildLine::render`] prints it;
//! [`BuildLine::render_scoped`] prints the brace form; both parse back to the
//! same [`BuildLine`] ([`crate::laws::law_path_flag`] checks this over every
//! edge of every impl).
//!
//! # The DAG
//!
//! [`BuildDag::derive`] walks the requested edges from the target: each
//! becomes a [`BuildStep`] carrying the coordinate, the typ the artifact must
//! have, the step's OWN `--opt` line (the consumer's flags under the edge's
//! prefix, re-spelled for the donor's door) and the pin it must satisfy
//! ([`StepPin`], a kernel's ledger key + fragment sha). A donor's own edges
//! are followed transitively — a build you asked for is a build of what it
//! needs — and a cycle is refused BY NAME, spelling the ring. The steps come
//! out dependencies-first; `Display` prints the exact CLI lines in that order.
//!
//! # What is decided here and what is not
//!
//! The kernel axis is spelled `kernel.<handle>` at EVERY door, including the
//! kernel variant's own (`split_kernel_opts` in the forge; the repair line
//! `resolve_built_kernel` has always spelled) — so a kernel step's line reads
//! `--opt kernel.preset=nuked --opt kernel.unix=off`, which is also what the
//! consumer typed. For any other typ the step's line carries the donor's root
//! paths verbatim ([`door_path`]). The runner is dwarves' (containers build
//! things — never the host's libs); this module never runs anything.
use std::borrow::Cow;
use std::collections::BTreeMap;
use std::fmt;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, anyhow, bail};
use serde::{Deserialize, Serialize};
use crate::flags::Flag;
use crate::tree::{Edge, KERNEL_PATH, PATH_SEP, SettingsTree};
use crate::variant::{ArtifactKind, BuildVariantSet};
/// The verb every step line starts with — the ONE CLI door
/// (`dwarves buildthing <repo> --variant <variant> …`).
pub const BUILD_VERB: &str = "dwarves buildthing";
// ── the coordinate ────────────────────────────────────────────────────────────
/// **`repo/variant`** — the donor checkout and the variant inside it.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct Coordinate {
/// The checkout's name — its directory beside the consuming repo.
pub repo: String,
/// The variant declared inside that checkout.
pub variant: String,
}
impl Coordinate {
/// Both halves given.
pub fn new(repo: impl Into<String>, variant: impl Into<String>) -> Self {
Coordinate { repo: repo.into(), variant: variant.into() }
}
/// Parse `repo/variant` — exactly one `/`, both halves named, no
/// whitespace. Refused by name otherwise.
pub fn parse(s: &str) -> Result<Self> {
let s = s.trim();
let Some((repo, variant)) = s.split_once('/') else {
bail!("'{s}' is not a coordinate — a coordinate is `repo/variant`");
};
if repo.is_empty() || variant.is_empty() || variant.contains('/') {
bail!("'{s}' is not a coordinate — a coordinate is `repo/variant`, one '/', both halves named");
}
if s.chars().any(char::is_whitespace) {
bail!("'{s}': a coordinate carries no whitespace");
}
Ok(Coordinate::new(repo, variant))
}
/// The coordinate an edge points at.
pub fn of_edge(e: &Edge) -> Result<Self> {
Coordinate::parse(&e.coordinate)
}
/// Whether `s` has the shape of a coordinate (one `/`).
pub fn looks_like(s: &str) -> bool {
s.contains('/')
}
}
impl fmt::Display for Coordinate {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}/{}", self.repo, self.variant)
}
}
impl std::str::FromStr for Coordinate {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self> {
Coordinate::parse(s)
}
}
// ── the grammar ───────────────────────────────────────────────────────────────
/// **One `--build` line, canonical** — every flag with its FULL consumer path,
/// in the order typed; a brace `--build X { … }` has been desugared to `--build
/// X` followed by its inner flags with `X.` prefixed. Paths are carried as
/// typed (an alias, a repo short form); [`canonical_in`](Self::canonical_in)
/// resolves them against a tree.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct BuildLine {
/// The flags, canonical order.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub flags: Vec<Flag>,
}
impl BuildLine {
/// Parse an argv tail with the brace grammar: `["--build", "kernel", "{",
/// "--opt", "unix=off", "}"]`, the joined shell spelling `["--build kernel
/// { --opt unix=off }"]`, or the flat form — order is kept. Braces glued
/// to their neighbours (`{--opt`, `unix=off}`) are split. Refused by name:
/// a brace with no `--build` before it, an unclosed brace, a stray `}`, a
/// nested `--build` inside a brace (spell the deeper scope by path:
/// `--opt b.c.x=on`).
pub fn parse_argv<S: AsRef<str>>(argv: &[S]) -> Result<Self> {
let mut tokens: Vec<String> = Vec::with_capacity(argv.len() * 2);
for a in argv {
for word in a.as_ref().split_whitespace() {
push_split_braces(&mut tokens, word);
}
}
let mut flags = Vec::new();
let mut i = 0;
while i < tokens.len() {
let t = tokens[i].as_str();
match t {
"{" => bail!("'{{' with no `--build <X>` before it — a brace scopes the flags of one --build"),
"}" => bail!("'}}' closes no brace"),
"--opt" | "--param" | "--build" => {
let v = tokens.get(i + 1).ok_or_else(|| anyhow!("{t} at the end of the line takes an argument"))?;
if v == "{" || v == "}" {
bail!("{t} takes an argument, got '{v}'");
}
let flag = Flag::parse(&format!("{t} {v}"))?;
i += 2;
if let Flag::Build { target } = &flag {
if Coordinate::looks_like(target) {
Coordinate::parse(target)?;
}
flags.push(flag.clone());
if tokens.get(i).is_some_and(|n| n == "{") {
i += 1;
let mut closed = false;
while i < tokens.len() {
let w = tokens[i].as_str();
if w == "}" {
closed = true;
i += 1;
break;
}
match w {
"--opt" | "--param" => {
let v = tokens
.get(i + 1)
.ok_or_else(|| anyhow!("--build {target} {{ {w} … : {w} takes an argument"))?;
let inner = Flag::parse(&format!("{w} {v}"))?;
flags.push(scope_flag(inner, target));
i += 2;
}
"--build" => bail!(
"--build {target} {{ --build … }}: a nested --build inside a brace is not a \
spelling this door takes — spell the deeper scope by path (`--opt \
{target}.<edge>.<setting>=…`)"
),
"{" => bail!("--build {target} {{ {{ : a brace inside a brace"),
other => bail!(
"--build {target} {{ {other} … : inside a brace only `--opt` and `--param` \
are spoken"
),
}
}
if !closed {
bail!("--build {target} {{ … : the brace is never closed");
}
}
} else {
flags.push(flag);
}
}
other => flags.push(Flag::parse(other)?),
}
}
Ok(BuildLine { flags })
}
/// [`parse_argv`](Self::parse_argv) over one whitespace-separated line.
pub fn parse(line: &str) -> Result<Self> {
Self::parse_argv(&[line])
}
/// The line the raw door arguments spell (`BuildArgs.opts` / `.params` /
/// `.builds`, each `k=v` or a target) — the flat form, in the order
/// opts · params · builds. A `--build X { … }` typed on the prompt arrives
/// through `builds` as one string and is parsed with its brace.
pub fn from_args<S: AsRef<str>>(opts: &[S], params: &[S], builds: &[S]) -> Result<Self> {
let mut argv: Vec<String> = Vec::with_capacity(opts.len() + params.len() + builds.len());
argv.extend(opts.iter().map(|o| format!("--opt {}", o.as_ref())));
argv.extend(params.iter().map(|p| format!("--param {}", p.as_ref())));
argv.extend(builds.iter().map(|b| format!("--build {}", b.as_ref())));
Self::parse_argv(&argv)
}
/// The `--build` targets, in order, as typed.
pub fn builds(&self) -> Vec<&str> {
self.flags
.iter()
.filter_map(|f| match f {
Flag::Build { target } => Some(target.as_str()),
_ => None,
})
.collect()
}
/// The `--opt` / `--param` flags, in order.
pub fn settings(&self) -> Vec<&Flag> {
self.flags.iter().filter(|f| !matches!(f, Flag::Build { .. })).collect()
}
/// **Render, flat** — the canonical spelling: `--build kernel --opt
/// kernel.unix=off`. Parses back to `self`.
pub fn render(&self) -> String {
self.flags.iter().map(Flag::render).collect::<Vec<_>>().join(" ")
}
/// **Render, scoped** — the brace spelling: each `--build X` gathers the
/// flags that FOLLOW it under `X.` into `--build X { … }` with the prefix
/// stripped; everything else stays flat. Parses back to `self`.
pub fn render_scoped(&self) -> String {
let mut out: Vec<String> = Vec::with_capacity(self.flags.len());
let mut i = 0;
while i < self.flags.len() {
match &self.flags[i] {
Flag::Build { target } => {
let prefix = format!("{target}{PATH_SEP}");
let mut inner: Vec<String> = Vec::new();
let mut j = i + 1;
while j < self.flags.len() {
match &self.flags[j] {
Flag::Opt { path, value } if path.starts_with(&prefix) => {
inner.push(Flag::choice(&path[prefix.len()..], value).render());
}
Flag::Param { path, value } if path.starts_with(&prefix) => {
inner.push(Flag::param(&path[prefix.len()..], value).render());
}
_ => break,
}
j += 1;
}
if inner.is_empty() {
out.push(format!("--build {target}"));
} else {
out.push(format!("--build {target} {{ {} }}", inner.join(" ")));
}
i = j;
}
other => {
out.push(other.render());
i += 1;
}
}
}
out.join(" ")
}
/// **Canonical against a tree** — every `--build` target resolved to the
/// edge's PATH ([`resolve_build_target`]; a short form that resolves
/// rewrites the flags scoped under it: `tunnr.unix` → `kernel.unix`), every
/// `--opt` / `--param` path resolved to its full path when the tree knows
/// it (`unix=off` → `kernel.unix=off` when unambiguous). A path the tree
/// does not know is kept verbatim ONLY when it lives under an edge (the
/// donor's door judges it: the kernel's symbol set depends on the preset);
/// otherwise it is refused by name. `--opt preset=<bundle>` is NOT expanded
/// here — [`BuildDag::derive`] folds the bundle in through the tree's one
/// expansion.
pub fn canonical_in(&self, tree: &SettingsTree) -> Result<BuildLine> {
let edge_paths: Vec<String> = tree
.walk()
.into_iter()
.filter(|n| n.edge.is_some())
.map(|n| n.path.clone())
.collect();
// Pass 1: the targets, and the prefix rewrite each implies.
let mut rewrites: Vec<(String, String)> = Vec::new();
let mut flags: Vec<Flag> = Vec::with_capacity(self.flags.len());
for f in &self.flags {
if let Flag::Build { target } = f {
let (path, _edge) = resolve_build_target(tree, target)?;
if &path != target {
rewrites.push((format!("{target}{PATH_SEP}"), format!("{path}{PATH_SEP}")));
}
flags.push(Flag::build(path));
} else {
flags.push(f.clone());
}
}
// Pass 2: the settings.
for f in flags.iter_mut() {
let (path, is_opt) = match f {
Flag::Opt { path, .. } => (path, true),
Flag::Param { path, .. } => (path, false),
Flag::Build { .. } => continue,
};
let mut p = path.clone();
for (from, to) in &rewrites {
if let Some(rest) = p.strip_prefix(from.as_str()) {
p = format!("{to}{rest}");
break;
}
}
let canonical = match tree.canonical_path(&p) {
Ok(c) => c,
Err(e) => {
let under_edge = edge_paths.iter().any(|ep| p.starts_with(&format!("{ep}{PATH_SEP}")));
if under_edge {
p.clone()
} else {
let word = if is_opt { "--opt" } else { "--param" };
return Err(anyhow!("{word} {p}: {e:#}"));
}
}
};
*path = canonical;
}
Ok(BuildLine { flags })
}
}
impl fmt::Display for BuildLine {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.render())
}
}
/// Split `{`/`}` glued to a word into their own tokens: `{--opt` → `{`,
/// `--opt`; `unix=off}` → `unix=off`, `}`; `{}` → `{`, `}`.
fn push_split_braces(out: &mut Vec<String>, word: &str) {
let mut w = word;
let mut trailing = 0usize;
while let Some(s) = w.strip_prefix('{') {
out.push("{".to_string());
w = s;
}
while let Some(s) = w.strip_suffix('}') {
trailing += 1;
w = s;
}
if !w.is_empty() {
out.push(w.to_string());
}
for _ in 0..trailing {
out.push("}".to_string());
}
}
/// `--opt unix=off` inside `--build kernel { … }` → `--opt kernel.unix=off`.
fn scope_flag(inner: Flag, scope: &str) -> Flag {
match inner {
Flag::Opt { path, value } => Flag::Opt { path: format!("{scope}{PATH_SEP}{path}"), value },
Flag::Param { path, value } => Flag::Param { path: format!("{scope}{PATH_SEP}{path}"), value },
b @ Flag::Build { .. } => b,
}
}
/// **Resolve a `--build` target against a variant's tree** to `(edge path,
/// edge)`. In order: the edge's feature path exactly; the edge's coordinate
/// exactly; a short name the tree resolves to a node carrying an edge; the
/// donor REPO when exactly one edge points into it; the donor VARIANT when
/// exactly one edge names it. Two candidates is an AMBIGUOUS refusal listing
/// both; none is a refusal listing the edges the variant declares (or that it
/// declares none).
pub fn resolve_build_target(tree: &SettingsTree, target: &str) -> Result<(String, Edge)> {
let target = target.trim();
let edges: Vec<(&str, &Edge)> = tree
.walk()
.into_iter()
.filter_map(|n| n.edge.as_ref().map(|e| (n.path.as_str(), e)))
.collect();
let listing = || {
if edges.is_empty() {
"it declares none (an edge is `[build.<kind>.kernel_donor]` or `[build.<kind>.edges.<feature>]`)".to_string()
} else {
format!(
"its edges: {}",
edges.iter().map(|(p, e)| format!("{p} → {} : {}", e.coordinate, e.typ.as_str())).collect::<Vec<_>>().join(", ")
)
}
};
if let Some((p, e)) = edges.iter().find(|(p, _)| *p == target) {
return Ok((p.to_string(), (*e).clone()));
}
if let Ok(r) = tree.resolve(target) {
if let Some(e) = &r.node.edge {
return Ok((r.node.path.clone(), e.clone()));
}
}
// The coordinate, the repo, the variant — each unique or refused: two edges
// into the same build thing make its coordinate ambiguous too (which edge's
// scope the flags belong to is not the model's to guess).
let by_coordinate: Vec<&(&str, &Edge)> = edges.iter().filter(|(_, e)| e.coordinate == target).collect();
let by_repo: Vec<&(&str, &Edge)> = edges.iter().filter(|(_, e)| e.split().is_some_and(|(r, _)| r == target)).collect();
let by_variant: Vec<&(&str, &Edge)> = edges.iter().filter(|(_, e)| e.split().is_some_and(|(_, v)| v == target)).collect();
let hits: Vec<&(&str, &Edge)> = [by_coordinate, by_repo, by_variant].into_iter().find(|h| !h.is_empty()).unwrap_or_default();
match hits.as_slice() {
[(p, e)] => Ok((p.to_string(), (*e).clone())),
[] => bail!("--build {target}: '{target}' is no consumption edge of variant '{}' — {}", tree.variant, listing()),
many => bail!(
"--build {target}: '{target}' is AMBIGUOUS in variant '{}' — {} edges match; spell the path or the \
coordinate: {}",
tree.variant,
many.len(),
many.iter().map(|(p, e)| format!("--build {p} (= --build {})", e.coordinate)).collect::<Vec<_>>().join(", ")
),
}
}
/// **The donor door's spelling of a path under an edge.** The kernel axis is
/// `kernel.<handle>` at every door, including the kernel variant's own; every
/// other typ takes its root paths verbatim.
pub fn door_path(typ: ArtifactKind, inner: &str) -> String {
if typ == ArtifactKind::Kernel {
format!("{KERNEL_PATH}{PATH_SEP}{inner}")
} else {
inner.to_string()
}
}
// ── the steps ─────────────────────────────────────────────────────────────────
/// **Through which edge a step was pulled in** — the consumer and the feature
/// path on it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Via {
/// The consuming build thing.
pub consumer: Coordinate,
/// The feature path on the consumer that carries the edge (`kernel`,
/// `b.c` for a chained one).
pub path: String,
}
/// **The pin a step's artifact must satisfy** — for a kernel, the ledger key
/// its pin files under and the merged fragment sha the pin must record
/// (`preset` for the refusal's words). The runner verifies the produced
/// artifact against this the way `resolve_built_kernel` does.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StepPin {
/// [`crate::kernel::ledger_key`] over the chosen declaration — empty for a
/// step whose surface has no ledger (a container's).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub ledger_key: String,
/// sha256 of the text the step's surface pins to: a kernel's merged kconfig
/// fragment, a toolchain image's canonical tool closure
/// ([`crate::toolchain_requires::ToolRequirements::canonical`]).
pub fragment_sha256: String,
/// What the pin started from — a kernel's preset, an image's name.
pub preset: String,
}
/// **One step of a chained build** — plain, serializable, and `Display` is
/// the exact CLI line the runner runs.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BuildStep {
/// What is built.
pub coordinate: Coordinate,
/// The typ the artifact must have — the edge's, checked against the
/// donor's declaration at derivation.
pub typ: ArtifactKind,
/// The edge that pulled the step in; `None` for the target itself.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub via: Option<Via>,
/// The step's OWN `--opt` / `--param` line, in the donor door's spelling
/// ([`door_path`]).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub opts: Vec<Flag>,
/// The pin the artifact must satisfy, when the model can compute one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pin: Option<StepPin>,
/// **What every consumer in the chain requires of THIS step's artifact** —
/// the union over the consumers' enabled features ([`crate::requires`]),
/// typed by the DONOR's surface: Kconfig rows for a kernel, tools for a
/// toolchain image. The runner lands it on the donor's declaration before
/// building; the pin above already covers it. `None` for the target and for
/// a step nobody required anything of.
///
/// ⚠ This field WAS `Option<KernelRequirements>` — the last kernel-shaped
/// thing in the planner, and the one that made a second surface impossible:
/// `visit` computed a generic [`crate::requires::SurfaceClosure`] and then
/// called `as_kconfig()` on it, so a non-Kconfig closure was silently
/// DROPPED on the floor at the last step of the recursion (lane AF,
/// 2026-08-30). Readers that want the Kconfig half ask
/// [`kernel_requires`](Self::kernel_requires) for it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requires: Option<crate::requires::SurfaceClosure>,
}
impl BuildStep {
/// The Kconfig half of [`requires`](Self::requires), for a kernel step — the
/// typed accessor the kernel readers (the ledger, `resolve_built_kernel`,
/// the fragment merge) take, so generalizing the field cost them one call
/// and no second model.
pub fn kernel_requires(&self) -> Option<&crate::kernel_requires::KernelRequirements> {
self.requires.as_ref().and_then(|c| c.as_kconfig())
}
/// The toolchain half, for a container step.
pub fn tool_requires(&self) -> Option<&crate::toolchain_requires::ToolRequirements> {
self.requires.as_ref().and_then(|c| c.as_tools())
}
/// A step with no edge and no pin — the target's own.
pub fn new(coordinate: Coordinate, typ: ArtifactKind) -> Self {
BuildStep { coordinate, typ, via: None, opts: Vec::new(), pin: None, requires: None }
}
/// The arguments after [`BUILD_VERB`]: `[repo, --variant, variant, --opt,
/// k=v, …]` — what a runner hands the door.
pub fn args(&self) -> Vec<String> {
let mut out = vec![self.coordinate.repo.clone(), "--variant".to_string(), self.coordinate.variant.clone()];
for f in &self.opts {
let r = f.render();
if let Some((w, v)) = r.split_once(' ') {
out.push(w.to_string());
out.push(v.to_string());
}
}
out
}
/// The exact CLI line: `dwarves buildthing tunnr --variant tunnr-kernel
/// --opt kernel.preset=nuked --opt kernel.unix=off`.
pub fn line(&self) -> String {
let mut s = format!("{BUILD_VERB} {} --variant {}", self.coordinate.repo, self.coordinate.variant);
for f in &self.opts {
s.push(' ');
s.push_str(&f.render());
}
s
}
}
impl fmt::Display for BuildStep {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.line())
}
}
// ── the declarations a derivation reads ───────────────────────────────────────
/// **Where a derivation reads declarations from** — by repo name. The forge
/// fills it with its coordinate ladder, [`BesideCheckouts`] with the
/// constellation layout rule, [`InMemoryDeclarations`] with sets a test built.
pub trait Declarations {
/// The declaration of checkout `repo`. `Err` when it cannot be read or
/// declares no build space — named, never a silent empty set.
fn declaration(&self, repo: &str) -> Result<Cow<'_, BuildVariantSet>>;
/// The checkout root, when the declaration lives in a directory — a
/// kernel's pin needs the repo's veto file. `None` for in-memory sets
/// (the step then carries no pin).
fn root(&self, repo: &str) -> Option<PathBuf>;
}
/// **The constellation layout rule** — a donor named `repo` is the checkout
/// `<parent of own_root>/<repo>`; the own repo is `own_root` itself. The same
/// rule [`BuildVariantSet::validate_edges_in`] resolves by.
pub struct BesideCheckouts {
/// This checkout's name (its directory name).
pub own: String,
/// This checkout's root.
pub own_root: PathBuf,
}
impl BesideCheckouts {
/// The rule over `repo_root`.
pub fn of(repo_root: &Path) -> Self {
BesideCheckouts {
own: repo_root.file_name().and_then(|s| s.to_str()).unwrap_or("").to_string(),
own_root: repo_root.to_path_buf(),
}
}
}
impl Declarations for BesideCheckouts {
fn declaration(&self, repo: &str) -> Result<Cow<'_, BuildVariantSet>> {
let root = self.root(repo).ok_or_else(|| {
anyhow!(
"no checkout '{repo}' beside {} ({} is not a directory)",
self.own_root.display(),
self.own_root.parent().unwrap_or(Path::new("")).join(repo).display()
)
})?;
let set = BuildVariantSet::discover(&root)
.with_context(|| format!("'{repo}': read its build space"))?
.ok_or_else(|| {
anyhow!(
"'{repo}' at {} declares no build space ({} is not a file)",
root.display(),
BuildVariantSet::conventional_path(&root).display()
)
})?;
Ok(Cow::Owned(set))
}
fn root(&self, repo: &str) -> Option<PathBuf> {
if repo == self.own {
return Some(self.own_root.clone());
}
let root = self.own_root.parent()?.join(repo);
root.is_dir().then_some(root)
}
}
/// **Declarations held in memory**, by repo name — what a test or a bench
/// derives over. Steps derived here carry no pin (no checkout to read a veto
/// file from).
#[derive(Default)]
pub struct InMemoryDeclarations {
/// repo → its set.
pub sets: BTreeMap<String, BuildVariantSet>,
/// repo → its checkout, for the pins that need to read a file (a kernel's
/// veto fragment, a toolchain image's Containerfile). Empty ⇒ every `root`
/// is `None`, which is the rootless plan a step's pin must survive.
pub roots: BTreeMap<String, PathBuf>,
}
impl InMemoryDeclarations {
/// Add one repo's declaration.
pub fn with(mut self, repo: impl Into<String>, set: BuildVariantSet) -> Self {
self.sets.insert(repo.into(), set);
self
}
/// Add one repo's checkout root.
pub fn rooted_at(mut self, repo: impl Into<String>, root: impl Into<PathBuf>) -> Self {
self.roots.insert(repo.into(), root.into());
self
}
}
impl Declarations for InMemoryDeclarations {
fn declaration(&self, repo: &str) -> Result<Cow<'_, BuildVariantSet>> {
self.sets
.get(repo)
.map(Cow::Borrowed)
.ok_or_else(|| anyhow!("no declaration for '{repo}' (known: {})", self.sets.keys().cloned().collect::<Vec<_>>().join(", ")))
}
fn root(&self, repo: &str) -> Option<PathBuf> {
self.roots.get(repo).cloned()
}
}
// ── the DAG ───────────────────────────────────────────────────────────────────
/// **The chained build, ordered** — dependencies first, the target last. Plain
/// data for the runner; `Display` prints one CLI line per step, in order.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct BuildDag {
/// The steps, topologically ordered (every step's donors precede it).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub steps: Vec<BuildStep>,
}
impl BuildDag {
/// **Derive the DAG** for `target` from a `--build` line.
///
/// 1. the target's tree ([`SettingsTree::of_set`]) canonicalises the line
/// ([`BuildLine::canonical_in`]) and folds a root `preset=<bundle>` in
/// (`SettingsTree::expand_opts`, the one expansion);
/// 2. every `--build` target resolves to an edge; the donor's declaration
/// is read through `decls` and must declare the variant WITH the edge's
/// typ (refused by name otherwise — the same type check the load does);
/// 3. the step's opts are the line's flags under the edge's prefix,
/// re-spelled for the donor's door; a kernel step's pin is computed
/// when the donor checkout is on disk;
/// 4. the donor's own edges are followed transitively (scope `a.b`) —
/// **each only while its feature is on**, so `--opt kernel.toolchain=off`
/// takes that donor out of the plan and `=on` puts it back — a ring is
/// refused naming every coordinate on it, and a coordinate reached twice
/// must be asked for the SAME build (else refused);
/// 5. the target's step comes last, carrying the whole canonical line.
pub fn derive(target: &Coordinate, line: &BuildLine, decls: &dyn Declarations) -> Result<Self> {
let set = decls.declaration(&target.repo).with_context(|| format!("--build: the target '{target}'"))?;
let tree = SettingsTree::of_set(&set, &target.variant).ok_or_else(|| {
anyhow!("--build: '{}' declares no variant '{}' (declared: {})", target.repo, target.variant, set.declared_names().join(", "))
})?;
let line = line.canonical_in(&tree)?;
let flags = expand_root_preset(&tree, &line)?;
let mut walker = Walker { decls, steps: Vec::new(), stack: vec![target.clone()] };
// The target's own closure: what its ENABLED features require of each
// edge, under the line's root-scope toggles (`--opt dhcp=on`).
let toggles = crate::requires::toggles_in_scope(&flags, "")?;
let closure = crate::requires::closure_of_consumer(&set, &target.repo, &target.variant, &toggles)?;
for b in line.builds() {
let (path, edge) = resolve_build_target(&tree, b)?;
// **An edge is consumed only while its feature is ON.** Asked to
// BUILD what the same line switched off, the door says so instead of
// building it (a plan that contradicts its own line) or dropping it
// (a `--build` that silently does nothing).
if toggles.get(&path) == Some(&false) {
bail!(
"--build {b}: edge '{path}' → {} : {} is OFF on this line (`--opt {path}=off`) — a build thing \
that is not consumed is not built; drop the `--build` or turn the feature on",
edge.coordinate,
edge.typ.as_str()
);
}
walker.visit(target, "", closure.as_ref(), &path, &edge, &flags)?;
}
let mut root = BuildStep::new(target.clone(), tree.typ);
root.opts = flags.into_iter().filter(|f| !matches!(f, Flag::Build { .. })).collect();
walker.steps.push(root);
Ok(BuildDag { steps: walker.steps })
}
/// The target's own step — the last one.
pub fn target(&self) -> Option<&BuildStep> {
self.steps.last()
}
/// Every step but the target, in run order.
pub fn donors(&self) -> &[BuildStep] {
match self.steps.len() {
0 => &[],
n => &self.steps[..n - 1],
}
}
/// The CLI lines, in run order.
pub fn lines(&self) -> Vec<String> {
self.steps.iter().map(BuildStep::line).collect()
}
/// The annotated form: `n/N · typ · via · pin` above each line.
pub fn describe(&self) -> String {
let n = self.steps.len();
let mut out = String::new();
for (i, s) in self.steps.iter().enumerate() {
let via = s.via.as_ref().map(|v| format!(" via {}:{}", v.consumer, v.path)).unwrap_or_default();
let pin = s.pin.as_ref().map(|p| format!(" pin fragment {}…", &p.fragment_sha256[..p.fragment_sha256.len().min(12)])).unwrap_or_default();
let req = s.requires.as_ref().map(|r| format!(" · requires {}", r.summary())).unwrap_or_default();
out.push_str(&format!("# {}/{n} {} : {}{via}{pin}{req}\n{}\n", i + 1, s.coordinate, s.typ.as_str(), s.line()));
}
out
}
}
impl fmt::Display for BuildDag {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for (i, s) in self.steps.iter().enumerate() {
if i > 0 {
f.write_str("\n")?;
}
f.write_str(&s.line())?;
}
Ok(())
}
}
/// Fold `--opt preset=<bundle>` in through the tree's one expansion; the
/// result is the flat flag list with the bundle's choices first.
fn expand_root_preset(tree: &SettingsTree, line: &BuildLine) -> Result<Vec<Flag>> {
let opts: Vec<String> = line
.flags
.iter()
.filter_map(|f| match f {
Flag::Opt { path, value } => Some(format!("{path}={value}")),
_ => None,
})
.collect();
let params: Vec<String> = line
.flags
.iter()
.filter_map(|f| match f {
Flag::Param { path, value } => Some(format!("{path}={value}")),
_ => None,
})
.collect();
let x = tree.expand_opts(&opts, ¶ms)?;
let mut out: Vec<Flag> = Vec::with_capacity(line.flags.len());
for o in &x.opts {
let (k, v) = o.split_once('=').ok_or_else(|| anyhow!("--opt {o}: no '='"))?;
out.push(Flag::choice(k, v));
}
for p in &x.params {
let (k, v) = p.split_once('=').ok_or_else(|| anyhow!("--param {p}: no '='"))?;
out.push(Flag::param(k, v));
}
out.extend(line.flags.iter().filter(|f| matches!(f, Flag::Build { .. })).cloned());
Ok(out)
}
/// **The pin of one step, by the DONOR's surface** — the one place a step's pin
/// is computed, so adding a surface is adding an arm here rather than another
/// `if edge.typ == Kernel` somewhere in the recursion (which is what `visit`
/// carried, twice, until lane AF).
///
/// * a KERNEL step pins its merged kconfig fragment under its ledger key, and
/// with no checkout to read a veto file from it still JUDGES the requirements
/// against the preset — a contradiction is refused at plan time on every road;
/// * a CONTAINER step pins the canonical tool closure
/// ([`crate::toolchain_requires::ToolRequirements::canonical`]) and refuses,
/// by name, an image whose recipe does not carry a required tool;
/// * every other typ has no surface yet, so it has no pin, and saying `None` is
/// the honest answer rather than a fabricated one.
fn step_pin_for(
decls: &dyn Declarations,
donor: &BuildVariantSet,
coord: &Coordinate,
typ: ArtifactKind,
opts: &[Flag],
required: Option<&crate::requires::SurfaceClosure>,
) -> Result<Option<StepPin>> {
match typ {
ArtifactKind::Kernel => {
let k = required.and_then(|c| c.as_kconfig());
match decls.root(&coord.repo) {
Some(root) => Ok(Some(crate::kernel::kernel_step_pin_for(donor, &root, &coord.variant, opts, k)?)),
None => {
if let Some(r) = k {
donor.require_kernel(&coord.variant)?.clone().apply_requirements(r)?;
}
Ok(None)
}
}
}
ArtifactKind::Container => {
let Some(t) = required.and_then(|c| c.as_tools()) else { return Ok(None) };
let Some(c) = donor.containers.iter().find(|c| c.name == coord.variant) else { return Ok(None) };
// The image's own recipe is what the requirements are judged
// against; without a checkout there is nothing to read, so the
// requirements ride the plan unjudged and the BUILD is where they
// are met — the same shape the kernel arm takes for a rootless plan.
let Some(root) = decls.root(&coord.repo) else { return Ok(None) };
let recipe = c.containerfile_text(&root)?;
c.clone().apply_tool_requirements(t, &recipe)?;
Ok(Some(StepPin {
ledger_key: String::new(),
fragment_sha256: nornir_hash::sha256_hex(t.canonical().as_bytes()),
preset: c.name.clone(),
}))
}
_ => Ok(None),
}
}
struct Walker<'d> {
decls: &'d dyn Declarations,
steps: Vec<BuildStep>,
stack: Vec<Coordinate>,
}
impl Walker<'_> {
/// `scope` is the prefix under which `consumer`'s own flags live (`""` for
/// the target, `"a."` for a donor reached over edge `a`); `closure` is what
/// the consumer's enabled features require of its edges, keyed by the
/// consumer's LOCAL edge path.
fn visit(&mut self, consumer: &Coordinate, scope: &str, closure: Option<&crate::requires::Closure>, path: &str, edge: &Edge, flags: &[Flag]) -> Result<()> {
let coord = Coordinate::of_edge(edge).with_context(|| format!("edge '{path}' of {consumer}"))?;
let who = format!("{consumer}: edge '{path}' → {coord} : {}", edge.typ.as_str());
if let Some(at) = self.stack.iter().position(|c| *c == coord) {
let ring: Vec<String> = self.stack[at..].iter().map(ToString::to_string).chain(std::iter::once(coord.to_string())).collect();
bail!("{who}: the edges form a CYCLE — {} — a build thing cannot consume itself", ring.join(" → "));
}
let donor = self.decls.declaration(&coord.repo).with_context(|| who.clone())?;
match donor.kind_of(&coord.variant) {
None => bail!("{who}: '{}' declares no variant '{}' (declared: {})", coord.repo, coord.variant, donor.declared_names().join(", ")),
// The typ refusal has ONE writer (`tree::wrong_typ`), shared with
// the declaration-time check: the same defect reads the same way
// whether the load names it or the plan does.
Some(k) if k != edge.typ => return Err(crate::tree::wrong_typ(&who, &coord.repo, &coord.variant, k, edge.typ)),
Some(_) => {}
}
// The donor's OWN edges, read from its tree — needed before the step's
// line, because an edge's on/off word is answered HERE and must not
// also be handed to the donor's door (which would judge `kernel.toolchain`
// as a kconfig symbol and refuse a word that was never one).
let donor_tree = SettingsTree::of_set(&donor, &coord.variant)
.ok_or_else(|| anyhow!("{who}: '{}' has no tree for '{}'", coord.repo, coord.variant))?;
let donor_edges: Vec<(String, Edge)> = donor_tree
.walk()
.into_iter()
.filter_map(|n| n.edge.as_ref().map(|e| (n.path.clone(), e.clone())))
.collect();
// The step's own line: the flags scoped under this edge, re-spelled —
// minus the donor's own edge toggles, which this walk consumes. A flag
// BELOW such an edge (`kernel.toolchain.x`) stays, exactly as `b`'s line
// keeps `cee.fast=off`: it is the donor's choice about what IT consumes.
let prefix = format!("{path}{PATH_SEP}");
let is_edge_toggle = |inner: &str| donor_edges.iter().any(|(p, _)| p == inner);
let opts: Vec<Flag> = flags
.iter()
.filter_map(|f| match f {
Flag::Opt { path: p, value } => p
.strip_prefix(prefix.as_str())
.filter(|inner| !is_edge_toggle(inner))
.map(|inner| Flag::choice(door_path(edge.typ, inner), value)),
Flag::Param { path: p, value } => p.strip_prefix(prefix.as_str()).map(|inner| Flag::param(door_path(edge.typ, inner), value)),
Flag::Build { .. } => None,
})
.collect();
// What THIS consumer's features require of the thing behind this edge —
// the closure is keyed by the consumer's local edge path.
let local = path.strip_prefix(scope).unwrap_or(path);
let required: Option<crate::requires::SurfaceClosure> = match closure.and_then(|c| c.on_edge(local)) {
None => None,
Some(c) if c.typ() != edge.typ => bail!(
"{who}: the consumer's features place a {} requirement on this edge, but the donor is typ {} — the surface does not match",
c.typ().as_str(),
edge.typ.as_str()
),
// THE WHOLE CLOSURE, whatever its surface. This line used to be
// `c.as_kconfig().cloned()`, which is why a second surface could be
// declared, collected, unioned and refused — and then vanish here.
Some(c) => Some(c.clone()),
};
// A coordinate reached twice is ONE build: the requirements are the
// UNION over every consumer (that is the recursion), the lines must agree.
if let Some(i) = self.steps.iter().position(|s| s.coordinate == coord) {
if self.steps[i].opts != opts {
bail!(
"{who}: '{coord}' is reached twice with DIFFERENT lines — via {} `{}` and via {consumer}:{path} `{}` \
— one build per coordinate; make the two scopes agree",
self.steps[i].via.as_ref().map(|v| format!("{}:{}", v.consumer, v.path)).unwrap_or_default(),
self.steps[i].line(),
BuildStep { coordinate: coord.clone(), typ: edge.typ, via: None, opts: opts.clone(), pin: None, requires: None }.line()
);
}
if let Some(r) = required {
let joined = match &self.steps[i].requires {
Some(have) => {
let mut j = have.clone();
j.union(&r).with_context(|| who.clone())?;
j
}
None => r,
};
self.steps[i].pin = step_pin_for(self.decls, &donor, &coord, edge.typ, &opts, Some(&joined)).with_context(|| who.clone())?;
self.steps[i].requires = Some(joined);
}
return Ok(());
}
let pin = step_pin_for(self.decls, &donor, &coord, edge.typ, &opts, required.as_ref()).with_context(|| who.clone())?;
let step = BuildStep { coordinate: coord.clone(), typ: edge.typ, via: Some(Via { consumer: consumer.clone(), path: path.to_string() }), opts, pin, requires: required };
// Transitively: what the donor itself consumes, scoped `a.b`.
self.stack.push(coord.clone());
// The donor as a CONSUMER in its turn: its own closure under its scope.
let donor_scope = prefix.clone();
let donor_toggles = crate::requires::toggles_in_scope(flags, &donor_scope)?;
let donor_closure = if donor_edges.is_empty() {
None
} else {
crate::requires::closure_of_consumer(&donor, &coord.repo, &coord.variant, &donor_toggles).with_context(|| who.clone())?
};
for (p, e) in donor_edges {
// **The bijection, both ways.** An edge is a FEATURE of its
// consumer, so the line that switches the feature off takes the
// donor OUT of the plan — and switching it on puts it back, with
// the step, its scoped line and its pin. A declaration that cannot
// fail is not a declaration: this is the same proof `CONFIG_TUN`
// gives for a kernel requirement, one level up, over whole build
// things. The state is read where every other feature's is
// (`toggles_in_scope`, under the donor's own scope), so
// `--opt kernel.toolchain=off` and `--opt wireguard=off` are one
// mechanism.
if donor_toggles.get(&p) == Some(&false) {
continue;
}
let scoped = format!("{donor_scope}{p}");
self.visit(&coord, &donor_scope, donor_closure.as_ref(), &scoped, &e, flags)?;
}
self.stack.pop();
// The consumer's step goes AFTER what it consumes: post-order.
self.steps.push(step);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::installer::{InstallerBuild, KernelDonor};
use crate::tree::EdgeDecl;
use crate::variant::BuildVariant;
fn kernel_set(name: &str) -> BuildVariantSet {
let mut set = BuildVariantSet::default();
set.kernels.push(crate::kernel::KernelBuild {
name: name.into(),
version: "6.12.104".into(),
tarball_sha256: "0".repeat(64),
preset: "normal".into(),
..Default::default()
});
set
}
fn installer_set() -> BuildVariantSet {
let mut set = BuildVariantSet::default();
set.installers.push(InstallerBuild {
name: "gunnar-installer".into(),
options: vec![
crate::options::OptionSpec { name: "dhcp".into(), feature: "dhcp".into(), default: true, ..Default::default() },
crate::options::OptionSpec { name: "wireguard".into(), feature: "wireguard".into(), default: true, ..Default::default() },
],
kernel_donor: Some(KernelDonor { repo: "tunnr".into(), kernel: "tunnr-kernel".into() }),
..Default::default()
});
set
}
fn decls() -> InMemoryDeclarations {
InMemoryDeclarations::default().with("gunnar", installer_set()).with("tunnr", kernel_set("tunnr-kernel"))
}
fn tree() -> SettingsTree {
SettingsTree::of_set(&installer_set(), "gunnar-installer").unwrap()
}
#[test]
fn coordinate_parses_and_prints_itself() {
let c = Coordinate::parse("tunnr/tunnr-kernel").unwrap();
assert_eq!(c, Coordinate::new("tunnr", "tunnr-kernel"));
assert_eq!(c.to_string(), "tunnr/tunnr-kernel");
for bad in ["tunnr", "/x", "x/", "a/b/c", "a b/c"] {
assert!(Coordinate::parse(bad).is_err(), "{bad}");
}
}
#[test]
fn the_brace_and_the_path_prefix_are_the_same_statement() {
let scoped = BuildLine::parse("--build kernel { --opt unix=off --opt preset=nuked }").unwrap();
let flat = BuildLine::parse("--build kernel --opt kernel.unix=off --opt kernel.preset=nuked").unwrap();
assert_eq!(scoped, flat);
assert_eq!(flat.render(), "--build kernel --opt kernel.unix=off --opt kernel.preset=nuked");
assert_eq!(flat.render_scoped(), "--build kernel { --opt unix=off --opt preset=nuked }");
// Both renderings parse back to the same canonical line (law C).
assert_eq!(BuildLine::parse(&flat.render()).unwrap(), flat);
assert_eq!(BuildLine::parse(&flat.render_scoped()).unwrap(), flat);
// Glued braces and the argv shape parse the same.
let glued = BuildLine::parse_argv(&["--build", "kernel", "{--opt", "unix=off", "--opt", "preset=nuked}"]).unwrap();
assert_eq!(glued, flat);
// Flat flags outside the brace stay where they were typed.
let mixed = BuildLine::parse("--opt dhcp=off --build kernel { --param x=@fixture } --opt wireguard=on").unwrap();
assert_eq!(
mixed.flags,
vec![Flag::opt("dhcp", false), Flag::build("kernel"), Flag::param("kernel.x", "@fixture"), Flag::opt("wireguard", true)]
);
assert_eq!(BuildLine::parse(&mixed.render_scoped()).unwrap(), mixed);
}
#[test]
fn malformed_braces_are_refused_by_name() {
for (bad, why) in [
("{ --opt x=on }", "no `--build <X>` before it"),
("--build kernel { --opt x=on", "never closed"),
("--opt x=on }", "closes no brace"),
("--build kernel { --build foo { } }", "nested --build"),
("--build kernel { frob }", "only `--opt` and `--param`"),
("--build a/b/c", "one '/'"),
("--build {", "takes an argument"),
] {
let e = format!("{:#}", BuildLine::parse(bad).unwrap_err());
assert!(e.contains(why), "'{bad}': {e}");
}
}
#[test]
fn short_forms_resolve_through_the_edges_and_ambiguity_is_refused() {
let t = tree();
for spelling in ["kernel", "tunnr/tunnr-kernel", "tunnr", "tunnr-kernel"] {
let (path, edge) = resolve_build_target(&t, spelling).unwrap_or_else(|e| panic!("{spelling}: {e:#}"));
assert_eq!(path, "kernel");
assert_eq!(edge.coordinate, "tunnr/tunnr-kernel");
}
let e = format!("{:#}", resolve_build_target(&t, "dhcp").unwrap_err());
assert!(e.contains("no consumption edge") && e.contains("kernel → tunnr/tunnr-kernel : kernel"), "{e}");
// Two edges into the same repo: the repo short form is ambiguous.
let mut set = BuildVariantSet::default();
let mut v = BuildVariant { name: "app".into(), ..Default::default() };
v.edges.insert("srv".into(), EdgeDecl { repo: "tunnr".into(), variant: "tunnr-server".into(), typ: ArtifactKind::Exe, lazy: false });
v.edges.insert("cli".into(), EdgeDecl { repo: "tunnr".into(), variant: "tunnr-cli".into(), typ: ArtifactKind::Exe, lazy: false });
set.variants.push(v);
let t2 = SettingsTree::of_set(&set, "app").unwrap();
let e = format!("{:#}", resolve_build_target(&t2, "tunnr").unwrap_err());
assert!(e.contains("AMBIGUOUS") && e.contains("--build cli") && e.contains("--build srv"), "{e}");
assert_eq!(resolve_build_target(&t2, "tunnr-cli").unwrap().0, "cli");
}
#[test]
fn canonical_in_rewrites_a_short_target_scope_and_refuses_unknown_paths() {
let t = tree();
let a = BuildLine::parse("--build tunnr { --opt unix=off }").unwrap().canonical_in(&t).unwrap();
let b = BuildLine::parse("--opt kernel.unix=off --build kernel").unwrap().canonical_in(&t).unwrap();
assert_eq!(a.builds(), ["kernel"]);
assert_eq!(a.settings(), b.settings());
// An alias resolves to its full path; a path under the edge the tree does not know is kept for the donor's door.
let c = BuildLine::parse("--opt preset=nuked --opt kernel.frobnicate=off").unwrap().canonical_in(&t).unwrap();
assert_eq!(c.flags, vec![Flag::choice("kernel.preset", "nuked"), Flag::opt("kernel.frobnicate", false)]);
let e = format!("{:#}", BuildLine::parse("--opt frobnicate=off").unwrap().canonical_in(&t).unwrap_err());
assert!(e.contains("--opt frobnicate") && e.contains("not a setting"), "{e}");
}
/// ★ RED-FIRST 2026-08-29 (lane K) — **the closure reaches the kernel build.**
/// The planner put `requires` on the kernel step and the forge dropped it on
/// the floor: tunnr's built `kernel.why.toml` said `feature = 0,
/// preset_only = 69` while gunnar's installer had required CONFIG_SMP, VT
/// and PROC_FS under `appliance-boot`. The step carries the closure, and the
/// BuildStep → kernel build bridge lands it on the donor's row.
#[test]
fn the_kernel_steps_requires_carries_the_appliance_boot_closure_and_the_bridge_lands_it() {
let line = BuildLine::parse("--build kernel --opt dhcp=on --opt wireguard=off").unwrap();
let dag = BuildDag::derive(&Coordinate::new("gunnar", "gunnar-installer"), &line, &decls()).unwrap();
let step = &dag.steps[0];
assert_eq!(step.typ, ArtifactKind::Kernel);
// The step carries the SURFACE closure now; the Kconfig half is asked
// for by name, which is the whole cost of the second surface here.
assert_eq!(step.requires.as_ref().map(|c| c.typ()), Some(ArtifactKind::Kernel));
let r = step.kernel_requires().expect("the installer requires the appliance-boot contract of its kernel");
for sym in ["CONFIG_SMP", "CONFIG_VT", "CONFIG_PROC_FS"] {
let rows = r.rows.get(sym).unwrap_or_else(|| panic!("{sym} is required: {:?}", r.rows.keys().collect::<Vec<_>>()));
assert!(
rows.iter().any(|(v, q)| *v == crate::kernel_requires::KconfigValue::Y && q.feature == crate::kernel_requires::APPLIANCE_BOOT_FEATURE && q.consumer == "gunnar/gunnar-installer"),
"{sym}: {rows:?}"
);
}
// The bridge: the KernelBuild the runner builds from IS the chosen row.
let k = crate::kernel::kernel_build_of_step(&kernel_set("tunnr-kernel"), step).unwrap();
assert_eq!(k.requirements.as_ref(), Some(r), "apply_requirements landed the step's closure");
let set = crate::kernel::set_for_kernel_step(&kernel_set("tunnr-kernel"), step).unwrap();
assert_eq!(set.require_kernel("tunnr-kernel").unwrap().requirements.as_ref(), Some(r));
let fragment = crate::kernel::merged_fragment(&k, None).unwrap();
let why = crate::kernel_requires::WhyIndex::of(&fragment, r);
assert!(why.counts().0 > 0, "the why-index names feature rows once the closure is landed: {:?}", why.counts());
// A non-kernel step is refused by the bridge, by name.
let e = format!("{:#}", crate::kernel::kernel_build_of_step(&kernel_set("tunnr-kernel"), dag.target().unwrap()).unwrap_err());
assert!(e.contains("not a kernel step"), "{e}");
}
#[test]
fn the_dag_for_the_installer_prints_the_donor_first_then_the_installer() {
let line = BuildLine::parse("--build kernel { --opt unix=off } --opt dhcp=off").unwrap();
let dag = BuildDag::derive(&Coordinate::new("gunnar", "gunnar-installer"), &line, &decls()).unwrap();
assert_eq!(
dag.to_string(),
"dwarves buildthing tunnr --variant tunnr-kernel --opt kernel.unix=off\n\
dwarves buildthing gunnar --variant gunnar-installer --opt kernel.unix=off --opt dhcp=off"
);
let donor = &dag.steps[0];
assert_eq!(donor.typ, ArtifactKind::Kernel);
assert_eq!(donor.via, Some(Via { consumer: Coordinate::new("gunnar", "gunnar-installer"), path: "kernel".into() }));
assert_eq!(donor.args(), ["tunnr", "--variant", "tunnr-kernel", "--opt", "kernel.unix=off"]);
assert!(donor.pin.is_none(), "in-memory declarations carry no pin");
assert_eq!(dag.target().unwrap().typ, ArtifactKind::Installer);
assert_eq!(dag.donors().len(), 1);
// The same statement spelled flat gives the same DAG.
let flat = BuildLine::parse("--opt kernel.unix=off --opt dhcp=off --build tunnr").unwrap();
let dag2 = BuildDag::derive(&Coordinate::new("gunnar", "gunnar-installer"), &flat, &decls()).unwrap();
assert_eq!(dag2.steps[0], dag.steps[0]);
// Serializable, and back.
let json = serde_json::to_string(&dag).unwrap();
assert_eq!(serde_json::from_str::<BuildDag>(&json).unwrap(), dag);
}
#[test]
fn no_build_flag_means_a_one_step_dag_and_a_wrong_typ_is_refused() {
let line = BuildLine::parse("--opt dhcp=off").unwrap();
let dag = BuildDag::derive(&Coordinate::new("gunnar", "gunnar-installer"), &line, &decls()).unwrap();
assert_eq!(dag.steps.len(), 1);
assert_eq!(dag.to_string(), "dwarves buildthing gunnar --variant gunnar-installer --opt dhcp=off");
// The donor declares the variant as something else: refused by name.
let mut liar = BuildVariantSet::default();
liar.variants.push(BuildVariant { name: "tunnr-kernel".into(), kind: ArtifactKind::Exe, ..Default::default() });
let d = InMemoryDeclarations::default().with("gunnar", installer_set()).with("tunnr", liar);
let e = format!("{:#}", BuildDag::derive(&Coordinate::new("gunnar", "gunnar-installer"), &BuildLine::parse("--build kernel").unwrap(), &d).unwrap_err());
assert!(e.contains("as typ exe, not kernel"), "{e}");
// The donor declares no such variant.
let d = InMemoryDeclarations::default().with("gunnar", installer_set()).with("tunnr", kernel_set("other"));
let e = format!("{:#}", BuildDag::derive(&Coordinate::new("gunnar", "gunnar-installer"), &BuildLine::parse("--build kernel").unwrap(), &d).unwrap_err());
assert!(e.contains("declares no variant 'tunnr-kernel'") && e.contains("other"), "{e}");
}
/// a → b → c, three repos deep; then c → a closes a ring.
fn chain(with_cycle: bool) -> InMemoryDeclarations {
let mk = |name: &str, edge: Option<(&str, &str, &str)>| {
let mut set = BuildVariantSet::default();
let mut v = BuildVariant { name: name.into(), kind: ArtifactKind::Exe, open: ["fast".to_string()].into_iter().collect(), ..Default::default() };
if let Some((feature, repo, variant)) = edge {
v.edges.insert(feature.into(), EdgeDecl { repo: repo.into(), variant: variant.into(), typ: ArtifactKind::Exe, lazy: false });
}
set.variants.push(v);
set
};
InMemoryDeclarations::default()
.with("ra", mk("a", Some(("bee", "rb", "b"))))
.with("rb", mk("b", Some(("cee", "rc", "c"))))
.with("rc", mk("c", with_cycle.then_some(("ay", "ra", "a"))))
}
#[test]
fn a_three_deep_chain_orders_dependencies_first_with_nested_scopes() {
let line = BuildLine::parse("--build bee { --opt fast=on --opt cee.fast=off } --opt fast=on").unwrap();
let dag = BuildDag::derive(&Coordinate::new("ra", "a"), &line, &chain(false)).unwrap();
assert_eq!(
dag.lines(),
[
"dwarves buildthing rc --variant c --opt fast=off",
// b's line keeps ITS choice for what it consumes (`cee.fast=off`),
// exactly as the installer's line keeps `kernel.unix=off`: the
// consumer's pin check compares against the same choice.
"dwarves buildthing rb --variant b --opt fast=on --opt cee.fast=off",
"dwarves buildthing ra --variant a --opt bee.fast=on --opt bee.cee.fast=off --opt fast=on",
]
);
assert_eq!(dag.steps[0].via.as_ref().unwrap().path, "bee.cee");
assert_eq!(dag.steps[0].via.as_ref().unwrap().consumer, Coordinate::new("rb", "b"));
let d = dag.describe();
assert!(d.contains("# 1/3 rc/c : exe via rb/b:bee.cee"), "{d}");
}
/// **The bijection, over a whole build thing** (design f3 · f5's rule "a
/// declaration that cannot fail is not a declaration", one level up from
/// `CONFIG_TUN`).
///
/// `ra/a` consumes `rb/b`, and `rb/b` consumes `rc/c` — the edge nobody
/// typed a `--build` for, because a donor's own edges are followed for it.
/// Turning THAT feature off takes `rc/c` out of the plan; turning it on
/// puts it back, byte for byte the plan the untouched line derives. The
/// off-plan is not merely shorter: `rc/c` appears nowhere in it.
#[test]
fn a_donor_edge_whose_feature_is_off_leaves_the_plan_and_comes_back_on() {
let d = chain(false);
let target = Coordinate::new("ra", "a");
let plain = BuildDag::derive(&target, &BuildLine::parse("--build bee").unwrap(), &d).unwrap();
assert_eq!(
plain.steps.iter().map(|s| s.coordinate.to_string()).collect::<Vec<_>>(),
["rc/c", "rb/b", "ra/a"]
);
// OFF: the deepest donor is GONE — not skipped in the run, absent from
// the plan, with nothing left behind that names it.
let off = BuildDag::derive(&target, &BuildLine::parse("--build bee --opt bee.cee=off").unwrap(), &d).unwrap();
assert_eq!(off.steps.iter().map(|s| s.coordinate.to_string()).collect::<Vec<_>>(), ["rb/b", "ra/a"]);
assert!(!off.to_string().contains("rc"), "{off}");
// ON: back, in the same place, with the same line as the untouched plan.
let on = BuildDag::derive(&target, &BuildLine::parse("--build bee --opt bee.cee=on").unwrap(), &d).unwrap();
assert_eq!(on.steps.iter().map(|s| s.coordinate.to_string()).collect::<Vec<_>>(), ["rc/c", "rb/b", "ra/a"]);
assert_eq!(on.steps[0].coordinate, plain.steps[0].coordinate);
assert_eq!(on.steps[0].opts, plain.steps[0].opts);
assert_eq!(on.steps[0].via, plain.steps[0].via);
// And the same word on the line the operator TYPED a `--build` for is a
// contradiction, so it is refused by name rather than half-obeyed.
let e = format!("{:#}", BuildDag::derive(&target, &BuildLine::parse("--build bee --opt bee=off").unwrap(), &d).unwrap_err());
assert!(e.contains("--build bee") && e.contains("is OFF on this line") && e.contains("rb/b"), "{e}");
}
/// **A general edge of a typ nobody has a surface for** — holger's
/// `mvp-server-musl` (`[[build.variant]]`, no `kind` → typ `exe`) consumed
/// by a feature called `registry`. The edge is not the kernel's and not a
/// container's, and everything still holds: the donor is a step of its own,
/// it comes FIRST, its line carries the flags scoped under the feature with
/// the donor's own root spelling, and naming it as another typ is refused
/// at DECLARATION time, before a plan exists.
#[test]
fn a_general_edge_onto_an_exe_donor_plans_donor_first_and_a_wrong_typ_is_refused_at_declaration() {
use crate::tree::ConsumptionEdge;
let mut holger = BuildVariantSet::default();
holger.variants.push(BuildVariant {
name: "mvp-server-musl".into(),
package: Some("holger-server-cli".to_string()),
bin: Some("holger-mvp".to_string()),
features: ["mvp-server".to_string(), "appliance".to_string()].into_iter().collect(),
target: Some("x86_64-unknown-linux-musl".to_string()),
open: ["tls".to_string()].into_iter().collect(),
..Default::default()
});
let mut consumer = BuildVariantSet::default();
let mut app = BuildVariant { name: "front".into(), kind: ArtifactKind::Exe, ..Default::default() };
app.edges.insert(
"registry".into(),
EdgeDecl { repo: "holger".into(), variant: "mvp-server-musl".into(), typ: ArtifactKind::Exe, lazy: false },
);
consumer.variants.push(app);
// The contract shape, whole: the feature sits BESIDE the coordinate.
let edges = consumer.consumption_edges("front").expect("a declared variant");
assert_eq!(
edges,
vec![ConsumptionEdge {
feature: "registry".into(),
repo: "holger".into(),
variant: "mvp-server-musl".into(),
typ: "exe".into(),
lazy: false,
}]
);
assert_eq!(edges[0].coordinate(), "holger/mvp-server-musl");
assert_eq!(edges[0].kind(), ArtifactKind::Exe);
assert_eq!(edges[0].edge(), Edge::new("holger", "mvp-server-musl", ArtifactKind::Exe));
// …and it is the same list the pair-shaped reader gets.
assert_eq!(consumer.edges_of("front").unwrap(), vec![("registry".to_string(), edges[0].decl())]);
let decls = InMemoryDeclarations::default().with("vetra", consumer.clone()).with("holger", holger.clone());
let line = BuildLine::parse("--build registry { --opt tls=on }").unwrap();
let dag = BuildDag::derive(&Coordinate::new("vetra", "front"), &line, &decls).unwrap();
assert_eq!(
dag.lines(),
[
"dwarves buildthing holger --variant mvp-server-musl --opt tls=on",
"dwarves buildthing vetra --variant front --opt registry.tls=on",
]
);
assert_eq!(dag.donors().len(), 1);
assert_eq!(dag.donors()[0].typ, ArtifactKind::Exe);
assert!(dag.donors()[0].pin.is_none(), "an exe has no surface yet, and saying so beats inventing one");
// DECLARATION TIME: the consumer calls it a container, holger builds an
// exe — refused when the declaration is read, naming consumer, feature,
// coordinate and both typs.
let dir = tempfile::tempdir().unwrap();
let holger_root = dir.path().join("holger");
std::fs::create_dir_all(holger_root.join(".nornir")).unwrap();
std::fs::write(
holger_root.join(".nornir/build-variants.toml"),
toml::to_string(&crate::variant::BuildConfig::emitting(&holger)).unwrap(),
)
.unwrap();
let resolve = |repo: &str| -> Result<PathBuf> {
if repo == "holger" { Ok(holger_root.clone()) } else { bail!("no checkout '{repo}'") }
};
consumer.validate_edges_with("vetra", &resolve).expect("exe ↔ exe agrees");
let mut liar = consumer.clone();
liar.variants[0].edges.get_mut("registry").unwrap().typ = ArtifactKind::Container;
let e = format!("{:#}", liar.validate_edges_with("vetra", &resolve).unwrap_err());
assert!(e.contains("exe 'front': edge 'registry' → holger/mvp-server-musl : container"), "{e}");
assert!(e.contains("'holger' declares 'mvp-server-musl' as typ exe, not container"), "{e}");
// …and the plan is never reached: the load already said no. Asked
// anyway, the walker refuses in the very same words.
let liar_decls = InMemoryDeclarations::default().with("vetra", liar).with("holger", holger);
let e2 = format!(
"{:#}",
BuildDag::derive(&Coordinate::new("vetra", "front"), &BuildLine::parse("--build registry").unwrap(), &liar_decls).unwrap_err()
);
assert!(e2.contains("'holger' declares 'mvp-server-musl' as typ exe, not container"), "{e2}");
}
#[test]
fn a_cycle_is_refused_naming_the_ring() {
let line = BuildLine::parse("--build bee").unwrap();
let e = format!("{:#}", BuildDag::derive(&Coordinate::new("ra", "a"), &line, &chain(true)).unwrap_err());
assert!(e.contains("CYCLE") && e.contains("ra/a → rb/b → rc/c → ra/a"), "{e}");
}
#[test]
fn a_coordinate_reached_twice_must_be_asked_the_same_way() {
// a consumes b twice, through two edges, with different scoped opts.
let mut set = BuildVariantSet::default();
let mut v = BuildVariant { name: "a".into(), kind: ArtifactKind::Exe, ..Default::default() };
v.edges.insert("one".into(), EdgeDecl { repo: "rb".into(), variant: "b".into(), typ: ArtifactKind::Exe, lazy: false });
v.edges.insert("two".into(), EdgeDecl { repo: "rb".into(), variant: "b".into(), typ: ArtifactKind::Exe, lazy: false });
set.variants.push(v);
let mut bset = BuildVariantSet::default();
bset.variants.push(BuildVariant { name: "b".into(), kind: ArtifactKind::Exe, open: ["fast".to_string()].into_iter().collect(), ..Default::default() });
let d = InMemoryDeclarations::default().with("ra", set).with("rb", bset);
let same = BuildLine::parse("--build one --build two").unwrap();
let dag = BuildDag::derive(&Coordinate::new("ra", "a"), &same, &d).unwrap();
assert_eq!(dag.steps.len(), 2, "one build of rb/b, not two");
let differ = BuildLine::parse("--build one { --opt fast=on } --build two { --opt fast=off }").unwrap();
let e = format!("{:#}", BuildDag::derive(&Coordinate::new("ra", "a"), &differ, &d).unwrap_err());
assert!(e.contains("reached twice with DIFFERENT lines"), "{e}");
}
#[test]
fn from_args_takes_the_raw_door_arguments() {
let l = BuildLine::from_args(&["dhcp=off"], &["static_ip=10.0.0.9/24"], &["kernel { --opt unix=off }"]).unwrap();
assert_eq!(l.render(), "--opt dhcp=off --param static_ip=10.0.0.9/24 --build kernel --opt kernel.unix=off");
}
/// **THE SECOND SURFACE, end to end.** A three-level chain —
/// `gunnar/gunnar-installer` → `tunnr/tunnr-kernel` → `tunnr/kernel-toolchain`
/// — in which the middle step is a CONSUMER as well as a donor, and the
/// requirement it places is not a Kconfig symbol at all.
///
/// Everything on the way is the machinery Kconfig already goes through, and
/// nothing on the way asks what typ it is holding: `closure_of_consumer`
/// collects it (`ToolchainTools`), `visit` carries it (the closure, not
/// `as_kconfig()`), the coordinate's union joins it, and `step_pin_for`
/// gives it a pin over its own canonical text.
///
/// Seen red in three places, each by construction:
/// * the tool the image's recipe does not carry — refused BY NAME, with the
/// consumer, the feature and the measured reason;
/// * the row's own axis taken away (`solver = ""`) — the requirement is GONE
/// and the pin MOVES, which is what makes it a declaration and not a habit;
/// * `BuildStep::requires` typed as the surface it is: `kernel_requires()`
/// on the container step is `None`, and so is `tool_requires()` on the
/// kernel step.
#[test]
fn a_kernel_is_a_consumer_too_and_its_toolchain_image_goes_through_the_same_closure() {
let dir = std::env::temp_dir().join(format!("af-toolchain-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("containerfiles")).unwrap();
std::fs::write(
dir.join("containerfiles/kernel-toolchain.Containerfile"),
crate::kernel::KERNEL_TOOLCHAIN_CONTAINERFILE,
)
.unwrap();
let mut tunnr = kernel_set("tunnr-kernel");
tunnr.kernels[0].solver = "kmax".into();
tunnr.kernels[0].edges.insert(
"toolchain".into(),
EdgeDecl { repo: "tunnr".into(), variant: crate::kernel::KERNEL_TOOLCHAIN_IMAGE.into(), typ: ArtifactKind::Container, lazy: false },
);
tunnr.containers.push(crate::kernel::kernel_toolchain_container("containerfiles/kernel-toolchain.Containerfile"));
let decls = InMemoryDeclarations::default()
.with("gunnar", installer_set())
.with("tunnr", tunnr.clone())
.rooted_at("tunnr", &dir);
let line = BuildLine::parse("--build kernel").unwrap();
let dag = BuildDag::derive(&Coordinate::new("gunnar", "gunnar-installer"), &line, &decls).unwrap();
// Post-order: the toolchain image the kernel consumes comes FIRST.
let names: Vec<String> = dag.steps.iter().map(|s| s.coordinate.to_string()).collect();
assert_eq!(
names,
["tunnr/nordisk-kernel-toolchain", "tunnr/tunnr-kernel", "gunnar/gunnar-installer"],
"the donor's own edge was followed transitively"
);
let tc = &dag.steps[0];
assert_eq!(tc.typ, ArtifactKind::Container);
let tools = tc.tool_requires().expect("the kernel row requires tools of its toolchain image");
assert!(tc.kernel_requires().is_none(), "a container step carries no Kconfig closure");
assert!(dag.steps[1].tool_requires().is_none(), "and a kernel step carries no tool closure");
// The requirers are named, per STEP of the kernel build.
assert_eq!(tools.rows["gcc"][0].1.consumer, "tunnr/tunnr-kernel");
assert_eq!(tools.rows["gcc"][0].1.feature, crate::toolchain_requires::COMPILE_FEATURE);
assert_eq!(tools.rows["kmax"][0].1.feature, crate::toolchain_requires::MINIFY_FEATURE);
assert!(tools.summary().contains("tunnr/tunnr-kernel: kernel.compile"), "{}", tools.summary());
// …and the step got a PIN, over its own canonical text.
let pin = tc.pin.as_ref().expect("a container step whose consumers require something pins to the closure");
assert_eq!(pin.fragment_sha256, nornir_hash::sha256_hex(tools.canonical().as_bytes()));
assert_eq!(pin.preset, crate::kernel::KERNEL_TOOLCHAIN_IMAGE);
assert!(pin.ledger_key.is_empty(), "the toolchain surface has no ledger; saying so beats inventing one");
// The why-index, beside the artifact: every tool the image carries is
// `feature` or `image-only`, and the partition holds.
let recipe = crate::kernel::KERNEL_TOOLCHAIN_CONTAINERFILE;
let mut image = tunnr.containers[0].clone();
image.apply_tool_requirements(tools, recipe).expect("the nordisk image carries every tool this row asks for");
let everything: Vec<String> = crate::kernel::kernel_toolchain_tools().into_iter().map(|(t, _)| t).collect();
let why = image.tool_why_index(recipe, &everything);
let (feature, image_only) = why.counts();
// Six compile + three minify + two probe: this row declares `gcc` and
// no `lto`, so the LLVM road's two tools are in the image and NOBODY on
// this row asks for them — `image-only`, the twin of `preset-only`, and
// read the same way: a question (who needs the LTO road?), not a verdict.
assert_eq!(feature, 11, "compile + minify + probe: {:?}", why.rows.iter().map(|r| &r.tool).collect::<Vec<_>>());
assert_eq!(why.image_only(), ["clang", "lld"], "in the image, asked for by nobody on THIS row");
assert_eq!(image_only, 2);
assert!(why.to_toml().contains("class = \"image-only\""));
// RED 1: a tool the image's recipe does not carry, refused by name.
let mut wants_more = tools.clone();
wants_more.require(
"tunnr/tunnr-kernel",
"kernel.sign",
&crate::toolchain_requires::ToolReq::present("sbsigntool", "the bzImage is Secure-Boot signed inside the container"),
);
let e = format!("{:#}", tunnr.containers[0].clone().apply_tool_requirements(&wants_more, recipe).unwrap_err());
assert!(
e.contains("sbsigntool") && e.contains("tunnr/tunnr-kernel:kernel.sign") && e.contains("Secure-Boot") && e.contains("nordisk-kernel-toolchain"),
"{e}"
);
// RED 2: take the row's own axis away and the requirement is GONE — and
// the pin moves with it, which is the difference between a declaration
// and a decoration.
let mut no_solver = tunnr.clone();
no_solver.kernels[0].solver = String::new();
let decls2 = InMemoryDeclarations::default().with("gunnar", installer_set()).with("tunnr", no_solver).rooted_at("tunnr", &dir);
let dag2 = BuildDag::derive(&Coordinate::new("gunnar", "gunnar-installer"), &line, &decls2).unwrap();
let t2 = dag2.steps[0].tool_requires().expect("the compile step still needs its tools");
assert!(!t2.rows.contains_key("kmax"), "no solver asks nothing of kmax: {:?}", t2.rows.keys().collect::<Vec<_>>());
assert_ne!(dag2.steps[0].pin, dag.steps[0].pin, "a different closure is a different pin");
let _ = std::fs::remove_dir_all(&dir);
}
}