nornir 0.4.54

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
//! `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,
}

#[derive(Debug, Clone, 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,
}

impl EditionReport {
    pub fn is_clean(&self) -> bool {
        self.static_findings.is_empty() && self.lints.is_empty()
    }
}

/// 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::Value>().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()))
}

/// Static + dynamic gate for `repo_root`. Static always runs (cheap); the dynamic
/// lint pass shells out to `cargo check` and is best-effort — if cargo fails to
/// run we record `lint_pass_ran = false` rather than failing the whole gate on an
/// environment hiccup.
pub fn run_gate(repo_root: &Path) -> 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("cargo")
        .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),
    };

    Ok(EditionReport { static_findings, lints, lint_pass_ran })
}

#[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);
    }
}