crates/nornir-release-core/src/edition.rs
//! `release gate edition` — Rust **2024** / cargo-standards compliance.
//!
//! "Is this crate OK and following the current Rust/Cargo standard?" answered at
//! release time. Two layers:
//!
//! * **static** (cheap, via `cargo_metadata`): every PUBLISHABLE package declares
//! `edition = "2024"` and an MSRV `rust-version >= 1.85` (edition 2024's floor),
//! and the workspace sets `resolver = "3"` (the 2024 default).
//! * **dynamic** (via a `cargo` subprocess): force the `rust-2024-compatibility`
//! lint group to warn and `cargo check --message-format=json`; any diagnostic
//! that renders a "Rust 2024 / 2024 edition" note is a real incompatibility.
//!
//! There is NO stable cargo library API — the supported integration is exactly
//! this: `cargo_metadata` for structure + the `cargo` CLI with JSON messages for
//! lints. The pure analyzers below are unit-tested; the thin runner shells out.
use std::path::Path;
use std::process::Command;
use anyhow::{Context, Result};
use serde::Serialize;
/// Edition 2024 requires Rust 1.85+.
const EDITION_MSRV: (u64, u64) = (1, 85);
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct EditionFinding {
/// Package name, or `[workspace]` for the resolver finding.
pub package: String,
pub issue: String,
}
/// One package's edition-relevant facts, lifted out of `cargo metadata` so the
/// classifier stays pure.
#[derive(Debug, Clone)]
pub struct PkgEdition {
pub name: String,
pub edition: String,
/// Declared `rust-version` (MSRV), if any, as a version string.
pub rust_version: Option<String>,
/// `false` only when `publish = false` (those crates never ship, so they don't
/// gate a release).
pub publishable: bool,
}
/// `Default` = the historical CLEAN report: every vec empty and `lint_pass_ran = false`,
/// so `EditionReport::default().is_clean()` is `true`. It exists so a FUTURE field
/// addition can't break a consumer's literal construction — fallback/fixture sites (here
/// and in downstream crates like nornir) spread `..Default::default()` and pick up the new
/// field for free. The `default_report_is_clean` test locks this semantics down.
#[derive(Debug, Clone, Default, Serialize)]
pub struct EditionReport {
pub static_findings: Vec<EditionFinding>,
/// Rendered `rust-2024-compatibility` diagnostics from `cargo check`.
pub lints: Vec<String>,
/// `true` when the dynamic lint pass actually ran (cargo invoked + parsed).
pub lint_pass_ran: bool,
/// P4 — ADVISORY notes: a resolved dependency whose own `rust-version` (MSRV) EXCEEDS
/// the workspace's declared MSRV floor, so it SILENTLY raises our effective MSRV above
/// what we promise downstream (the sneaky vector `edition.rs`'s floor check misses).
/// Informational (never reds `is_clean`) — surfacing, not blocking, per the doc.
pub msrv_advisories: Vec<String>,
/// P4 — errors from a real build on the declared MSRV toolchain (`verify_msrv_build`):
/// the crate declares `rust-version = X` but does NOT actually build on rustc X. Empty
/// unless the heavy verify pass ran AND found a failure; a HARD gate signal when present.
pub msrv_verify_errors: Vec<String>,
/// P4 — errors from the docs.rs rehearsal (`cargo doc --all-features`, `docs_rehearsal`):
/// docs.rs builds `--all-features` AFTER publish and a failure is IRREVERSIBLE (the
/// version is immutable). Empty unless the rehearsal ran AND found a compile error; a
/// HARD gate signal when present.
pub docs_errors: Vec<String>,
}
impl EditionReport {
/// Clean ⟺ no static findings, no 2024-compat lints, and (when the heavy P4 passes ran)
/// no MSRV-verify or docs.rs failures. The ADVISORY `msrv_advisories` never red this —
/// they inform, they do not block — so the DEFAULT [`EditionGateConfig`] (heavy passes
/// off) preserves the historical `is_clean` semantics exactly.
pub fn is_clean(&self) -> bool {
self.static_findings.is_empty()
&& self.lints.is_empty()
&& self.msrv_verify_errors.is_empty()
&& self.docs_errors.is_empty()
}
}
/// Toggles for the P4 heavy/opt-in edition passes. The DEFAULT keeps the historical gate
/// behavior: the cheap advisory dep-MSRV-raise DETECTION is ON (informational only, so it
/// cannot redden a green gate), while the TWO heavy passes — a build on the declared MSRV
/// toolchain and the `cargo doc --all-features` docs.rs rehearsal — are OFF (the doc places
/// them in the heavier gate/bench container that carries the MSRV toolchain + nightly
/// rustdoc). Turn them on for the container pass.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EditionGateConfig {
/// Detect a dependency whose MSRV exceeds our declared floor (advisory). Cheap + pure.
pub detect_dep_msrv_raise: bool,
/// Verify the crate actually builds on its declared MSRV toolchain (`cargo +<msrv>
/// check`). Heavy; needs the toolchain installed (degrades to a note if absent).
pub verify_msrv_build: bool,
/// Rehearse the docs.rs build (`cargo doc --all-features`) before the irreversible
/// upload. Heavy; catches an all-features docs break that only bites post-publish.
pub docs_rehearsal: bool,
}
impl Default for EditionGateConfig {
fn default() -> Self {
Self { detect_dep_msrv_raise: true, verify_msrv_build: false, docs_rehearsal: false }
}
}
/// Parse `"1.85"` / `"1.85.0"` / `"1.85.1"` into `(major, minor)`, tolerating a
/// leading caret etc. Missing parts read as 0.
fn major_minor(s: &str) -> (u64, u64) {
let cleaned = s.trim().trim_start_matches(|c: char| !c.is_ascii_digit());
let mut it = cleaned.split('.').map(|p| {
p.chars().take_while(|c| c.is_ascii_digit()).collect::<String>().parse::<u64>().unwrap_or(0)
});
(it.next().unwrap_or(0), it.next().unwrap_or(0))
}
/// PURE: the static edition findings. Empty = every publishable package declares
/// edition 2024 + MSRV ≥ 1.85 and the workspace resolver is "3".
pub fn static_findings(pkgs: &[PkgEdition], resolver: Option<&str>) -> Vec<EditionFinding> {
let mut out = Vec::new();
for p in pkgs {
if !p.publishable {
continue;
}
if p.edition != "2024" {
out.push(EditionFinding {
package: p.name.clone(),
issue: format!("edition = \"{}\" — want \"2024\"", p.edition),
});
}
match &p.rust_version {
None => out.push(EditionFinding {
package: p.name.clone(),
issue: "no rust-version (MSRV) — edition 2024 needs ≥ 1.85".into(),
}),
Some(rv) if major_minor(rv) < EDITION_MSRV => out.push(EditionFinding {
package: p.name.clone(),
issue: format!("rust-version = \"{rv}\" < 1.85 (edition 2024 floor)"),
}),
Some(_) => {}
}
}
if resolver != Some("3") {
out.push(EditionFinding {
package: "[workspace]".into(),
issue: format!(
"resolver = {} — want \"3\" (the edition-2024 default)",
resolver.map(|r| format!("\"{r}\"")).unwrap_or_else(|| "unset".into())
),
});
}
out
}
/// PURE: pull `rust-2024-compatibility` diagnostics out of `cargo …
/// --message-format=json` stdout. Each line is one cargo JSON message; we keep
/// compiler-messages whose RENDERED text flags a 2024-edition incompatibility
/// (cargo phrases these as "… in the 2024 edition" / "Rust 2024"), which is robust
/// without hard-coding the (evolving) set of individual lint names.
pub fn parse_2024_lints(cargo_json_stdout: &str) -> Vec<String> {
let mut out = Vec::new();
for line in cargo_json_stdout.lines() {
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else { continue };
if v.get("reason").and_then(|r| r.as_str()) != Some("compiler-message") {
continue;
}
let msg = &v["message"];
let rendered = msg.get("rendered").and_then(|r| r.as_str()).unwrap_or("");
let primary = msg.get("message").and_then(|m| m.as_str()).unwrap_or("");
let hay = rendered.to_ascii_lowercase();
if hay.contains("2024 edition") || hay.contains("rust 2024") || hay.contains("edition 2024")
{
let code = msg
.get("code")
.and_then(|c| c.get("code"))
.and_then(|c| c.as_str())
.unwrap_or("rust_2024_compatibility");
out.push(format!("{code}: {primary}"));
}
}
out
}
/// Read `resolver` from a workspace/package root `Cargo.toml` (workspace table
/// preferred, then package). `None` if unset/unreadable.
pub fn read_resolver(repo_root: &Path) -> Option<String> {
let txt = std::fs::read_to_string(repo_root.join("Cargo.toml")).ok()?;
let doc = txt.parse::<toml::Table>().map(toml::Value::Table).ok()?;
doc.get("workspace")
.and_then(|w| w.get("resolver"))
.or_else(|| doc.get("package").and_then(|p| p.get("resolver")))
.and_then(|r| r.as_str())
.map(str::to_string)
}
/// The STATIC findings only (edition / MSRV / resolver), via `cargo_metadata` — no
/// `cargo check` subprocess. Cheap enough to run on a viz load (a few hundred ms),
/// unlike the dynamic lint pass. Used by the viz Gate pane's always-on view.
pub fn static_findings_only(repo_root: &Path) -> Result<Vec<EditionFinding>> {
let meta = cargo_metadata::MetadataCommand::new()
.current_dir(repo_root)
.exec()
.with_context(|| format!("cargo metadata in {}", repo_root.display()))?;
let ws: std::collections::BTreeSet<_> = meta.workspace_members.iter().collect();
let pkgs: Vec<PkgEdition> = meta
.packages
.iter()
.filter(|p| ws.contains(&p.id))
.map(|p| PkgEdition {
name: p.name.to_string(),
edition: p.edition.as_str().to_string(),
rust_version: p.rust_version.as_ref().map(|v| v.to_string()),
publishable: !matches!(&p.publish, Some(v) if v.is_empty()),
})
.collect();
Ok(static_findings(&pkgs, read_resolver(repo_root).as_deref()))
}
// ─── P4: MSRV verification + docs.rs rehearsal ───────────────────────────────
/// PURE: the ADVISORY dep-MSRV-raise findings. `floor` is the workspace's declared MSRV
/// floor `(major, minor)`; `deps` are `(name, rust_version)` for every RESOLVED dependency.
/// A dep whose parsed `rust_version` is strictly ABOVE the floor SILENTLY raises the crate's
/// effective MSRV above what it promises — the sneaky vector the declared-floor check misses.
/// Deps with no `rust-version` (unknown MSRV) are not flagged (no evidence). Returns one note
/// per offending dep.
pub fn dep_msrv_advisories(floor: (u64, u64), deps: &[(String, Option<String>)]) -> Vec<String> {
let mut out = Vec::new();
for (name, rv) in deps {
if let Some(rv) = rv {
let dep_mm = major_minor(rv);
if dep_mm > floor {
out.push(format!(
"dependency `{name}` requires rustc {}.{} — ABOVE the declared MSRV floor \
{}.{}; it silently raises the effective MSRV. Raise `rust-version` (and \
verify the build) or pin `{name}` to an MSRV-compatible version.",
dep_mm.0, dep_mm.1, floor.0, floor.1
));
}
}
}
out
}
/// PURE: pull COMPILE ERRORS out of a `cargo doc … --message-format=json` stdout — each line
/// is one cargo JSON message; we keep compiler-messages at `level == "error"` (the docs.rs
/// rehearsal only cares whether the doc build COMPILES, `--all-features`). Robust across
/// toolchains (no hard-coded lint set). Mirrors [`parse_2024_lints`].
pub fn parse_doc_errors(cargo_json_stdout: &str) -> Vec<String> {
let mut out = Vec::new();
for line in cargo_json_stdout.lines() {
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else { continue };
if v.get("reason").and_then(|r| r.as_str()) != Some("compiler-message") {
continue;
}
let msg = &v["message"];
if msg.get("level").and_then(|l| l.as_str()) != Some("error") {
continue;
}
let primary = msg.get("message").and_then(|m| m.as_str()).unwrap_or("").to_string();
let code = msg
.get("code")
.and_then(|c| c.get("code"))
.and_then(|c| c.as_str())
.unwrap_or("error");
out.push(format!("{code}: {primary}"));
}
out
}
/// The workspace's declared MSRV FLOOR: the MINIMUM `rust-version` among publishable members
/// (the weakest promise the release makes). Members without a declared MSRV are ignored here
/// (the static check already flags a missing MSRV); if NONE declare one, the edition floor
/// (`1.85`) stands in.
fn declared_msrv_floor(pkgs: &[PkgEdition]) -> (u64, u64) {
pkgs.iter()
.filter(|p| p.publishable)
.filter_map(|p| p.rust_version.as_deref().map(major_minor))
.min()
.unwrap_or(EDITION_MSRV)
}
/// Gather `(name, rust_version)` for every RESOLVED package (members + deps) from
/// `cargo metadata`, and the member [`PkgEdition`] rows — the inputs the P4 pure cores need.
fn metadata_msrv_inputs(repo_root: &Path) -> Result<(Vec<PkgEdition>, Vec<(String, Option<String>)>)> {
let meta = cargo_metadata::MetadataCommand::new()
.current_dir(repo_root)
.exec()
.with_context(|| format!("cargo metadata in {}", repo_root.display()))?;
let ws: std::collections::BTreeSet<_> = meta.workspace_members.iter().collect();
let members: Vec<PkgEdition> = meta
.packages
.iter()
.filter(|p| ws.contains(&p.id))
.map(|p| PkgEdition {
name: p.name.to_string(),
edition: p.edition.as_str().to_string(),
rust_version: p.rust_version.as_ref().map(|v| v.to_string()),
publishable: !matches!(&p.publish, Some(v) if v.is_empty()),
})
.collect();
// Dependencies = every resolved package that is NOT a workspace member.
let deps: Vec<(String, Option<String>)> = meta
.packages
.iter()
.filter(|p| !ws.contains(&p.id))
.map(|p| (p.name.to_string(), p.rust_version.as_ref().map(|v| v.to_string())))
.collect();
Ok((members, deps))
}
/// Static + dynamic gate for `repo_root`, DEFAULT config (historical behavior: 2024
/// static+lints plus the advisory dep-MSRV-raise detection; the heavy MSRV-verify + docs
/// rehearsal OFF). See [`run_gate_with`].
pub fn run_gate(repo_root: &Path) -> Result<EditionReport> {
run_gate_with(repo_root, EditionGateConfig::default())
}
/// Static + dynamic gate for `repo_root` under `cfg`. Static always runs (cheap); the dynamic
/// lint pass, the MSRV build-verify, and the docs.rs rehearsal all shell out and are
/// best-effort — a tooling/environment failure degrades to a note (or `lint_pass_ran =
/// false`), never a false HARD gate.
pub fn run_gate_with(repo_root: &Path, cfg: EditionGateConfig) -> Result<EditionReport> {
let static_findings = static_findings_only(repo_root)?;
// Dynamic: force the whole 2024-compat group to warn, capture JSON.
let (lints, lint_pass_ran) = match Command::new(crate::cargo::cargo_bin())
.current_dir(repo_root)
.args(["check", "--workspace", "--message-format=json"])
.env("RUSTFLAGS", "--force-warn rust-2024-compatibility")
.output()
{
Ok(o) => (parse_2024_lints(&String::from_utf8_lossy(&o.stdout)), true),
Err(_) => (Vec::new(), false),
};
let mut msrv_advisories = Vec::new();
let mut msrv_verify_errors = Vec::new();
if cfg.detect_dep_msrv_raise || cfg.verify_msrv_build {
if let Ok((members, deps)) = metadata_msrv_inputs(repo_root) {
let floor = declared_msrv_floor(&members);
if cfg.detect_dep_msrv_raise {
msrv_advisories = dep_msrv_advisories(floor, &deps);
}
if cfg.verify_msrv_build {
msrv_verify_errors = verify_msrv_build(repo_root, floor);
}
}
}
let docs_errors = if cfg.docs_rehearsal { docs_rehearsal(repo_root) } else { Vec::new() };
Ok(EditionReport {
static_findings,
lints,
lint_pass_ran,
msrv_advisories,
msrv_verify_errors,
docs_errors,
})
}
/// Best-effort: build the workspace on the declared MSRV toolchain (`cargo +<major.minor>
/// check`). A rustc-not-installed / spawn failure returns a single NOTE (never a false HARD
/// error); a non-zero build with real errors returns the rendered failures. The toolchain
/// spec `+1.85` resolves to the latest 1.85.x rustup has.
fn verify_msrv_build(repo_root: &Path, floor: (u64, u64)) -> Vec<String> {
let toolchain = format!("+{}.{}", floor.0, floor.1);
match Command::new(crate::cargo::cargo_bin())
.current_dir(repo_root)
.args([&toolchain, "check", "--workspace", "--message-format=json"])
.output()
{
Ok(o) if o.status.success() => Vec::new(),
Ok(o) => {
let errs = parse_doc_errors(&String::from_utf8_lossy(&o.stdout));
if errs.is_empty() {
// Non-zero but no parseable compiler error ⇒ likely the toolchain is not
// installed (`error: toolchain '1.85' is not installed`) — inconclusive note.
vec![format!(
"MSRV verify inconclusive: `cargo {toolchain} check` exited {} (is the {}.{} \
toolchain installed? `rustup toolchain install {}.{}`)",
o.status, floor.0, floor.1, floor.0, floor.1
)]
} else {
errs
}
}
Err(e) => vec![format!("MSRV verify not runnable ({e}) — install the declared toolchain")],
}
}
/// Best-effort: rehearse the docs.rs build with `cargo doc --all-features --no-deps` and
/// return any COMPILE ERRORS (docs.rs builds `--all-features` after publish; a break is
/// irreversible). A spawn failure returns a single note.
fn docs_rehearsal(repo_root: &Path) -> Vec<String> {
match Command::new(crate::cargo::cargo_bin())
.current_dir(repo_root)
.args(["doc", "--workspace", "--all-features", "--no-deps", "--message-format=json"])
.env("RUSTDOCFLAGS", "--cfg docsrs")
.output()
{
Ok(o) => parse_doc_errors(&String::from_utf8_lossy(&o.stdout)),
Err(e) => vec![format!("docs.rs rehearsal not runnable ({e})")],
}
}
#[cfg(test)]
mod tests {
use super::*;
fn pkg(name: &str, edition: &str, rv: Option<&str>, publishable: bool) -> PkgEdition {
PkgEdition {
name: name.into(),
edition: edition.into(),
rust_version: rv.map(str::to_string),
publishable,
}
}
#[test]
fn clean_when_2024_msrv_and_resolver3() {
let pkgs = [pkg("a", "2024", Some("1.85"), true), pkg("b", "2024", Some("1.86.0"), true)];
assert!(static_findings(&pkgs, Some("3")).is_empty());
}
#[test]
fn flags_old_edition_low_msrv_and_resolver() {
let pkgs = [pkg("old", "2021", Some("1.80"), true)];
let f = static_findings(&pkgs, Some("2"));
// edition, msrv, resolver → 3 findings.
assert_eq!(f.len(), 3, "{f:?}");
assert!(f.iter().any(|x| x.package == "old" && x.issue.contains("edition")));
assert!(f.iter().any(|x| x.package == "old" && x.issue.contains("< 1.85")));
assert!(f.iter().any(|x| x.package == "[workspace]" && x.issue.contains("resolver")));
}
#[test]
fn missing_msrv_is_flagged_and_resolver_unset() {
let pkgs = [pkg("a", "2024", None, true)];
let f = static_findings(&pkgs, None);
assert!(f.iter().any(|x| x.issue.contains("no rust-version")));
assert!(f.iter().any(|x| x.issue.contains("unset")));
}
#[test]
fn unpublishable_packages_are_skipped() {
// A publish=false crate on edition 2021 must NOT gate the release.
let pkgs = [pkg("xtask", "2021", None, false)];
assert!(static_findings(&pkgs, Some("3")).is_empty());
}
#[test]
fn parse_2024_lints_picks_edition_diagnostics_only() {
let json = [
// a real 2024-compat warning (rendered mentions the 2024 edition)
r#"{"reason":"compiler-message","message":{"code":{"code":"tail_expr_drop_order"},"message":"relative drop order changing","rendered":"warning: this will be a hard error in the 2024 edition"}}"#,
// an unrelated warning — must be ignored
r#"{"reason":"compiler-message","message":{"code":{"code":"unused_variables"},"message":"unused variable: x","rendered":"warning: unused variable: x"}}"#,
// a non-message line
r#"{"reason":"compiler-artifact"}"#,
]
.join("\n");
let lints = parse_2024_lints(&json);
assert_eq!(lints.len(), 1, "only the 2024 diagnostic: {lints:?}");
assert!(lints[0].contains("tail_expr_drop_order"));
}
#[test]
fn major_minor_parses_versions() {
assert_eq!(major_minor("1.85"), (1, 85));
assert_eq!(major_minor("1.85.0"), (1, 85));
assert_eq!(major_minor("1.84.9"), (1, 84));
assert!((1, 84) < EDITION_MSRV && (1, 85) >= EDITION_MSRV);
}
/// RED-WHEN-BROKEN (P4): a dependency whose MSRV EXCEEDS the declared floor is flagged
/// as a silent-MSRV-raise advisory; a dep AT/BELOW the floor, and a dep with no declared
/// MSRV, are not. If [`dep_msrv_advisories`] stops comparing, the flags flip.
#[test]
fn dep_msrv_advisories_flag_a_silent_raise() {
let floor = (1, 85);
let deps = vec![
("greedy".to_string(), Some("1.90.0".to_string())), // ABOVE floor ⇒ flagged
("fine".to_string(), Some("1.85.0".to_string())), // at floor ⇒ ok
("older".to_string(), Some("1.80.0".to_string())), // below floor ⇒ ok
("unknown".to_string(), None), // no MSRV ⇒ no evidence
];
let notes = dep_msrv_advisories(floor, &deps);
let greedy_flagged = notes.iter().any(|n| n.contains("greedy") && n.contains("1.90"));
let only_one = notes.len() == 1;
let no_false = !notes.iter().any(|n| n.contains("fine") || n.contains("older") || n.contains("unknown"));
let pass = greedy_flagged && only_one && no_false;
#[cfg(feature = "testmatrix")]
nornir_testmatrix::functional_status(
"release-edition",
"dep_msrv_advisories_flag_a_silent_raise",
pass,
&format!("greedy_flagged={greedy_flagged} only_one={only_one} no_false={no_false}"),
);
assert!(greedy_flagged, "a dep whose MSRV exceeds the floor is flagged");
assert!(only_one, "exactly the one above-floor dep is flagged");
assert!(no_false, "at/below-floor and unknown-MSRV deps are not flagged");
}
/// RED-WHEN-BROKEN (P4): the docs.rs-rehearsal parser keeps only compile ERRORS from
/// `cargo doc --message-format=json`, ignoring warnings and non-message lines. If it
/// stops filtering on `level == "error"`, the count changes.
#[test]
fn parse_doc_errors_keeps_only_errors() {
let json = [
r#"{"reason":"compiler-message","message":{"level":"error","code":{"code":"E0432"},"message":"unresolved import `foo`","rendered":"..."}}"#,
r#"{"reason":"compiler-message","message":{"level":"warning","code":{"code":"unused"},"message":"unused import","rendered":"..."}}"#,
r#"{"reason":"compiler-artifact"}"#,
]
.join("\n");
let errs = parse_doc_errors(&json);
assert_eq!(errs.len(), 1, "only the error-level message: {errs:?}");
assert!(errs[0].contains("E0432"), "carries the error code");
}
/// RED-WHEN-BROKEN (P4): the declared MSRV floor is the MINIMUM member `rust-version`;
/// with none declared it falls back to the edition floor. And the default gate config
/// keeps the heavy passes OFF so `is_clean` semantics are unchanged.
#[test]
fn declared_floor_is_min_and_default_config_keeps_heavy_off() {
let pkgs = [
pkg("a", "2024", Some("1.88"), true),
pkg("b", "2024", Some("1.85"), true),
pkg("xtask", "2024", Some("1.70"), false), // unpublishable ⇒ ignored
];
assert_eq!(declared_msrv_floor(&pkgs), (1, 85), "floor = min publishable member MSRV");
assert_eq!(declared_msrv_floor(&[]), EDITION_MSRV, "no members ⇒ edition floor");
let cfg = EditionGateConfig::default();
assert!(cfg.detect_dep_msrv_raise, "advisory dep-MSRV detection defaults ON");
assert!(!cfg.verify_msrv_build, "the heavy MSRV build-verify defaults OFF");
assert!(!cfg.docs_rehearsal, "the heavy docs.rs rehearsal defaults OFF");
// A report with only advisories stays CLEAN (advisories never block); a docs/verify
// error reds it.
let mut r = EditionReport {
lint_pass_ran: true,
msrv_advisories: vec!["dep raises MSRV".into()],
..Default::default()
};
assert!(r.is_clean(), "advisories alone never red the gate");
r.docs_errors.push("E0432: unresolved import".into());
assert!(!r.is_clean(), "a docs.rs rehearsal error reds the gate");
}
/// RED-WHEN-BROKEN (#80): `EditionReport::default()` MUST be a CLEAN report — every vec
/// empty, `is_clean() == true`. This is the contract that lets fallback/fixture and
/// downstream (nornir) sites build via `..Default::default()` without a field addition
/// silently reddening a would-be-clean report. If a new gate-affecting field is added
/// whose zero value is NOT clean, this test goes red and forces an explicit decision.
#[test]
fn default_report_is_clean() {
let r = EditionReport::default();
assert!(r.is_clean(), "the Default EditionReport is clean by construction");
assert!(r.static_findings.is_empty());
assert!(r.lints.is_empty());
assert!(r.msrv_advisories.is_empty());
assert!(r.msrv_verify_errors.is_empty());
assert!(r.docs_errors.is_empty());
assert!(!r.lint_pass_ran, "no lint pass ran on a default report");
}
}