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/toolchain_requires.rs

//! **The TOOLCHAIN-IMAGE surface — the second sub build-thing a consumer can
//! place requirements on** (lane AF, 2026-08-30, the owner: *"the kernel is just
//! ONE sub build-thing"*).
//!
//! [`crate::requires`] states the engine as generic over surfaces and names
//! three that do not exist yet: a toolchain image, a container base, a
//! firmware. This is the first of them, and it was picked because the
//! requirement was ALREADY WRITTEN — as a flat `const` nobody owned:
//!
//! ```text
//! pub const KERNEL_TOOLCHAIN_TOOLS: &[(&str, &str)] = &[
//!     ("gcc", "compile"), … ("clang", "LTO/DCE"), … ("kmax", "minify"),
//!     ("qemu-system-x86", "probe-boot"), …
//! ];
//! ```
//!
//! Every line of it is a requirement with a reason, and the test over it
//! (`the_toolchain_image_carries_every_step_s_tool`) is a closure check written
//! by hand: it asserts the Containerfile names each tool AND names the step
//! beside it. What it could not do is what a closure does — the list is
//! UNCONDITIONAL, so a `toolchain = "gcc"` kernel that runs no LTO pass still
//! demanded `clang` and `lld`, and a kernel that never probe-boots still
//! demanded `qemu-system-x86`. Nobody could ask *why* a tool was in the image,
//! and no consumer's own axes could take one out. That is exactly the
//! `preset-only` condition [`crate::kernel_requires`] closed for Kconfig, one
//! surface over.
//!
//! # The shape, mirrored from the Kconfig surface
//!
//! | Kconfig | toolchain |
//! |---|---|
//! | [`KconfigReq`](crate::kernel_requires::KconfigReq) `symbol = value, because` | [`ToolReq`] `tool present\|absent, because` |
//! | [`KernelRequirements`](crate::kernel_requires::KernelRequirements) | [`ToolRequirements`] |
//! | [`Contradiction`](crate::kernel_requires::Contradiction) | [`ToolContradiction`] |
//! | [`WhyIndex`](crate::kernel_requires::WhyIndex) → `kernel.why.toml` | [`ToolWhyIndex`] → [`TOOLCHAIN_WHY_FILE`] |
//! | `preset-only` (a fragment line nobody asked for) | `image-only` (a package the Containerfile installs that nobody asked for) |
//! | [`KernelBuild::apply_requirements`](crate::kernel::KernelBuild::apply_requirements) | [`ContainerBuild::apply_tool_requirements`](crate::container::ContainerBuild::apply_tool_requirements) |
//!
//! The engine is untouched by all of it: [`crate::requires::Requirement`] grows
//! a variant, [`crate::requires::SurfaceClosure`] grows a variant, and the
//! source ([`crate::requires::ToolchainTools`]) joins `default_sources`. The
//! union, the refusal, the recursion in [`crate::build_dag::BuildDag::visit`]
//! and the pin are the ones Kconfig already goes through.
//!
//! # Who declares against it
//!
//! The KERNEL row, which is a consumer as well as a donor — that is the sentence
//! the generic engine makes sayable. `tunnr-kernel` consumes
//! `tunnr/nordisk-kernel-toolchain : container` over the edge `toolchain`, and
//! [`kernel_tool_requirements`] places one requirement per STEP the row actually
//! runs, under that step's own feature name:
//!
//! | feature | tools | when |
//! |---|---|---|
//! | `kernel.compile` | `gcc` `make` `flex` `bison` `libelf-dev` `libssl-dev` | always |
//! | `kernel.lto` | `clang` `lld` | `toolchain = "clang"` or `lto = true` |
//! | `kernel.minify` | `python3` `python3-dev` `kmax` | `solver` names one |
//! | `kernel.probe-boot` | `qemu-system-x86` `cpio` | the row probe-boots |
//!
//! so `toolchain.why.toml` answers "why is clang in this image?" with the row
//! that asked, and an image carrying a tool nothing asks for is `image-only` —
//! a nuke candidate BY NAME, which is the whole point of an inverted index.

use std::collections::BTreeMap;
use std::fmt;

use anyhow::{bail, Result};
use serde::{Deserialize, Serialize};

/// The file the inverted index is written to, beside the built image.
pub const TOOLCHAIN_WHY_FILE: &str = "toolchain.why.toml";

