use std::path::{Path, PathBuf};
const RUST_TOOLCHAIN_TOML: &str = include_str!("../../../rust-toolchain.toml");
const WORKSPACE_CARGO_TOML: &str = include_str!("../../../Cargo.toml");
const CLIPPY_TOML: &str = include_str!("../../../clippy.toml");
const CONTAINERFILE: &str = include_str!("../../../Containerfile");
const TOOLCHAIN_ACTION: &str = "dtolnay/rust-toolchain@";
fn quoted_value<'a>(text: &'a str, key: &str) -> Option<&'a str> {
let needle = format!("{key} = \"");
let start = text.find(&needle)? + needle.len();
let end = text[start..].find('"')? + start;
Some(&text[start..end])
}
fn arg_value<'a>(text: &'a str, key: &str) -> Option<&'a str> {
let prefix = format!("ARG {key}=");
text.lines()
.find_map(|line| line.strip_prefix(prefix.as_str()))
.map(str::trim)
}
fn major_minor(version: &str) -> String {
version.split('.').take(2).collect::<Vec<_>>().join(".")
}
fn workspace_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(2)
.expect("workspace root two levels above crates/codelore-lib")
.to_path_buf()
}
fn action_toolchain_refs(workflows: &Path) -> Vec<(String, String)> {
let Ok(entries) = std::fs::read_dir(workflows) else {
return Vec::new();
};
let mut refs = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("yml") {
continue;
}
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default()
.to_string();
let text = std::fs::read_to_string(&path).expect("read workflow file");
for line in text.lines() {
if let Some(idx) = line.find(TOOLCHAIN_ACTION) {
let tag = line[idx + TOOLCHAIN_ACTION.len()..]
.split_whitespace()
.next()
.unwrap_or_default();
refs.push((name.clone(), tag.to_string()));
}
}
}
refs
}
fn record_mismatch(out: &mut Vec<String>, msrv: &str, site: &str, found: &str) {
if major_minor(found) != msrv {
out.push(format!(" {site} pins {found}"));
}
}
#[test]
fn every_rust_version_pin_agrees_with_the_toolchain_file() {
let channel = quoted_value(RUST_TOOLCHAIN_TOML, "channel")
.expect("rust-toolchain.toml declares a channel");
let msrv = major_minor(channel);
let mut mismatches = Vec::new();
record_mismatch(
&mut mismatches,
&msrv,
"workspace Cargo.toml rust-version",
quoted_value(WORKSPACE_CARGO_TOML, "rust-version")
.expect("workspace Cargo.toml declares rust-version"),
);
record_mismatch(
&mut mismatches,
&msrv,
"clippy.toml msrv",
quoted_value(CLIPPY_TOML, "msrv").expect("clippy.toml declares msrv"),
);
record_mismatch(
&mut mismatches,
&msrv,
"Containerfile ARG RUST_VERSION",
arg_value(CONTAINERFILE, "RUST_VERSION").expect("Containerfile declares ARG RUST_VERSION"),
);
let workflows = workspace_root().join(".github/workflows");
let refs = action_toolchain_refs(&workflows);
assert!(
!refs.is_empty(),
"found no {TOOLCHAIN_ACTION} references under {} — workflow-path resolution is broken, \
so this guard would pass vacuously",
workflows.display()
);
for (file, tag) in &refs {
record_mismatch(
&mut mismatches,
&msrv,
&format!(".github/workflows/{file}"),
tag,
);
}
assert!(
mismatches.is_empty(),
"rust-toolchain.toml pins {channel} (major.minor {msrv}), but {} pin site(s) disagree:\n{}\n\n\
rust-toolchain.toml is the source of truth — bump every site to match it. Bumping only \
the workflow action tag is a silent no-op, because rust-toolchain.toml overrides what \
the action installs.\n\n\
Not covered by this check: the Containerfile builder base carries an inline \
`@sha256:` digest, and a digest wins over the tag beside it. A RUST_VERSION bump without \
a matching digest refresh keeps building on the old toolchain, invisibly to any textual \
check — refresh it by hand or via the Dependabot docker stanza.",
mismatches.len(),
mismatches.join("\n"),
);
}