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

//! **Which source files a variant actually compiles.**
//!
//! The graph answered "what does this build depend on". It did not answer the question
//! that has cost this constellation the most: *what code is actually IN this build?*
//!
//! Every expensive bug of 2026-07-22 was a feature nobody built. `dupes-fat`,
//! `coverage-fat` and `mimir-fat` were named by no variant, so three complete
//! subsystems shipped dead. `gui` sat in holger's `offers` with nothing enabling it,
//! so `cargo build --all-targets` skipped the UI *silently*. `scip` compiled no
//! writer. In every case the code was on disk, looked finished, and was excluded from
//! the binary by a `cfg` nobody was looking at. A picture of which files a variant
//! **excludes** is the fastest way to see that.
//!
//! # Three states, never two
//!
//! ```text
//! Ungated     — always compiled; no cfg gate on it
//! CompiledIn  — gated, and THIS variant enables the gate
//! GatedOut    — gated, and THIS variant does not
//! ```
//!
//! `GatedOut` must never be collapsed with "file not found" — that conflation is the
//! recurring bug of this whole session (an inapplicable axis read as an absent one, an
//! unmeasured egress read as clean, an undeclared feature space read as complete). A
//! file the scanner never saw is **not** a file this variant excludes, and
//! [`GatingIndex::scanned`] says which situation you are in.
//!
//! # Why this is host-side
//!
//! A build thing runs in a wasm sandbox with no filesystem — deliberately, that is the
//! whole security posture. It cannot walk a source tree, so it cannot compute this.
//! The scan therefore runs on the HOST and [`augment_graph`] merges the result into
//! the graph the plugin returned. The plugin still owns what a build IS; the host adds
//! what it could not see.
//!
//! # What this is not
//!
//! Not a `cfg` evaluator. It reads `#[cfg(feature = "…")]` / `#[cfg(any(...))]` /
//! `#[cfg(all(...))]` / `#[cfg(not(...))]` at **file and module granularity** plus
//! Cargo's own `required-features`, which is where the silent-skip rot actually lives.
//! Expression forms it cannot decide are reported as [`Gate::Unresolved`] rather than
//! guessed — an undecided gate that claimed to be decided would be the same lie in a
//! new place.

use std::collections::BTreeSet;
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

/// A parsed `cfg` gate, reduced to the features it turns on.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "gate", rename_all = "kebab-case")]
pub enum Gate {
    /// `#[cfg(feature = "x")]` — needs exactly this feature.
    Feature(String),
    /// `#[cfg(any(feature = "a", feature = "b"))]` — any one of them.
    Any(Vec<String>),
    /// `#[cfg(all(feature = "a", feature = "b"))]` — all of them.
    All(Vec<String>),
    /// `#[cfg(not(feature = "x"))]` — compiled only when the feature is OFF.
    Not(String),
    /// Cargo `[[bin]] required-features` — the gate that makes
    /// `cargo build --all-targets` skip a target **in silence**.
    RequiredFeatures(Vec<String>),
    /// A `cfg` this scanner will not pretend to decide (a target/os predicate, a
    /// nested form it does not parse). Reported, never guessed.
    Unresolved(String),
}

impl Gate {
    /// Every feature named by the gate.
    pub fn features(&self) -> Vec<&str> {
        match self {
            Gate::Feature(f) | Gate::Not(f) => vec![f.as_str()],
            Gate::Any(v) | Gate::All(v) | Gate::RequiredFeatures(v) => {
                v.iter().map(|s| s.as_str()).collect()
            }
            Gate::Unresolved(_) => Vec::new(),
        }
    }
    /// How to render it.
    pub fn label(&self) -> String {
        match self {
            Gate::Feature(f) => format!("feature = \"{f}\""),
            Gate::Any(v) => format!("any({})", v.join(", ")),
            Gate::All(v) => format!("all({})", v.join(", ")),
            Gate::Not(f) => format!("not(feature = \"{f}\")"),
            Gate::RequiredFeatures(v) => format!("required-features = [{}]", v.join(", ")),
            Gate::Unresolved(s) => format!("cfg({s}) — NOT DECIDED"),
        }
    }
    /// Whether `enabled` satisfies the gate. `None` = this scanner will not say.
    pub fn satisfied_by(&self, enabled: &BTreeSet<String>) -> Option<bool> {
        match self {
            Gate::Feature(f) => Some(enabled.contains(f)),
            Gate::Any(v) => Some(v.iter().any(|f| enabled.contains(f))),
            Gate::All(v) | Gate::RequiredFeatures(v) => Some(v.iter().all(|f| enabled.contains(f))),
            Gate::Not(f) => Some(!enabled.contains(f)),
            Gate::Unresolved(_) => None,
        }
    }
}