/// **One requirement a feature places on a toolchain image** — a tool that must
/// be IN it (or, for the negative law, must not be), and the measured why.
///
/// `tool` is the name as the image's Containerfile spells it: an apt package
/// (`libelf-dev`), a binary (`qemu-system-x86`) or a pip/venv install (`kmax`).
/// One name, because the image is asked ONE question — does your recipe name
/// this? — and inventing a second vocabulary (package vs binary) would be a
/// second model of the same fact.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolReq {
    /// The tool, as the image names it.
    pub tool: String,
    /// `true` = the image must carry it; `false` = must NOT (the negative law:
    /// a build that must stay airgapped can require `curl` absent).
    #[serde(default = "yes")]
    pub present: bool,
    /// Why — the sentence the inverted index prints.
    #[serde(default)]
    pub reason: String,
}

fn yes() -> bool {
    true
}

impl ToolReq {
    /// The image must carry `tool`, with its why.
    pub fn present(tool: &str, reason: &str) -> Self {
        ToolReq { tool: tool.into(), present: true, reason: reason.into() }
    }
    /// The image must NOT carry `tool`, with its why.
    pub fn absent(tool: &str, reason: &str) -> Self {
        ToolReq { tool: tool.into(), present: false, reason: reason.into() }
    }
    /// A declaration-time check. A tool name with whitespace in it is a line
    /// that was meant to be two rows, and an empty one is a requirement on
    /// nothing — both are the shape a Containerfile scan would silently "find".
    pub fn validate(&self, who: &str) -> Result<()> {
        let t = self.tool.trim();
        if t.is_empty() || t.chars().any(char::is_whitespace) {
            bail!("{who}: toolchain requires tool {:?}, which is not one tool name", self.tool);
        }
        Ok(())
    }
    /// The wire spelling of the wanted state.
    pub fn state(&self) -> &'static str {
        if self.present { "present" } else { "absent" }
    }
}

/// **The union of every tool requirement the plan collected** — tool → the
/// `(present, requirer)` rows. The twin of
/// [`KernelRequirements`](crate::kernel_requires::KernelRequirements), and the
/// requirer type is the SAME one: a requirer is a requirer whatever surface it
/// speaks to.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolRequirements {
    /// Tool → every requirement placed on it (several requirers may agree).
    pub rows: BTreeMap<String, Vec<(bool, crate::kernel_requires::Requirer)>>,
}

/// A refusal: one tool, two answers.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolContradiction {
    /// The tool.
    pub tool: String,
    /// What the requirer wants and who.
    pub wanted: (bool, crate::kernel_requires::Requirer),
    /// What stands against it: `(present, by)` — another requirer's
    /// `consumer:feature`, or the image layer (`the image's Containerfile`).
    pub against: (bool, String),
}

impl fmt::Display for ToolContradiction {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "{} must be {} for {}:{} ({}) but {} says {}",
            self.tool,
            if self.wanted.0 { "present" } else { "absent" },
            self.wanted.1.consumer,
            self.wanted.1.feature,
            self.wanted.1.reason,
            self.against.1,
            if self.against.0 { "present" } else { "absent" }
        )
    }
}

impl ToolRequirements {
    /// Nothing required.
    pub fn is_empty(&self) -> bool {
        self.rows.is_empty()
    }

    /// Place one requirement.
    pub fn require(&mut self, consumer: &str, feature: &str, req: &ToolReq) {
        let r = crate::kernel_requires::Requirer {
            consumer: consumer.into(),
            feature: feature.into(),
            reason: req.reason.clone(),
        };
        let rows = self.rows.entry(req.tool.clone()).or_default();
        if !rows.iter().any(|(p, q)| *p == req.present && *q == r) {
            rows.push((req.present, r));
        }
    }

    /// Place every requirement of one feature.
    pub fn require_all(&mut self, consumer: &str, feature: &str, reqs: &[ToolReq]) {
        for r in reqs {
            self.require(consumer, feature, r);
        }
    }

    /// Union another consumer's requirements in (the recursive walk's join).
    pub fn union(&mut self, other: &ToolRequirements) {
        for (tool, rows) in &other.rows {
            let mine = self.rows.entry(tool.clone()).or_default();
            for row in rows {
                if !mine.contains(row) {
                    mine.push(row.clone());
                }
            }
        }
    }

