Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

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, &params)?;
    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);
    }
}