/// Whether a file is in this variant's compilation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Inclusion {
    /// No gate — always compiled.
    Ungated,
    /// Gated, and this variant enables it.
    CompiledIn,
    /// Gated, and this variant does NOT enable it. **This code is not in the binary.**
    GatedOut,
    /// The gate is one this scanner will not decide. Distinct from both — pretending
    /// otherwise is how a wrong answer gets a confident colour.
    Undecided,
}

impl Inclusion {
    /// Stable label.
    pub fn as_str(self) -> &'static str {
        match self {
            Inclusion::Ungated => "ungated",
            Inclusion::CompiledIn => "compiled-in",
            Inclusion::GatedOut => "gated-out",
            Inclusion::Undecided => "undecided",
        }
    }
    /// True when the file's code really is in this build.
    pub fn is_in(self) -> bool {
        matches!(self, Inclusion::Ungated | Inclusion::CompiledIn)
    }
}

/// One source file (or cargo target) and the gate on it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GatedFile {
    /// Repo-relative path.
    pub path: String,
    /// The gate found on it.
    pub gate: Gate,
    /// Bytes on disk — so "how much code does this variant exclude" is answerable.
    #[serde(default)]
    pub bytes: u64,
}

/// The scan of one repo: which files carry a gate, and — the honesty field — whether
/// the scan happened at all.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct GatingIndex {
    /// The repo root scanned.
    pub root: String,
    /// **Whether a scan ran.** `false` ⇒ every file is UNKNOWN, not ungated. Without
    /// this an unscanned repo would render as "nothing is gated here", which is the
    /// clean-bill-of-health lie in a new costume.
    pub scanned: bool,
    /// Files carrying a gate.
    pub files: Vec<GatedFile>,
    /// How many source files were looked at (the denominator).
    pub files_seen: usize,
}

impl GatingIndex {
    /// Every feature any gate mentions, sorted.
    pub fn features(&self) -> Vec<String> {
        let mut s: BTreeSet<String> = BTreeSet::new();
        for f in &self.files {
            for feat in f.gate.features() {
                s.insert(feat.to_string());
            }
        }
        s.into_iter().collect()
    }

    /// Classify every gated file against the feature set a variant enables.
    pub fn classify(&self, enabled: &BTreeSet<String>) -> Vec<(GatedFile, Inclusion)> {
        self.files
            .iter()
            .map(|f| {
                let inc = match f.gate.satisfied_by(enabled) {
                    Some(true) => Inclusion::CompiledIn,
                    Some(false) => Inclusion::GatedOut,
                    None => Inclusion::Undecided,
                };
                (f.clone(), inc)
            })
            .collect()
    }

    /// `(compiled_in, gated_out, undecided)` counts, plus the bytes excluded.
    pub fn tally(&self, enabled: &BTreeSet<String>) -> GatingTally {
        let mut t = GatingTally { files_seen: self.files_seen, scanned: self.scanned, ..Default::default() };
        for (f, inc) in self.classify(enabled) {
            match inc {
                Inclusion::CompiledIn => t.compiled_in += 1,
                Inclusion::GatedOut => {
                    t.gated_out += 1;
                    t.bytes_excluded += f.bytes;
                }
                Inclusion::Undecided => t.undecided += 1,
                Inclusion::Ungated => {}
            }
        }
        t
    }

