use std::path::Path;
use std::process::Command;
use anyhow::{Context, Result};
use serde::Serialize;
const EDITION_MSRV: (u64, u64) = (1, 85);
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct EditionFinding {
pub package: String,
pub issue: String,
}
#[derive(Debug, Clone)]
pub struct PkgEdition {
pub name: String,
pub edition: String,
pub rust_version: Option<String>,
pub publishable: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct EditionReport {
pub static_findings: Vec<EditionFinding>,
pub lints: Vec<String>,
pub lint_pass_ran: bool,
}
impl EditionReport {
pub fn is_clean(&self) -> bool {
self.static_findings.is_empty() && self.lints.is_empty()
}
}
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))
}
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
}
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
}
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)
}
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()))
}
pub fn run_gate(repo_root: &Path) -> Result<EditionReport> {
let static_findings = static_findings_only(repo_root)?;
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"));
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() {
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 = [
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"}}"#,
r#"{"reason":"compiler-message","message":{"code":{"code":"unused_variables"},"message":"unused variable: x","rendered":"warning: unused variable: x"}}"#,
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);
}
}