    /// Distinct `(consumer, feature)` pairs that required anything.
    pub fn features(&self) -> Vec<(String, String)> {
        let mut out: Vec<(String, String)> = Vec::new();
        for rows in self.rows.values() {
            for (_, r) in rows {
                let k = (r.consumer.clone(), r.feature.clone());
                if !out.contains(&k) {
                    out.push(k);
                }
            }
        }
        out.sort();
        out
    }

    /// **Requirer against requirer**: one tool, two answers.
    pub fn conflicts(&self) -> Vec<ToolContradiction> {
        let mut out = Vec::new();
        for (tool, rows) in &self.rows {
            let Some(first) = rows.first() else { continue };
            for other in rows.iter().skip(1) {
                if other.0 != first.0 {
                    out.push(ToolContradiction {
                        tool: tool.clone(),
                        wanted: other.clone(),
                        against: (first.0, format!("{}:{}", first.1.consumer, first.1.feature)),
                    });
                }
            }
        }
        out
    }

    /// **Requirer against the IMAGE**: every requirement the image's recipe does
    /// not honour. `provides` is what the image names (see
    /// [`ContainerBuild::provides_tools`](crate::container::ContainerBuild::provides_tools));
    /// `layer` names it for the refusal.
    pub fn contradictions(&self, provides: &[String], layer: &str) -> Vec<ToolContradiction> {
        let mut out = Vec::new();
        for (tool, rows) in &self.rows {
            let has = provides.iter().any(|p| p == tool);
            for row in rows {
                if row.0 != has {
                    out.push(ToolContradiction { tool: tool.clone(), wanted: row.clone(), against: (has, layer.to_string()) });
                }
            }
        }
        out
    }

    /// The step-log sentence.
    pub fn summary(&self) -> String {
        let feats = self.features();
        let mut by: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
        for (c, f) in &feats {
            by.entry(c.as_str()).or_default().push(f.as_str());
        }
        format!(
            "{} tool(s) required by {} feature(s) ({})",
            self.rows.len(),
            feats.len(),
            by.iter().map(|(c, fs)| format!("{c}: {}", fs.join(" · "))).collect::<Vec<_>>().join("; ")
        )
    }

    /// The canonical text this closure pins to — one `tool present|absent` line
    /// per tool, sorted. The container step's pin is a sha over this, exactly as
    /// a kernel step's pin is a sha over the merged fragment.
    pub fn canonical(&self) -> String {
        let mut s = String::new();
        for (tool, rows) in &self.rows {
            if let Some((present, _)) = rows.first() {
                s.push_str(tool);
                s.push(' ');
                s.push_str(if *present { "present" } else { "absent" });
                s.push('\n');
            }
        }
        s
    }
}

// ── the inverted index ───────────────────────────────────────────────────────

/// Which class a tool of the image belongs to. The twin of
/// [`WhyClass`](crate::kernel_requires::WhyClass), one class shorter: an image
/// has no `kconfig-derived` — nothing in a Containerfile appears by inference.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", tag = "class")]
pub enum ToolWhyClass {
    /// One or more features required it — the rows say who.
    Feature {
        /// Everyone who asked.
        requirers: Vec<crate::kernel_requires::Requirer>,
    },
    /// The image's recipe installs it and no feature asked: a nuke candidate.
    ImageOnly,
}

/// One row of [`TOOLCHAIN_WHY_FILE`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolWhyRow {
    /// The tool.
    pub tool: String,
    /// Whether the image carries it.
    pub present: bool,
    /// Who / why.
    #[serde(flatten)]
    pub class: ToolWhyClass,
}

/// **tool → why** over what an image provides, joined with what was required.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolWhyIndex {
    /// The rows, by tool.
    pub rows: Vec<ToolWhyRow>,
    /// Required tools the image does NOT provide (empty = green).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub missing: Vec<String>,
}