    /// The one honest line a pane leads with.
    pub fn headline(&self, enabled: &BTreeSet<String>) -> String {
        if !self.scanned {
            return "source gating NOT SCANNED — no file is known to be gated, which is \
                    not the same as nothing being gated"
                .to_string();
        }
        let t = self.tally(enabled);
        let mut s = format!(
            "{} gated file(s) of {} scanned — {} compiled IN, {} GATED OUT",
            self.files.len(),
            t.files_seen,
            t.compiled_in,
            t.gated_out
        );
        if t.bytes_excluded > 0 {
            s.push_str(&format!(" ({} excluded)", human_kb(t.bytes_excluded)));
        }
        if t.undecided > 0 {
            s.push_str(&format!(" · {} gate(s) NOT DECIDED", t.undecided));
        }
        s
    }
}

/// The counts a pane shows.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct GatingTally {
    /// Files carrying a gate this variant satisfies.
    pub compiled_in: usize,
    /// Files carrying a gate this variant does NOT satisfy — code not in the binary.
    pub gated_out: usize,
    /// Gates the scanner declined to decide.
    pub undecided: usize,
    /// Source files looked at.
    pub files_seen: usize,
    /// Bytes of source excluded from this build.
    pub bytes_excluded: u64,
    /// Whether a scan ran at all.
    pub scanned: bool,
}

fn human_kb(n: u64) -> String {
    if n < 1024 {
        format!("{n} B")
    } else if n < 1024 * 1024 {
        format!("{:.1} KB", n as f64 / 1024.0)
    } else {
        format!("{:.1} MB", n as f64 / (1024.0 * 1024.0))
    }
}

// ── the scan ─────────────────────────────────────────────────────────────────

/// Scan a repo for feature-gated source files and `required-features` targets.
///
/// Walks `src/` under the repo root and each workspace member, reading only the first
/// [`HEAD_LINES`] lines of each `.rs` file — a file-level `#![cfg(...)]` or a leading
/// `#[cfg(...)] mod x;` is where file-granularity gating actually lives, and reading
/// whole trees of a 400 MB checkout to find it would make the pane unusable.
pub fn scan(repo_root: &Path) -> GatingIndex {
    let mut idx =
        GatingIndex { root: repo_root.display().to_string(), scanned: false, ..Default::default() };
    if !repo_root.is_dir() {
        return idx;
    }
    idx.scanned = true;

    // ① Cargo `required-features` — the gate that skips a target IN SILENCE, and the
    // one that cost five binaries today. Read before any source file, because it is
    // the highest-value finding in the whole scan.
    for manifest in manifests(repo_root) {
        let Ok(text) = std::fs::read_to_string(&manifest) else { continue };
        let Ok(doc) = toml::from_str::<toml::Value>(&text) else { continue };
        for table in ["bin", "example", "test", "bench"] {
            let Some(arr) = doc.get(table).and_then(|b| b.as_array()) else { continue };
            for t in arr {
                let Some(req) = t.get("required-features").and_then(|r| r.as_array()) else {
                    continue;
                };
                let feats: Vec<String> =
                    req.iter().filter_map(|f| f.as_str().map(|s| s.to_string())).collect();
                if feats.is_empty() {
                    continue;
                }
                let name = t.get("name").and_then(|n| n.as_str()).unwrap_or("?");
                let path = t
                    .get("path")
                    .and_then(|p| p.as_str())
                    .map(|p| {
                        rel(repo_root, &manifest.parent().unwrap_or(repo_root).join(p))
                    })
                    .unwrap_or_else(|| format!("[[{table}]] {name}"));
                let bytes = manifest
                    .parent()
                    .map(|d| d.join(t.get("path").and_then(|p| p.as_str()).unwrap_or("")))
                    .and_then(|p| std::fs::metadata(p).ok())
                    .map(|m| m.len())
                    .unwrap_or(0);
                idx.files.push(GatedFile {
                    path,
                    gate: Gate::RequiredFeatures(feats),
                    bytes,
                });
            }
        }
    }

    // ② source files carrying a leading cfg.
    for dir in source_dirs(repo_root) {
        walk(&dir, &mut |p| {
            if p.extension().and_then(|e| e.to_str()) != Some("rs") {
                return;
            }
            idx.files_seen += 1;
            let Ok(text) = std::fs::read_to_string(p) else { return };
            let bytes = text.len() as u64;
            if let Some(gate) = leading_gate(&text) {
                idx.files.push(GatedFile { path: rel(repo_root, p), gate, bytes });
            }
        });
    }
    idx.files.sort_by(|a, b| a.path.cmp(&b.path));
    idx.files.dedup_by(|a, b| a.path == b.path);
    idx
}

