use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::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 {
let major = version_key(version).0;
policy
.forbidden
.iter()
.any(|f| f.crate_name == crate_name && version_key(&f.version).0 == major)
}
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)]
pub struct RepoGraph {
pub repo: String,
pub produces: std::collections::BTreeSet<String>,
pub deps: std::collections::BTreeSet<String>,
}
pub fn gather_repo_graph(repo: &str, root: &Path) -> Result<RepoGraph> {
use std::collections::BTreeSet;
let mut produces = BTreeSet::new();
let mut deps = BTreeSet::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;
}
if let Some(n) = package.and_then(|p| p.get("name")).and_then(|n| n.as_str()) {
produces.insert(n.to_string());
}
for key in ["dependencies", "build-dependencies"] {
if let Some(t) = doc.get(key).and_then(|t| t.as_table()) {
for (name, 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 {
deps.insert(name.clone());
}
}
}
}
}
Ok(RepoGraph { repo: repo.to_string(), produces, deps })
}
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, 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 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>,
}
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();
Ok(DoctorReport {
dirty,
skew,
topo: publish_order(&graphs),
blast,
cycle_advice: cycle_advice(&graphs),
repo_edges: repo_dep_edges(&graphs),
patch_forks,
promote_blocked,
})
}
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("\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));
}
}
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 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(),
}
}
#[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"]);
}
}