impl ToolWhyIndex {
    /// The picture over what the image PROVIDES: every tool it names is
    /// `feature` or `image-only`, and every required-and-missing tool is listed
    /// under `missing` — the refusal, kept beside the artifact rather than only
    /// in a log line.
    pub fn of(provides: &[String], reqs: &ToolRequirements) -> Self {
        let mut rows: Vec<ToolWhyRow> = Vec::new();
        for tool in provides {
            let class = match reqs.rows.get(tool) {
                Some(r) if r.iter().any(|(p, _)| *p) => {
                    ToolWhyClass::Feature { requirers: r.iter().filter(|(p, _)| *p).map(|(_, q)| q.clone()).collect() }
                }
                _ => ToolWhyClass::ImageOnly,
            };
            rows.push(ToolWhyRow { tool: tool.clone(), present: true, class });
        }
        // A requirement the image does not provide is still a row — an index
        // that only listed what is there could never say what is missing.
        let mut missing = Vec::new();
        for (tool, r) in &reqs.rows {
            let has = provides.iter().any(|p| p == tool);
            let wants_present = r.iter().any(|(p, _)| *p);
            if !has && wants_present {
                missing.push(format!(
                    "{tool} ← {}",
                    r.iter().filter(|(p, _)| *p).map(|(_, q)| format!("{}:{}", q.consumer, q.feature)).collect::<Vec<_>>().join(", ")
                ));
                rows.push(ToolWhyRow {
                    tool: tool.clone(),
                    present: false,
                    class: ToolWhyClass::Feature { requirers: r.iter().filter(|(p, _)| *p).map(|(_, q)| q.clone()).collect() },
                });
            }
        }
        rows.sort_by(|a, b| a.tool.cmp(&b.tool));
        ToolWhyIndex { rows, missing }
    }

    /// Counts per class: `(feature, image_only)`.
    pub fn counts(&self) -> (usize, usize) {
        let mut c = (0, 0);
        for r in &self.rows {
            match r.class {
                ToolWhyClass::Feature { .. } => c.0 += 1,
                ToolWhyClass::ImageOnly => c.1 += 1,
            }
        }
        c
    }

    /// The `image-only` tools — installed by the recipe, wanted by no feature:
    /// the nuke candidates, by name. Read it as a QUESTION, exactly as
    /// `preset-only` is read: it means UNDECLARED, not unused.
    pub fn image_only(&self) -> Vec<&str> {
        self.rows.iter().filter(|r| r.class == ToolWhyClass::ImageOnly).map(|r| r.tool.as_str()).collect()
    }

    /// **The bijection over the image layer**: `feature ∪ image-only` is exactly
    /// what the image provides, and no tool is in both. `Err` names the tool
    /// that breaks it.
    pub fn provides_is_partitioned(&self, provides: &[String]) -> Result<()> {
        let mut seen = std::collections::BTreeSet::new();
        for r in self.rows.iter().filter(|r| r.present) {
            if !seen.insert(r.tool.as_str()) {
                bail!("{} appears twice in the toolchain why-index", r.tool);
            }
            if !provides.iter().any(|p| p == &r.tool) {
                bail!("{} is in the why-index as present but the image does not provide it", r.tool);
            }
        }
        for p in provides {
            if !seen.contains(p.as_str()) {
                bail!("{p} is provided by the image but the why-index has no row for it");
            }
        }
        Ok(())
    }

    /// [`TOOLCHAIN_WHY_FILE`] — the file a human reads beside the image.
    pub fn to_toml(&self) -> String {
        let (f, i) = self.counts();
        let mut s = String::new();
        s.push_str("# toolchain.why.toml — tool → who required it. Emitted by nornir-build-thing::toolchain_requires.\n");
        s.push_str("# class = \"feature\": a consumer's feature asked (requirers say who and why)\n");
        s.push_str("# class = \"image-only\": the Containerfile installs it, no feature asked → a candidate to nuke\n");
        s.push_str(&format!("[summary]\nfeature = {f}\nimage_only = {i}\n"));
        if !self.missing.is_empty() {
            s.push_str("missing = [\n");
            for m in &self.missing {
                s.push_str(&format!("  {},\n", crate::kernel_requires::toml_str(m)));
            }
            s.push_str("]\n");
        }
        s.push('\n');
        for r in &self.rows {
            s.push_str("[[tool]]\n");
            s.push_str(&format!("name = {}\npresent = {}\n", crate::kernel_requires::toml_str(&r.tool), r.present));
            match &r.class {
                ToolWhyClass::Feature { requirers } => {
                    s.push_str("class = \"feature\"\n");
                    s.push_str(&format!("requirers = {}\n", crate::kernel_requires::toml_requirers(requirers)));
                }
                ToolWhyClass::ImageOnly => s.push_str("class = \"image-only\"\n"),
            }
            s.push('\n');
        }
        s
    }
}

// ── the contract the KERNEL kind itself places ───────────────────────────────