/// How many lines of a file the scanner reads looking for a gate.
pub const HEAD_LINES: usize = 40;

/// The first `cfg` gate in a file's head, if any.
fn leading_gate(text: &str) -> Option<Gate> {
    for line in text.lines().take(HEAD_LINES) {
        let l = line.trim();
        // `#![cfg(...)]` (whole file) or `#[cfg(...)]` immediately preceding an item.
        let inner = l
            .strip_prefix("#![cfg(")
            .or_else(|| l.strip_prefix("#[cfg("))?
            .strip_suffix(")]")?;
        return Some(parse_cfg(inner));
    }
    None
}

/// Parse a `cfg(...)` body into a [`Gate`]. Forms it cannot decide become
/// [`Gate::Unresolved`] — never a guess.
pub fn parse_cfg(body: &str) -> Gate {
    let b = body.trim();
    if let Some(rest) = b.strip_prefix("not(") {
        if let Some(inner) = rest.strip_suffix(')') {
            if let Some(f) = feature_of(inner) {
                return Gate::Not(f);
            }
        }
        return Gate::Unresolved(b.to_string());
    }
    for (kw, ctor) in [
        ("any(", Gate::Any as fn(Vec<String>) -> Gate),
        ("all(", Gate::All as fn(Vec<String>) -> Gate),
    ] {
        if let Some(rest) = b.strip_prefix(kw) {
            if let Some(inner) = rest.strip_suffix(')') {
                let feats: Vec<String> = inner.split(',').filter_map(feature_of).collect();
                // Only decide when EVERY term was a feature; a mixed `any(unix,
                // feature="x")` is not something to answer confidently.
                if !feats.is_empty() && feats.len() == inner.split(',').count() {
                    return ctor(feats);
                }
            }
            return Gate::Unresolved(b.to_string());
        }
    }
    match feature_of(b) {
        Some(f) => Gate::Feature(f),
        None => Gate::Unresolved(b.to_string()),
    }
}

/// `feature = "x"` → `x`.
fn feature_of(s: &str) -> Option<String> {
    let s = s.trim();
    let rest = s.strip_prefix("feature")?.trim_start().strip_prefix('=')?.trim();
    let rest = rest.strip_prefix('"')?;
    let end = rest.find('"')?;
    Some(rest[..end].to_string())
}

fn rel(root: &Path, p: &Path) -> String {
    p.strip_prefix(root).unwrap_or(p).display().to_string()
}

/// Manifests worth reading: the root and one level of workspace members.
fn manifests(root: &Path) -> Vec<PathBuf> {
    let mut out = vec![root.join("Cargo.toml")];
    if let Ok(text) = std::fs::read_to_string(root.join("Cargo.toml")) {
        if let Ok(doc) = toml::from_str::<toml::Value>(&text) {
            if let Some(members) =
                doc.get("workspace").and_then(|w| w.get("members")).and_then(|m| m.as_array())
            {
                for m in members.iter().filter_map(|m| m.as_str()) {
                    if let Some(prefix) = m.strip_suffix("/*") {
                        if let Ok(rd) = std::fs::read_dir(root.join(prefix)) {
                            for e in rd.flatten() {
                                out.push(e.path().join("Cargo.toml"));
                            }
                        }
                    } else {
                        out.push(root.join(m).join("Cargo.toml"));
                    }
                }
            }
        }
    }
    out.retain(|p| p.is_file());
    out
}

/// `src/` under the root and under each member.
fn source_dirs(root: &Path) -> Vec<PathBuf> {
    manifests(root)
        .into_iter()
        .filter_map(|m| m.parent().map(|d| d.join("src")))
        .filter(|d| d.is_dir())
        .collect()
}

/// Recursive walk, skipping `target/` and dotfiles.
fn walk(dir: &Path, f: &mut impl FnMut(&Path)) {
    let Ok(rd) = std::fs::read_dir(dir) else { return };
    for e in rd.flatten() {
        let p = e.path();
        let name = e.file_name();
        let name = name.to_string_lossy();
        if name.starts_with('.') || name == "target" {
            continue;
        }
        if p.is_dir() {
            walk(&p, f);
        } else {
            f(&p);
        }
    }
}

