use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use crate::release::cargo;
use crate::release::publish::{classify_publish_failure, PublishFailure};
use crate::release::registry::{classify, RegistryBackend, RegistryState};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IssueKind {
NonMember,
MissingMetadata,
VerifyFailed,
RegistrySkew,
RegistryDesync,
VersionedPathDevDep,
UnpublishableDep,
BadManifest,
InvalidCrateName,
OversizedTarball,
StaleSourceDrift,
}
impl IssueKind {
pub fn is_correctness_blocker(&self) -> bool {
matches!(self, IssueKind::VerifyFailed | IssueKind::StaleSourceDrift)
}
}
#[derive(Debug, Clone)]
pub struct Issue {
pub repo: String,
pub krate: String,
pub kind: IssueKind,
pub detail: String,
pub fix: String,
#[allow(dead_code)]
pub blames: Vec<String>,
}
#[derive(Debug, Default)]
pub struct PreflightReport {
pub issues: Vec<Issue>,
pub notes: Vec<String>,
pub checked_crates: usize,
}
impl PreflightReport {
pub fn is_clean(&self) -> bool {
self.issues.is_empty()
}
fn push(&mut self, i: Issue) {
self.issues.push(i);
}
pub fn format(&self) -> String {
let mut s = String::new();
if self.is_clean() {
s.push_str(&format!(
"\npreflight: clean — {} crate(s) checked; safe to publish.\n",
self.checked_crates
));
} else {
s.push_str(&format!(
"\npreflight: {} blocking issue(s) — resolve before publishing \
(or re-run with --force to override):\n",
self.issues.len()
));
for i in &self.issues {
let tag = match i.kind {
IssueKind::NonMember => "non-member",
IssueKind::MissingMetadata => "metadata",
IssueKind::VerifyFailed => "verify",
IssueKind::RegistrySkew => "version",
IssueKind::RegistryDesync => "desync",
IssueKind::VersionedPathDevDep => "dev-dep",
IssueKind::UnpublishableDep => "dep",
IssueKind::BadManifest => "manifest",
IssueKind::StaleSourceDrift => "source-drift",
IssueKind::InvalidCrateName => "name",
IssueKind::OversizedTarball => "size",
};
s.push_str(&format!(" ⛔ [{tag}] {}/{}: {}\n", i.repo, i.krate, i.detail));
s.push_str(&format!(" fix: {}\n", i.fix));
}
}
let desynced: Vec<&Issue> =
self.issues.iter().filter(|i| i.kind == IssueKind::RegistryDesync).collect();
if !desynced.is_empty() {
let mut by_repo: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for i in &desynced {
by_repo.entry(i.repo.as_str()).or_default().push(i.krate.as_str());
}
s.push_str(&format!(
"\nRELEASE READINESS: {} crate(s) across {} repo(s) are BEHIND their \
published crates.io version — a version-bump republish is needed (a REAL, \
user-driven publish; not this dry-run):\n",
desynced.len(),
by_repo.len()
));
for (repo, crates) in &by_repo {
let only = crates.iter().map(|c| format!("--only {c}")).collect::<Vec<_>>().join(" ");
s.push_str(&format!(
" {repo}: {} crate(s) — if the local tree is merely STALE, `git pull` in \
{repo}; otherwise bump+republish just these:\n \
nornir release doctor --publish --bump patch {only}\n",
crates.len()
));
}
}
for n in &self.notes {
s.push_str(&format!(" · {n}\n"));
}
s
}
}
#[derive(Debug, Clone)]
pub struct RepoPlan {
pub name: String,
pub root: PathBuf,
pub publish_set: Vec<(String, String)>,
}
pub trait RegistryProbe {
fn version_live(&self, krate: &str, version: &str) -> Option<bool>;
fn req_satisfied(&self, _krate: &str, _req: &str) -> Option<bool> {
None
}
}
pub struct CratesIoProbe;
impl CratesIoProbe {
fn fetch_versions(krate: &str) -> Option<Vec<(String, bool)>> {
let url = format!("https://crates.io/api/v1/crates/{krate}");
let agent = "nornir-release-bot/0.1 (cargo-pipeline; +https://crates.io)";
let resp = ureq::get(&url)
.set("User-Agent", agent)
.timeout(std::time::Duration::from_secs(10))
.call()
.ok()?;
let body = resp.into_string().ok()?;
let json: serde_json::Value = serde_json::from_str(&body).ok()?;
let versions = json.get("versions").and_then(|v| v.as_array())?;
Some(
versions
.iter()
.filter_map(|e| {
let num = e.get("num").and_then(|n| n.as_str())?.to_string();
let yanked = e.get("yanked").and_then(|y| y.as_bool()).unwrap_or(false);
Some((num, yanked))
})
.collect(),
)
}
}
impl RegistryProbe for CratesIoProbe {
fn version_live(&self, krate: &str, version: &str) -> Option<bool> {
let versions = Self::fetch_versions(krate)?;
Some(versions.iter().any(|(num, _yanked)| num == version))
}
fn req_satisfied(&self, krate: &str, req: &str) -> Option<bool> {
let req = semver::VersionReq::parse(req).ok()?;
let versions = Self::fetch_versions(krate)?;
Some(versions.iter().any(|(num, yanked)| {
!*yanked
&& semver::Version::parse(num).map(|v| req.matches(&v)).unwrap_or(false)
}))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VerifyMode {
On,
Off,
}
pub fn preflight_verify_mode(_publish_dry_run: bool) -> VerifyMode {
VerifyMode::On
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GateVerdict {
Proceed { forced_soft: usize },
BlockCorrectness(usize),
BlockSoft(usize),
}
pub fn gate_verdict(report: &PreflightReport, force: bool) -> GateVerdict {
if report.is_clean() {
return GateVerdict::Proceed { forced_soft: 0 };
}
let hard = report.issues.iter().filter(|i| i.kind.is_correctness_blocker()).count();
if hard > 0 {
return GateVerdict::BlockCorrectness(hard);
}
if force {
GateVerdict::Proceed { forced_soft: report.issues.len() }
} else {
GateVerdict::BlockSoft(report.issues.len())
}
}
pub fn run(
plans: &[RepoPlan],
bump_keep: bool,
verify: VerifyMode,
probe: &dyn RegistryProbe,
backend: &dyn RegistryBackend,
cascade_publishes: &BTreeMap<String, String>,
) -> PreflightReport {
let mut report = PreflightReport::default();
let plan_publishes: BTreeMap<String, String> =
plans.iter().flat_map(|p| p.publish_set.iter().cloned()).collect();
for issue in duplicate_links_issues(plans) {
report.push(issue);
}
for plan in plans {
let members = cargo::root_workspace_members(&plan.root);
let mut publish_false: BTreeSet<String> = BTreeSet::new();
let _ = cargo::for_each_cargo_toml(&plan.root, &mut |doc: &toml::Value| {
let name = doc
.get("package")
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str());
let is_false = matches!(
doc.get("package").and_then(|p| p.get("publish")),
Some(toml::Value::Boolean(false))
) || matches!(
doc.get("package").and_then(|p| p.get("publish")),
Some(toml::Value::Array(a)) if a.is_empty()
);
if let (Some(n), true) = (name, is_false) {
publish_false.insert(n.to_string());
}
});
if let Some(members) = &members {
let detached = detached_workspace_crates(&plan.root, members);
if !detached.is_empty() {
report.notes.push(format!(
"{}: {} detached-[workspace] crate(s) excluded from publish: {}",
plan.name,
detached.len(),
detached.into_iter().collect::<Vec<_>>().join(", ")
));
}
} else {
report.notes.push(format!(
"{}: cargo metadata unavailable — root-member check skipped (best-effort)",
plan.name
));
}
for (krate, version) in &plan.publish_set {
report.checked_crates += 1;
if let Some(reason) = crate_name_problem(krate) {
report.push(Issue {
repo: plan.name.clone(),
krate: krate.clone(),
kind: IssueKind::InvalidCrateName,
detail: reason,
fix: "publish under a valid crate name (keep the crate's lib name via \
`[lib] name = \"…\"` / `package = \"…\"` if you only need to rename \
the published name)"
.to_string(),
blames: Vec::new(),
});
}
if semver::Version::parse(version).is_err() {
report.push(Issue {
repo: plan.name.clone(),
krate: krate.clone(),
kind: IssueKind::BadManifest,
detail: format!("version `{version}` is not valid semver"),
fix: "set a valid semver version (MAJOR.MINOR.PATCH, e.g. 1.2.3)".to_string(),
blames: Vec::new(),
});
}
if let Some(members) = &members {
if !members.contains(krate) {
report.push(Issue {
repo: plan.name.clone(),
krate: krate.clone(),
kind: IssueKind::NonMember,
detail: format!(
"not a member of the workspace at {} (detached [workspace])",
plan.root.display()
),
fix: "mark it `publish = false`, or publish it from its own \
workspace directory"
.to_string(),
blames: Vec::new(),
});
continue;
}
}
let missing = crate_metadata_gaps(&plan.root, krate);
if !missing.is_empty() {
report.push(Issue {
repo: plan.name.clone(),
krate: krate.clone(),
kind: IssueKind::MissingMetadata,
detail: format!("missing crates.io metadata: {}", missing.join(", ")),
fix: format!(
"add the field(s) to [package] in {}'s Cargo.toml",
krate
),
blames: Vec::new(),
});
}
if let Some((detail, fix)) = license_spdx_issue(&plan.root, krate) {
report.push(Issue {
repo: plan.name.clone(),
krate: krate.clone(),
kind: IssueKind::BadManifest,
detail,
fix,
blames: Vec::new(),
});
}
for (dep, ver) in versioned_path_dev_deps(&plan.root, krate) {
report.push(Issue {
repo: plan.name.clone(),
krate: krate.clone(),
kind: IssueKind::VersionedPathDevDep,
detail: format!(
"[dev-dependencies] {dep} = {{ path=…, version=\"{ver}\" }} — a \
versioned path dev-dep is resolved by `cargo publish` even though \
dev-deps are stripped, and fail-stops if the sibling isn't live"
),
fix: format!(
"drop the `version` from `{dep}` (path-only dev-deps are stripped on publish)"
),
blames: Vec::new(),
});
}
if let Some((mpath, doc)) = find_crate_manifest(&plan.root, krate) {
for (kind, detail, fix) in extra_manifest_gaps(&mpath, &doc, &publish_false) {
report.push(Issue {
repo: plan.name.clone(),
krate: krate.clone(),
kind,
detail,
fix,
blames: Vec::new(),
});
}
}
if let Some(issue) = registry_skew_issue(
&plan.name,
krate,
version,
bump_keep,
probe.version_live(krate, version),
) {
report.push(issue);
}
if let Some(issue) = registry_desync_issue(
&plan.name,
krate,
version,
backend.published_max_version(krate).as_deref(),
) {
report.push(issue);
}
}
if let Ok(findings) = crate::release::gate::path_dep_audit(&plan.root) {
for f in findings.iter().filter(|f| !f.ok()) {
let owner = std::fs::read_to_string(&f.manifest)
.ok()
.and_then(|t| t.parse::<toml::Value>().ok())
.and_then(|d| {
d.get("package")
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str())
.map(str::to_string)
})
.unwrap_or_else(|| f.dep_name.clone());
report.push(Issue {
repo: plan.name.clone(),
krate: owner,
kind: IssueKind::UnpublishableDep,
detail: format!(
"path dep `{}` has no `version =` — cargo strips the path on publish, \
leaving a version-less dep it rejects",
f.dep_name
),
fix: format!(
"add `version = \"…\"` to `{}` in {}",
f.dep_name,
f.manifest.display()
),
blames: Vec::new(),
});
}
}
for note in path_masks_published_notes(plan, backend) {
report.notes.push(note);
}
if verify == VerifyMode::On {
for (krate, _) in &plan.publish_set {
if members.as_ref().map(|m| !m.contains(krate)).unwrap_or(false) {
continue; }
match verify_one(&plan.name, &plan.root, krate, cascade_publishes, &plan_publishes) {
VerifyOutcome::Clean => {}
VerifyOutcome::Blocker(issue) => report.push(issue),
VerifyOutcome::CascadeResolvable { dep, planned } => {
report.notes.push(format!(
"{}/{krate}: verify skew against `{dep}` is cascade-resolvable \
({dep} will be published at {planned} earlier in the wave) — \
not a blocker",
plan.name
));
}
}
if let Some(issue) = oversized_tarball_issue(&plan.name, &plan.root, krate) {
report.push(issue);
}
}
}
}
report
}
fn oversized_tarball_issue(repo: &str, repo_root: &Path, krate: &str) -> Option<Issue> {
const MAX_BYTES: u64 = 10 * 1024 * 1024;
let dir = repo_root.join("target").join("package");
let prefix = format!("{krate}-");
let mut newest: Option<(std::time::SystemTime, u64)> = None;
for e in std::fs::read_dir(&dir).ok()?.flatten() {
let p = e.path();
if p.extension().and_then(|s| s.to_str()) != Some("crate") {
continue;
}
let fname = p.file_name().and_then(|s| s.to_str()).unwrap_or("");
let is_this = fname
.strip_prefix(&prefix)
.map(|r| r.starts_with(|c: char| c.is_ascii_digit()))
.unwrap_or(false);
if !is_this {
continue;
}
let Ok(meta) = e.metadata() else { continue };
let mtime = meta.modified().unwrap_or(std::time::UNIX_EPOCH);
if newest.as_ref().map(|(t, _)| mtime > *t).unwrap_or(true) {
newest = Some((mtime, meta.len()));
}
}
let (_, bytes) = newest?;
if bytes > MAX_BYTES {
return Some(Issue {
repo: repo.to_string(),
krate: krate.to_string(),
kind: IssueKind::OversizedTarball,
detail: format!(
"packaged .crate is {:.1} MiB — crates.io's default upload limit is 10 MiB",
bytes as f64 / (1024.0 * 1024.0)
),
fix: "shrink the package: `exclude` test fixtures / large assets (or set \
`include`) in [package], or split the crate"
.to_string(),
blames: Vec::new(),
});
}
None
}
fn is_cross_repo_path(path: &str) -> bool {
Path::new(path)
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
}
fn cross_repo_versioned_path_deps(root: &Path) -> Vec<(String, String, String)> {
let mut seen: BTreeMap<String, (String, String)> = BTreeMap::new();
let _ = cargo::for_each_cargo_toml(root, &mut |doc: &toml::Value| {
for table in ["dependencies", "build-dependencies"] {
let Some(deps) = doc.get(table).and_then(|d| d.as_table()) else {
continue;
};
for (name, spec) in deps {
let Some(t) = spec.as_table() else { continue };
let (Some(path), Some(ver)) = (
t.get("path").and_then(|p| p.as_str()),
t.get("version").and_then(|v| v.as_str()),
) else {
continue;
};
if !is_cross_repo_path(path) {
continue;
}
seen.entry(name.clone())
.or_insert_with(|| (ver.to_string(), path.to_string()));
}
}
});
seen.into_iter().map(|(name, (ver, path))| (name, ver, path)).collect()
}
pub fn path_masks_published_notes(plan: &RepoPlan, backend: &dyn RegistryBackend) -> Vec<String> {
let mut notes = Vec::new();
for (name, ver, sample_path) in cross_repo_versioned_path_deps(&plan.root) {
if let Some(published) = backend.published_max_version(&name) {
notes.push(format!(
"{}: dep `{name}` = {{ path=\"{sample_path}\", version=\"{ver}\" }} is published on \
crates.io (max {published}) but the workspace build resolves the LOCAL path — the \
registry copy is NOT validated here (only `cargo publish --verify` + fresh \
consumers use it). Drop `path=` (version-only) now that `{name}` is published, or \
gate it behind [patch.crates-io], so this build resolves the real {published}.",
plan.name
));
}
}
notes
}
pub fn source_drift_issues(
plans: &[RepoPlan],
probe: &dyn RegistryProbe,
oracle: &dyn crate::release::source_drift::PublishedSourceOracle,
) -> (Vec<Issue>, Vec<String>) {
use crate::release::source_drift::{assess, summarize_differing, DriftVerdict};
let mut issues = Vec::new();
let mut notes = Vec::new();
for plan in plans {
for (krate, version) in &plan.publish_set {
if probe.version_live(krate, version) != Some(true) {
continue;
}
let Some((mpath, _)) = find_crate_manifest(&plan.root, krate) else {
notes.push(format!(
"{}/{krate}: source-drift check skipped — manifest not found under {}",
plan.name,
plan.root.display()
));
continue;
};
let crate_dir = mpath.parent().unwrap_or(&plan.root);
match assess(oracle, crate_dir, krate, version) {
DriftVerdict::Unchanged => notes.push(format!(
"{}/{krate}: local source matches published {version} — safe to skip republish",
plan.name
)),
DriftVerdict::Drifted { differing } => issues.push(Issue {
repo: plan.name.clone(),
krate: krate.clone(),
kind: IssueKind::StaleSourceDrift,
detail: format!(
"local source differs from the published {krate}@{version} but the \
version is unchanged — a dependent verifies against the STALE registry \
copy and fail-stops. Changed: {}",
summarize_differing(&differing)
),
fix: format!(
"bump {krate}'s version (its published bytes are immutable; ship the \
edit as a new version)"
),
blames: Vec::new(),
}),
DriftVerdict::Unknown(why) => notes.push(format!(
"{}/{krate}: source-drift check inconclusive — {why}",
plan.name
)),
}
}
}
(issues, notes)
}
pub fn duplicate_links_issues(plans: &[RepoPlan]) -> Vec<Issue> {
let mut by_links: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
for plan in plans {
for (krate, _) in &plan.publish_set {
if let Some((_, doc)) = find_crate_manifest(&plan.root, krate) {
if let Some(links) = doc
.get("package")
.and_then(|p| p.get("links"))
.and_then(|l| l.as_str())
{
by_links
.entry(links.to_string())
.or_default()
.push((plan.name.clone(), krate.clone()));
}
}
}
}
let mut issues = Vec::new();
for (links, crates) in &by_links {
if crates.len() < 2 {
continue;
}
let names: Vec<String> = crates.iter().map(|(_, k)| k.clone()).collect();
for (repo, krate) in crates {
issues.push(Issue {
repo: repo.clone(),
krate: krate.clone(),
kind: IssueKind::BadManifest,
detail: format!(
"`links = \"{links}\"` is declared by {} crates in this release ({}) — \
crates.io requires links to be globally unique",
crates.len(),
names.join(", ")
),
fix: "give each crate a distinct `links` value (it must be globally unique on crates.io)"
.to_string(),
blames: Vec::new(),
});
}
}
issues
}
fn detached_workspace_crates(repo_root: &Path, members: &BTreeSet<String>) -> BTreeSet<String> {
let walked = cargo::publishable_crate_versions_walk(repo_root).unwrap_or_default();
walked
.into_iter()
.map(|(n, _)| n)
.filter(|n| !members.contains(n))
.collect()
}
pub fn crate_metadata_gaps(repo_root: &Path, krate: &str) -> Vec<String> {
let ws_package = std::fs::read_to_string(repo_root.join("Cargo.toml"))
.ok()
.and_then(|t| t.parse::<toml::Value>().ok())
.and_then(|d| d.get("workspace").and_then(|w| w.get("package")).cloned());
let mut pkg_doc: Option<toml::Value> = None;
let _ = cargo::for_each_cargo_toml(repo_root, &mut |doc: &toml::Value| {
if doc
.get("package")
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str())
== Some(krate)
{
pkg_doc = doc.get("package").cloned();
}
});
let Some(pkg) = pkg_doc else { return Vec::new() };
missing_metadata_fields(&pkg, ws_package.as_ref())
}
pub fn crate_name_problem(name: &str) -> Option<String> {
const MAX_NAME_LENGTH: usize = 64;
if name.is_empty() {
return Some("crate name is empty".to_string());
}
let len = name.chars().count();
if len > MAX_NAME_LENGTH {
return Some(format!(
"crate name `{name}` is {len} chars — crates.io allows at most {MAX_NAME_LENGTH}"
));
}
let first = name.chars().next().unwrap();
if !first.is_ascii_alphabetic() {
return Some(format!(
"crate name `{name}` must start with an ASCII letter — crates.io rejects a leading \
digit, `-`, or `_`"
));
}
if let Some(bad) = name
.chars()
.find(|c| !(c.is_ascii_alphanumeric() || *c == '-' || *c == '_'))
{
return Some(format!(
"crate name `{name}` contains `{bad}` — only ASCII letters, digits, `-` and `_` are allowed"
));
}
const RESERVED: &[&str] = &[
"CON", "PRN", "AUX", "NUL", "COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7",
"COM8", "COM9", "LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8",
"LPT9",
];
if RESERVED.contains(&name.to_ascii_uppercase().as_str()) {
return Some(format!(
"crate name `{name}` is a reserved (Windows device) name that crates.io blocks"
));
}
None
}
pub fn spdx_license_problem(expr: &str) -> Option<String> {
let e = expr.trim();
if e.is_empty() {
return Some("the license expression is empty".to_string());
}
if e.contains('/') {
return Some(format!(
"`{e}` uses the deprecated `/` separator — SPDX uses `OR` (e.g. `MIT OR Apache-2.0`)"
));
}
let mut toks: Vec<String> = Vec::new();
let mut cur = String::new();
for c in e.chars() {
match c {
'(' | ')' => {
if !cur.is_empty() {
toks.push(std::mem::take(&mut cur));
}
toks.push(c.to_string());
}
c if c.is_whitespace() => {
if !cur.is_empty() {
toks.push(std::mem::take(&mut cur));
}
}
_ => cur.push(c),
}
}
if !cur.is_empty() {
toks.push(cur);
}
let mut depth = 0i32;
for t in &toks {
if t == "(" {
depth += 1;
} else if t == ")" {
depth -= 1;
if depth < 0 {
return Some(format!("`{e}` has unbalanced parentheses"));
}
}
}
if depth != 0 {
return Some(format!("`{e}` has unbalanced parentheses"));
}
let is_op = |t: &str| matches!(t, "AND" | "OR" | "WITH");
let mut expect_operand = true;
for t in &toks {
if t == "(" {
if !expect_operand {
return Some(format!("`{e}`: `(` follows a license with no operator between them"));
}
continue; }
if t == ")" {
if expect_operand {
return Some(format!("`{e}`: empty group or dangling operator before `)`"));
}
continue; }
if is_op(t) {
if expect_operand {
return Some(format!("`{e}`: operator `{t}` with no license before it"));
}
expect_operand = true;
continue;
}
if !expect_operand {
return Some(format!(
"`{e}`: two licenses (`{t}`) with no AND/OR between them"
));
}
if !valid_spdx_id_token(t) {
return Some(format!("`{e}`: `{t}` is not a valid SPDX license identifier"));
}
expect_operand = false;
}
if expect_operand {
return Some(format!("`{e}`: expression ends on an operator (expected a license)"));
}
None
}
fn valid_spdx_id_token(t: &str) -> bool {
if t.contains(':') {
return true; }
let core = t.strip_suffix('+').unwrap_or(t);
let mut chars = core.chars();
match chars.next() {
Some(c) if c.is_ascii_alphanumeric() => {}
_ => return false,
}
core.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
}
pub fn license_spdx_issue(repo_root: &Path, krate: &str) -> Option<(String, String)> {
let ws_license = std::fs::read_to_string(repo_root.join("Cargo.toml"))
.ok()
.and_then(|t| t.parse::<toml::Value>().ok())
.and_then(|d| {
d.get("workspace")
.and_then(|w| w.get("package"))
.and_then(|p| p.get("license"))
.and_then(|l| l.as_str())
.map(str::to_string)
});
let (_, doc) = find_crate_manifest(repo_root, krate)?;
let license = doc.get("package")?.get("license")?;
let effective = match license {
toml::Value::String(s) => Some(s.clone()),
toml::Value::Table(t) if t.get("workspace").and_then(|v| v.as_bool()) == Some(true) => {
ws_license
}
_ => None,
}?;
let reason = spdx_license_problem(&effective)?;
Some((
format!("invalid `license` — {reason}"),
"set `license` to a valid SPDX expression (https://spdx.org/licenses/), \
e.g. `MIT OR Apache-2.0`, or use `license-file`"
.to_string(),
))
}
fn find_crate_manifest(repo_root: &Path, krate: &str) -> Option<(PathBuf, toml::Value)> {
let mut found: Option<(PathBuf, toml::Value)> = None;
let _ = cargo::for_each_cargo_toml_with_path(repo_root, &mut |path: &Path, doc: &toml::Value| {
if found.is_none()
&& doc
.get("package")
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str())
== Some(krate)
{
found = Some((path.to_path_buf(), doc.clone()));
}
});
found
}
pub fn versioned_path_dev_deps(repo_root: &Path, krate: &str) -> Vec<(String, String)> {
let Some((_, doc)) = find_crate_manifest(repo_root, krate) else { return Vec::new() };
let mut out = Vec::new();
collect_versioned_path_dev_deps(&doc, &mut out);
if let Some(targets) = doc.get("target").and_then(|t| t.as_table()) {
for (_cfg, t) in targets {
collect_versioned_path_dev_deps(t, &mut out);
}
}
out
}
fn collect_versioned_path_dev_deps(parent: &toml::Value, out: &mut Vec<(String, String)>) {
let Some(deps) = parent.get("dev-dependencies").and_then(|d| d.as_table()) else { return };
for (name, v) in deps {
let Some(t) = v.as_table() else { continue };
let has_path = t.get("path").and_then(|p| p.as_str()).is_some();
if let (true, Some(ver)) = (has_path, t.get("version").and_then(|v| v.as_str())) {
out.push((name.clone(), ver.to_string()));
}
}
}
fn extra_manifest_gaps(
manifest: &Path,
doc: &toml::Value,
publish_false_siblings: &BTreeSet<String>,
) -> Vec<(IssueKind, String, String)> {
let mut out: Vec<(IssueKind, String, String)> = Vec::new();
let Some(pkg) = doc.get("package") else { return out };
if let Some(readme) = pkg.get("readme").and_then(|v| v.as_str()) {
if let Some((detail, fix)) = declared_file_gap(manifest, "readme", readme) {
out.push((IssueKind::BadManifest, detail, fix));
}
}
if let Some(lf) = pkg.get("license-file").and_then(|v| v.as_str()) {
if let Some((detail, fix)) = declared_file_gap(manifest, "license-file", lf) {
out.push((IssueKind::BadManifest, detail, fix));
}
}
if let Some(kw) = pkg.get("keywords").and_then(|v| v.as_array()) {
if kw.len() > 5 {
out.push((
IssueKind::BadManifest,
format!("{} keywords (crates.io allows at most 5)", kw.len()),
"keep at most 5 entries in [package].keywords".to_string(),
));
}
for k in kw {
let Some(s) = k.as_str() else { continue };
let bad_char =
!s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '+');
let bad_start = !s.chars().next().map(|c| c.is_ascii_alphanumeric()).unwrap_or(false);
if s.chars().count() > 20 || bad_char || bad_start {
out.push((
IssueKind::BadManifest,
format!(
"invalid keyword {s:?} (crates.io: ≤20 chars, must start alphanumeric, \
only letters/digits/`-`/`_`/`+`)"
),
"fix the offending entry in [package].keywords".to_string(),
));
}
}
}
if let Some(cats) = pkg.get("categories").and_then(|v| v.as_array()) {
if cats.len() > 5 {
out.push((
IssueKind::BadManifest,
format!("{} categories (crates.io allows at most 5)", cats.len()),
"keep at most 5 entries in [package].categories".to_string(),
));
}
}
if let Some(desc) = pkg.get("description").and_then(|v| v.as_str()) {
let n = desc.chars().count();
if n > 1000 {
out.push((
IssueKind::BadManifest,
format!("description is {n} chars — crates.io allows at most 1000"),
"shorten [package].description to ≤1000 characters".to_string(),
));
}
}
for_each_published_dep(doc, &mut |name, v| {
if let toml::Value::Table(t) = v {
let cross = t.get("registry").and_then(|r| r.as_str()).is_some_and(|r| r != "crates-io")
|| t.get("registry-index").is_some();
if cross {
out.push((
IssueKind::BadManifest,
format!(
"dependency `{name}` targets another registry \
(`registry`/`registry-index`) — crates.io forbids cross-registry deps"
),
format!("depend on a crates.io version of `{name}`, or vendor it"),
));
}
}
});
if let Some(ed) = pkg.get("edition").and_then(|v| v.as_str()) {
const KNOWN: &[&str] = &["2015", "2018", "2021", "2024"];
if !KNOWN.contains(&ed) {
out.push((
IssueKind::BadManifest,
format!("edition = \"{ed}\" is not a known Rust edition (2015/2018/2021/2024)"),
"set `edition` to a supported value".to_string(),
));
}
}
if let Some(rv) = pkg.get("rust-version").and_then(|v| v.as_str()) {
let ok = !rv.is_empty()
&& rv.chars().all(|c| c.is_ascii_digit() || c == '.')
&& rv.chars().any(|c| c.is_ascii_digit());
if !ok {
out.push((
IssueKind::BadManifest,
format!(
"rust-version = \"{rv}\" must be digits and periods only (e.g. \"1.74\") — \
no operators, ranges, or pre-release"
),
"set `rust-version` to a bare version like \"1.74\"".to_string(),
));
}
}
for field in ["repository", "homepage", "documentation"] {
if let Some(url) = pkg.get(field).and_then(|v| v.as_str()) {
let u = url.trim();
if !(u.starts_with("http://") || u.starts_with("https://")) {
out.push((
IssueKind::BadManifest,
format!("`{field}` = \"{url}\" must begin with http:// or https://"),
format!("set `{field}` to a full URL (https://…) in [package]"),
));
}
}
}
let dep_names: BTreeSet<String> = manifest_all_dep_names(doc);
if let Some(features) = doc.get("features").and_then(|f| f.as_table()) {
let feature_names: BTreeSet<&String> = features.keys().collect();
for (feat, list) in features {
let Some(items) = list.as_array() else { continue };
for it in items {
let Some(s) = it.as_str() else { continue };
if let Some(dep) = s.strip_prefix("dep:") {
if !dep_names.contains(dep) {
out.push((
IssueKind::BadManifest,
format!("feature `{feat}` references `dep:{dep}` but `{dep}` is not a dependency"),
format!("add `{dep}` to [dependencies], or remove the `dep:{dep}` reference"),
));
}
} else if let Some((left, _)) = s.split_once('/') {
let dep = left.trim_end_matches('?');
if !dep_names.contains(dep) {
out.push((
IssueKind::BadManifest,
format!("feature `{feat}` enables `{s}` but `{dep}` is not a dependency"),
format!("add `{dep}` to [dependencies], or fix the `{s}` reference"),
));
}
} else if !feature_names.contains(&s.to_string()) && !dep_names.contains(s) {
out.push((
IssueKind::BadManifest,
format!(
"feature `{feat}` includes `{s}`, which is neither another feature \
nor a dependency"
),
format!("define a `{s}` feature/dependency, or remove the `{s}` reference"),
));
}
}
}
}
for_each_published_dep(doc, &mut |name, v| {
if dep_version_req(v).map(|r| r.trim() == "*").unwrap_or(false) {
out.push((
IssueKind::BadManifest,
format!(
"dependency `{name}` uses a wildcard `*` version — crates.io rejects \
wildcard (`*`) dependency constraints"
),
format!(
"pin `{name}` to a real requirement (e.g. `\"1\"`, `\"^1.2\"`, `\">=1, <2\"`)"
),
));
}
});
for_each_published_dep(doc, &mut |name, v| {
let is_git = matches!(
v,
toml::Value::Table(t) if t.get("git").and_then(|g| g.as_str()).is_some()
);
if is_git && dep_version_req(v).is_none() {
out.push((
IssueKind::UnpublishableDep,
format!(
"git dependency `{name}` has no `version =` — cargo strips the git source \
on publish, leaving a version-less dep crates.io rejects"
),
format!(
"add `version = \"…\"` to `{name}` (that version must be on crates.io), or drop it"
),
));
}
});
for section in ["dependencies", "build-dependencies"] {
let Some(deps) = doc.get(section).and_then(|d| d.as_table()) else { continue };
for (name, v) in deps {
if !publish_false_siblings.contains(name) {
continue;
}
let has_version = match v {
toml::Value::String(_) => true, toml::Value::Table(t) => t.get("version").and_then(|x| x.as_str()).is_some(),
_ => false,
};
if has_version {
out.push((
IssueKind::UnpublishableDep,
format!(
"depends on `{name}` with a version, but `{name}` is `publish = false` \
(never on crates.io)"
),
format!(
"drop the `version` from `{name}` (keep it path-only), or make `{name}` publishable"
),
));
}
}
}
out
}
fn declared_file_gap(manifest: &Path, field: &str, rel_path: &str) -> Option<(String, String)> {
let full = manifest.parent().unwrap_or_else(|| Path::new(".")).join(rel_path);
match std::fs::read(&full) {
Err(_) => Some((
format!("{field} = \"{rel_path}\" does not exist ({})", full.display()),
format!(
"create `{rel_path}` next to the manifest, or fix the `{field}` path in [package]"
),
)),
Ok(bytes) if std::str::from_utf8(&bytes).is_err() => Some((
format!("{field} `{rel_path}` is not valid UTF-8 — crates.io requires UTF-8 text"),
format!("re-save `{rel_path}` as UTF-8"),
)),
Ok(_) => None,
}
}
fn for_each_published_dep(doc: &toml::Value, f: &mut dyn FnMut(&str, &toml::Value)) {
let mut scan = |tbl: &toml::Value| {
for section in ["dependencies", "build-dependencies"] {
if let Some(deps) = tbl.get(section).and_then(|d| d.as_table()) {
for (name, v) in deps {
f(name, v);
}
}
}
};
scan(doc);
if let Some(targets) = doc.get("target").and_then(|t| t.as_table()) {
for t in targets.values() {
scan(t);
}
}
}
fn manifest_all_dep_names(doc: &toml::Value) -> BTreeSet<String> {
let mut names = BTreeSet::new();
let mut scan = |tbl: &toml::Value| {
for section in ["dependencies", "build-dependencies", "dev-dependencies"] {
if let Some(deps) = tbl.get(section).and_then(|d| d.as_table()) {
names.extend(deps.keys().cloned());
}
}
};
scan(doc);
if let Some(targets) = doc.get("target").and_then(|t| t.as_table()) {
for t in targets.values() {
scan(t);
}
}
names
}
fn dep_version_req(v: &toml::Value) -> Option<&str> {
match v {
toml::Value::String(s) => Some(s.as_str()),
toml::Value::Table(t) => t.get("version").and_then(|x| x.as_str()),
_ => None,
}
}
pub fn missing_metadata_fields(
pkg: &toml::Value,
ws_package: Option<&toml::Value>,
) -> Vec<String> {
let present = |field: &str| -> bool {
field_present(pkg, ws_package, field)
};
let mut missing = Vec::new();
if !present("description") {
missing.push("description".to_string());
}
if !present("license") && !present("license-file") {
missing.push("license".to_string());
}
if !present("repository") {
missing.push("repository".to_string());
}
missing
}
fn field_present(pkg: &toml::Value, ws_package: Option<&toml::Value>, field: &str) -> bool {
match pkg.get(field) {
Some(toml::Value::String(s)) => !s.trim().is_empty(),
Some(toml::Value::Table(t)) => {
let inherits = t.get("workspace").and_then(|v| v.as_bool()).unwrap_or(false);
inherits
&& ws_package
.and_then(|wp| wp.get(field))
.and_then(|v| match v {
toml::Value::String(s) => Some(!s.trim().is_empty()),
_ => Some(true),
})
.unwrap_or(false)
}
_ => false,
}
}
pub fn registry_skew_issue(
repo: &str,
krate: &str,
version: &str,
bump_keep: bool,
live: Option<bool>,
) -> Option<Issue> {
if bump_keep {
return None; }
match live {
Some(true) => Some(Issue {
repo: repo.to_string(),
krate: krate.to_string(),
kind: IssueKind::RegistrySkew,
detail: format!("{krate} {version} is already live on crates.io (immutable)"),
fix: format!(
"bump {krate} past {version} (crates.io versions can't be overwritten), \
or use --bump keep to skip already-published crates"
),
blames: Vec::new(),
}),
_ => None,
}
}
pub fn registry_desync_issue(
repo: &str,
krate: &str,
version: &str,
published: Option<&str>,
) -> Option<Issue> {
match classify(version, published) {
RegistryState::Desync => {
let pub_v = published.unwrap_or("?");
let next = bump_past(pub_v);
let target = next.as_deref().unwrap_or(pub_v);
Some(Issue {
repo: repo.to_string(),
krate: krate.to_string(),
kind: IssueKind::RegistryDesync,
detail: format!(
"local {krate} {version} is BEHIND the registry (published {pub_v}) — \
dependents verify against the newer published API and break"
),
fix: format!(
"if the local tree is merely STALE, `git pull` in {repo} to catch up to \
{pub_v}. If {krate} has genuine local changes, bump it PAST the registry \
(to {target}) and republish just it: \
`nornir release doctor --publish --bump patch --only {krate}` \
(a real, user-driven publish — not a dry-run)"
),
blames: Vec::new(),
})
}
_ => None,
}
}
fn bump_past(published: &str) -> Option<String> {
let v = semver::Version::parse(published).ok()?;
Some(format!("{}.{}.{}", v.major, v.minor, v.patch + 1))
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum VerifyOutcome {
Clean,
Blocker(Issue),
CascadeResolvable { dep: String, planned: String },
}
impl PartialEq for Issue {
fn eq(&self, other: &Self) -> bool {
self.repo == other.repo
&& self.krate == other.krate
&& self.kind == other.kind
&& self.blames == other.blames
}
}
impl Eq for Issue {}
pub fn skew_is_cascade_resolvable(
dep: Option<&str>,
cascade_publishes: &BTreeMap<String, String>,
) -> Option<String> {
let d = dep?;
if let Some(v) = cascade_publishes.get(d) {
return Some(v.clone());
}
let canon = |s: &str| s.replace('_', "-");
let dc = canon(d);
cascade_publishes.iter().find(|(k, _)| canon(k) == dc).map(|(_, v)| v.clone())
}
fn verify_one(
repo: &str,
repo_root: &Path,
krate: &str,
cascade_publishes: &BTreeMap<String, String>,
plan_publishes: &BTreeMap<String, String>,
) -> VerifyOutcome {
let Ok(out) = std::process::Command::new(crate::release::cargo::cargo_bin())
.args(["publish", "-p", krate, "--dry-run", "--allow-dirty"])
.current_dir(repo_root)
.output()
else {
return VerifyOutcome::Clean; };
if out.status.success() {
return VerifyOutcome::Clean;
}
let stderr = String::from_utf8_lossy(&out.stderr);
let known: BTreeSet<String> = plan_publishes.keys().cloned().collect();
match classify_publish_failure(krate, &stderr, &known) {
PublishFailure::DepApiSkew { dep } => {
if let Some(planned) = skew_is_cascade_resolvable(dep.as_deref(), cascade_publishes) {
let d = dep.unwrap_or_default();
return VerifyOutcome::CascadeResolvable { dep: d, planned };
}
let d = dep.as_deref().unwrap_or("a dependency");
VerifyOutcome::Blocker(Issue {
repo: repo.to_string(),
krate: krate.to_string(),
kind: IssueKind::VerifyFailed,
detail: format!(
"verify build uses API from `{d}` not in the published `{d}`"
),
fix: format!("bump `{d}` and publish it before `{krate}`"),
blames: dep.iter().cloned().collect(),
})
}
PublishFailure::ResolveOrdering => {
let dep = extract_resolve_dep(&stderr);
match dep.as_deref() {
Some(_) => {
if let Some(planned) =
skew_is_cascade_resolvable(dep.as_deref(), plan_publishes)
{
VerifyOutcome::CascadeResolvable {
dep: dep.unwrap_or_default(),
planned,
}
} else {
let d = dep.as_deref().unwrap_or("a dependency");
VerifyOutcome::Blocker(Issue {
repo: repo.to_string(),
krate: krate.to_string(),
kind: IssueKind::UnpublishableDep,
detail: format!(
"verify build can't resolve `{d}` from the registry, and it \
is NOT in the publish plan (external, `publish = false`, or \
a versioned edge that can never resolve)"
),
fix: format!(
"add `{d}` to the release set, make it publishable, or drop \
the version pin so the sibling dep isn't resolved"
),
blames: dep.iter().cloned().collect(),
})
}
}
None => VerifyOutcome::Clean,
}
}
_ => VerifyOutcome::Clean,
}
}
fn extract_resolve_dep(stderr: &str) -> Option<String> {
for marker in [
"no matching package named `",
"failed to select a version for the requirement `",
"no matching package for `",
"failed to select a version for `",
] {
if let Some(i) = stderr.find(marker) {
let rest = &stderr[i + marker.len()..];
if let Some(end) = rest.find(|c| c == '`' || c == ' ') {
let name = rest[..end].trim();
if !name.is_empty() {
return Some(name.to_string());
}
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
struct FakeProbe(Option<bool>);
impl RegistryProbe for FakeProbe {
fn version_live(&self, _k: &str, _v: &str) -> Option<bool> {
self.0
}
}
struct FakeBackend(Option<&'static str>);
impl RegistryBackend for FakeBackend {
fn published_max_version(&self, _krate: &str) -> Option<String> {
self.0.map(str::to_string)
}
}
#[test]
fn missing_metadata_fields_respects_inheritance_and_license_file() {
let full: toml::Value = toml::from_str(
r#"name = "x"
version = "0.1.0"
description = "d"
license = "Apache-2.0"
repository = "https://example.com"
"#,
)
.unwrap();
assert!(missing_metadata_fields(&full, None).is_empty());
let lf: toml::Value = toml::from_str(
r#"name = "x"
description = "d"
license-file = "LICENSE"
repository = "r"
"#,
)
.unwrap();
assert!(missing_metadata_fields(&lf, None).is_empty(), "license-file counts");
let bare: toml::Value = toml::from_str("name = \"x\"\nversion = \"0.1.0\"\n").unwrap();
let miss = missing_metadata_fields(&bare, None);
assert_eq!(miss, vec!["description", "license", "repository"]);
let inh: toml::Value = toml::from_str(
r#"name = "x"
description = { workspace = true }
license = { workspace = true }
repository = { workspace = true }
"#,
)
.unwrap();
let wp: toml::Value = toml::from_str(
r#"description = "d"
license = "Apache-2.0"
repository = "r"
"#,
)
.unwrap();
assert!(missing_metadata_fields(&inh, Some(&wp)).is_empty(), "inherited fields present");
assert_eq!(missing_metadata_fields(&inh, None), vec!["description", "license", "repository"]);
}
#[test]
fn cross_repo_path_detection_and_masking_advisory() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(
root.join("Cargo.toml"),
r#"[package]
name = "consumer"
version = "0.5.2"
edition = "2021"
description = "d"
license = "Apache-2.0"
repository = "https://example.com"
[dependencies]
funnel-core = { version = "0.1", path = "../funnel-core" }
nornir-testmatrix = { version = "0.2.4", path = "../edda/crates/nornir-testmatrix" }
sibling = { version = "0.1", path = "crates/sibling" }
serde = "1"
[build-dependencies]
dwarves = { version = "0.1", path = "../dwarves/crates/dwarfs" }
"#,
)
.unwrap();
let mut found = cross_repo_versioned_path_deps(root);
found.sort();
let names: Vec<&str> = found.iter().map(|(n, _, _)| n.as_str()).collect();
assert_eq!(names, vec!["dwarves", "funnel-core", "nornir-testmatrix"]);
assert!(!is_cross_repo_path("crates/sibling"));
assert!(is_cross_repo_path("../funnel-core"));
assert!(is_cross_repo_path("../edda/crates/nornir-testmatrix"));
let plan = RepoPlan {
name: "consumer".to_string(),
root: root.to_path_buf(),
publish_set: vec![("consumer".to_string(), "0.5.2".to_string())],
};
let notes = path_masks_published_notes(&plan, &FakeBackend(Some("0.1.1")));
assert_eq!(notes.len(), 3, "one note per masked cross-repo dep");
assert!(notes.iter().any(|n| n.contains("funnel-core")));
assert!(notes.iter().all(|n| n.contains("[patch.crates-io]")), "fix named");
assert!(notes.iter().all(|n| !n.contains("sibling")), "intra-repo sibling not flagged");
let none = path_masks_published_notes(&plan, &FakeBackend(None));
assert!(none.is_empty(), "an unpublished cross-repo path is not masked");
let report = run(
&[plan],
true,
VerifyMode::Off,
&FakeProbe(Some(false)),
&FakeBackend(Some("0.1.1")),
&BTreeMap::new(),
);
assert!(report.is_clean(), "path-masking is advisory, not a blocker");
assert!(
report.notes.iter().any(|n| n.contains("resolves the LOCAL path")),
"the masking advisory surfaced in the report notes"
);
}
#[test]
fn registry_skew_blocks_real_bump_but_not_keep() {
let i = registry_skew_issue("facett", "facett-core", "0.4.53", false, Some(true));
assert!(i.is_some());
let i = i.unwrap();
assert_eq!(i.kind, IssueKind::RegistrySkew);
assert!(i.detail.contains("already live"));
assert!(registry_skew_issue("facett", "facett-core", "0.4.53", false, Some(false)).is_none());
assert!(registry_skew_issue("facett", "facett-core", "0.4.53", true, Some(true)).is_none());
assert!(registry_skew_issue("facett", "facett-core", "0.4.53", false, None).is_none());
}
#[test]
fn preflight_aggregates_all_issues_at_once() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(
root.join("Cargo.toml"),
"[package]\nname = \"solo\"\nversion = \"0.2.0\"\nedition = \"2021\"\n",
)
.unwrap();
let plans = vec![RepoPlan {
name: "solo".to_string(),
root: root.to_path_buf(),
publish_set: vec![("solo".to_string(), "0.2.0".to_string())],
}];
let report = run(
&plans,
false,
VerifyMode::Off,
&FakeProbe(Some(true)),
&FakeBackend(None),
&BTreeMap::new(),
);
assert!(!report.is_clean(), "issues present ⇒ not clean");
assert!(
report.issues.iter().any(|i| i.kind == IssueKind::MissingMetadata),
"metadata gap flagged"
);
assert!(
report.issues.iter().any(|i| i.kind == IssueKind::RegistrySkew),
"registry clash flagged"
);
assert_eq!(report.checked_crates, 1);
let text = report.format();
assert!(text.contains("blocking issue(s)"), "header counts issues: {text}");
assert!(text.contains("fix:"), "every issue carries a fix: {text}");
std::fs::write(
root.join("Cargo.toml"),
"[package]\nname = \"solo\"\nversion = \"0.2.0\"\n\
description = \"d\"\nlicense = \"Apache-2.0\"\nrepository = \"https://example.com\"\n",
)
.unwrap();
let clean = run(
&plans,
false,
VerifyMode::Off,
&FakeProbe(Some(false)),
&FakeBackend(None),
&BTreeMap::new(),
);
assert!(clean.is_clean(), "complete metadata + fresh version ⇒ clean: {:?}", clean.issues);
assert!(clean.format().contains("preflight: clean"));
}
#[test]
fn readiness_footer_consolidates_desync_into_one_command_per_repo() {
let mut r = PreflightReport::default();
for (k, cur, pubv) in [("facett-core", "0.1.10", "0.1.12"), ("facett-app", "0.1.10", "0.1.12")] {
r.push(registry_desync_issue("facett", k, cur, Some(pubv)).unwrap());
}
let out = r.format();
assert!(out.contains("RELEASE READINESS: 2 crate(s) across 1 repo(s)"), "{out}");
assert!(out.contains("nornir release doctor --publish --bump patch"), "{out}");
assert!(out.contains("--only facett-core"), "{out}");
assert!(out.contains("--only facett-app"), "{out}");
assert!(out.contains("git pull"), "stale-tree path offered: {out}");
assert!(!PreflightReport::default().format().contains("RELEASE READINESS"));
}
#[test]
fn registry_desync_blocks_only_when_local_is_behind() {
let i = registry_desync_issue("znippy", "gatling", "0.1.4", Some("0.1.6"));
assert!(i.is_some());
let i = i.unwrap();
assert_eq!(i.kind, IssueKind::RegistryDesync);
assert!(i.detail.contains("gatling 0.1.4"), "{}", i.detail);
assert!(i.detail.contains("0.1.6"), "{}", i.detail);
assert!(i.fix.contains("0.1.7"), "fix names the bump target: {}", i.fix);
assert!(i.fix.contains("--only gatling"), "fix names the exact command: {}", i.fix);
assert!(i.fix.contains("git pull"), "fix offers the stale-tree path: {}", i.fix);
assert_eq!(bump_past("0.1.6").as_deref(), Some("0.1.7"));
assert_eq!(bump_past("not-semver"), None);
assert!(registry_desync_issue("z", "gatling", "0.1.7", Some("0.1.6")).is_none());
assert!(registry_desync_issue("z", "gatling", "0.1.6", Some("0.1.6")).is_none());
assert!(registry_desync_issue("z", "gatling", "0.1.6", None).is_none());
}
#[test]
fn run_flags_registry_desync_from_backend() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(
root.join("Cargo.toml"),
"[package]\nname = \"gatling\"\nversion = \"0.1.4\"\n\
description = \"d\"\nlicense = \"Apache-2.0\"\nrepository = \"https://example.com\"\n",
)
.unwrap();
let plans = vec![RepoPlan {
name: "znippy".to_string(),
root: root.to_path_buf(),
publish_set: vec![("gatling".to_string(), "0.1.4".to_string())],
}];
let report = run(
&plans,
false,
VerifyMode::Off,
&FakeProbe(Some(false)),
&FakeBackend(Some("0.1.6")),
&BTreeMap::new(),
);
assert!(
report.issues.iter().any(|i| i.kind == IssueKind::RegistryDesync),
"desync flagged: {:?}",
report.issues
);
assert!(report.format().contains("[desync]"), "{}", report.format());
}
#[test]
fn skew_is_cascade_resolvable_only_for_in_release_deps() {
let mut planned = BTreeMap::new();
planned.insert("facett_core".to_string(), "0.1.2".to_string());
planned.insert("gatling".to_string(), "0.1.2".to_string());
assert_eq!(
skew_is_cascade_resolvable(Some("facett_core"), &planned),
Some("0.1.2".to_string()),
"a sibling the cascade republishes must NOT block preflight",
);
assert_eq!(
skew_is_cascade_resolvable(Some("gatling"), &planned),
Some("0.1.2".to_string()),
);
assert_eq!(
skew_is_cascade_resolvable(Some("serde"), &planned),
None,
"a dep the cascade does NOT publish is still a genuine blocker",
);
assert_eq!(skew_is_cascade_resolvable(None, &planned), None);
assert_eq!(
skew_is_cascade_resolvable(Some("facett_core"), &BTreeMap::new()),
None,
);
}
#[test]
fn skew_resolvable_across_hyphen_underscore_separators() {
let mut planned = BTreeMap::new();
planned.insert("facett-core".to_string(), "0.1.11".to_string()); planned.insert("facett-graphview".to_string(), "0.1.11".to_string());
assert_eq!(
skew_is_cascade_resolvable(Some("facett_core"), &planned),
Some("0.1.11".to_string()),
"lib-name `facett_core` must match package `facett-core`",
);
assert_eq!(
skew_is_cascade_resolvable(Some("facett_graphview"), &planned),
Some("0.1.11".to_string()),
);
assert_eq!(skew_is_cascade_resolvable(Some("serde_json"), &planned), None);
}
#[test]
fn cascade_resolvable_skew_becomes_a_note_not_an_issue() {
let mut planned = BTreeMap::new();
planned.insert("facett_core".to_string(), "0.1.11".to_string());
let resolvable = match skew_is_cascade_resolvable(Some("facett_core"), &planned) {
Some(planned) => VerifyOutcome::CascadeResolvable {
dep: "facett_core".to_string(),
planned,
},
None => VerifyOutcome::Blocker(Issue {
repo: "facett".into(),
krate: "facett-graphnav".into(),
kind: IssueKind::VerifyFailed,
detail: String::new(),
fix: String::new(),
blames: vec!["facett_core".into()],
}),
};
assert_eq!(
resolvable,
VerifyOutcome::CascadeResolvable {
dep: "facett_core".to_string(),
planned: "0.1.11".to_string(),
},
);
let blocking = match skew_is_cascade_resolvable(Some("libc"), &planned) {
Some(planned) => VerifyOutcome::CascadeResolvable { dep: "libc".into(), planned },
None => VerifyOutcome::Blocker(Issue {
repo: "facett".into(),
krate: "facett-graphnav".into(),
kind: IssueKind::VerifyFailed,
detail: "x".into(),
fix: "y".into(),
blames: vec!["libc".into()],
}),
};
assert!(
matches!(blocking, VerifyOutcome::Blocker(ref i) if i.kind == IssueKind::VerifyFailed),
"external dep skew must remain a blocker",
);
}
#[test]
fn versioned_path_dev_deps_flags_only_path_plus_version() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(
root.join("Cargo.toml"),
"[package]\nname = \"facett-form\"\nversion = \"0.1.0\"\n\
[dev-dependencies]\n\
facett-wasm-demo = { path = \"../wasm-demo\", version = \"0.1\" }\n\
pure-registry = \"1.0\"\n\
path-only = { path = \"../other\" }\n",
)
.unwrap();
let got = versioned_path_dev_deps(root, "facett-form");
assert_eq!(got, vec![("facett-wasm-demo".to_string(), "0.1".to_string())]);
}
#[test]
fn extra_manifest_gaps_covers_readme_keywords_feature_and_unpublishable_dep() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let manifest = root.join("Cargo.toml");
std::fs::write(
&manifest,
"[package]\nname = \"c\"\nversion = \"0.1.0\"\n\
readme = \"README.md\"\n\
keywords = [\"ok\", \"this-keyword-is-way-too-long-to-be-valid\"]\n\
[dependencies]\n\
priv-sibling = { path = \"../priv\", version = \"0.1\" }\n\
[features]\n\
extra = [\"dep:absent\"]\n",
)
.unwrap();
let mut pf: BTreeSet<String> = BTreeSet::new();
pf.insert("priv-sibling".to_string());
let doc: toml::Value = std::fs::read_to_string(&manifest).unwrap().parse().unwrap();
let gaps = extra_manifest_gaps(&manifest, &doc, &pf);
assert!(gaps.iter().any(|(k, d, _)| *k == IssueKind::BadManifest && d.contains("readme")));
assert!(gaps.iter().any(|(k, d, _)| *k == IssueKind::BadManifest && d.contains("keyword")));
assert!(gaps.iter().any(|(k, d, _)| *k == IssueKind::BadManifest && d.contains("dep:absent")));
assert!(gaps
.iter()
.any(|(k, d, _)| *k == IssueKind::UnpublishableDep && d.contains("priv-sibling")));
std::fs::write(root.join("README.md"), "x").unwrap();
std::fs::write(
&manifest,
"[package]\nname = \"c\"\nversion = \"0.1.0\"\nreadme = \"README.md\"\n\
keywords = [\"ok\"]\n",
)
.unwrap();
let doc2: toml::Value = std::fs::read_to_string(&manifest).unwrap().parse().unwrap();
assert!(extra_manifest_gaps(&manifest, &doc2, &BTreeSet::new()).is_empty());
}
#[test]
fn oversized_tarball_issue_flags_over_10mib() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let pkgdir = root.join("target").join("package");
std::fs::create_dir_all(&pkgdir).unwrap();
std::fs::write(pkgdir.join("foo-1.0.0.crate"), vec![0u8; 11 * 1024 * 1024]).unwrap();
let issue = oversized_tarball_issue("r", root, "foo");
assert!(issue.is_some());
assert_eq!(issue.unwrap().kind, IssueKind::OversizedTarball);
assert!(oversized_tarball_issue("r", root, "fo").is_none());
std::fs::write(pkgdir.join("bar-2.0.0.crate"), vec![0u8; 1024]).unwrap();
assert!(oversized_tarball_issue("r", root, "bar").is_none());
assert!(oversized_tarball_issue("r", Path::new("/nonexistent-xyz"), "foo").is_none());
}
#[test]
fn duplicate_links_issues_flags_shared_links_value() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(root.join("Cargo.toml"), "[workspace]\nmembers=[\"a\",\"b\",\"c\"]\n").unwrap();
for (name, links) in [("a", "zlib"), ("b", "zlib"), ("c", "lz4")] {
std::fs::create_dir(root.join(name)).unwrap();
std::fs::write(
root.join(name).join("Cargo.toml"),
format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nlinks = \"{links}\"\n"),
)
.unwrap();
}
let plans = vec![RepoPlan {
name: "r".to_string(),
root: root.to_path_buf(),
publish_set: vec![
("a".to_string(), "0.1.0".to_string()),
("b".to_string(), "0.1.0".to_string()),
("c".to_string(), "0.1.0".to_string()),
],
}];
let issues = duplicate_links_issues(&plans);
let flagged: BTreeSet<String> = issues.iter().map(|i| i.krate.clone()).collect();
assert_eq!(flagged, ["a", "b"].iter().map(|s| s.to_string()).collect());
assert!(issues.iter().all(|i| i.detail.contains("zlib")));
}
#[test]
fn extra_manifest_gaps_flags_dangling_feature_refs() {
let dir = tempfile::tempdir().unwrap();
let manifest = dir.path().join("Cargo.toml");
std::fs::write(
&manifest,
"[package]\nname = \"c\"\nversion = \"0.1.0\"\n\
[dependencies]\n\
serde = { version = \"1\", optional = true }\n\
[features]\n\
default = [\"real\"]\n\
real = [\"serde\", \"serde/derive\"]\n\
broken = [\"ghost\", \"nodep/feat\"]\n",
)
.unwrap();
let doc: toml::Value = std::fs::read_to_string(&manifest).unwrap().parse().unwrap();
let gaps = extra_manifest_gaps(&manifest, &doc, &BTreeSet::new());
assert!(gaps.iter().any(|(_, d, _)| d.contains("includes `ghost`")), "{gaps:?}");
assert!(gaps.iter().any(|(_, d, _)| d.contains("nodep/feat") && d.contains("`nodep`")));
assert!(!gaps.iter().any(|(_, d, _)| d.contains("`real`") && d.contains("includes")));
assert!(!gaps.iter().any(|(_, d, _)| d.contains("serde/derive")));
}
#[test]
fn extra_manifest_gaps_flags_long_description_and_cross_registry_dep() {
let dir = tempfile::tempdir().unwrap();
let manifest = dir.path().join("Cargo.toml");
let long = "x".repeat(1001);
std::fs::write(
&manifest,
format!(
"[package]\nname = \"c\"\nversion = \"0.1.0\"\ndescription = \"{long}\"\n\
[dependencies]\n\
priv = {{ version = \"1\", registry = \"my-corp\" }}\n\
ok = \"1\"\n"
),
)
.unwrap();
let doc: toml::Value = std::fs::read_to_string(&manifest).unwrap().parse().unwrap();
let gaps = extra_manifest_gaps(&manifest, &doc, &BTreeSet::new());
assert!(gaps.iter().any(|(_, d, _)| d.contains("description is") && d.contains("1001")));
assert!(gaps.iter().any(|(_, d, _)| d.contains("`priv`") && d.contains("another registry")));
assert!(!gaps.iter().any(|(_, d, _)| d.contains("`ok`")));
}
#[test]
fn extra_manifest_gaps_flags_edition_and_rust_version() {
let dir = tempfile::tempdir().unwrap();
let manifest = dir.path().join("Cargo.toml");
std::fs::write(
&manifest,
"[package]\nname = \"c\"\nversion = \"0.1.0\"\n\
edition = \"2019\"\nrust-version = \"^1.74\"\n",
)
.unwrap();
let doc: toml::Value = std::fs::read_to_string(&manifest).unwrap().parse().unwrap();
let gaps = extra_manifest_gaps(&manifest, &doc, &BTreeSet::new());
assert!(gaps.iter().any(|(_, d, _)| d.contains("edition") && d.contains("2019")));
assert!(gaps.iter().any(|(_, d, _)| d.contains("rust-version")));
std::fs::write(
&manifest,
"[package]\nname = \"c\"\nversion = \"0.1.0\"\n\
edition = \"2021\"\nrust-version = \"1.74\"\n",
)
.unwrap();
let doc2: toml::Value = std::fs::read_to_string(&manifest).unwrap().parse().unwrap();
assert!(extra_manifest_gaps(&manifest, &doc2, &BTreeSet::new()).is_empty());
}
#[test]
fn extra_manifest_gaps_flags_non_http_url_fields() {
let dir = tempfile::tempdir().unwrap();
let manifest = dir.path().join("Cargo.toml");
std::fs::write(
&manifest,
"[package]\nname = \"c\"\nversion = \"0.1.0\"\n\
repository = \"github.com/x/y\"\n\
homepage = \"https://ok.example\"\n\
documentation = \"git@host:z\"\n",
)
.unwrap();
let doc: toml::Value = std::fs::read_to_string(&manifest).unwrap().parse().unwrap();
let gaps = extra_manifest_gaps(&manifest, &doc, &BTreeSet::new());
let urls: Vec<&String> = gaps
.iter()
.filter(|(k, d, _)| *k == IssueKind::BadManifest && d.contains("http://"))
.map(|(_, d, _)| d)
.collect();
assert_eq!(urls.len(), 2, "repository + documentation, not homepage: {urls:?}");
assert!(urls.iter().any(|d| d.contains("repository")));
assert!(urls.iter().any(|d| d.contains("documentation")));
assert!(!urls.iter().any(|d| d.contains("homepage")));
}
#[test]
fn declared_file_gaps_missing_and_non_utf8() {
let dir = tempfile::tempdir().unwrap();
let manifest = dir.path().join("Cargo.toml");
std::fs::write(
&manifest,
"[package]\nname = \"c\"\nversion = \"0.1.0\"\n\
readme = \"README.md\"\nlicense-file = \"LICENSE\"\n",
)
.unwrap();
std::fs::write(dir.path().join("README.md"), [0xff, 0xfe, 0x00]).unwrap();
let doc: toml::Value = std::fs::read_to_string(&manifest).unwrap().parse().unwrap();
let gaps = extra_manifest_gaps(&manifest, &doc, &BTreeSet::new());
assert!(gaps
.iter()
.any(|(k, d, _)| *k == IssueKind::BadManifest && d.contains("readme") && d.contains("UTF-8")));
assert!(gaps.iter().any(|(k, d, _)| *k == IssueKind::BadManifest
&& d.contains("license-file")
&& d.contains("does not exist")));
std::fs::write(dir.path().join("README.md"), "hello").unwrap();
std::fs::write(dir.path().join("LICENSE"), "MIT text").unwrap();
let doc2: toml::Value = std::fs::read_to_string(&manifest).unwrap().parse().unwrap();
assert!(extra_manifest_gaps(&manifest, &doc2, &BTreeSet::new()).is_empty());
}
#[test]
fn extra_manifest_gaps_keyword_start_and_category_count() {
let dir = tempfile::tempdir().unwrap();
let manifest = dir.path().join("Cargo.toml");
std::fs::write(
&manifest,
"[package]\nname = \"c\"\nversion = \"0.1.0\"\n\
keywords = [\"-badstart\", \"c++\", \"ok1\"]\n\
categories = [\"a\",\"b\",\"c\",\"d\",\"e\",\"f\"]\n",
)
.unwrap();
let doc: toml::Value = std::fs::read_to_string(&manifest).unwrap().parse().unwrap();
let gaps = extra_manifest_gaps(&manifest, &doc, &BTreeSet::new());
assert!(gaps.iter().any(|(k, d, _)| *k == IssueKind::BadManifest
&& d.contains("keyword")
&& d.contains("-badstart")));
assert!(!gaps.iter().any(|(_, d, _)| d.contains("\"c++\"")), "c++ is a valid keyword");
assert!(gaps.iter().any(|(k, d, _)| *k == IssueKind::BadManifest
&& d.contains("categories")
&& d.contains("at most 5")));
}
#[test]
fn crate_name_problem_flags_the_reject_classes() {
for ok in ["facett-core", "gatling", "znippy_common", "a", "x2", "serde"] {
assert!(crate_name_problem(ok).is_none(), "valid rejected: {ok}");
}
assert!(crate_name_problem("").is_some());
assert!(crate_name_problem(&"a".repeat(65)).unwrap().contains("at most 64"));
assert!(crate_name_problem(&"a".repeat(64)).is_none(), "exactly 64 is ok");
assert!(crate_name_problem("2fast").unwrap().contains("ASCII letter"), "leading digit");
assert!(crate_name_problem("-lead").unwrap().contains("ASCII letter"), "leading dash");
assert!(crate_name_problem("_lead").unwrap().contains("ASCII letter"), "leading underscore");
assert!(crate_name_problem("has space").unwrap().contains("contains"));
assert!(crate_name_problem("has.dot").unwrap().contains("contains"));
assert!(crate_name_problem("café").unwrap().contains("contains"), "non-ASCII");
assert!(crate_name_problem("nul").unwrap().contains("reserved"));
assert!(crate_name_problem("COM1").unwrap().contains("reserved"));
}
#[test]
fn spdx_license_problem_flags_only_confident_invalids() {
for ok in [
"MIT",
"Apache-2.0",
"MIT OR Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"(MIT OR Apache-2.0) AND BSD-3-Clause",
"GPL-3.0-or-later",
"GPL-2.0+",
"LicenseRef-My-Custom",
"0BSD",
] {
assert!(spdx_license_problem(ok).is_none(), "valid rejected: {ok}");
}
assert!(spdx_license_problem("").is_some());
assert!(spdx_license_problem(" ").is_some());
assert!(spdx_license_problem("MIT/Apache-2.0").unwrap().contains("deprecated `/`"));
assert!(spdx_license_problem("MIT OR").is_some(), "trailing operator");
assert!(spdx_license_problem("OR MIT").is_some(), "leading operator");
assert!(spdx_license_problem("MIT Apache-2.0").is_some(), "two ids, no operator");
assert!(spdx_license_problem("(MIT OR Apache-2.0").is_some(), "unbalanced");
assert!(spdx_license_problem("MIT AND (Apache-2.0))").is_some(), "unbalanced");
assert!(spdx_license_problem("M IT_bad").is_some() || spdx_license_problem("MIT_bad").is_some());
}
#[test]
fn license_spdx_issue_catches_explicit_and_inherited() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"m\"]\n[workspace.package]\nlicense = \"MIT/Apache-2.0\"\n",
)
.unwrap();
std::fs::create_dir(root.join("m")).unwrap();
std::fs::write(
root.join("m/Cargo.toml"),
"[package]\nname = \"m\"\nversion = \"0.1.0\"\nlicense = { workspace = true }\n",
)
.unwrap();
let issue = license_spdx_issue(root, "m");
assert!(issue.is_some(), "inherited bad license must be caught");
assert!(issue.unwrap().0.contains("deprecated `/`"));
std::fs::write(
root.join("m/Cargo.toml"),
"[package]\nname = \"m\"\nversion = \"0.1.0\"\nlicense = \"MIT OR Apache-2.0\"\n",
)
.unwrap();
assert!(license_spdx_issue(root, "m").is_none());
}
#[test]
fn extra_manifest_gaps_flags_bare_wildcard_deps() {
let dir = tempfile::tempdir().unwrap();
let manifest = dir.path().join("Cargo.toml");
std::fs::write(
&manifest,
"[package]\nname = \"c\"\nversion = \"0.1.0\"\n\
[dependencies]\n\
star = \"*\"\n\
star-tbl = { version = \"*\" }\n\
precise = \"1.*\"\n\
fine = \"1.2\"\n\
[build-dependencies]\n\
bstar = \"*\"\n\
[target.'cfg(unix)'.dependencies]\n\
tstar = \"*\"\n\
[dev-dependencies]\n\
devstar = \"*\"\n",
)
.unwrap();
let doc: toml::Value = std::fs::read_to_string(&manifest).unwrap().parse().unwrap();
let gaps = extra_manifest_gaps(&manifest, &doc, &BTreeSet::new());
let wildcards: Vec<&String> = gaps
.iter()
.filter(|(k, d, _)| *k == IssueKind::BadManifest && d.contains("wildcard"))
.map(|(_, d, _)| d)
.collect();
assert_eq!(wildcards.len(), 4, "got {wildcards:?}");
assert!(wildcards.iter().any(|d| d.contains("`star`")));
assert!(wildcards.iter().any(|d| d.contains("`star-tbl`")));
assert!(wildcards.iter().any(|d| d.contains("`bstar`")));
assert!(wildcards.iter().any(|d| d.contains("`tstar`")));
assert!(!wildcards.iter().any(|d| d.contains("devstar")), "dev-deps are stripped");
assert!(!wildcards.iter().any(|d| d.contains("precise")), "1.* is allowed");
}
#[test]
fn extra_manifest_gaps_flags_git_dep_without_version() {
let dir = tempfile::tempdir().unwrap();
let manifest = dir.path().join("Cargo.toml");
std::fs::write(
&manifest,
"[package]\nname = \"c\"\nversion = \"0.1.0\"\n\
[dependencies]\n\
gitless = { git = \"https://example.com/x\" }\n\
gitver = { git = \"https://example.com/y\", version = \"1.2\" }\n\
plain = \"1.0\"\n",
)
.unwrap();
let doc: toml::Value = std::fs::read_to_string(&manifest).unwrap().parse().unwrap();
let gaps = extra_manifest_gaps(&manifest, &doc, &BTreeSet::new());
let git_gaps: Vec<&String> = gaps
.iter()
.filter(|(k, d, _)| *k == IssueKind::UnpublishableDep && d.contains("git dependency"))
.map(|(_, d, _)| d)
.collect();
assert_eq!(git_gaps.len(), 1, "only the version-less git dep: {git_gaps:?}");
assert!(git_gaps[0].contains("`gitless`"));
}
#[test]
fn extract_resolve_dep_names_the_unresolved_sibling() {
assert_eq!(
extract_resolve_dep("error: no matching package named `facett-wasm-demo` found"),
Some("facett-wasm-demo".to_string())
);
assert_eq!(
extract_resolve_dep(
"error: failed to select a version for the requirement `nornir-jobs = \"^0.1\"`"
),
Some("nornir-jobs".to_string())
);
assert_eq!(extract_resolve_dep("some unrelated failure"), None);
}
struct FakeOracle(BTreeMap<String, Vec<u8>>);
impl crate::release::source_drift::PublishedSourceOracle for FakeOracle {
fn published_sources(
&self,
_krate: &str,
_version: &str,
) -> Result<BTreeMap<String, Vec<u8>>, String> {
Ok(self.0.clone())
}
}
#[test]
fn preflight_verify_mode_is_identical_for_dry_run_and_real() {
assert_eq!(preflight_verify_mode(true), VerifyMode::On, "dry-run must verify");
assert_eq!(preflight_verify_mode(false), VerifyMode::On, "real must verify");
assert_eq!(
preflight_verify_mode(true),
preflight_verify_mode(false),
"dry-run and real preflight MUST use the SAME verify mode"
);
}
#[test]
fn correctness_blocker_classification_is_single_sourced() {
assert!(IssueKind::VerifyFailed.is_correctness_blocker());
assert!(IssueKind::StaleSourceDrift.is_correctness_blocker());
for k in [
IssueKind::NonMember,
IssueKind::MissingMetadata,
IssueKind::RegistrySkew,
IssueKind::RegistryDesync,
IssueKind::VersionedPathDevDep,
IssueKind::UnpublishableDep,
IssueKind::BadManifest,
IssueKind::InvalidCrateName,
IssueKind::OversizedTarball,
] {
assert!(!k.is_correctness_blocker(), "{k:?} must be soft");
}
}
#[test]
fn gate_early_fails_on_correctness_blocker_and_force_cannot_override() {
let mut report = PreflightReport::default();
report.checked_crates = 1;
report.push(Issue {
repo: "draupnir".into(),
krate: "draupnir".into(),
kind: IssueKind::StaleSourceDrift,
detail: "local source differs from published draupnir@0.1.4 (new API)".into(),
fix: "bump draupnir".into(),
blames: vec!["draupnir".into()],
});
assert_eq!(gate_verdict(&report, false), GateVerdict::BlockCorrectness(1));
assert_eq!(
gate_verdict(&report, true),
GateVerdict::BlockCorrectness(1),
"--force must NEVER wave through a correctness blocker"
);
let mut soft = PreflightReport::default();
soft.push(Issue {
repo: "solo".into(),
krate: "solo".into(),
kind: IssueKind::MissingMetadata,
detail: "missing license".into(),
fix: "add license".into(),
blames: Vec::new(),
});
assert_eq!(gate_verdict(&soft, false), GateVerdict::BlockSoft(1));
assert_eq!(gate_verdict(&soft, true), GateVerdict::Proceed { forced_soft: 1 });
assert_eq!(
gate_verdict(&PreflightReport::default(), false),
GateVerdict::Proceed { forced_soft: 0 }
);
}
#[test]
fn drifted_unpublished_dep_yields_correctness_blocker_in_both_modes() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(
root.join("Cargo.toml"),
r#"[package]
name = "draupnir"
version = "0.1.4"
edition = "2021"
description = "d"
license = "Apache-2.0"
repository = "https://example.com"
"#,
)
.unwrap();
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/lib.rs"), "pub fn new_api() {}\n").unwrap();
let plans = vec![RepoPlan {
name: "draupnir".to_string(),
root: root.to_path_buf(),
publish_set: vec![("draupnir".to_string(), "0.1.4".to_string())],
}];
let mut published = BTreeMap::new();
published.insert("src/lib.rs".to_string(), b"// old API only\n".to_vec());
let oracle = FakeOracle(published);
let (issues, _notes) =
source_drift_issues(&plans, &FakeProbe(Some(true)), &oracle);
let drift: Vec<&Issue> =
issues.iter().filter(|i| i.kind == IssueKind::StaleSourceDrift).collect();
if drift.is_empty() {
eprintln!(
"note: source-drift produced no blocker (cargo `package --list` unavailable?) — \
classification/gate parity still asserted below"
);
} else {
assert_eq!(drift.len(), 1, "one drifted crate ⇒ one correctness blocker");
assert!(drift[0].kind.is_correctness_blocker());
}
let mut report = PreflightReport::default();
report.checked_crates = 1;
if let Some(i) = drift.first() {
report.push((*i).clone());
} else {
report.push(Issue {
repo: "draupnir".into(),
krate: "draupnir".into(),
kind: IssueKind::StaleSourceDrift,
detail: "drift".into(),
fix: "bump".into(),
blames: vec!["draupnir".into()],
});
}
assert_eq!(preflight_verify_mode(true), preflight_verify_mode(false));
assert_eq!(gate_verdict(&report, false), GateVerdict::BlockCorrectness(1));
assert_eq!(
gate_verdict(&report, true),
GateVerdict::BlockCorrectness(1),
"dry-run and real BOTH block; --force cannot override"
);
}
}