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,
}
#[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",
};
s.push_str(&format!(" ⛔ [{tag}] {}/{}: {}\n", i.repo, i.krate, i.detail));
s.push_str(&format!(" fix: {}\n", i.fix));
}
}
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>;
}
pub struct CratesIoProbe;
impl RegistryProbe for CratesIoProbe {
fn version_live(&self, krate: &str, version: &str) -> Option<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().any(|e| e.get("num").and_then(|n| n.as_str()) == Some(version)))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VerifyMode {
On,
Off,
}
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();
for plan in plans {
let members = cargo::root_workspace_members(&plan.root);
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(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(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 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) {
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
));
}
}
}
}
}
report
}
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 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("?");
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!(
"bump {krate} to at least {pub_v} (ideally past it) so the local tree \
matches or leads the registry before starting the cascade"
),
blames: Vec::new(),
})
}
_ => None,
}
}
#[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>,
) -> VerifyOutcome {
let Ok(out) = std::process::Command::new("cargo")
.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);
match classify_publish_failure(krate, &stderr) {
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(),
})
}
_ => VerifyOutcome::Clean,
}
}
#[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 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 = \"r\"\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 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!(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 = \"r\"\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",
);
}
}