// ── merging into the graph ───────────────────────────────────────────────────

/// **Add the source-file layer to a graph the plugin returned.**
///
/// The plugin cannot do this — it has no filesystem, by design — so the host merges
/// it in. One `SourceFile` node per gated file, and a `Gates` edge from the variant to
/// it. The [`Inclusion`] rides on the node so a renderer can colour "in this build"
/// against "excluded from this build" without re-deriving anything.
///
/// `max_files` caps the merge: a graph of two thousand nodes is not a picture. The
/// cap keeps GATED-OUT files first, because they are the finding.
pub fn augment_graph(
    graph: &mut crate::plugin::BuildGraph,
    idx: &GatingIndex,
    enabled: &BTreeSet<String>,
    max_files: usize,
) {
    use crate::plugin::{EdgeKind, GraphEdge, GraphNode, NodeKind};
    if graph.root.is_empty() || !idx.scanned {
        return;
    }
    let root_id = format!("variant:{}", graph.root);
    if !graph.nodes.iter().any(|n| n.id == root_id) {
        // No root to hang them from — adding file nodes would produce dangling edges,
        // which `dangling_edges()` exists to make impossible.
        return;
    }
    let mut classified = idx.classify(enabled);
    // Gated-OUT first: that is the finding, and a cap must not hide it.
    classified.sort_by_key(|(f, inc)| {
        (
            match inc {
                Inclusion::GatedOut => 0,
                Inclusion::Undecided => 1,
                Inclusion::CompiledIn => 2,
                Inclusion::Ungated => 3,
            },
            std::cmp::Reverse(f.bytes),
        )
    });
    for (f, inc) in classified.into_iter().take(max_files) {
        let id = format!("file:{}", f.path);
        graph.nodes.push(GraphNode {
            id: id.clone(),
            label: f.path.rsplit('/').next().unwrap_or(&f.path).to_string(),
            kind: NodeKind::SourceFile,
            detail: Some(format!("{} — {} [{}]", f.path, f.gate.label(), inc.as_str())),
            selects: None,
        });
        graph.edges.push(GraphEdge {
            from: root_id.clone(),
            to: id,
            kind: EdgeKind::Gates,
            label: Some(inc.as_str().to_string()),
        });
    }
}

