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:?}"
);
}
}