use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ForbiddenDep {
pub crate_name: String,
pub version: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DepPolicy {
pub forbidden: Vec<ForbiddenDep>,
}
#[derive(Debug, Clone)]
pub struct RepoExternals {
pub repo: String,
pub deps: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum SkewStatus {
Ok,
Behind,
Forbidden,
}
#[derive(Debug, Clone, Serialize)]
pub struct RepoCrateStatus {
pub repo: String,
pub version: String,
pub status: SkewStatus,
#[serde(default)]
pub held_by_transitive_pin: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct CrateSkew {
pub crate_name: String,
pub target: String,
pub diverged: bool,
pub entries: Vec<RepoCrateStatus>,
}
impl CrateSkew {
pub fn bump_repos(&self) -> Vec<&str> {
self.entries
.iter()
.filter(|e| e.status != SkewStatus::Ok)
.map(|e| e.repo.as_str())
.collect()
}
}
fn version_key(s: &str) -> (u64, 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), it.next().unwrap_or(0))
}
fn is_forbidden(crate_name: &str, version: &str, policy: &DepPolicy) -> bool {
policy
.forbidden
.iter()
.any(|f| f.crate_name == crate_name && same_semver_line(&f.version, version))
}
fn same_semver_line(forbidden: &str, version: &str) -> bool {
let (fm, fmin, _) = version_key(forbidden);
let (vm, vmin, _) = version_key(version);
if fm == 0 { fm == vm && fmin == vmin } else { fm == vm }
}
pub fn analyze_skew(repos: &[RepoExternals], policy: &DepPolicy) -> Vec<CrateSkew> {
let mut by_crate: BTreeMap<&str, Vec<(&str, &str)>> = BTreeMap::new();
for r in repos {
for (c, v) in &r.deps {
by_crate.entry(c.as_str()).or_default().push((r.repo.as_str(), v.as_str()));
}
}
let mut out = Vec::new();
for (crate_name, mut uses) in by_crate {
let has_forbidden = uses.iter().any(|(_, v)| is_forbidden(crate_name, v, policy));
let distinct: std::collections::BTreeSet<(u64, u64, u64)> =
uses.iter().map(|(_, v)| version_key(v)).collect();
let diverged = distinct.len() >= 2;
if !diverged && !has_forbidden {
continue;
}
uses.sort_by(|a, b| a.0.cmp(b.0)); let target_key = uses.iter().map(|(_, v)| version_key(v)).max().unwrap();
let target = uses
.iter()
.find(|(_, v)| version_key(v) == target_key)
.map(|(_, v)| v.to_string())
.unwrap();
let entries = uses
.iter()
.map(|(repo, v)| {
let status = if is_forbidden(crate_name, v, policy) {
SkewStatus::Forbidden
} else if version_key(v) < target_key {
SkewStatus::Behind
} else {
SkewStatus::Ok
};
RepoCrateStatus {
repo: repo.to_string(),
version: v.to_string(),
status,
held_by_transitive_pin: false,
}
})
.collect();
out.push(CrateSkew { crate_name: crate_name.to_string(), target, diverged, entries });
}
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum ForkKind {
Path,
Git,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct PatchForkBlock {
pub crate_name: String,
pub patched_dep: String,
pub fork_kind: ForkKind,
pub source: String,
pub reason: String,
pub is_foreign_fork: bool,
}
pub fn patch_fork_blockers(repo_root: &Path) -> Vec<PatchForkBlock> {
let mut out = Vec::new();
for toml_path in find_cargo_tomls(repo_root, 4) {
let Ok(txt) = std::fs::read_to_string(&toml_path) else { continue };
let Ok(doc) = txt.parse::<toml::Value>() else { continue };
let owner = doc
.get("package")
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str())
.map(str::to_string);
let Some(patch) = doc.get("patch") else { continue };
let Some(patch_tbl) = patch.as_table() else { continue };
for key in ["crates-io"] {
let Some(entries) = patch_tbl.get(key).and_then(|t| t.as_table()) else { continue };
for (dep, spec) in entries {
let (kind, source) = match spec {
toml::Value::String(_) => continue,
toml::Value::Table(t) => {
if let Some(p) = t.get("path").and_then(|v| v.as_str()) {
(ForkKind::Path, p.to_string())
} else if let Some(g) = t.get("git").and_then(|v| v.as_str()) {
(ForkKind::Git, g.to_string())
} else {
continue;
}
}
_ => continue,
};
let owner_name = owner.clone().unwrap_or_else(|| {
repo_root
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("workspace")
.to_string()
});
let reason = format!(
"publishing strips [patch.crates-io] → stock {dep} is incompatible \
(fork: {source}). Unblock: publish the fork, or wait for upstream."
);
out.push(PatchForkBlock {
crate_name: owner_name,
patched_dep: dep.clone(),
fork_kind: kind,
source,
reason,
is_foreign_fork: false,
});
}
}
}
out.sort_by(|a, b| {
(&a.crate_name, &a.patched_dep).cmp(&(&b.crate_name, &b.patched_dep))
});
out
}
pub fn promote_blocked_crates(
graphs: &[RepoGraph],
directly_blocked: &std::collections::BTreeSet<String>,
) -> std::collections::BTreeSet<String> {
use std::collections::BTreeSet;
let produces: Vec<(&str, &BTreeSet<String>, &BTreeSet<String>)> = graphs
.iter()
.map(|g| (g.repo.as_str(), &g.produces, &g.deps))
.collect();
let mut blocked: BTreeSet<String> = directly_blocked.clone();
loop {
let mut grew = false;
for (_repo, repo_produces, repo_deps) in &produces {
let repo_blocked = repo_produces.iter().any(|c| blocked.contains(c))
|| repo_deps.iter().any(|d| blocked.contains(d));
if repo_blocked {
for c in repo_produces.iter() {
if blocked.insert(c.clone()) {
grew = true;
}
}
}
}
if !grew {
break;
}
}
blocked
}
pub fn gather_crate_deps(
root: &Path,
) -> BTreeMap<String, std::collections::BTreeSet<String>> {
use std::collections::BTreeSet;
let mut out: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for toml_path in find_cargo_tomls(root, 4) {
let Ok(txt) = std::fs::read_to_string(&toml_path) else { continue };
let Ok(doc) = txt.parse::<toml::Value>() else { continue };
let package = doc.get("package");
let publishable = package
.and_then(|p| p.get("publish"))
.and_then(|v| v.as_bool())
.unwrap_or(true);
if !publishable {
continue;
}
let Some(name) =
package.and_then(|p| p.get("name")).and_then(|n| n.as_str())
else {
continue;
};
let entry = out.entry(name.to_string()).or_default();
for key in ["dependencies", "build-dependencies"] {
if let Some(t) = doc.get(key).and_then(|t| t.as_table()) {
for (dep, spec) in t {
let optional = spec
.as_table()
.and_then(|d| d.get("optional"))
.and_then(|o| o.as_bool())
.unwrap_or(false);
if !optional {
entry.insert(dep.clone());
}
}
}
}
}
out
}
pub fn promote_blocked_crates_precise(
crate_deps: &BTreeMap<String, std::collections::BTreeSet<String>>,
foreign_forks: &std::collections::BTreeSet<String>,
) -> std::collections::BTreeSet<String> {
use std::collections::BTreeSet;
let mut blocked: BTreeSet<String> = BTreeSet::new();
if foreign_forks.is_empty() {
return blocked;
}
for crate_name in crate_deps.keys() {
let mut stack = vec![crate_name.clone()];
let mut seen: BTreeSet<String> = BTreeSet::new();
let mut hit = false;
while let Some(c) = stack.pop() {
if foreign_forks.contains(&c) {
hit = true;
break;
}
if !seen.insert(c.clone()) {
continue;
}
if let Some(deps) = crate_deps.get(&c) {
for d in deps {
if foreign_forks.contains(d) {
hit = true;
break;
}
if crate_deps.contains_key(d) {
stack.push(d.clone());
}
}
}
if hit {
break;
}
}
if hit {
blocked.insert(crate_name.clone());
}
}
blocked
}
pub fn compute_promote_block<I, P>(repos: I) -> PromoteBlockResult
where
I: IntoIterator<Item = (String, P)>,
P: AsRef<Path>,
{
use std::collections::BTreeSet;
let mut crate_deps: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
let mut forks: Vec<PatchForkBlock> = Vec::new();
for (_name, path) in repos {
let path = path.as_ref();
for (c, deps) in gather_crate_deps(path) {
crate_deps.entry(c).or_default().extend(deps);
}
forks.extend(patch_fork_blockers(path));
}
let produced: BTreeSet<String> = crate_deps.keys().cloned().collect();
let foreign_forks: BTreeSet<String> = forks
.iter()
.map(|b| b.patched_dep.clone())
.filter(|d| !produced.contains(d))
.collect();
for b in forks.iter_mut() {
b.is_foreign_fork = foreign_forks.contains(&b.patched_dep);
}
forks.sort_by(|a, b| {
(&a.crate_name, &a.patched_dep).cmp(&(&b.crate_name, &b.patched_dep))
});
let blocked = promote_blocked_crates_precise(&crate_deps, &foreign_forks);
PromoteBlockResult { forks, foreign_forks, blocked }
}
#[derive(Debug, Clone, Default)]
pub struct PromoteBlockResult {
pub forks: Vec<PatchForkBlock>,
pub foreign_forks: std::collections::BTreeSet<String>,
pub blocked: std::collections::BTreeSet<String>,
}
pub fn crate_majors_in_lock(lock_text: &str, crate_name: &str) -> std::collections::BTreeSet<u64> {
let mut out = std::collections::BTreeSet::new();
let Ok(doc) = lock_text.parse::<toml::Value>() else { return out };
let Some(pkgs) = doc.get("package").and_then(|p| p.as_array()) else { return out };
for pkg in pkgs {
let name = pkg.get("name").and_then(|n| n.as_str());
let ver = pkg.get("version").and_then(|v| v.as_str());
if name == Some(crate_name) {
if let Some(v) = ver {
out.insert(version_key(v).0);
}
}
}
out
}
pub fn enrich_transitive_pins(
skew: &mut [CrateSkew],
repo_locks: &BTreeMap<String, String>,
) {
for c in skew.iter_mut() {
let target_major = version_key(&c.target).0;
for e in c.entries.iter_mut() {
if e.status != SkewStatus::Behind {
continue;
}
let declared_major = version_key(&e.version).0;
if declared_major >= target_major {
continue;
}
if let Some(lock) = repo_locks.get(&e.repo) {
let majors = crate_majors_in_lock(lock, &c.crate_name);
if majors.contains(&declared_major) && majors.contains(&target_major) {
e.held_by_transitive_pin = true;
}
}
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct RepoDirty {
pub repo: String,
pub dirty: bool,
pub error: Option<String>,
}
pub fn check_dirty(repos: &[(String, PathBuf)]) -> Vec<RepoDirty> {
repos
.iter()
.map(|(name, path)| match crate::gitio::worktree_freshness(path) {
Ok(f) => RepoDirty { repo: name.clone(), dirty: f.dirty, error: None },
Err(e) => RepoDirty { repo: name.clone(), dirty: false, error: Some(e.to_string()) },
})
.collect()
}
pub fn gather_repo_externals(repo: &str, root: &Path) -> Result<RepoExternals> {
let mut deps: BTreeMap<String, String> = BTreeMap::new();
for toml_path in find_cargo_tomls(root, 4) {
let Ok(txt) = std::fs::read_to_string(&toml_path) else { continue };
let Ok(doc) = txt.parse::<toml::Value>() else { continue };
for key in ["dependencies", "build-dependencies"] {
collect_deps(doc.get(key), &mut deps);
}
if let Some(ws) = doc.get("workspace").and_then(|w| w.get("dependencies")) {
collect_deps(Some(ws), &mut deps);
}
}
Ok(RepoExternals { repo: repo.to_string(), deps })
}
fn collect_deps(table: Option<&toml::Value>, deps: &mut BTreeMap<String, String>) {
let Some(table) = table.and_then(|t| t.as_table()) else { return };
for (name, spec) in table {
let version = match spec {
toml::Value::String(v) => Some(v.clone()),
toml::Value::Table(t) => {
if t.contains_key("path") {
None } else {
t.get("version").and_then(|v| v.as_str()).map(str::to_string)
}
}
_ => None,
};
if let Some(v) = version {
deps.entry(name.clone())
.and_modify(|cur| {
if version_key(&v) > version_key(cur) {
*cur = v.clone();
}
})
.or_insert(v);
}
}
}
fn find_cargo_tomls(root: &Path, max_depth: usize) -> Vec<PathBuf> {
let mut out = Vec::new();
let mut stack = vec![(root.to_path_buf(), 0usize)];
while let Some((dir, depth)) = stack.pop() {
let manifest = dir.join("Cargo.toml");
if manifest.is_file() {
out.push(manifest);
}
if depth >= max_depth {
continue;
}
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
for e in entries.flatten() {
let p = e.path();
if e.file_type().map(|t| t.is_symlink()).unwrap_or(false) {
continue;
}
if !p.is_dir() {
continue;
}
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name == "target" || name.starts_with('.') {
continue;
}
stack.push((p, depth + 1));
}
}
out
}
#[derive(Debug, Clone, Serialize)]
pub struct PathDepVersionGap {
pub repo: String,
pub manifest: String,
pub crate_name: String,
pub dep: String,
pub dep_owner: Option<String>,
pub suggested_version: Option<String>,
pub via_workspace: bool,
}
fn read_repo_manifests(
root: &Path,
) -> (Vec<(PathBuf, toml::Value)>, Option<String>, BTreeMap<String, toml::Value>) {
let mut docs = Vec::new();
let mut ws_pkg_ver = None;
let mut ws_deps = BTreeMap::new();
for p in find_cargo_tomls(root, 4) {
let Ok(txt) = std::fs::read_to_string(&p) else { continue };
let Ok(doc) = txt.parse::<toml::Value>() else { continue };
if let Some(w) = doc.get("workspace") {
if let Some(v) =
w.get("package").and_then(|pk| pk.get("version")).and_then(|v| v.as_str())
{
ws_pkg_ver = Some(v.to_string());
}
if let Some(t) = w.get("dependencies").and_then(|t| t.as_table()) {
for (k, spec) in t {
ws_deps.insert(k.clone(), spec.clone());
}
}
}
docs.push((p, doc));
}
(docs, ws_pkg_ver, ws_deps)
}
fn crate_version_of(pkg: &toml::Value, ws_pkg_ver: &Option<String>) -> Option<String> {
match pkg.get("version") {
Some(toml::Value::String(s)) => Some(s.clone()),
Some(toml::Value::Table(t))
if t.get("workspace").and_then(|w| w.as_bool()).unwrap_or(false) =>
{
ws_pkg_ver.clone()
}
_ => None,
}
}
pub fn scan_path_dep_version_gaps(repos: &[(String, PathBuf)]) -> Vec<PathDepVersionGap> {
let mut produced: BTreeMap<String, (String, Option<String>)> = BTreeMap::new();
for (repo, root) in repos {
let (docs, ws_pkg_ver, _) = read_repo_manifests(root);
for (_p, doc) in &docs {
let Some(pkg) = doc.get("package") else { continue };
if !pkg.get("publish").and_then(|v| v.as_bool()).unwrap_or(true) {
continue;
}
let Some(name) = pkg.get("name").and_then(|n| n.as_str()) else { continue };
let ver = crate_version_of(pkg, &ws_pkg_ver);
produced.entry(name.to_string()).or_insert((repo.clone(), ver));
}
}
let mut gaps: Vec<PathDepVersionGap> = Vec::new();
for (repo, root) in repos {
let (docs, _ws_pkg_ver, ws_deps) = read_repo_manifests(root);
for (path, doc) in &docs {
let Some(pkg) = doc.get("package") else { continue };
if !pkg.get("publish").and_then(|v| v.as_bool()).unwrap_or(true) {
continue;
}
let Some(crate_name) = pkg.get("name").and_then(|n| n.as_str()) else { continue };
let rel = path.strip_prefix(root).unwrap_or(path).display().to_string();
for tbl in publish_dep_tables(doc) {
for (dkey, spec) in tbl {
let via_ws = spec
.as_table()
.and_then(|t| t.get("workspace"))
.and_then(|w| w.as_bool())
.unwrap_or(false);
let eff = if via_ws { ws_deps.get(dkey).unwrap_or(spec) } else { spec };
let Some(et) = eff.as_table() else { continue }; if !et.contains_key("path") || et.contains_key("version") {
continue; }
let real = dep_real_name(dkey, eff);
let owner = produced.get(&real);
gaps.push(PathDepVersionGap {
repo: repo.clone(),
manifest: rel.clone(),
crate_name: crate_name.to_string(),
dep: real,
dep_owner: owner.map(|(r, _)| r.clone()),
suggested_version: owner.and_then(|(_, v)| v.clone()),
via_workspace: via_ws,
});
}
}
}
}
gaps.sort_by(|a, b| {
(a.dep_owner.is_some(), &a.repo, &a.manifest, &a.dep)
.cmp(&(b.dep_owner.is_some(), &b.repo, &b.manifest, &b.dep))
});
gaps.dedup_by(|a, b| a.repo == b.repo && a.manifest == b.manifest && a.dep == b.dep);
gaps
}
fn publish_dep_tables(doc: &toml::Value) -> Vec<&toml::value::Table> {
let mut out = Vec::new();
for key in ["dependencies", "build-dependencies"] {
if let Some(t) = doc.get(key).and_then(|t| t.as_table()) {
out.push(t);
}
}
if let Some(targets) = doc.get("target").and_then(|t| t.as_table()) {
for (_cfg, ct) in targets {
for key in ["dependencies", "build-dependencies"] {
if let Some(t) = ct.get(key).and_then(|t| t.as_table()) {
out.push(t);
}
}
}
}
out
}
fn dep_table_mut<'a>(
doc: &'a mut toml_edit::DocumentMut,
cfg: Option<&str>,
table_name: &str,
) -> Option<&'a mut toml_edit::Table> {
match cfg {
None => doc.get_mut(table_name).and_then(|t| t.as_table_mut()),
Some(c) => doc
.get_mut("target")
.and_then(|t| t.as_table_mut())
.and_then(|t| t.get_mut(c))
.and_then(|c| c.as_table_mut())
.and_then(|c| c.get_mut(table_name))
.and_then(|t| t.as_table_mut()),
}
}
fn resolve_crate_version(dep_manifest: &Path) -> Option<String> {
let text = std::fs::read_to_string(dep_manifest).ok()?;
let doc = text.parse::<toml::Value>().ok()?;
match doc.get("package").and_then(|p| p.get("version")) {
Some(toml::Value::String(s)) => return Some(s.clone()),
Some(toml::Value::Table(t))
if t.get("workspace").and_then(|w| w.as_bool()).unwrap_or(false) => {}
_ => return None,
}
let mut dir = dep_manifest.parent();
while let Some(d) = dir {
if let Ok(txt) = std::fs::read_to_string(d.join("Cargo.toml")) {
if let Ok(doc) = txt.parse::<toml::Value>() {
if let Some(v) = doc
.get("workspace")
.and_then(|w| w.get("package"))
.and_then(|pk| pk.get("version"))
.and_then(|v| v.as_str())
{
return Some(v.to_string());
}
}
}
dir = d.parent();
}
None
}
#[derive(Debug, Default)]
pub struct FixOutcome {
pub fixed: usize,
pub skipped: Vec<String>,
}
pub fn fix_path_dep_versions(
gaps: &[PathDepVersionGap],
repos: &[(String, PathBuf)],
) -> Result<FixOutcome> {
let repo_root: BTreeMap<&str, &Path> =
repos.iter().map(|(n, p)| (n.as_str(), p.as_path())).collect();
let mut by_file: BTreeMap<PathBuf, Vec<&PathDepVersionGap>> = BTreeMap::new();
for g in gaps {
let Some(root) = repo_root.get(g.repo.as_str()) else {
continue;
};
by_file.entry(root.join(&g.manifest)).or_default().push(g);
}
let mut out = FixOutcome::default();
for (manifest, gs) in by_file {
let text = std::fs::read_to_string(&manifest)
.with_context(|| format!("read {}", manifest.display()))?;
let mut doc: toml_edit::DocumentMut =
text.parse().with_context(|| format!("parse {}", manifest.display()))?;
let mdir = manifest.parent().unwrap_or_else(|| Path::new("."));
let mut changed = false;
let cfgs: Vec<String> = doc
.get("target")
.and_then(|t| t.as_table())
.map(|t| t.iter().map(|(k, _)| k.to_string()).collect())
.unwrap_or_default();
let mut locators: Vec<(Option<String>, &str)> = Vec::new();
for tn in ["dependencies", "build-dependencies"] {
locators.push((None, tn));
}
for c in &cfgs {
for tn in ["dependencies", "build-dependencies"] {
locators.push((Some(c.clone()), tn));
}
}
for g in gs {
let mut handled = false;
for (cfg, table_name) in &locators {
let Some(tbl) = dep_table_mut(&mut doc, cfg.as_deref(), table_name) else {
continue;
};
let key = tbl.iter().find_map(|(k, item)| {
let real =
item.get("package").and_then(|p| p.as_str()).unwrap_or(k);
let has_path = item.get("path").is_some();
let has_ver = item.get("version").is_some();
(real == g.dep && has_path && !has_ver).then(|| k.to_string())
});
let Some(key) = key else { continue };
let path_val = tbl
.get(&key)
.and_then(|i| i.get("path"))
.and_then(|p| p.as_str())
.map(|p| mdir.join(p).join("Cargo.toml"));
let ver = path_val
.as_deref()
.and_then(resolve_crate_version)
.or_else(|| g.suggested_version.clone());
match ver {
Some(v) => {
if let Some(item) = tbl.get_mut(&key) {
if let Some(it) = item.as_inline_table_mut() {
it.insert("version", v.into());
} else if let Some(t) = item.as_table_mut() {
t.insert("version", toml_edit::value(v));
}
out.fixed += 1;
changed = true;
}
}
None => out.skipped.push(format!(
"{}/{}: {} (no resolvable version)",
g.repo, g.manifest, g.dep
)),
}
handled = true;
break;
}
if !handled {
out.skipped.push(format!(
"{}/{}: {} (dep entry not found — already fixed?)",
g.repo, g.manifest, g.dep
));
}
}
if changed {
std::fs::write(&manifest, doc.to_string())
.with_context(|| format!("write {}", manifest.display()))?;
}
}
Ok(out)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum DepKind {
Normal,
Build,
Dev,
}
#[derive(Debug, Clone, Default)]
pub struct RepoGraph {
pub repo: String,
pub produces: std::collections::BTreeSet<String>,
pub deps: std::collections::BTreeSet<String>,
pub dev_deps: std::collections::BTreeSet<String>,
pub optional_deps: std::collections::BTreeMap<String, std::collections::BTreeSet<String>>,
pub cfg_deps: std::collections::BTreeMap<String, std::collections::BTreeSet<String>>,
pub version_pinned: std::collections::BTreeSet<String>,
pub crate_deps: std::collections::BTreeMap<String, std::collections::BTreeSet<String>>,
}
fn dep_real_name(key: &str, spec: &toml::Value) -> String {
spec.as_table()
.and_then(|t| t.get("package"))
.and_then(|p| p.as_str())
.unwrap_or(key)
.to_string()
}
fn dep_is_source_path(
spec: &toml::Value,
real: &str,
ws_paths: &BTreeMap<String, bool>,
) -> bool {
match spec {
toml::Value::Table(t) => {
if t.contains_key("path") {
return true;
}
if t.get("workspace").and_then(|w| w.as_bool()).unwrap_or(false) {
return *ws_paths.get(real).unwrap_or(&false);
}
false
}
_ => false,
}
}
pub fn gather_repo_graph(repo: &str, root: &Path) -> Result<RepoGraph> {
use std::collections::BTreeSet;
let manifests: Vec<toml::Value> = find_cargo_tomls(root, 4)
.into_iter()
.filter_map(|p| std::fs::read_to_string(&p).ok())
.filter_map(|t| t.parse::<toml::Value>().ok())
.collect();
let mut ws_paths: BTreeMap<String, bool> = BTreeMap::new();
for doc in &manifests {
if let Some(t) = doc
.get("workspace")
.and_then(|w| w.get("dependencies"))
.and_then(|t| t.as_table())
{
for (key, spec) in t {
let real = dep_real_name(key, spec);
let has_path = spec.as_table().map(|s| s.contains_key("path")).unwrap_or(false);
ws_paths.insert(real, has_path);
}
}
}
let mut g = RepoGraph { repo: repo.to_string(), ..Default::default() };
for doc in &manifests {
let package = doc.get("package");
let publishable = package
.and_then(|p| p.get("publish"))
.and_then(|v| v.as_bool())
.unwrap_or(true);
let pkg_name = package
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str())
.map(str::to_string);
if publishable {
if let Some(n) = &pkg_name {
g.produces.insert(n.clone());
g.crate_deps.entry(n.clone()).or_default();
}
}
let kinds = [
("dependencies", DepKind::Normal),
("build-dependencies", DepKind::Build),
("dev-dependencies", DepKind::Dev),
];
let mut tables: Vec<(DepKind, Option<String>, &toml::Value)> = Vec::new();
for (k, kind) in kinds {
if let Some(t) = doc.get(k) {
tables.push((kind, None, t));
}
}
if let Some(targets) = doc.get("target").and_then(|t| t.as_table()) {
for (cfg, ct) in targets {
for (k, kind) in kinds {
if let Some(t) = ct.get(k) {
tables.push((kind, Some(cfg.clone()), t));
}
}
}
}
let mut optional_keys: BTreeSet<String> = BTreeSet::new();
for (kind, _cfg, t) in &tables {
if *kind == DepKind::Dev {
continue;
}
let Some(tbl) = t.as_table() else { continue };
for (key, spec) in tbl {
if spec.as_table().and_then(|d| d.get("optional")).and_then(|o| o.as_bool()).unwrap_or(false) {
optional_keys.insert(key.clone());
}
}
}
let mut key_features: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
if let Some(feats) = doc.get("features").and_then(|f| f.as_table()) {
for (feat, list) in feats {
let Some(arr) = list.as_array() else { continue };
for item in arr {
let Some(s) = item.as_str() else { continue };
let enabled = if let Some(rest) = s.strip_prefix("dep:") {
Some(rest.to_string())
} else if let Some((name, _)) = s.split_once('/') {
if name.ends_with('?') { None } else { Some(name.to_string()) }
} else {
Some(s.to_string())
};
if let Some(dep) = enabled {
if optional_keys.contains(&dep) {
key_features.entry(dep).or_default().insert(feat.clone());
}
}
}
}
}
for key in &optional_keys {
key_features.entry(key.clone()).or_default().insert(key.clone());
}
for (kind, cfg, t) in &tables {
let Some(tbl) = t.as_table() else { continue };
for (key, spec) in tbl {
let real = dep_real_name(key, spec);
if *kind == DepKind::Dev {
if real != repo {
g.dev_deps.insert(real);
}
continue;
}
if !publishable {
continue;
}
if dep_is_source_path(spec, &real, &ws_paths) {
if let Some(pn) = &pkg_name {
if *pn != real {
g.crate_deps.entry(pn.clone()).or_default().insert(real.clone());
}
}
if real == repo {
continue;
}
g.deps.insert(real.clone());
if optional_keys.contains(key) {
let feats = key_features.get(key).cloned().unwrap_or_default();
g.optional_deps.entry(real.clone()).or_default().extend(feats);
}
if let Some(c) = cfg {
g.cfg_deps.entry(real.clone()).or_default().insert(c.clone());
}
} else {
g.version_pinned.insert(real);
}
}
}
}
Ok(g)
}
fn repo_edges(graphs: &[RepoGraph]) -> BTreeMap<String, std::collections::BTreeSet<String>> {
let mut out: BTreeMap<String, std::collections::BTreeSet<String>> = BTreeMap::new();
for a in graphs {
let set = out.entry(a.repo.clone()).or_default();
for b in graphs {
if a.repo != b.repo && b.produces.iter().any(|c| a.deps.contains(c)) {
set.insert(b.repo.clone());
}
}
}
out
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct TopoReport {
pub order: Vec<String>,
pub cycle: Vec<String>,
}
pub fn publish_order(graphs: &[RepoGraph]) -> TopoReport {
let deps_on = repo_edges(graphs);
let mut indeg: BTreeMap<String, usize> =
deps_on.iter().map(|(r, d)| (r.clone(), d.len())).collect();
let mut dependents: BTreeMap<String, Vec<String>> = BTreeMap::new();
for (a, ds) in &deps_on {
for b in ds {
dependents.entry(b.clone()).or_default().push(a.clone());
}
}
let mut ready: std::collections::BTreeSet<String> =
indeg.iter().filter(|&(_, &d)| d == 0).map(|(r, _)| r.clone()).collect();
let mut order = Vec::new();
while let Some(n) = ready.iter().next().cloned() {
ready.remove(&n);
order.push(n.clone());
if let Some(deps) = dependents.get(&n) {
for a in deps {
if let Some(d) = indeg.get_mut(a) {
*d -= 1;
if *d == 0 {
ready.insert(a.clone());
}
}
}
}
}
let cycle: Vec<String> =
indeg.keys().filter(|r| !order.contains(r)).cloned().collect();
TopoReport { order, cycle }
}
pub fn blast_radius(graphs: &[RepoGraph], repo: &str) -> Vec<String> {
let deps_on = repo_edges(graphs);
let mut result = std::collections::BTreeSet::new();
let mut stack = vec![repo.to_string()];
while let Some(cur) = stack.pop() {
for (a, ds) in &deps_on {
if ds.contains(&cur) && result.insert(a.clone()) {
stack.push(a.clone());
}
}
}
result.into_iter().collect()
}
#[derive(Debug, Clone, Serialize)]
pub struct CycleAdvice {
pub members: Vec<String>,
pub cut_from: String,
pub cut_to: String,
pub via: Vec<String>,
pub rationale: String,
}
fn edge_via(graphs: &[RepoGraph], from: &str, to: &str) -> Vec<String> {
let (Some(f), Some(t)) = (
graphs.iter().find(|g| g.repo == from),
graphs.iter().find(|g| g.repo == to),
) else {
return vec![];
};
let mut v: Vec<String> = t.produces.intersection(&f.deps).cloned().collect();
v.sort();
v
}
fn sccs(edges: &BTreeMap<String, std::collections::BTreeSet<String>>) -> Vec<Vec<String>> {
use std::collections::BTreeSet;
let nodes: Vec<String> = edges.keys().cloned().collect();
let mut index: BTreeMap<String, usize> = BTreeMap::new();
let mut low: BTreeMap<String, usize> = BTreeMap::new();
let mut on_stack: BTreeSet<String> = BTreeSet::new();
let mut stack: Vec<String> = Vec::new();
let mut idx = 0usize;
let mut out: Vec<Vec<String>> = Vec::new();
for start in &nodes {
if index.contains_key(start) {
continue;
}
let mut work: Vec<(String, usize)> = vec![(start.clone(), 0)];
while let Some((v, mut i)) = work.pop() {
if i == 0 {
index.insert(v.clone(), idx);
low.insert(v.clone(), idx);
idx += 1;
stack.push(v.clone());
on_stack.insert(v.clone());
}
let succs: Vec<String> =
edges.get(&v).map(|s| s.iter().cloned().collect()).unwrap_or_default();
let mut recursed = false;
while i < succs.len() {
let w = &succs[i];
if !index.contains_key(w) {
work.push((v.clone(), i + 1));
work.push((w.clone(), 0));
recursed = true;
break;
} else if on_stack.contains(w) {
let lw = index[w];
let lv = low[&v];
low.insert(v.clone(), lv.min(lw));
}
i += 1;
}
if recursed {
continue;
}
if low[&v] == index[&v] {
let mut comp = Vec::new();
while let Some(w) = stack.pop() {
on_stack.remove(&w);
comp.push(w.clone());
if w == v {
break;
}
}
comp.sort();
out.push(comp);
}
if let Some((parent, _)) = work.last() {
let lp = low[parent];
let lv = low[&v];
low.insert(parent.clone(), lp.min(lv));
}
}
}
out
}
pub fn cycle_advice(graphs: &[RepoGraph]) -> Vec<CycleAdvice> {
let edges = repo_edges(graphs);
let mut advice = Vec::new();
for comp in sccs(&edges) {
let in_comp: std::collections::BTreeSet<&str> = comp.iter().map(|s| s.as_str()).collect();
let self_loop =
comp.len() == 1 && edges.get(&comp[0]).map(|d| d.contains(&comp[0])).unwrap_or(false);
if comp.len() < 2 && !self_loop {
continue;
}
let mut best: Option<(String, String, Vec<String>)> = None;
for from in &comp {
if let Some(deps) = edges.get(from) {
for to in deps {
if !in_comp.contains(to.as_str()) {
continue;
}
let via = edge_via(graphs, from, to);
let better = match &best {
None => true,
Some((bf, bt, bv)) => {
(via.len(), from.as_str(), to.as_str())
< (bv.len(), bf.as_str(), bt.as_str())
}
};
if better {
best = Some((from.clone(), to.clone(), via));
}
}
}
}
if let Some((cut_from, cut_to, via)) = best {
let rationale = if via.is_empty() {
format!("cut `{cut_from} → {cut_to}` (fewest crates)")
} else if via.len() == 1 {
format!(
"`{cut_from} → {cut_to}` rides on one crate (`{}`); extract it into a leaf crate both depend on, or make the dep optional/dev-only",
via[0]
)
} else {
format!(
"`{cut_from} → {cut_to}` rides on {} crates ({}); extract them into a shared leaf crate, or make the dep optional/dev-only",
via.len(),
via.join(", ")
)
};
advice.push(CycleAdvice { members: comp, cut_from, cut_to, via, rationale });
}
}
advice
}
#[derive(Debug, Clone, Serialize)]
pub struct RepoEdge {
pub from: String,
pub to: String,
pub via: Vec<String>,
}
pub fn repo_dep_edges(graphs: &[RepoGraph]) -> Vec<RepoEdge> {
let mut out = Vec::new();
for a in graphs {
for b in graphs {
if a.repo == b.repo {
continue;
}
let mut via: Vec<String> =
b.produces.iter().filter(|c| a.deps.contains(*c)).cloned().collect();
if !via.is_empty() {
via.sort();
out.push(RepoEdge { from: a.repo.clone(), to: b.repo.clone(), via });
}
}
}
out.sort_by(|x, y| (&x.from, &x.to).cmp(&(&y.from, &y.to)));
out
}
#[derive(Debug, Clone, Serialize)]
pub struct DevEdge {
pub from: String,
pub to: String,
pub via: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct OptionalCrossDep {
pub from: String,
pub to: String,
pub krate: String,
pub features: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct CfgCrossDep {
pub from: String,
pub to: String,
pub krate: String,
pub cfgs: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct DepCycle {
pub members: Vec<String>,
pub edges: Vec<RepoEdge>,
}
fn producer_index(graphs: &[RepoGraph]) -> BTreeMap<String, String> {
let mut idx = BTreeMap::new();
for g in graphs {
for c in &g.produces {
idx.insert(c.clone(), g.repo.clone());
}
}
idx
}
pub fn excluded_dev_edges(graphs: &[RepoGraph]) -> Vec<DevEdge> {
let producer = producer_index(graphs);
let mut grouped: BTreeMap<(String, String), std::collections::BTreeSet<String>> = BTreeMap::new();
for g in graphs {
for krate in &g.dev_deps {
if let Some(to) = producer.get(krate) {
if *to != g.repo {
grouped.entry((g.repo.clone(), to.clone())).or_default().insert(krate.clone());
}
}
}
}
grouped
.into_iter()
.map(|((from, to), via)| DevEdge { from, to, via: via.into_iter().collect() })
.collect()
}
pub fn optional_cross_deps(graphs: &[RepoGraph]) -> Vec<OptionalCrossDep> {
let producer = producer_index(graphs);
let mut out = Vec::new();
for g in graphs {
for (krate, feats) in &g.optional_deps {
if let Some(to) = producer.get(krate) {
if *to != g.repo {
out.push(OptionalCrossDep {
from: g.repo.clone(),
to: to.clone(),
krate: krate.clone(),
features: feats.iter().cloned().collect(),
});
}
}
}
}
out.sort_by(|a, b| (&a.from, &a.krate).cmp(&(&b.from, &b.krate)));
out
}
pub fn version_pinned_cross_deps(graphs: &[RepoGraph]) -> Vec<RepoEdge> {
let producer = producer_index(graphs);
let mut grouped: BTreeMap<(String, String), std::collections::BTreeSet<String>> = BTreeMap::new();
for g in graphs {
for krate in &g.version_pinned {
if let Some(to) = producer.get(krate) {
if *to != g.repo {
grouped.entry((g.repo.clone(), to.clone())).or_default().insert(krate.clone());
}
}
}
}
grouped
.into_iter()
.map(|((from, to), via)| RepoEdge { from, to, via: via.into_iter().collect() })
.collect()
}
pub fn cfg_cross_deps(graphs: &[RepoGraph]) -> Vec<CfgCrossDep> {
let producer = producer_index(graphs);
let mut out = Vec::new();
for g in graphs {
for (krate, cfgs) in &g.cfg_deps {
if let Some(to) = producer.get(krate) {
if *to != g.repo {
out.push(CfgCrossDep {
from: g.repo.clone(),
to: to.clone(),
krate: krate.clone(),
cfgs: cfgs.iter().cloned().collect(),
});
}
}
}
}
out.sort_by(|a, b| (&a.from, &a.krate).cmp(&(&b.from, &b.krate)));
out
}
fn crate_graph(
graphs: &[RepoGraph],
) -> (
BTreeMap<String, String>,
BTreeMap<String, std::collections::BTreeSet<String>>,
) {
use std::collections::BTreeSet;
let mut owner: BTreeMap<String, String> = BTreeMap::new();
let produced: BTreeSet<String> =
graphs.iter().flat_map(|g| g.crate_deps.keys().cloned()).collect();
let mut adj: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for g in graphs {
for (c, deps) in &g.crate_deps {
owner.entry(c.clone()).or_insert_with(|| g.repo.clone());
let e = adj.entry(c.clone()).or_default();
for d in deps {
if d != c && produced.contains(d) {
e.insert(d.clone());
}
}
}
}
(owner, adj)
}
pub fn detect_cycles(graphs: &[RepoGraph]) -> Vec<DepCycle> {
let (_owner, adj) = crate_graph(graphs);
let mut out = Vec::new();
for comp in sccs(&adj) {
let in_comp: std::collections::BTreeSet<&str> = comp.iter().map(|s| s.as_str()).collect();
let self_loop =
comp.len() == 1 && adj.get(&comp[0]).map(|d| d.contains(&comp[0])).unwrap_or(false);
if comp.len() < 2 && !self_loop {
continue;
}
let mut cyc_edges = Vec::new();
for from in &comp {
if let Some(deps) = adj.get(from) {
for to in deps {
if in_comp.contains(to.as_str()) {
cyc_edges.push(RepoEdge { from: from.clone(), to: to.clone(), via: vec![] });
}
}
}
}
out.push(DepCycle { members: comp, edges: cyc_edges });
}
out
}
pub fn crate_publish_order(graphs: &[RepoGraph]) -> TopoReport {
use std::collections::BTreeSet;
let edges = repo_edges(graphs); let comps = sccs(&edges); let n = comps.len();
let mut comp_of: BTreeMap<String, usize> = BTreeMap::new();
for (i, c) in comps.iter().enumerate() {
for r in c {
comp_of.insert(r.clone(), i);
}
}
let mut cadj: Vec<BTreeSet<usize>> = vec![BTreeSet::new(); n];
for (from, tos) in &edges {
let cf = comp_of[from];
for to in tos {
let ct = comp_of[to];
if cf != ct {
cadj[cf].insert(ct);
}
}
}
let mut indeg: Vec<usize> = (0..n).map(|c| cadj[c].len()).collect();
let mut consumers: Vec<Vec<usize>> = vec![Vec::new(); n];
for (cf, deps) in cadj.iter().enumerate() {
for &ct in deps {
consumers[ct].push(cf);
}
}
let key = |c: usize| comps[c].first().cloned().unwrap_or_default();
let mut ready: BTreeSet<(String, usize)> = (0..n)
.filter(|&c| indeg[c] == 0)
.map(|c| (key(c), c))
.collect();
let mut order: Vec<String> = Vec::new();
while let Some((k, c)) = ready.iter().next().cloned() {
ready.remove(&(k, c));
order.extend(comps[c].iter().cloned());
for &con in &consumers[c] {
indeg[con] -= 1;
if indeg[con] == 0 {
ready.insert((key(con), con));
}
}
}
let owner = crate_graph(graphs).0;
let cycle: Vec<String> = detect_cycles(graphs)
.iter()
.flat_map(|c| c.members.iter().filter_map(|kr| owner.get(kr).cloned()))
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
TopoReport { order, cycle }
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct DoctorReport {
pub dirty: Vec<RepoDirty>,
pub skew: Vec<CrateSkew>,
pub topo: TopoReport,
pub blast: BTreeMap<String, Vec<String>>,
#[serde(default)]
pub cycle_advice: Vec<CycleAdvice>,
#[serde(default)]
pub repo_edges: Vec<RepoEdge>,
#[serde(default)]
pub patch_forks: Vec<PatchForkBlock>,
#[serde(default)]
pub promote_blocked: Vec<String>,
#[serde(default)]
pub excluded_dev_edges: Vec<DevEdge>,
#[serde(default)]
pub optional_cross_deps: Vec<OptionalCrossDep>,
#[serde(default)]
pub cfg_cross_deps: Vec<CfgCrossDep>,
#[serde(default)]
pub cycles: Vec<DepCycle>,
#[serde(default)]
pub version_pinned_cross_deps: Vec<RepoEdge>,
#[serde(default)]
pub path_dep_version_gaps: Vec<PathDepVersionGap>,
}
pub fn run(repos: &[(String, PathBuf)], policy: &DepPolicy) -> Result<DoctorReport> {
let externals = repos
.iter()
.map(|(name, path)| gather_repo_externals(name, path))
.collect::<Result<Vec<_>>>()?;
let graphs = repos
.iter()
.map(|(name, path)| gather_repo_graph(name, path))
.collect::<Result<Vec<_>>>()?;
let dirty = check_dirty(repos);
let blast = dirty
.iter()
.filter(|d| d.dirty)
.map(|d| (d.repo.clone(), blast_radius(&graphs, &d.repo)))
.collect();
let repo_locks: BTreeMap<String, String> = repos
.iter()
.filter_map(|(name, path)| {
std::fs::read_to_string(path.join("Cargo.lock")).ok().map(|t| (name.clone(), t))
})
.collect();
let mut skew = analyze_skew(&externals, policy);
enrich_transitive_pins(&mut skew, &repo_locks);
let block = compute_promote_block(
repos.iter().map(|(n, p)| (n.clone(), p.as_path())),
);
let patch_forks = block.forks;
let promote_blocked: Vec<String> = block.blocked.into_iter().collect();
let cycles = detect_cycles(&graphs);
let cycle_advice = if cycles.is_empty() { Vec::new() } else { cycle_advice(&graphs) };
Ok(DoctorReport {
dirty,
skew,
topo: crate_publish_order(&graphs),
blast,
cycle_advice,
repo_edges: repo_dep_edges(&graphs),
patch_forks,
promote_blocked,
excluded_dev_edges: excluded_dev_edges(&graphs),
optional_cross_deps: optional_cross_deps(&graphs),
cfg_cross_deps: cfg_cross_deps(&graphs),
cycles,
version_pinned_cross_deps: version_pinned_cross_deps(&graphs),
path_dep_version_gaps: scan_path_dep_version_gaps(repos),
})
}
pub fn format_report(report: &DoctorReport) -> String {
let mut s = String::new();
s.push_str("nornir release doctor — advisory\n\n");
s.push_str("Working trees:\n");
let dirty: Vec<_> = report.dirty.iter().filter(|d| d.dirty).collect();
if dirty.is_empty() {
s.push_str(" ✅ all clean\n");
} else {
for d in &dirty {
s.push_str(&format!(" 🟡 {} — uncommitted changes\n", d.repo));
}
}
for d in report.dirty.iter().filter(|d| d.error.is_some()) {
s.push_str(&format!(" ⚠ {} — {}\n", d.repo, d.error.as_deref().unwrap_or("")));
}
s.push_str("\nPublish preflight — path deps need a version:\n");
if report.path_dep_version_gaps.is_empty() {
s.push_str(" ✅ every path dep of a publishable crate carries a version\n");
} else {
let fixable: Vec<_> =
report.path_dep_version_gaps.iter().filter(|g| g.dep_owner.is_some()).collect();
let decide: Vec<_> =
report.path_dep_version_gaps.iter().filter(|g| g.dep_owner.is_none()).collect();
if !fixable.is_empty() {
s.push_str(" auto-fixable (member-owned — add version=):\n");
for g in &fixable {
let ver = g
.suggested_version
.as_deref()
.map(|v| format!("version = \"{v}\""))
.unwrap_or_else(|| "version = \"<its version>\"".to_string());
let ws = if g.via_workspace { " [via workspace.dependencies]" } else { "" };
s.push_str(&format!(
" 🔧 {}/{}: {} → {} 💡 {}{}\n",
g.repo, g.manifest, g.crate_name, g.dep, ver, ws
));
}
}
if !decide.is_empty() {
s.push_str(
" ⛔ needs decision (NON-member dep — join the release cascade or publish it first):\n",
);
for g in &decide {
s.push_str(&format!(
" ⛔ {}/{}: {} → {} (not a release member)\n",
g.repo, g.manifest, g.crate_name, g.dep
));
}
}
}
s.push_str("\nExternal dependency skew:\n");
if report.skew.is_empty() {
s.push_str(" ✅ no divergence\n");
} else {
for c in &report.skew {
let forbidden = c.entries.iter().any(|e| e.status == SkewStatus::Forbidden);
s.push_str(&format!(" {} (target {})", c.crate_name, c.target));
if forbidden {
s.push_str(" ⚠ FORBIDDEN version present");
}
s.push('\n');
for e in &c.entries {
let mark = match e.status {
SkewStatus::Ok => "✓",
SkewStatus::Behind if e.held_by_transitive_pin => "⛔",
SkewStatus::Behind => "·",
SkewStatus::Forbidden => "⚠",
};
let note = if e.held_by_transitive_pin {
format!(" (held: lock already resolves {}, a transitive dep pins {})", c.target, e.version)
} else {
String::new()
};
s.push_str(&format!(" {} {} {}{}\n", mark, e.repo, e.version, note));
}
let held: Vec<&str> = c
.entries
.iter()
.filter(|e| e.status == SkewStatus::Behind && e.held_by_transitive_pin)
.map(|e| e.repo.as_str())
.collect();
let free: Vec<&str> = c
.entries
.iter()
.filter(|e| e.status != SkewStatus::Ok && !e.held_by_transitive_pin)
.map(|e| e.repo.as_str())
.collect();
if !free.is_empty() {
s.push_str(&format!(" 💡 bump → {}: {}\n", c.target, free.join(", ")));
}
if !held.is_empty() {
s.push_str(&format!(
" ⛔ blocked → {}: a transitive dep pins {} (run `cargo tree -i {}` to find it)\n",
held.join(", "),
c.crate_name,
c.crate_name,
));
}
}
}
s.push_str("\nPublish order (dependencies first):\n");
if report.topo.order.is_empty() {
s.push_str(" (no repos)\n");
} else {
s.push_str(&format!(" {}\n", report.topo.order.join(" → ")));
}
if !report.topo.cycle.is_empty() {
s.push_str(&format!(" ⚠ dependency cycle, unordered: {}\n", report.topo.cycle.join(", ")));
}
if !report.cycle_advice.is_empty() {
s.push_str("\nBreak the cycle (suggested cuts):\n");
for a in &report.cycle_advice {
s.push_str(&format!(" ⟲ {} — 💡 {}\n", a.members.join(" ⇄ "), a.rationale));
}
}
s.push_str("\nCycles (crate-level publish graph):\n");
if report.cycles.is_empty() {
s.push_str(" ✅ none\n");
} else {
for c in &report.cycles {
s.push_str(&format!(" ⛔ {}\n", c.members.join(" ⇄ ")));
for e in &c.edges {
let via = if e.via.is_empty() { String::new() } else { format!(" (via {})", e.via.join(", ")) };
s.push_str(&format!(" {} → {}{}\n", e.from, e.to, via));
}
}
}
if !report.excluded_dev_edges.is_empty() {
s.push_str("\nExcluded dev-dep cross-repo edges (not order-gating):\n");
for e in &report.excluded_dev_edges {
let via = if e.via.is_empty() { String::new() } else { format!(" [{}]", e.via.join(", ")) };
s.push_str(&format!(" {} --dev--> {}{}\n", e.from, e.to, via));
}
}
if !report.optional_cross_deps.is_empty() {
s.push_str("\nFeature-gated / optional cross-repo deps:\n");
for d in &report.optional_cross_deps {
let feats = if d.features.is_empty() {
"optional".to_string()
} else {
format!("optional, feature={}", d.features.join("|"))
};
s.push_str(&format!(" {} --[{}]--> {} ({})\n", d.from, feats, d.krate, d.to));
}
}
if !report.cfg_cross_deps.is_empty() {
s.push_str("\nTarget-cfg cross-repo deps:\n");
for d in &report.cfg_cross_deps {
s.push_str(&format!(" {} --[{}]--> {} ({})\n", d.from, d.cfgs.join(" | "), d.krate, d.to));
}
}
if !report.version_pinned_cross_deps.is_empty() {
s.push_str("\nCrates.io version-pinned cross-repo deps (not order-gating):\n");
for e in &report.version_pinned_cross_deps {
s.push_str(&format!(" {} --version--> {} [{}]\n", e.from, e.to, e.via.join(", ")));
}
}
if !report.patch_forks.is_empty() {
s.push_str("\nPatch-fork promote gate:\n");
let kind_of = |b: &PatchForkBlock| match b.fork_kind {
ForkKind::Path => "path",
ForkKind::Git => "git",
};
let foreign: Vec<&PatchForkBlock> =
report.patch_forks.iter().filter(|b| b.is_foreign_fork).collect();
for b in &foreign {
s.push_str(&format!(
" ⛔ promote-blocked: {} rides a patch-fork ({} → {} [{}]); \
publishing strips it → stock {} would be incompatible. \
Unblock: publish {}'s real version, or wait for upstream.\n",
b.crate_name, b.patched_dep, b.source, kind_of(b), b.patched_dep, b.patched_dep,
));
}
let overrides: Vec<&PatchForkBlock> =
report.patch_forks.iter().filter(|b| !b.is_foreign_fork).collect();
for b in &overrides {
s.push_str(&format!(
" ℹ️ local dev override (safe): {} → {} [{}] — our own crate; \
stripped on publish, publish-order resolves it.\n",
b.patched_dep, b.source, kind_of(b),
));
}
if !report.promote_blocked.is_empty() {
s.push_str(&format!(
" ⛔ held from crates.io ({} crate(s) that transitively need a forked dep): {}\n",
report.promote_blocked.len(),
report.promote_blocked.join(", "),
));
}
if foreign.is_empty() && report.promote_blocked.is_empty() {
s.push_str(" ✅ no foreign forks — all crates promotable\n");
}
}
let blast: Vec<_> = report.blast.iter().filter(|(_, d)| !d.is_empty()).collect();
if !blast.is_empty() {
s.push_str("\nBlast radius of dirty repos (re-validate on change):\n");
for (repo, deps) in blast {
s.push_str(&format!(" {} → {}\n", repo, deps.join(", ")));
}
}
s
}
#[cfg(test)]
mod tests {
use super::*;
fn repo(name: &str, deps: &[(&str, &str)]) -> RepoExternals {
RepoExternals {
repo: name.to_string(),
deps: deps.iter().map(|(c, v)| (c.to_string(), v.to_string())).collect(),
}
}
#[test]
fn arrow58_case_matches_hand_analysis() {
let repos = [
repo("znippy", &[("arrow", "58.3.0"), ("serde", "1")]),
repo("skade", &[("arrow", "57.1"), ("serde", "1")]),
repo("nornir", &[("arrow", "57"), ("serde", "1")]),
repo("knut", &[("arrow", "57"), ("serde", "1")]),
repo("facett", &[("arrow", "56"), ("serde", "1")]),
repo("korp", &[("arrow", "56"), ("serde", "1")]),
];
let policy = DepPolicy {
forbidden: vec![ForbiddenDep { crate_name: "arrow".into(), version: "56".into() }],
};
let skew = analyze_skew(&repos, &policy);
assert_eq!(skew.len(), 1, "only arrow should be flagged");
let arrow = &skew[0];
assert_eq!(arrow.crate_name, "arrow");
assert_eq!(arrow.target, "58.3.0", "target = highest declared (znippy)");
assert!(arrow.diverged);
let status = |r: &str| {
arrow.entries.iter().find(|e| e.repo == r).map(|e| e.status.clone()).unwrap()
};
assert_eq!(status("znippy"), SkewStatus::Ok);
assert_eq!(status("skade"), SkewStatus::Behind);
assert_eq!(status("nornir"), SkewStatus::Behind);
assert_eq!(status("knut"), SkewStatus::Behind);
assert_eq!(status("facett"), SkewStatus::Forbidden);
assert_eq!(status("korp"), SkewStatus::Forbidden);
let mut bump = arrow.bump_repos();
bump.sort();
assert_eq!(bump, vec!["facett", "knut", "korp", "nornir", "skade"]);
}
#[test]
fn forbidden_zero_x_compares_on_minor() {
let policy = DepPolicy {
forbidden: vec![ForbiddenDep { crate_name: "tokio".into(), version: "0.9".into() }],
};
assert!(is_forbidden("tokio", "0.9.3", &policy));
assert!(!is_forbidden("tokio", "0.10.1", &policy));
let p1 = DepPolicy {
forbidden: vec![ForbiddenDep { crate_name: "arrow".into(), version: "56".into() }],
};
assert!(is_forbidden("arrow", "56.2.1", &p1));
assert!(!is_forbidden("arrow", "57.0.0", &p1));
}
#[test]
fn no_skew_when_all_agree() {
let repos = [repo("a", &[("arrow", "58")]), repo("b", &[("arrow", "58")])];
assert!(analyze_skew(&repos, &DepPolicy::default()).is_empty());
}
#[test]
fn forbidden_surfaces_even_without_divergence() {
let repos = [repo("a", &[("arrow", "56")]), repo("b", &[("arrow", "56")])];
let policy = DepPolicy {
forbidden: vec![ForbiddenDep { crate_name: "arrow".into(), version: "56".into() }],
};
let skew = analyze_skew(&repos, &policy);
assert_eq!(skew.len(), 1);
assert!(skew[0].entries.iter().all(|e| e.status == SkewStatus::Forbidden));
}
#[test]
fn crate_majors_in_lock_collects_all_majors() {
let lock = r#"
[[package]]
name = "arrow"
version = "57.3.1"
[[package]]
name = "arrow"
version = "58.3.0"
[[package]]
name = "serde"
version = "1.0.2"
"#;
assert_eq!(crate_majors_in_lock(lock, "arrow"), [57u64, 58].into_iter().collect());
assert_eq!(crate_majors_in_lock(lock, "serde"), [1u64].into_iter().collect());
assert!(crate_majors_in_lock(lock, "absent").is_empty());
}
#[test]
fn transitive_pin_marks_dual_major_behind_repo() {
let repos = [
repo("znippy", &[("arrow", "58.3.0")]),
repo("nornir", &[("arrow", "57")]),
];
let mut skew = analyze_skew(&repos, &DepPolicy::default());
let nornir_lock = r#"
[[package]]
name = "arrow"
version = "57.3.1"
[[package]]
name = "arrow"
version = "58.3.0"
"#;
let locks: BTreeMap<String, String> =
[("nornir".to_string(), nornir_lock.to_string())].into_iter().collect();
enrich_transitive_pins(&mut skew, &locks);
let arrow = skew.iter().find(|c| c.crate_name == "arrow").unwrap();
let nornir = arrow.entries.iter().find(|e| e.repo == "nornir").unwrap();
assert_eq!(nornir.status, SkewStatus::Behind);
assert!(nornir.held_by_transitive_pin, "dual-major lock ⇒ held by transitive pin");
let mut skew2 = analyze_skew(&repos, &DepPolicy::default());
let only_57 = "[[package]]\nname = \"arrow\"\nversion = \"57.3.1\"\n";
let locks2: BTreeMap<String, String> =
[("nornir".to_string(), only_57.to_string())].into_iter().collect();
enrich_transitive_pins(&mut skew2, &locks2);
let nornir2 = skew2[0].entries.iter().find(|e| e.repo == "nornir").unwrap();
assert!(!nornir2.held_by_transitive_pin, "single-major lock ⇒ free bump");
}
#[test]
fn version_key_tolerates_partials_and_operators() {
assert_eq!(version_key("58.3.0"), (58, 3, 0));
assert_eq!(version_key("57"), (57, 0, 0));
assert_eq!(version_key("^1.2"), (1, 2, 0));
assert_eq!(version_key(">=0.9.0"), (0, 9, 0));
assert_eq!(version_key("=56.2.1"), (56, 2, 1));
}
#[test]
fn gatherer_reads_external_versions_and_skips_path_deps() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
r#"
[package]
name = "demo"
[dependencies]
arrow = "58.3.0"
serde = { version = "1.0", features = ["derive"] }
znippy-common = { version = "0.9.4", path = "../znippy-common" }
gitdep = { git = "https://example.com/x" }
"#,
)
.unwrap();
let ext = gather_repo_externals("demo", dir.path()).unwrap();
assert_eq!(ext.deps.get("arrow").map(String::as_str), Some("58.3.0"));
assert_eq!(ext.deps.get("serde").map(String::as_str), Some("1.0"));
assert!(!ext.deps.contains_key("znippy-common"), "path dep is workspace-internal");
assert!(!ext.deps.contains_key("gitdep"), "git dep has no version");
}
fn member(root: &Path, name: &str, deps: &[&str]) {
let dir = root.join(name);
std::fs::create_dir_all(&dir).unwrap();
let mut t = format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\n[dependencies]\n");
for d in deps {
t.push_str(&format!("{d} = \"1\"\n"));
}
std::fs::write(dir.join("Cargo.toml"), t).unwrap();
}
#[test]
fn precise_gate_blocks_only_crates_that_reach_the_foreign_fork() {
let mut cd: BTreeMap<String, std::collections::BTreeSet<String>> = BTreeMap::new();
let set = |xs: &[&str]| xs.iter().map(|s| s.to_string()).collect();
cd.insert("skade".into(), set(&["iceberg", "serde"]));
cd.insert("znippy-iceberg".into(), set(&["iceberg"]));
cd.insert("nornir".into(), set(&["skade", "clap"]));
cd.insert("znippy-common".into(), set(&["serde"]));
cd.insert("lgz".into(), set(&["znippy-common"]));
let foreign: std::collections::BTreeSet<String> =
["iceberg".to_string()].into_iter().collect();
let blocked = promote_blocked_crates_precise(&cd, &foreign);
assert!(blocked.contains("skade"), "skade rides the fork");
assert!(blocked.contains("znippy-iceberg"), "znippy-iceberg rides the fork");
assert!(blocked.contains("nornir"), "nornir → skade → iceberg (transitive)");
assert!(!blocked.contains("znippy-common"), "znippy-common never touches iceberg → FREE");
assert!(!blocked.contains("lgz"), "lgz → znippy-common only → FREE");
}
#[test]
fn compute_promote_block_classifies_foreign_vs_own_overrides() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let ws = root.join("nordic");
std::fs::create_dir_all(&ws).unwrap();
std::fs::write(
ws.join("Cargo.toml"),
r#"
[workspace]
members = ["skade", "znippy-common", "znippy-iceberg"]
[patch.crates-io]
iceberg = { path = "../iceberg-arrow58" }
skade = { path = "../skade" }
"#,
)
.unwrap();
member(&ws, "skade", &["iceberg"]);
member(&ws, "znippy-common", &["serde"]);
member(&ws, "znippy-iceberg", &["iceberg", "znippy-common"]);
let repos = vec![("nordic".to_string(), ws.clone())];
let block = compute_promote_block(repos.iter().map(|(n, p)| (n.clone(), p.as_path())));
assert!(block.foreign_forks.contains("iceberg"), "iceberg is a foreign fork");
assert!(!block.foreign_forks.contains("skade"), "skade is our own crate, not foreign");
let iceberg_block = block.forks.iter().find(|b| b.patched_dep == "iceberg").unwrap();
assert!(iceberg_block.is_foreign_fork);
let skade_block = block.forks.iter().find(|b| b.patched_dep == "skade").unwrap();
assert!(!skade_block.is_foreign_fork, "skade override is safe, not a blocker");
assert!(block.blocked.contains("skade"));
assert!(block.blocked.contains("znippy-iceberg"));
assert!(!block.blocked.contains("znippy-common"), "clean sibling is FREE");
}
fn graph(repo: &str, produces: &[&str], deps: &[&str]) -> RepoGraph {
RepoGraph {
repo: repo.to_string(),
produces: produces.iter().map(|s| s.to_string()).collect(),
deps: deps.iter().map(|s| s.to_string()).collect(),
..Default::default()
}
}
#[test]
fn publish_order_is_dependencies_first() {
let graphs = [
graph("znippy", &["znippy-common", "lgz"], &["serde"]),
graph("skade", &["skade-katalog"], &["arrow"]),
graph("nornir", &["nornir"], &["znippy-common", "skade-katalog", "serde"]),
];
let topo = publish_order(&graphs);
assert!(topo.cycle.is_empty(), "clean DAG");
let pos = |r: &str| topo.order.iter().position(|x| x == r).unwrap();
assert!(pos("znippy") < pos("nornir"), "znippy before nornir");
assert!(pos("skade") < pos("nornir"), "skade before nornir");
assert_eq!(topo.order.len(), 3);
}
#[test]
fn blast_radius_is_transitive_dependents() {
let graphs = [
graph("skade", &["skade-katalog"], &[]),
graph("nornir", &["nornir"], &["skade-katalog"]),
graph("cli", &["cli"], &["nornir"]),
];
let mut radius = blast_radius(&graphs, "skade");
radius.sort();
assert_eq!(radius, vec!["cli", "nornir"], "changing skade re-validates nornir + cli");
}
#[test]
fn publish_order_flags_cycle() {
let graphs = [
graph("a", &["a-crate"], &["b-crate"]),
graph("b", &["b-crate"], &["a-crate"]),
];
let topo = publish_order(&graphs);
assert!(topo.order.is_empty(), "all on a cycle → none ordered");
assert_eq!(topo.cycle.len(), 2);
}
#[test]
fn cycle_advice_empty_on_clean_dag() {
let graphs = [
graph("znippy", &["znippy-common"], &["serde"]),
graph("nornir", &["nornir"], &["znippy-common"]),
];
assert!(cycle_advice(&graphs).is_empty(), "a DAG has no cycle to break");
}
#[test]
fn cycle_advice_two_node_picks_deterministic_edge() {
let graphs = [
graph("a", &["a-crate"], &["b-crate"]),
graph("b", &["b-crate"], &["a-crate"]),
];
let advice = cycle_advice(&graphs);
assert_eq!(advice.len(), 1, "one cycle");
let c = &advice[0];
assert_eq!(c.members, vec!["a", "b"]);
assert_eq!((c.cut_from.as_str(), c.cut_to.as_str()), ("a", "b"));
assert_eq!(c.via, vec!["b-crate"]);
}
#[test]
fn cycle_advice_cuts_the_cheapest_edge() {
let graphs = [
graph("x", &["x1"], &["y1", "y2"]),
graph("y", &["y1", "y2"], &["x1"]),
];
let advice = cycle_advice(&graphs);
assert_eq!(advice.len(), 1);
let c = &advice[0];
assert_eq!((c.cut_from.as_str(), c.cut_to.as_str()), ("y", "x"));
assert_eq!(c.via, vec!["x1"], "the single-crate edge is the cut");
}
#[test]
fn patch_fork_detects_path_and_git_but_not_registry_pin() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(root.join("Cargo.toml"), r#"[package]
name = "skade"
version = "0.1.0"
[dependencies]
iceberg = "0.9"
[patch.crates-io]
iceberg = { path = "../iceberg-arrow58" }
forkgit = { git = "https://example.com/forkgit" }
serde = "1.0.200"
toml = { version = "0.8" }
"#).unwrap();
let blocks = patch_fork_blockers(root);
assert_eq!(blocks.len(), 2, "{blocks:#?}");
let iceberg = blocks.iter().find(|b| b.patched_dep == "iceberg").unwrap();
assert_eq!(iceberg.fork_kind, ForkKind::Path);
assert_eq!(iceberg.crate_name, "skade");
assert!(iceberg.source.contains("iceberg-arrow58"));
let git = blocks.iter().find(|b| b.patched_dep == "forkgit").unwrap();
assert_eq!(git.fork_kind, ForkKind::Git);
assert!(!blocks.iter().any(|b| b.patched_dep == "serde"));
assert!(!blocks.iter().any(|b| b.patched_dep == "toml"));
}
#[test]
fn patch_fork_registry_only_patch_is_not_blocked() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(root.join("Cargo.toml"), r#"[package]
name = "clean"
version = "0.1.0"
[patch.crates-io]
foo = "1.2"
bar = { version = "2.0" }
"#).unwrap();
assert!(patch_fork_blockers(root).is_empty(), "registry-version patches are publishable");
}
#[test]
fn promote_block_is_transitive_over_workspace_deps() {
let graphs = [
graph("skade", &["skade-katalog"], &["iceberg"]),
graph("nornir", &["nornir"], &["skade-katalog"]),
graph("facett", &["facett"], &["serde"]),
];
let directly: std::collections::BTreeSet<String> =
["skade-katalog".to_string()].into_iter().collect();
let blocked = promote_blocked_crates(&graphs, &directly);
assert!(blocked.contains("skade-katalog"), "the fork rider is blocked");
assert!(blocked.contains("nornir"), "nornir depends on skade-katalog → blocked");
assert!(!blocked.contains("facett"), "facett is independent → publishable");
}
#[test]
fn cycle_advice_handles_three_node_cycle() {
let graphs = [
graph("a", &["a-c"], &["b-c"]),
graph("b", &["b-c"], &["c-c"]),
graph("c", &["c-c"], &["a-c"]),
];
let advice = cycle_advice(&graphs);
assert_eq!(advice.len(), 1, "one 3-node SCC");
assert_eq!(advice[0].members, vec!["a", "b", "c"]);
}
}