/// The features a variant enables, as a set — what [`classify`](GatingIndex::classify)
/// takes.
pub fn enabled_features(v: &crate::variant::BuildVariant) -> BTreeSet<String> {
    v.features.iter().cloned().collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::BTreeMap;

    fn feats(f: &[&str]) -> BTreeSet<String> {
        f.iter().map(|s| s.to_string()).collect()
    }

    /// The scan, run against the REAL constellation. Opt-in — it walks whole
    /// checkouts — but it is how the claim "this finds the rot" was checked rather
    /// than asserted:
    ///
    /// ```text
    /// cargo test -p nornir-build-thing gating::tests::real -- --nocapture --ignored
    /// ```
    #[test]
    #[ignore = "walks real checkouts; run with --nocapture for the report"]
    fn real_constellation_gating_report() {
        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .parent()
            .and_then(|p| p.parent())
            .and_then(|p| p.parent())
            .unwrap()
            .to_path_buf();
        for repo in ["dwarves", "holger", "korp", "nornir"] {
            let r = root.join(repo);
            if !r.is_dir() {
                continue;
            }
            let idx = scan(&r);
            println!("\n=== {repo} ===");
            println!("  {}", idx.headline(&BTreeSet::new()));
            let req: Vec<&GatedFile> = idx
                .files
                .iter()
                .filter(|f| matches!(f.gate, Gate::RequiredFeatures(_)))
                .collect();
            for f in req.iter().take(8) {
                println!("  required-features: {} — {}", f.path, f.gate.label());
            }
            println!("  features named by gates: {:?}", idx.features());
        }
    }

    #[test]
    fn cfg_forms_parse_and_undecidable_ones_say_so() {
        assert_eq!(parse_cfg(r#"feature = "gui""#), Gate::Feature("gui".into()));
        assert_eq!(
            parse_cfg(r#"any(feature = "a", feature = "b")"#),
            Gate::Any(vec!["a".into(), "b".into()])
        );
        assert_eq!(
            parse_cfg(r#"all(feature = "a", feature = "b")"#),
            Gate::All(vec!["a".into(), "b".into()])
        );
        assert_eq!(parse_cfg(r#"not(feature = "x")"#), Gate::Not("x".into()));
        // A target predicate is NOT a feature gate, and is not pretended to be one.
        assert!(matches!(parse_cfg("target_arch = \"wasm32\""), Gate::Unresolved(_)));
        // A mixed `any` is not answered confidently.
        assert!(matches!(parse_cfg(r#"any(unix, feature = "x")"#), Gate::Unresolved(_)));
        assert!(parse_cfg("target_os = \"linux\"").label().contains("NOT DECIDED"));
    }

    /// **The three states, kept apart.** This is the whole point of the module.
    #[test]
    fn compiled_in_gated_out_and_undecided_are_three_different_answers() {
        let idx = GatingIndex {
            root: "/x".into(),
            scanned: true,
            files_seen: 10,
            files: vec![
                GatedFile { path: "src/gui.rs".into(), gate: Gate::Feature("gui".into()), bytes: 4000 },
                GatedFile { path: "src/scip.rs".into(), gate: Gate::Feature("scip".into()), bytes: 9000 },
                GatedFile {
                    path: "src/wasm.rs".into(),
                    gate: Gate::Unresolved("target_arch = \"wasm32\"".into()),
                    bytes: 100,
                },
            ],
        };
        let enabled = feats(&["gui"]);
        let by_path: BTreeMap<String, Inclusion> =
            idx.classify(&enabled).into_iter().map(|(f, i)| (f.path, i)).collect();
        assert_eq!(by_path["src/gui.rs"], Inclusion::CompiledIn);
        assert_eq!(by_path["src/scip.rs"], Inclusion::GatedOut);
        assert_eq!(by_path["src/wasm.rs"], Inclusion::Undecided);
        assert!(Inclusion::CompiledIn.is_in() && !Inclusion::GatedOut.is_in());

        let t = idx.tally(&enabled);
        assert_eq!((t.compiled_in, t.gated_out, t.undecided), (1, 1, 1));
        assert_eq!(t.bytes_excluded, 9000, "the excluded code is measured, not guessed");
        assert!(idx.headline(&enabled).contains("1 GATED OUT"), "{}", idx.headline(&enabled));
    }

    /// **An unscanned repo is not an ungated one.** The conflation this session keeps
    /// paying for, refused once more.
    #[test]
    fn an_unscanned_repo_never_reads_as_nothing_gated() {
        let idx = GatingIndex::default();
        assert!(!idx.scanned);
        let h = idx.headline(&feats(&[]));
        assert!(h.contains("NOT SCANNED"), "{h}");
        assert!(
            !h.contains("0 GATED OUT"),
            "an unscanned repo must not report a count as if it had looked: {h}"
        );
    }

    /// `required-features` is the gate that skips a target in silence — the exact rot
    /// that hid five binaries today. It is found, and it is a first-class gate.
    #[test]
    fn required_features_targets_are_found_and_are_the_headline_rot() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        std::fs::write(
            root.join("Cargo.toml"),
            "[package]\nname=\"x\"\nversion=\"0.0.0\"\n\
             [[bin]]\nname=\"dwarves-server\"\npath=\"src/bin/s.rs\"\nrequired-features=[\"server\"]\n\
             [[bin]]\nname=\"plain\"\npath=\"src/main.rs\"\n",
        )
        .unwrap();
        std::fs::create_dir_all(root.join("src/bin")).unwrap();
        std::fs::write(root.join("src/main.rs"), "fn main(){}\n").unwrap();
        std::fs::write(root.join("src/bin/s.rs"), "fn main(){}\n").unwrap();
        std::fs::write(root.join("src/gui.rs"), "#![cfg(feature = \"gui\")]\npub fn a(){}\n")
            .unwrap();

        let idx = scan(root);
        assert!(idx.scanned);
        let req: Vec<&GatedFile> =
            idx.files.iter().filter(|f| matches!(f.gate, Gate::RequiredFeatures(_))).collect();
        assert_eq!(req.len(), 1, "the plain bin carries no gate: {:?}", idx.files);
        assert!(req[0].gate.label().contains("server"));
        // …and a variant without `server` shows it as GATED OUT, not as absent.
        let out: Vec<_> = idx
            .classify(&feats(&["gui"]))
            .into_iter()
            .filter(|(_, i)| *i == Inclusion::GatedOut)
            .collect();
        assert!(
            out.iter().any(|(f, _)| f.gate.label().contains("server")),
            "the silently-skipped target is visibly excluded: {out:?}"
        );
        // The source-level gate was found too.
        assert!(idx.files.iter().any(|f| f.path.ends_with("gui.rs")));
        assert!(idx.features().contains(&"gui".to_string()));
        assert!(idx.files_seen >= 3);
    }

    /// Merging into a graph keeps the honesty properties: no dangling edges, and a
    /// graph with no root is left alone rather than given orphan nodes.
    #[test]
    fn augmenting_a_graph_never_creates_a_dangling_edge() {
        use crate::plugin::{BuildGraph, EdgeKind, GraphNode, NodeKind};
        let idx = GatingIndex {
            root: "/x".into(),
            scanned: true,
            files_seen: 2,
            files: vec![GatedFile {
                path: "src/gui.rs".into(),
                gate: Gate::Feature("gui".into()),
                bytes: 10,
            }],
        };
        let mut g = BuildGraph {
            repo: "holger".into(),
            root: "mini".into(),
            nodes: vec![GraphNode {
                id: "variant:mini".into(),
                label: "mini".into(),
                kind: NodeKind::Variant,
                detail: None,
                selects: Some("mini".into()),
            }],
            edges: Vec::new(),
        };
        augment_graph(&mut g, &idx, &feats(&["mini"]), 50);
        assert!(g.dangling_edges().is_empty(), "{:?}", g.dangling_edges());
        assert_eq!(g.edges_of(EdgeKind::Gates).len(), 1);
        let file = g.nodes.iter().find(|n| n.kind == NodeKind::SourceFile).unwrap();
        assert_eq!(file.label, "gui.rs");
        assert!(file.detail.as_deref().unwrap().contains("gated-out"), "{file:?}");

        // A graph with no root node gets NOTHING rather than orphans.
        let mut empty = BuildGraph { repo: "x".into(), root: "ghost".into(), ..Default::default() };
        augment_graph(&mut empty, &idx, &feats(&[]), 50);
        assert!(empty.nodes.is_empty() && empty.edges.is_empty());
    }

    /// The cap keeps the FINDING, not the first N alphabetically.
    #[test]
    fn the_file_cap_keeps_the_gated_out_ones() {
        use crate::plugin::{BuildGraph, GraphNode, NodeKind};
        let mut files: Vec<GatedFile> = (0..30)
            .map(|i| GatedFile {
                path: format!("src/in{i}.rs"),
                gate: Gate::Feature("on".into()),
                bytes: 1,
            })
            .collect();
        files.push(GatedFile {
            path: "src/zzz_excluded.rs".into(),
            gate: Gate::Feature("off".into()),
            bytes: 5000,
        });
        let idx = GatingIndex { root: "/x".into(), scanned: true, files_seen: 31, files };
        let mut g = BuildGraph {
            repo: "x".into(),
            root: "v".into(),
            nodes: vec![GraphNode {
                id: "variant:v".into(),
                label: "v".into(),
                kind: NodeKind::Variant,
                detail: None,
                selects: None,
            }],
            edges: Vec::new(),
        };
        augment_graph(&mut g, &idx, &feats(&["on"]), 3);
        let labels: Vec<&str> = g
            .nodes
            .iter()
            .filter(|n| n.kind == NodeKind::SourceFile)
            .map(|n| n.label.as_str())
            .collect();
        assert_eq!(labels.len(), 3);
        assert!(
            labels.contains(&"zzz_excluded.rs"),
            "the cap must keep the EXCLUDED file — it is the finding: {labels:?}"
        );
    }
}