/// The feature a compile-step tool lands under.
pub const COMPILE_FEATURE: &str = "kernel.compile";
/// The feature the LTO/DCE pass's tools land under.
pub const LTO_FEATURE: &str = "kernel.lto";
/// The feature the minify (kmax/klocalizer) pass's tools land under.
pub const MINIFY_FEATURE: &str = "kernel.minify";
/// The feature the probe-boot's tools land under.
pub const PROBE_FEATURE: &str = "kernel.probe-boot";

/// **What a kernel row needs of the toolchain image behind its `toolchain`
/// edge**, per STEP, conditional on the row's own axes.
///
/// This is `kernel::KERNEL_TOOLCHAIN_TOOLS` turned from a flat const into a
/// closure, and the difference is the whole lane: the const demanded `clang`,
/// `lld`, `kmax` and `qemu-system-x86` of every image whatever the row did with
/// them, and no `toolchain.why.toml` could say who wanted a tool. Here a
/// `toolchain = "gcc"` row with no solver and no probe asks for six tools; the
/// full row asks for thirteen; and each answer names the step.
///
/// One writer: `KERNEL_TOOLCHAIN_TOOLS` is DERIVED from this (the full row's
/// answer), so the two cannot drift and the old const's test still holds.
pub fn kernel_tool_requirements(k: &crate::kernel::KernelBuild) -> Vec<(String, ToolReq)> {
    let mut out: Vec<(String, ToolReq)> = Vec::new();
    let mut add = |feature: &str, tool: &str, why: &str| out.push((feature.to_string(), ToolReq::present(tool, why)));

    // ── compile: unconditional, because a kernel row that does not compile is
    // not a kernel row.
    add(COMPILE_FEATURE, "gcc", "the kernel's default compiler — `make bzImage` invokes it unless LLVM=1 says otherwise");
    add(COMPILE_FEATURE, "make", "the kernel's build system IS make; there is no second driver");
    add(COMPILE_FEATURE, "flex", "kconfig is a language and the kernel builds its parser from source at every configure");
    add(COMPILE_FEATURE, "bison", "the other half of that parser (kconfig's grammar)");
    add(COMPILE_FEATURE, "libelf-dev", "BTF/pahole reads the built objects' ELF; scripts/ needs libelf to link");
    add(COMPILE_FEATURE, "libssl-dev", "the certs the build signs module/system keys with (CONFIG_MODULE_SIG, cert paths)");

    // ── LTO/DCE: only when the row actually asks for the LLVM road.
    if k.toolchain.trim().eq_ignore_ascii_case("clang") || k.lto {
        add(LTO_FEATURE, "clang", "this row builds with LLVM=1 (toolchain = clang / lto = true) — CONFIG_LTO_CLANG_FULL exists only there");
        add(LTO_FEATURE, "lld", "the LLVM linker the LTO build links through; ld.bfd cannot consume clang's bitcode objects");
    }

    // ── minify: only when a solver is named.
    if !k.solver.trim().is_empty() {
        add(MINIFY_FEATURE, "python3", "kmax and klocalizer are python + z3");
        add(MINIFY_FEATURE, "python3-dev", "kmax builds kextractor (the kernel's own kconfig parser) from C against the python headers");
        add(MINIFY_FEATURE, "kmax", "klocalizer + kclause: the SAT pass over the Kconfig DAG that produces the minimal .config");
    }

    // ── probe-boot: only when the row actually boots the candidate. The
    // timeout IS the switch — `probe_timeout_secs = 0` is "do not probe", the
    // same way `mem_limit_mb = 0` would be "unbounded" and is refused.
    if k.probe_timeout_secs > 0 {
        add(PROBE_FEATURE, "qemu-system-x86", "the candidate kernel boots HERE, inside the toolchain container, never on the host");
        add(PROBE_FEATURE, "cpio", "the probe's initramfs is a cpio archive");
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::kernel_requires::Requirer;

    fn who(feature: &str) -> Requirer {
        Requirer { consumer: "tunnr/tunnr-kernel".into(), feature: feature.into(), reason: "x".into() }
    }

    /// **The closure follows the ROW's axes** — the thing a flat const could
    /// never do. A gcc row with no solver and no probe asks for the six compile
    /// tools and nothing else; turning each axis on adds exactly that step's
    /// tools, under that step's feature name.
    #[test]
    fn a_kernel_rows_tool_requirements_follow_its_own_axes() {
        let plain = crate::kernel::KernelBuild {
            name: "k".into(),
            toolchain: "gcc".into(),
            lto: false,
            solver: String::new(),
            probe_timeout_secs: 0,
            ..Default::default()
        };
        let rows = kernel_tool_requirements(&plain);
        let tools: Vec<&str> = rows.iter().map(|(_, r)| r.tool.as_str()).collect();
        assert_eq!(tools, ["gcc", "make", "flex", "bison", "libelf-dev", "libssl-dev"], "a gcc row asks for no clang and no qemu");
        assert!(rows.iter().all(|(f, _)| f == COMPILE_FEATURE));

        let mut lto = plain.clone();
        lto.toolchain = "clang".into();
        lto.lto = true;
        let lto_rows = kernel_tool_requirements(&lto);
        let t: Vec<&str> = lto_rows.iter().map(|(_, r)| r.tool.as_str()).collect();
        assert!(t.contains(&"clang") && t.contains(&"lld"), "{t:?}");
        assert!(!t.contains(&"kmax"), "an LTO row still runs no solver: {t:?}");

        let mut full = lto.clone();
        full.solver = "kmax".into();
        full.probe_timeout_secs = 600;
        let rows = kernel_tool_requirements(&full);
        let by_feature: Vec<&str> = rows.iter().filter(|(f, _)| f == PROBE_FEATURE).map(|(_, r)| r.tool.as_str()).collect();
        assert_eq!(by_feature, ["qemu-system-x86", "cpio"]);
        assert_eq!(rows.len(), 13, "six compile, two lto, three minify, two probe");
        for (_, r) in &rows {
            r.validate("tunnr/tunnr-kernel").unwrap();
        }
    }

    /// **Union, conflict and the refusal that names tool · consumer · feature ·
    /// reason** — the same three properties the Kconfig surface has, on the
    /// surface that had none.
    #[test]
    fn two_requirers_agree_disagree_and_an_image_that_lacks_a_tool_is_refused_by_name() {
        let mut r = ToolRequirements::default();
        r.require("tunnr/tunnr-kernel", COMPILE_FEATURE, &ToolReq::present("gcc", "make bzImage invokes it"));
        r.require("gunnar/gunnar-server", "musl", &ToolReq::present("gcc", "the musl target links through it"));
        assert_eq!(r.rows["gcc"].len(), 2, "two requirers of one tool is the UNION, not a conflict");
        assert!(r.conflicts().is_empty());
        assert!(r.summary().contains("tunnr/tunnr-kernel: kernel.compile") && r.summary().contains("gunnar/gunnar-server: musl"), "{}", r.summary());

        // RED 1: requirer against requirer.
        let mut d = r.clone();
        d.require("holger/airgap", "no-network", &ToolReq::absent("gcc", "an airgapped image compiles nothing"));
        let c = d.conflicts();
        assert_eq!(c.len(), 1, "{c:?}");
        let text = c[0].to_string();
        assert!(text.contains("gcc") && text.contains("holger/airgap:no-network") && text.contains("tunnr/tunnr-kernel:kernel.compile"), "{text}");

        // RED 2: the IMAGE does not carry a required tool — named with the
        // requirer and its reason, never a bare "missing dependency".
        let provides = vec!["make".to_string(), "gcc".to_string()];
        let mut q = ToolRequirements::default();
        q.require("tunnr/tunnr-kernel", PROBE_FEATURE, &ToolReq::present("qemu-system-x86", "the candidate boots inside the container"));
        let c = q.contradictions(&provides, "the image's Containerfile");
        assert_eq!(c.len(), 1);
        let text = c[0].to_string();
        assert!(
            text.contains("qemu-system-x86") && text.contains("tunnr/tunnr-kernel:kernel.probe-boot") && text.contains("the candidate boots inside the container") && text.contains("Containerfile"),
            "{text}"
        );
        // …and the same closure over an image that DOES carry it is green.
        let mut ok = provides.clone();
        ok.push("qemu-system-x86".into());
        assert!(q.contradictions(&ok, "the image's Containerfile").is_empty());
        // The negative law: a tool the image carries that a feature forbids.
        let mut n = ToolRequirements::default();
        n.require("holger/airgap", "no-network", &ToolReq::absent("curl", "an airgapped build fetches nothing"));
        assert!(n.contradictions(&["curl".to_string()], "the image's Containerfile").len() == 1);
        assert!(n.contradictions(&["make".to_string()], "the image's Containerfile").is_empty());
    }

    /// **The inverted index partitions what the image provides, and names the
    /// nuke candidates** — the twin of the Kconfig why-index's own law.
    #[test]
    fn the_why_index_partitions_the_image_and_names_the_image_only_tools() {
        let mut r = ToolRequirements::default();
        r.require("tunnr/tunnr-kernel", COMPILE_FEATURE, &ToolReq::present("gcc", "make bzImage invokes it"));
        r.require("tunnr/tunnr-kernel", COMPILE_FEATURE, &ToolReq::present("make", "the kernel's build system"));
        let provides = vec!["gcc".to_string(), "make".to_string(), "clang".to_string(), "vim".to_string()];
        let why = ToolWhyIndex::of(&provides, &r);
        why.provides_is_partitioned(&provides).unwrap();
        assert_eq!(why.counts(), (2, 2));
        assert_eq!(why.image_only(), ["clang", "vim"], "installed, wanted by nobody — the question, not the answer");
        assert!(why.missing.is_empty());

        // A required tool the image does not carry appears as `missing` AND as a
        // row, so the file beside the artifact carries the refusal too.
        let mut q = r.clone();
        q.require("tunnr/tunnr-kernel", PROBE_FEATURE, &ToolReq::present("qemu-system-x86", "the probe boots here"));
        let why = ToolWhyIndex::of(&provides, &q);
        assert_eq!(why.missing.len(), 1);
        assert!(why.missing[0].starts_with("qemu-system-x86 ← tunnr/tunnr-kernel:kernel.probe-boot"), "{:?}", why.missing);
        assert!(why.provides_is_partitioned(&provides).is_ok(), "a missing row is not present, so it is not in the partition");

        let toml = why.to_toml();
        assert!(toml.contains("[[tool]]\nname = \"gcc\"\npresent = true\nclass = \"feature\""), "{toml}");
        assert!(toml.contains("class = \"image-only\""), "{toml}");
        assert!(toml.contains("missing = ["), "{toml}");
        // Two steps asking for one tool: ONE `requirers` array, and the file is
        // TOML (2026-10-29: it used to repeat the key, a duplicate to a parser).
        let mut two = q.clone();
        two.require("tunnr/tunnr-kernel", PROBE_FEATURE, &ToolReq::present("gcc", "the probe's init is compiled here"));
        let two = ToolWhyIndex::of(&provides, &two).to_toml();
        let parsed: ::toml::Value = ::toml::from_str(&two).unwrap_or_else(|e| panic!("toolchain.why.toml is not TOML: {e}\n{two}"));
        let gcc = parsed["tool"].as_array().unwrap().iter().find(|t| t["name"].as_str() == Some("gcc")).unwrap();
        assert_eq!(gcc["requirers"].as_array().unwrap().len(), 2, "{two}");

        // RED, by construction: an index that claims a tool the image does not
        // provide, and an image tool the index forgot.
        let mut bad = why.clone();
        bad.rows.push(ToolWhyRow { tool: "zsh".into(), present: true, class: ToolWhyClass::ImageOnly });
        assert!(bad.provides_is_partitioned(&provides).unwrap_err().to_string().contains("zsh"));
        let short = ToolWhyIndex::of(&provides[..2], &r);
        assert!(short.provides_is_partitioned(&provides).unwrap_err().to_string().contains("clang"));
    }

    /// The pin's text: one line per tool, sorted, so a closure that says the
    /// same thing pins to the same bytes.
    #[test]
    fn the_canonical_text_is_the_thing_a_container_step_pins_to() {
        let mut a = ToolRequirements::default();
        a.require_all("tunnr/tunnr-kernel", COMPILE_FEATURE, &[ToolReq::present("make", "x"), ToolReq::present("gcc", "y")]);
        let mut b = ToolRequirements::default();
        b.require("tunnr/tunnr-kernel", COMPILE_FEATURE, &ToolReq::present("gcc", "a DIFFERENT reason, same fact"));
        b.require("holger/h", "other", &ToolReq::present("make", "and another requirer"));
        assert_eq!(a.canonical(), "gcc present\nmake present\n");
        assert_eq!(a.canonical(), b.canonical(), "the pin is over the FACTS, not over who said them");
        let mut n = a.clone();
        n.rows.insert("curl".into(), vec![(false, who("no-network"))]);
        assert!(n.canonical().starts_with("curl absent\n"), "{}", n.canonical());
    }
}