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);
let explained = held_fork_reasons(&crate_deps, &forks, &foreign_forks);
PromoteBlockResult { forks, foreign_forks, blocked, explained }
}
#[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 explained: Vec<HeldExplanation>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct HeldFork {
pub patched_dep: String,
pub fork_kind: ForkKind,
pub source: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct HeldExplanation {
pub crate_name: String,
pub rides: Vec<HeldFork>,
pub direct: bool,
pub advice: String,
}
pub fn held_fork_reasons(
crate_deps: &BTreeMap<String, std::collections::BTreeSet<String>>,
forks: &[PatchForkBlock],
foreign_forks: &std::collections::BTreeSet<String>,
) -> Vec<HeldExplanation> {
use std::collections::BTreeSet;
if foreign_forks.is_empty() {
return Vec::new();
}
let mut fork_meta: BTreeMap<&str, &PatchForkBlock> = BTreeMap::new();
for b in forks.iter().filter(|b| b.is_foreign_fork) {
fork_meta.entry(b.patched_dep.as_str()).or_insert(b);
}
let mut out: Vec<HeldExplanation> = Vec::new();
for crate_name in crate_deps.keys() {
let mut reached: BTreeSet<String> = BTreeSet::new();
let mut stack = vec![crate_name.clone()];
let mut seen: BTreeSet<String> = BTreeSet::new();
while let Some(c) = stack.pop() {
if !seen.insert(c.clone()) {
continue;
}
if let Some(deps) = crate_deps.get(&c) {
for d in deps {
if foreign_forks.contains(d) {
reached.insert(d.clone());
} else if crate_deps.contains_key(d) {
stack.push(d.clone());
}
}
}
}
if reached.is_empty() {
continue;
}
let direct = crate_deps
.get(crate_name)
.map(|deps| deps.iter().any(|d| foreign_forks.contains(d)))
.unwrap_or(false);
let rides: Vec<HeldFork> = reached
.iter()
.map(|dep| match fork_meta.get(dep.as_str()) {
Some(b) => HeldFork {
patched_dep: dep.clone(),
fork_kind: b.fork_kind,
source: b.source.clone(),
},
None => HeldFork {
patched_dep: dep.clone(),
fork_kind: ForkKind::Path,
source: String::new(),
},
})
.collect();
let deps_list = reached.iter().cloned().collect::<Vec<_>>().join(", ");
let advice = if direct {
format!(
"publishing strips [patch.crates-io] → stock {deps_list} is incompatible. \
Unblock: publish the real (unforked) {deps_list} to crates.io, or wait for \
upstream, then this crate promotes."
)
} else {
format!(
"held transitively — a workspace dependency rides forked {deps_list}. \
Unblock: publish the real {deps_list} (and any rider dep) first, then this \
crate promotes."
)
};
out.push(HeldExplanation { crate_name: crate_name.clone(), rides, direct, advice });
}
out.sort_by(|a, b| a.crate_name.cmp(&b.crate_name));
out
}
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 local_crate_versions(repos: &[(String, PathBuf)]) -> BTreeMap<String, String> {
let mut out: BTreeMap<String, 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 };
if let Some(ver) = crate_version_of(pkg, &ws_pkg_ver) {
out.entry(name.to_string()).or_insert(ver);
}
}
}
out
}
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, PartialEq, Eq)]
pub struct VersionedPathDevDep {
pub repo: String,
pub manifest: PathBuf,
pub dep: String,
}
pub fn scan_versioned_path_dev_deps(repos: &[(String, PathBuf)]) -> Vec<VersionedPathDevDep> {
let mut out = Vec::new();
for (repo, root) in repos {
let _ = crate::release::cargo::for_each_cargo_toml_with_path(root, &mut |path, doc| {
let rel = path.strip_prefix(root).unwrap_or(path).to_path_buf();
let mut collect = |tbl: &toml::Value| {
if let Some(deps) = tbl.get("dev-dependencies").and_then(|d| d.as_table()) {
for (name, v) in deps {
let Some(t) = v.as_table() else { continue };
let has_path = t.get("path").and_then(|p| p.as_str()).is_some();
let has_ver = t.get("version").and_then(|v| v.as_str()).is_some();
if has_path && has_ver {
let real = t.get("package").and_then(|p| p.as_str()).unwrap_or(name);
out.push(VersionedPathDevDep {
repo: repo.clone(),
manifest: rel.clone(),
dep: real.to_string(),
});
}
}
}
};
collect(doc);
if let Some(targets) = doc.get("target").and_then(|t| t.as_table()) {
for (_cfg, t) in targets {
collect(t);
}
}
});
}
out
}
pub fn fix_versioned_path_dev_deps(
gaps: &[VersionedPathDevDep],
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<&VersionedPathDevDep>> = 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 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>> = vec![None];
for c in &cfgs {
locators.push(Some(c.clone()));
}
let mut changed = false;
for g in gs {
let mut handled = false;
for cfg in &locators {
let Some(tbl) = dev_dep_table_mut(&mut doc, cfg.as_deref()) 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 };
if let Some(item) = tbl.get_mut(&key) {
if let Some(it) = item.as_inline_table_mut() {
it.remove("version");
} else if let Some(t) = item.as_table_mut() {
t.remove("version");
}
out.fixed += 1;
changed = true;
}
handled = true;
break;
}
if !handled {
out.skipped.push(format!(
"{}/{}: {} (dev-dep entry not found — already fixed?)",
g.repo,
g.manifest.display(),
g.dep
));
}
}
if changed {
std::fs::write(&manifest, doc.to_string())
.with_context(|| format!("write {}", manifest.display()))?;
}
}
Ok(out)
}
fn dev_dep_table_mut<'a>(
doc: &'a mut toml_edit::DocumentMut,
cfg: Option<&str>,
) -> Option<&'a mut toml_edit::Table> {
match cfg {
None => doc.get_mut("dev-dependencies").and_then(|i| i.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(|i| i.as_table_mut())
.and_then(|t| t.get_mut("dev-dependencies"))
.and_then(|i| i.as_table_mut()),
}
}
#[derive(Debug, Default)]
pub struct TidyOutcome {
pub removed: usize,
pub cleaned: Vec<String>,
pub still_dirty: Vec<String>,
}
fn is_tidy_noise_dir(name: &str) -> bool {
name == ".claude"
}
fn is_tidy_noise_file(name: &str) -> bool {
name.ends_with(".arrows")
}
fn remove_tidy_noise(root: &Path) -> Result<usize> {
let mut removed = 0usize;
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
for e in entries.flatten() {
let p = e.path();
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("").to_string();
let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
if is_dir {
if name == ".git" || name == "target" {
continue;
}
if is_tidy_noise_dir(&name) {
std::fs::remove_dir_all(&p)
.with_context(|| format!("rm -r {}", p.display()))?;
removed += 1;
} else {
stack.push(p);
}
} else if is_tidy_noise_file(&name) {
std::fs::remove_file(&p).with_context(|| format!("rm {}", p.display()))?;
removed += 1;
}
}
}
Ok(removed)
}
pub fn tidy_dirty_repos(
dirty: &[RepoDirty],
repos: &[(String, PathBuf)],
) -> Result<TidyOutcome> {
let root_of: BTreeMap<&str, &Path> =
repos.iter().map(|(n, p)| (n.as_str(), p.as_path())).collect();
let mut out = TidyOutcome::default();
for d in dirty.iter().filter(|d| d.dirty) {
let Some(root) = root_of.get(d.repo.as_str()) else { continue };
out.removed += remove_tidy_noise(root)?;
match crate::gitio::worktree_freshness(root) {
Ok(f) if !f.dirty => out.cleaned.push(d.repo.clone()),
_ => out.still_dirty.push(d.repo.clone()),
}
}
Ok(out)
}
#[derive(Debug, Default)]
pub struct CutOutcome {
pub cut: Vec<(String, String)>,
pub unresolved: Vec<(String, String)>,
}
fn cut_dev_edge_in_manifest(doc: &mut toml_edit::DocumentMut, to_crate: &str) -> bool {
fn real_name<'a>(key: &'a str, item: &'a toml_edit::Item) -> &'a str {
item.get("package").and_then(|p| p.as_str()).unwrap_or(key)
}
let mut cut = false;
if let Some(tbl) = doc.get_mut("dev-dependencies").and_then(|i| i.as_table_like_mut()) {
let key = tbl.iter().find(|(k, item)| real_name(k, item) == to_crate).map(|(k, _)| k.to_string());
if let Some(k) = key {
tbl.remove(&k);
cut = true;
}
}
if let Some(targets) = doc.get_mut("target").and_then(|i| i.as_table_like_mut()) {
let cfgs: Vec<String> = targets.iter().map(|(k, _)| k.to_string()).collect();
for cfg in cfgs {
if let Some(tbl) = targets
.get_mut(&cfg)
.and_then(|i| i.get_mut("dev-dependencies"))
.and_then(|i| i.as_table_like_mut())
{
let key = tbl.iter().find(|(k, item)| real_name(k, item) == to_crate).map(|(k, _)| k.to_string());
if let Some(k) = key {
tbl.remove(&k);
cut = true;
}
}
}
}
cut
}
fn cut_edge_in_repo(repo_root: &Path, to_crate: &str) -> Result<bool> {
for manifest in find_cargo_tomls(repo_root, 3) {
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()))?;
if cut_dev_edge_in_manifest(&mut doc, to_crate) {
std::fs::write(&manifest, doc.to_string())
.with_context(|| format!("write {}", manifest.display()))?;
return Ok(true);
}
let opt_key = doc
.get("dependencies")
.and_then(|i| i.as_table_like())
.and_then(|tbl| {
tbl.iter().find_map(|(k, item)| {
let real = item.get("package").and_then(|p| p.as_str()).unwrap_or(k);
let optional = item.get("optional").and_then(|o| o.as_bool()).unwrap_or(false);
(real == to_crate && optional).then(|| k.to_string())
})
});
if let Some(key) = opt_key {
if let Some(tbl) = doc.get_mut("dependencies").and_then(|i| i.as_table_like_mut()) {
tbl.remove(&key);
}
scrub_feature_refs(&mut doc, &key);
std::fs::write(&manifest, doc.to_string())
.with_context(|| format!("write {}", manifest.display()))?;
return Ok(true);
}
}
Ok(false)
}
fn scrub_feature_refs(doc: &mut toml_edit::DocumentMut, key: &str) {
let Some(features) = doc.get_mut("features").and_then(|i| i.as_table_like_mut()) else {
return;
};
let feat_names: Vec<String> = features.iter().map(|(k, _)| k.to_string()).collect();
for fname in feat_names {
if let Some(arr) = features.get_mut(&fname).and_then(|i| i.as_array_mut()) {
arr.retain(|v| {
let s = v.as_str().unwrap_or("");
let dep = s.strip_prefix("dep:").unwrap_or(s);
let base = dep.split(['/', '?']).next().unwrap_or(dep);
base != key
});
}
}
}
pub fn apply_cheap_cycle_cuts(repos: &[(String, PathBuf)]) -> Result<CutOutcome> {
let graphs: Vec<RepoGraph> = repos
.iter()
.filter_map(|(n, p)| gather_repo_graph(n, p).ok())
.collect();
let (owner, _adj) = crate_graph(&graphs);
let root_of: BTreeMap<&str, &Path> =
repos.iter().map(|(n, p)| (n.as_str(), p.as_path())).collect();
let mut out = CutOutcome::default();
for cs in cycle_solutions(&graphs) {
let Some(best) = cs.solutions.first() else { continue };
if best.cost > 1 {
continue; }
for (from, to) in &best.edges {
let located = owner
.get(from)
.and_then(|repo| root_of.get(repo.as_str()))
.map(|root| cut_edge_in_repo(root, to))
.transpose()?
.unwrap_or(false);
if located {
out.cut.push((from.clone(), to.clone()));
} else {
out.unresolved.push((from.clone(), to.clone()));
}
}
}
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>>,
pub dep_reqs: 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_version_req_string(spec: &toml::Value) -> Option<String> {
match spec {
toml::Value::Table(t) => {
t.get("version").and_then(|v| v.as_str()).map(str::to_string)
}
toml::Value::String(s) => Some(s.clone()),
_ => None,
}
}
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();
let mut ws_reqs: BTreeMap<String, String> = 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.clone(), has_path);
if let Some(req) = dep_version_req_string(spec) {
ws_reqs.insert(real, req);
}
}
}
}
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());
let req = dep_version_req_string(spec)
.or_else(|| {
spec.as_table()
.and_then(|d| d.get("workspace"))
.and_then(|w| w.as_bool())
.unwrap_or(false)
.then(|| ws_reqs.get(&real).cloned())
.flatten()
})
.unwrap_or_default();
g.dep_reqs.entry(real.clone()).or_default().insert(req);
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>> {
repo_edges_gated(graphs, &std::collections::BTreeSet::new())
}
fn repo_edges_gated(
graphs: &[RepoGraph],
non_gating: &std::collections::BTreeSet<String>,
) -> 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 {
continue;
}
let gates =
b.produces.iter().any(|c| a.deps.contains(c) && !non_gating.contains(c));
if gates {
set.insert(b.repo.clone());
}
}
}
out
}
pub fn cross_repo_gating_crates(
graphs: &[RepoGraph],
) -> std::collections::BTreeSet<String> {
let mut out = std::collections::BTreeSet::new();
for a in graphs {
for b in graphs {
if a.repo == b.repo {
continue;
}
for c in b.produces.intersection(&a.deps) {
out.insert(c.clone());
}
}
}
out
}
pub fn registry_non_gating_crates(
graphs: &[RepoGraph],
probe: &dyn crate::release::preflight::RegistryProbe,
) -> std::collections::BTreeSet<String> {
use std::collections::{BTreeMap, BTreeSet};
let gating = cross_repo_gating_crates(graphs);
let mut reqs: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for a in graphs {
for c in &gating {
if a.deps.contains(c) && !a.produces.contains(c) {
let entry = reqs.entry(c.clone()).or_default();
match a.dep_reqs.get(c) {
Some(rs) if !rs.is_empty() => entry.extend(rs.iter().cloned()),
_ => {
entry.insert(String::new());
}
}
}
}
}
let mut out = BTreeSet::new();
for c in &gating {
let Some(rs) = reqs.get(c) else { continue };
if rs.is_empty() {
continue;
}
let all_satisfied = rs
.iter()
.all(|r| !r.is_empty() && probe.req_satisfied(c, r) == Some(true));
if all_satisfied {
out.insert(c.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>> {
super::graph_math::tarjan_scc(edges)
}
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
}
#[derive(Debug, Clone, Serialize)]
pub struct CutSolutionView {
pub cost: u32,
pub edges: Vec<(String, String)>,
pub actions: Vec<String>,
pub proof_order: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct CycleSolution {
pub members: Vec<String>,
pub solutions: Vec<CutSolutionView>,
}
pub fn cycle_solutions(graphs: &[RepoGraph]) -> Vec<CycleSolution> {
use std::collections::BTreeSet;
let (_owner, adj) = crate_graph(graphs);
let optional_crates: BTreeSet<String> =
graphs.iter().flat_map(|g| g.optional_deps.keys().cloned()).collect();
let mut out = Vec::new();
for comp in super::graph_math::cycles(&adj) {
let in_comp: BTreeSet<&str> = comp.iter().map(|s| s.as_str()).collect();
let mut 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()) {
continue;
}
let class = if optional_crates.contains(to) {
super::graph_math::EdgeClass::Optional
} else {
super::graph_math::EdgeClass::Normal
};
edges.push(super::graph_math::ClassifiedEdge {
from: from.clone(),
to: to.clone(),
via: vec![to.clone()],
class,
});
}
}
}
let sols = super::graph_math::min_feedback_arc_set(&comp, &edges);
out.push(CycleSolution {
members: comp,
solutions: sols
.into_iter()
.map(|s| CutSolutionView {
cost: s.cost,
edges: s.edges,
actions: s.actions,
proof_order: s.proof_order,
})
.collect(),
});
}
out
}
#[derive(Debug, Clone, Default)]
pub struct BinaryDerivedGraph {
pub adjacency: BTreeMap<String, std::collections::BTreeSet<String>>,
pub cycles: Vec<Vec<String>>,
pub symbol_rows: Vec<crate::knowledge::symbols::SymbolRow>,
pub call_edge_rows: Vec<crate::knowledge::symbols::CallEdgeRow>,
}
impl BinaryDerivedGraph {
pub fn from_workspace(wg: &crate::warehouse::dep_graph::WorkspaceGraph) -> Self {
let mut adjacency: BTreeMap<String, std::collections::BTreeSet<String>> = BTreeMap::new();
for name in wg.facts.keys() {
adjacency.entry(name.clone()).or_default();
}
for e in &wg.edges {
adjacency.entry(e.from.clone()).or_default().insert(e.to.clone());
adjacency.entry(e.to.clone()).or_default();
}
let cycles = super::graph_math::cycles(&adjacency);
Self { adjacency, cycles, symbol_rows: Vec::new(), call_edge_rows: Vec::new() }
}
}
pub fn graph_from_binary(paths: &[PathBuf]) -> Result<BinaryDerivedGraph> {
let bg = crate::graph::extract::binary::extract_graph(paths)?;
let wg = bg.to_workspace_graph();
let mut d = BinaryDerivedGraph::from_workspace(&wg);
d.symbol_rows = bg.to_symbol_rows();
d.call_edge_rows = bg.to_call_edge_rows();
Ok(d)
}
#[derive(Debug, Clone, Serialize)]
pub struct GraphCrossCheck {
pub shared_cycles: Vec<Vec<String>>,
pub source_only_cycles: Vec<Vec<String>>,
pub binary_only_cycles: Vec<Vec<String>>,
pub mfas_divergences: Vec<(Vec<String>, u32, u32)>,
}
impl GraphCrossCheck {
pub fn agrees(&self) -> bool {
self.source_only_cycles.is_empty()
&& self.binary_only_cycles.is_empty()
&& self.mfas_divergences.is_empty()
}
}
pub fn cross_check_source_vs_binary(
source: &BTreeMap<String, std::collections::BTreeSet<String>>,
binary: &BTreeMap<String, std::collections::BTreeSet<String>>,
) -> GraphCrossCheck {
use std::collections::BTreeSet;
let src_set: BTreeSet<Vec<String>> = super::graph_math::cycles(source).into_iter().collect();
let bin_set: BTreeSet<Vec<String>> = super::graph_math::cycles(binary).into_iter().collect();
let shared_cycles: Vec<Vec<String>> = src_set.intersection(&bin_set).cloned().collect();
let source_only_cycles: Vec<Vec<String>> = src_set.difference(&bin_set).cloned().collect();
let binary_only_cycles: Vec<Vec<String>> = bin_set.difference(&src_set).cloned().collect();
let mut mfas_divergences = Vec::new();
for comp in &shared_cycles {
let s_cost = min_cut_cost(source, comp);
let b_cost = min_cut_cost(binary, comp);
if s_cost != b_cost {
mfas_divergences.push((comp.clone(), s_cost, b_cost));
}
}
GraphCrossCheck { shared_cycles, source_only_cycles, binary_only_cycles, mfas_divergences }
}
fn min_cut_cost(
adj: &BTreeMap<String, std::collections::BTreeSet<String>>,
comp: &[String],
) -> u32 {
let members: std::collections::BTreeSet<&String> = comp.iter().collect();
let edges: Vec<super::graph_math::ClassifiedEdge> = comp
.iter()
.flat_map(|from| {
adj.get(from)
.into_iter()
.flatten()
.filter(|to| members.contains(to))
.map(move |to| super::graph_math::ClassifiedEdge {
from: from.clone(),
to: to.clone(),
via: Vec::new(),
class: super::graph_math::EdgeClass::Normal,
})
})
.collect();
super::graph_math::min_feedback_arc_set(comp, &edges)
.into_iter()
.map(|s| s.cost)
.min()
.unwrap_or(0)
}
pub fn publish_waves(graphs: &[RepoGraph]) -> Vec<Vec<String>> {
let (_owner, adj) = crate_graph(graphs);
super::graph_math::condense(&adj).waves()
}
pub fn symptoms(report: &DoctorReport) -> super::graph_math::Symptoms {
use super::graph_math::SymptomKind::*;
let mut s = super::graph_math::Symptoms::new();
s.set(
VersionGap,
report.path_dep_version_gaps.iter().filter(|g| g.dep_owner.is_some()).count(),
);
s.set(
NonMemberDep,
report.path_dep_version_gaps.iter().filter(|g| g.dep_owner.is_none()).count(),
);
s.set(
Skew,
report
.skew
.iter()
.filter(|c| {
c.entries
.iter()
.any(|e| e.status == SkewStatus::Behind && !e.held_by_transitive_pin)
})
.count(),
);
s.set(Dirty, report.dirty.iter().filter(|d| d.dirty).count());
let (mut cheap, mut hard) = (0usize, 0usize);
for cs in &report.cycle_solutions {
match cs.solutions.first() {
Some(best) if best.cost <= 1 => cheap += 1,
_ => hard += 1, }
}
s.set(CheapCycle, cheap);
s.set(HardCycle, hard);
s.set(PromoteBlock, report.promote_blocked.len());
s
}
pub fn crate_publish_order(graphs: &[RepoGraph]) -> TopoReport {
crate_publish_order_gated(graphs, &std::collections::BTreeSet::new())
}
pub fn crate_publish_order_gated(
graphs: &[RepoGraph],
non_gating: &std::collections::BTreeSet<String>,
) -> TopoReport {
use std::collections::BTreeSet;
let edges = repo_edges_gated(graphs, non_gating); 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, PartialEq, Eq, Serialize)]
pub struct OrderViolation {
pub repo: String,
pub dep_crate: String,
pub producer: String,
}
pub fn cascade_order_violations(
graphs: &[RepoGraph],
order: &[String],
available: &std::collections::BTreeSet<String>,
) -> Vec<OrderViolation> {
use std::collections::{BTreeMap, BTreeSet};
let mut producer: BTreeMap<String, String> = BTreeMap::new();
for g in graphs {
for c in &g.produces {
producer.insert(c.clone(), g.repo.clone());
}
}
let by_repo: BTreeMap<&str, &RepoGraph> =
graphs.iter().map(|g| (g.repo.as_str(), g)).collect();
let mut satisfied: BTreeSet<String> = available.clone();
let mut viol = Vec::new();
for repo in order {
let Some(g) = by_repo.get(repo.as_str()) else { continue };
for dep in &g.deps {
if g.produces.contains(dep) {
continue;
}
let Some(pr) = producer.get(dep) else { continue };
if pr == repo || satisfied.contains(dep) {
continue; }
viol.push(OrderViolation {
repo: repo.clone(),
dep_crate: dep.clone(),
producer: pr.clone(),
});
}
for c in &g.produces {
satisfied.insert(c.clone());
}
}
viol
}
#[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>,
#[serde(default)]
pub cycle_solutions: Vec<CycleSolution>,
#[serde(default)]
pub waves: Vec<Vec<String>>,
#[serde(default)]
pub semantic_blast: BTreeMap<String, crate::release::semantic_blast::SemanticBlast>,
#[serde(default)]
pub held_explained: Vec<HeldExplanation>,
}
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 held_explained = block.explained;
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),
cycle_solutions: cycle_solutions(&graphs),
waves: publish_waves(&graphs),
semantic_blast: BTreeMap::new(),
held_explained,
})
}
pub fn run_with_semantic_blast<'a>(
repos: &[(String, PathBuf)],
policy: &DepPolicy,
wh: &crate::warehouse::iceberg::IcebergWarehouse,
changed_by_repo: impl IntoIterator<
Item = (&'a str, &'a [crate::release::semantic_blast::ChangedSymbol]),
>,
) -> Result<DoctorReport> {
let mut report = run(repos, policy)?;
let graphs = repos
.iter()
.map(|(name, path)| gather_repo_graph(name, path))
.collect::<Result<Vec<_>>>()?;
report.semantic_blast =
crate::release::semantic_blast::compute_for_dirty(wh, &graphs, changed_by_repo)?;
Ok(report)
}
#[derive(Debug, Default, Clone)]
pub struct SemanticBlastAudit {
pub dirty_considered: Vec<String>,
pub changed_symbols: BTreeMap<String, usize>,
pub populated: bool,
pub note: String,
}
pub fn run_with_warehouse_semantic_blast(
repos: &[(String, PathBuf)],
policy: &DepPolicy,
wh: &crate::warehouse::iceberg::IcebergWarehouse,
) -> Result<(DoctorReport, SemanticBlastAudit)> {
use crate::release::semantic_blast::{diff_exported_symbols, ChangedSymbol};
let mut audit = SemanticBlastAudit::default();
let dirty: Vec<String> = check_dirty(repos)
.into_iter()
.filter(|d| d.dirty)
.map(|d| d.repo)
.collect();
audit.dirty_considered = dirty.clone();
if dirty.is_empty() {
audit.note =
"no dirty repos → no pending bump to diff; semantic blast empty (link gate skipped)"
.to_string();
return Ok((run(repos, policy)?, audit));
}
let mut changed_by_repo: Vec<(String, Vec<ChangedSymbol>)> = Vec::new();
for (name, path) in repos {
if !dirty.iter().any(|d| d == name) {
continue;
}
let old = match crate::knowledge::query::load_latest(wh, name) {
Ok(view) => view.symbols,
Err(_) => Vec::new(),
};
if old.is_empty() {
continue;
}
let new = match crate::knowledge::symbols::scan_repo(
path,
name,
uuid::Uuid::new_v4(),
chrono::Utc::now(),
) {
Ok(scan) => scan.symbols,
Err(_) => continue,
};
let delta = diff_exported_symbols(&old, &new);
if !delta.is_empty() {
audit.changed_symbols.insert(name.clone(), delta.len());
changed_by_repo.push((name.clone(), delta));
}
}
if changed_by_repo.is_empty() {
audit.note = format!(
"{} dirty repo(s), but no exported-surface delta \
(unchanged API or no persisted warehouse surface) → semantic blast empty (link gate skipped)",
dirty.len()
);
return Ok((run(repos, policy)?, audit));
}
let refs: Vec<(&str, &[ChangedSymbol])> = changed_by_repo
.iter()
.map(|(r, cs)| (r.as_str(), cs.as_slice()))
.collect();
let report = run_with_semantic_blast(repos, policy, wh, refs)?;
audit.populated = !report.semantic_blast.is_empty();
audit.note = format!(
"semantic blast: diffed {} dirty repo(s), {} with a surface delta; \
{} carry a populated cross-crate blast (link gate LIVE)",
dirty.len(),
changed_by_repo.len(),
report.semantic_blast.len(),
);
Ok((report, audit))
}
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.waves.is_empty() {
s.push_str("\nPublish waves (each wave = crates publishable concurrently):\n");
for (i, wave) in report.waves.iter().enumerate() {
s.push_str(&format!(" wave {}: {}\n", i, wave.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.cycle_solutions.is_empty() {
s.push_str("\nMFAS — ranked cycle solutions (proven to yield a DAG):\n");
for cs in &report.cycle_solutions {
s.push_str(&format!(" ⛓ {}\n", cs.members.join(" ⇄ ")));
if cs.solutions.is_empty() {
s.push_str(" (no single-arc cut found)\n");
continue;
}
for (rank, sol) in cs.solutions.iter().take(4).enumerate() {
let edges = sol
.edges
.iter()
.map(|(f, t)| format!("{f}→{t}"))
.collect::<Vec<_>>()
.join(" + ");
s.push_str(&format!(
" #{}. cost {} — cut {} ✓ residual DAG: {}\n",
rank + 1,
sol.cost,
edges,
sol.proof_order.join(" → ")
));
for act in &sol.actions {
s.push_str(&format!(" 💡 {act}\n"));
}
}
}
}
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(", "),
));
s.push_str(" ↳ run `release doctor --explain` for the per-crate reason + unblock advice\n");
}
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(", ")));
}
}
if !report.semantic_blast.is_empty() {
s.push_str("\nSemantic blast radius (exact call sites touching the changed API):\n");
for sb in report.semantic_blast.values() {
s.push_str(&crate::release::semantic_blast::format_semantic_blast(sb));
}
let link_errs: usize = report
.semantic_blast
.values()
.map(|sb| sb.link_errors().len())
.sum();
if link_errs > 0 {
let sites: usize = report
.semantic_blast
.values()
.map(|sb| sb.link_error_sites())
.sum();
s.push_str(&format!(
" ⛔ {link_errs} LINK ERROR(s): {sites} call site(s) invoke a REMOVED symbol — \
release BLOCKED (a linker would fail the same way)\n"
));
}
}
s
}
pub fn format_held_explanations(report: &DoctorReport) -> String {
let mut s = String::new();
s.push_str("\nHeld set — every crate withheld from the crates.io plan, with its reason:\n");
if report.held_explained.is_empty() {
s.push_str(" ✅ nothing held — every crate is promotable\n");
return s;
}
for h in &report.held_explained {
let rides = h
.rides
.iter()
.map(|f| {
let kind = match f.fork_kind {
ForkKind::Path => "path",
ForkKind::Git => "git",
};
if f.source.is_empty() {
format!("{} [{}]", f.patched_dep, kind)
} else {
format!("{} → {} [{}]", f.patched_dep, f.source, kind)
}
})
.collect::<Vec<_>>()
.join(", ");
let how = if h.direct { "directly rides" } else { "transitively rides" };
s.push_str(&format!(" ⛔ held: {} — {} fork {}\n", h.crate_name, how, rides));
s.push_str(&format!(" ↳ {}\n", h.advice));
}
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");
}
#[test]
fn scan_and_fix_versioned_path_dev_deps_strips_version_keeps_path() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(
root.join("Cargo.toml"),
r#"[package]
name = "consumer"
version = "0.5.0"
[dependencies]
serde = { version = "1", path = "../serde-fork" }
[dev-dependencies]
sibling-a = { path = "../sibling-a", version = "0.1" }
sibling-ok = { path = "../sibling-ok" }
[target.'cfg(unix)'.dev-dependencies]
sibling-b = { version = "0.2.1", path = "../sibling-b" }
"#,
)
.unwrap();
let repos = vec![("consumer".to_string(), root.to_path_buf())];
let mut gaps = scan_versioned_path_dev_deps(&repos);
gaps.sort_by(|a, b| a.dep.cmp(&b.dep));
let deps: Vec<&str> = gaps.iter().map(|g| g.dep.as_str()).collect();
assert_eq!(deps, vec!["sibling-a", "sibling-b"], "only path+version DEV-deps");
let out = fix_versioned_path_dev_deps(&gaps, &repos).unwrap();
assert_eq!(out.fixed, 2);
let after = std::fs::read_to_string(root.join("Cargo.toml")).unwrap();
let doc: toml::Value = after.parse().unwrap();
let dev = |v: &toml::Value, table: &str, name: &str| -> toml::Value {
v.get(table).and_then(|t| t.get(name)).cloned().unwrap()
};
let a = dev(&doc, "dev-dependencies", "sibling-a");
assert!(a.get("version").is_none(), "sibling-a version dropped");
assert_eq!(a.get("path").and_then(|p| p.as_str()), Some("../sibling-a"));
let b = doc["target"]["cfg(unix)"]["dev-dependencies"]["sibling-b"].clone();
assert!(b.get("version").is_none(), "sibling-b version dropped");
assert_eq!(b.get("path").and_then(|p| p.as_str()), Some("../sibling-b"));
let ok = dev(&doc, "dev-dependencies", "sibling-ok");
assert_eq!(ok.get("path").and_then(|p| p.as_str()), Some("../sibling-ok"));
let serde = dev(&doc, "dependencies", "serde");
assert_eq!(serde.get("version").and_then(|v| v.as_str()), Some("1"), "normal dep kept");
assert!(scan_versioned_path_dev_deps(&repos).is_empty(), "healed → nothing left");
}
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");
}
#[test]
fn held_explanations_cover_every_blocked_crate_with_a_reason() {
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"])); let foreign: std::collections::BTreeSet<String> =
["iceberg".to_string()].into_iter().collect();
let forks = vec![PatchForkBlock {
crate_name: "nordic".into(),
patched_dep: "iceberg".into(),
fork_kind: ForkKind::Path,
source: "../iceberg-arrow58".into(),
reason: String::new(),
is_foreign_fork: true,
}];
let explained = held_fork_reasons(&cd, &forks, &foreign);
let blocked = promote_blocked_crates_precise(&cd, &foreign);
let explained_names: std::collections::BTreeSet<String> =
explained.iter().map(|h| h.crate_name.clone()).collect();
let sets_match = explained_names == blocked;
let all_have_reason = explained.iter().all(|h| {
!h.rides.is_empty()
&& h.rides.iter().any(|r| r.patched_dep == "iceberg")
&& !h.advice.is_empty()
});
let by = |n: &str| explained.iter().find(|h| h.crate_name == n).unwrap();
let direct_ok = by("skade").direct
&& by("znippy-iceberg").direct
&& !by("nornir").direct;
let source_surfaced = by("skade").rides.iter().any(|r| r.source == "../iceberg-arrow58");
nornir_testmatrix::functional_status(
"release-doctor",
"held_explain_covers_every_crate",
sets_match && all_have_reason && direct_ok && source_surfaced,
&format!(
"explained={:?} blocked={:?}",
explained_names,
blocked,
),
);
assert!(sets_match, "every blocked crate has an explanation: {explained_names:?} vs {blocked:?}");
assert!(all_have_reason, "each held crate names its fork + advice");
assert!(direct_ok, "skade/znippy-iceberg are direct, nornir is transitive");
assert!(source_surfaced, "the fork source string is surfaced in the reason");
assert!(!explained.iter().any(|h| h.crate_name == "znippy-common"), "free crate is not held");
}
#[test]
fn compute_promote_block_populates_and_formats_held_explanations() {
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"),
"\n[workspace]\nmembers = [\"skade\", \"znippy-common\"]\n[patch.crates-io]\niceberg = { path = \"../iceberg-arrow58\" }\n",
)
.unwrap();
member(&ws, "skade", &["iceberg"]);
member(&ws, "znippy-common", &["serde"]);
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.explained.iter().any(|h| h.crate_name == "skade"), "skade explained");
let mut report = DoctorReport::default();
report.promote_blocked = block.blocked.iter().cloned().collect();
report.held_explained = block.explained.clone();
let text = format_held_explanations(&report);
assert!(text.contains("⛔ held: skade"), "renders a held line for skade: {text}");
assert!(text.contains("iceberg"), "names the forked dep: {text}");
assert!(text.contains("Unblock"), "gives unblock advice: {text}");
let empty = format_held_explanations(&DoctorReport::default());
assert!(empty.contains("nothing held"), "empty set answers explicitly: {empty}");
}
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 registry_aware_order_dissolves_false_cycle_from_published_dep() {
let graphs = [
graph("draupnir", &["draupnir"], &["tunnr-vm"]),
graph("tunnr", &["tunnr-vm"], &["nornir-testmatrix"]),
graph("edda", &["nornir-testmatrix", "jera"], &["draupnir"]),
];
let naive = crate_publish_order(&graphs);
let npos = |r: &str| naive.order.iter().position(|x| x == r).unwrap();
assert!(
npos("draupnir") < npos("tunnr"),
"naive order reproduces the bug (draupnir before tunnr): {:?}",
naive.order
);
let non_gating: std::collections::BTreeSet<String> =
["nornir-testmatrix".to_string()].into_iter().collect();
let gated = crate_publish_order_gated(&graphs, &non_gating);
assert!(gated.cycle.is_empty(), "false cycle dissolved: {:?}", gated.cycle);
let gpos = |r: &str| gated.order.iter().position(|x| x == r).unwrap();
assert!(
gpos("tunnr") < gpos("draupnir"),
"gated order is correct (tunnr before draupnir): {:?}",
gated.order
);
}
#[test]
fn cascade_order_violations_catches_bad_order_passes_good() {
let graphs = [
graph("draupnir", &["draupnir"], &["tunnr-vm"]),
graph("tunnr", &["tunnr-vm"], &["nornir-testmatrix"]),
graph("edda", &["nornir-testmatrix", "jera"], &["draupnir"]),
];
let available: std::collections::BTreeSet<String> =
["nornir-testmatrix".to_string()].into_iter().collect();
let bad = vec!["draupnir".to_string(), "edda".to_string(), "tunnr".to_string()];
let v = cascade_order_violations(&graphs, &bad, &available);
assert!(
v.iter().any(|x| x.repo == "draupnir" && x.dep_crate == "tunnr-vm"
&& x.producer == "tunnr"),
"guard names draupnir→tunnr-vm as the violation: {v:?}"
);
let good = vec!["tunnr".to_string(), "draupnir".to_string(), "edda".to_string()];
assert!(
cascade_order_violations(&graphs, &good, &available).is_empty(),
"corrected order is sound"
);
}
#[test]
fn cross_repo_gating_crates_are_the_edge_justifiers() {
let graphs = [
graph("draupnir", &["draupnir"], &["tunnr-vm"]),
graph("tunnr", &["tunnr-vm"], &["nornir-testmatrix"]),
graph("edda", &["nornir-testmatrix", "jera"], &["draupnir"]),
];
let via = cross_repo_gating_crates(&graphs);
let expect: std::collections::BTreeSet<String> =
["tunnr-vm", "nornir-testmatrix", "draupnir"].iter().map(|s| s.to_string()).collect();
assert_eq!(via, expect, "exactly the cross-repo edge crates");
assert!(!via.contains("jera"), "jera gates nothing cross-repo here");
}
fn graph_reqs(repo: &str, produces: &[&str], deps: &[(&str, &str)]) -> RepoGraph {
let mut g = RepoGraph {
repo: repo.to_string(),
produces: produces.iter().map(|s| s.to_string()).collect(),
..Default::default()
};
for (dep, req) in deps {
g.deps.insert((*dep).to_string());
g.dep_reqs.entry((*dep).to_string()).or_default().insert((*req).to_string());
}
g
}
struct MapProbe(std::collections::BTreeMap<&'static str, Vec<&'static str>>);
impl crate::release::preflight::RegistryProbe for MapProbe {
fn version_live(&self, k: &str, v: &str) -> Option<bool> {
self.0.get(k).map(|vs| vs.iter().any(|x| *x == v))
}
fn req_satisfied(&self, k: &str, req: &str) -> Option<bool> {
let req = semver::VersionReq::parse(req).ok()?;
let vs = self.0.get(k)?;
Some(vs.iter().any(|s| {
semver::Version::parse(s).map(|v| req.matches(&v)).unwrap_or(false)
}))
}
}
#[test]
fn registry_satisfied_cross_repo_dep_is_non_gating_even_when_producer_in_set() {
let graphs = [
graph_reqs("draupnir", &["draupnir"], &[("tunnr-vm", "0.1")]),
graph_reqs("tunnr", &["tunnr-vm"], &[]),
graph_reqs("dwarves", &["dwarves"], &[("nornir-build-thing", "0.1.0")]),
graph_reqs("holger", &["holger"], &[("nornir-build-thing", "0.1.0")]),
graph_reqs("nornir", &["nornir-build-thing"], &[]),
];
let probe = MapProbe(
[("tunnr-vm", vec!["0.1.1", "0.1.2"]), ("nornir-build-thing", vec!["0.1.0", "0.1.1"])]
.into_iter()
.collect(),
);
let non_gating = registry_non_gating_crates(&graphs, &probe);
assert!(non_gating.contains("tunnr-vm"), "tunnr-vm ^0.1 satisfied by live 0.1.2");
assert!(
non_gating.contains("nornir-build-thing"),
"nornir-build-thing ^0.1.0 satisfied by live 0.1.1"
);
let order = vec![
"draupnir".to_string(),
"dwarves".to_string(),
"holger".to_string(),
"tunnr".to_string(),
"nornir".to_string(),
];
let viol = cascade_order_violations(&graphs, &order, &non_gating);
assert!(viol.is_empty(), "no order violations once registry-satisfied deps are non-gating: {viol:?}");
}
#[test]
fn unpublished_or_unsatisfied_cross_repo_dep_stays_gating() {
let graphs = [
graph_reqs("draupnir", &["draupnir"], &[("tunnr-vm", "0.1")]),
graph_reqs("tunnr", &["tunnr-vm"], &[]),
graph_reqs("dwarves", &["dwarves"], &[("nornir-build-thing", "0.2")]),
graph_reqs("nornir", &["nornir-build-thing"], &[]),
];
let probe = MapProbe(
[("nornir-build-thing", vec!["0.1.0", "0.1.1"])].into_iter().collect(),
);
let non_gating = registry_non_gating_crates(&graphs, &probe);
assert!(
!non_gating.contains("tunnr-vm"),
"unpublished tunnr-vm must stay gating"
);
assert!(
!non_gating.contains("nornir-build-thing"),
"^0.2 unsatisfied by live 0.1.x — stays gating"
);
let bad = vec![
"draupnir".to_string(),
"dwarves".to_string(),
"tunnr".to_string(),
"nornir".to_string(),
];
let viol = cascade_order_violations(&graphs, &bad, &non_gating);
assert!(
viol.iter().any(|v| v.repo == "draupnir" && v.dep_crate == "tunnr-vm"),
"draupnir→tunnr-vm still flagged: {viol:?}"
);
assert!(
viol.iter().any(|v| v.repo == "dwarves" && v.dep_crate == "nornir-build-thing"),
"dwarves→nornir-build-thing still flagged: {viol:?}"
);
}
#[test]
fn versionless_path_dep_keeps_crate_gating() {
let mut consumer_no_ver = graph_reqs("holger", &["holger"], &[]);
consumer_no_ver.deps.insert("nornir-build-thing".to_string());
let graphs = [
graph_reqs("dwarves", &["dwarves"], &[("nornir-build-thing", "0.1.0")]),
consumer_no_ver,
graph_reqs("nornir", &["nornir-build-thing"], &[]),
];
let probe =
MapProbe([("nornir-build-thing", vec!["0.1.1"])].into_iter().collect());
let non_gating = registry_non_gating_crates(&graphs, &probe);
assert!(
!non_gating.contains("nornir-build-thing"),
"a versionless consumer keeps the crate gating (ALL-consumers rule)"
);
}
#[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 blast_radius_diamond_is_full_closure_without_self() {
let graphs = [
graph("core", &["core"], &[]),
graph("left", &["left"], &["core"]),
graph("right", &["right"], &["core"]),
graph("app", &["app"], &["left", "right"]),
];
let mut radius = blast_radius(&graphs, "core");
radius.sort();
assert_eq!(radius, vec!["app", "left", "right"], "closure over both diamond arms, deduped");
assert!(!radius.contains(&"core".to_string()), "a repo is not in its own blast radius");
assert!(blast_radius(&graphs, "app").is_empty(), "nobody depends on the sink app");
}
#[test]
fn blast_radius_empty_for_undepended_repo() {
let graphs = [
graph("a", &["a"], &["b"]),
graph("b", &["b"], &[]),
graph("island", &["island"], &[]), ];
assert!(blast_radius(&graphs, "island").is_empty());
assert_eq!(blast_radius(&graphs, "b"), vec!["a".to_string()]);
}
#[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"]);
}
}
#[cfg(test)]
mod semantic_blast_run_tests {
use super::*;
use crate::knowledge::symbols::{CallEdgeRow, SymbolRow, SymbolScan};
use crate::release::semantic_blast::ChangedSymbol;
use crate::warehouse::iceberg::IcebergWarehouse;
fn write(path: &Path, body: &str) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, body).unwrap();
}
fn build_repos(root: &Path) -> Vec<(String, PathBuf)> {
let facett = root.join("facett");
let korp = root.join("korp");
write(&facett.join("Cargo.toml"),
"[package]\nname = \"facett\"\nversion = \"0.1.0\"\nedition = \"2021\"\n");
write(&korp.join("Cargo.toml"),
"[package]\nname = \"korp\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\
[dependencies]\nfacett = { path = \"../facett\", version = \"0.1.0\" }\n");
vec![("facett".to_string(), facett), ("korp".to_string(), korp)]
}
fn seed_korp_calls_facett(wh: &IcebergWarehouse) {
let scan = SymbolScan {
snapshot_id: uuid::Uuid::new_v4(),
ts: chrono::Utc::now(),
repo: "korp".into(),
symbols: vec![SymbolRow {
crate_name: "korp".into(),
module_path: "korp::view".into(),
item_kind: "fn".into(),
item_name: "draw".into(),
visibility: "pub".into(),
file: "korp/src/view.rs".into(),
line: 1,
doc_lines: 0,
signature: None,
}],
calls: vec![CallEdgeRow {
crate_name: "korp".into(),
caller_path: "korp::view::draw".into(),
callee_ident: "facett::render".into(),
call_kind: "call".into(),
file: "korp/src/view.rs".into(),
line: 40,
}],
features: vec![],
tests: vec![],
};
wh.append_symbol_scan(&scan).unwrap();
}
#[test]
fn run_with_semantic_blast_equals_run_plus_populated_map() {
let dir = tempfile::tempdir().unwrap();
let repos = build_repos(dir.path());
let wh = IcebergWarehouse::open(&dir.path().join("wh")).unwrap();
seed_korp_calls_facett(&wh);
let policy = DepPolicy::default();
let base = run(&repos, &policy).unwrap();
assert!(base.semantic_blast.is_empty(), "plain run has no semantic blast");
assert_eq!(base.blast.get("facett"), None, "facett clean → not in dirty blast (non-git)");
assert_eq!(blast_radius_for(&repos, "facett"), vec!["korp".to_string()]);
let render = [ChangedSymbol::removed("render")];
let changed: Vec<(&str, &[ChangedSymbol])> = vec![("facett", &render)];
let report = run_with_semantic_blast(&repos, &policy, &wh, changed).unwrap();
assert_eq!(report.topo.order, base.topo.order, "topo order unchanged");
assert_eq!(report.blast, base.blast, "crate-level blast unchanged");
assert_eq!(report.repo_edges.len(), base.repo_edges.len(), "repo edges unchanged");
let sb = report.semantic_blast.get("facett").expect("facett semantic blast present");
assert_eq!(sb.source, "syn");
assert_eq!(sb.total_call_sites, 1);
assert_eq!(sb.impacts.len(), 1);
let site = &sb.impacts[0].call_sites[0];
assert_eq!(site.crate_name, "korp");
assert_eq!(site.caller, "korp::view::draw");
assert_eq!(site.file, "korp/src/view.rs");
assert_eq!(site.line, 40);
let internal = [ChangedSymbol::changed("private_helper")];
let changed2: Vec<(&str, &[ChangedSymbol])> = vec![("facett", &internal)];
let report2 = run_with_semantic_blast(&repos, &policy, &wh, changed2).unwrap();
assert!(report2.semantic_blast.is_empty(), "internal-only bump surfaces nothing");
}
fn git_init(root: &Path) {
std::fs::create_dir_all(root).unwrap();
let ok = std::process::Command::new("git")
.arg("-C").arg(root).arg("init")
.status().map(|s| s.success()).unwrap_or(false);
assert!(ok, "git init failed (is git installed?)");
}
fn seed_symbols(wh: &IcebergWarehouse, repo: &str, syms: &[(&str, &str)]) {
let symbols = syms
.iter()
.map(|(name, vis)| SymbolRow {
crate_name: repo.into(),
module_path: format!("{repo}::api"),
item_kind: "fn".into(),
item_name: (*name).into(),
visibility: (*vis).into(),
file: format!("{repo}/src/lib.rs"),
line: 1,
doc_lines: 0,
signature: None,
})
.collect();
let scan = SymbolScan {
snapshot_id: uuid::Uuid::new_v4(),
ts: chrono::Utc::now(),
repo: repo.into(),
symbols,
calls: vec![],
features: vec![],
tests: vec![],
};
wh.append_symbol_scan(&scan).unwrap();
}
#[test]
fn wired_path_populates_blast_and_link_gate_fires() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let facett = root.join("facett");
let korp = root.join("korp");
write(&facett.join("Cargo.toml"),
"[package]\nname = \"facett\"\nversion = \"0.1.0\"\nedition = \"2021\"\n");
write(&facett.join("src/lib.rs"), "pub fn open() {}\n");
git_init(&facett);
write(&korp.join("Cargo.toml"),
"[package]\nname = \"korp\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\
[dependencies]\nfacett = { path = \"../facett\", version = \"0.1.0\" }\n");
write(&korp.join("src/lib.rs"), "pub fn draw() {}\n");
let wh = IcebergWarehouse::open(&root.join("wh")).unwrap();
seed_symbols(&wh, "facett", &[("render", "pub"), ("open", "pub")]);
seed_korp_calls_facett(&wh);
let repos = vec![
("facett".to_string(), facett.clone()),
("korp".to_string(), korp.clone()),
];
let policy = DepPolicy::default();
let (report, audit) =
run_with_warehouse_semantic_blast(&repos, &policy, &wh).unwrap();
assert!(
audit.dirty_considered.contains(&"facett".to_string()),
"facett must be considered dirty: {:?}", audit.dirty_considered,
);
assert_eq!(
audit.changed_symbols.get("facett"), Some(&1),
"the removed `render` is the sole surface delta: {:?}", audit.changed_symbols,
);
assert!(audit.populated, "semantic blast must be populated: {}", audit.note);
let sb = report.semantic_blast.get("facett")
.expect("facett carries a populated semantic blast");
assert!(sb.has_link_error(), "removed `render` + a live korp call site = link error");
let gate = crate::release::gate::semantic_link_gate(&report.semantic_blast);
let fired = gate.is_err();
let msg = gate.err().map(|e| e.to_string()).unwrap_or_default();
nornir_testmatrix::functional_status(
"release-doctor",
"wired_semantic_link_gate_fires",
fired && audit.populated && msg.contains("render"),
&format!("note={:?} gate_msg={msg:?}", audit.note),
);
assert!(fired, "semantic_link_gate must FIRE on the wired blast (was inert): {}", audit.note);
assert!(msg.contains("LINK ERROR") && msg.contains("render"),
"gate names the removed symbol: {msg}");
}
#[test]
fn wired_path_degrades_to_empty_when_no_warehouse_surface() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let facett = root.join("facett");
write(&facett.join("Cargo.toml"),
"[package]\nname = \"facett\"\nversion = \"0.1.0\"\nedition = \"2021\"\n");
write(&facett.join("src/lib.rs"), "pub fn open() {}\n");
git_init(&facett);
let wh = IcebergWarehouse::open(&root.join("wh")).unwrap();
let repos = vec![("facett".to_string(), facett.clone())];
let policy = DepPolicy::default();
let (report, audit) =
run_with_warehouse_semantic_blast(&repos, &policy, &wh).unwrap();
assert!(report.semantic_blast.is_empty(), "no OLD surface ⇒ empty blast");
assert!(!audit.populated, "degraded, not populated: {}", audit.note);
assert!(crate::release::gate::semantic_link_gate(&report.semantic_blast).is_ok());
}
fn blast_radius_for(repos: &[(String, PathBuf)], repo: &str) -> Vec<String> {
let graphs: Vec<RepoGraph> = repos
.iter()
.map(|(n, p)| gather_repo_graph(n, p).unwrap())
.collect();
blast_radius(&graphs, repo)
}
#[test]
fn tidy_dirty_sheds_noise_but_keeps_real_changes() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("myrepo");
write(&root.join("Cargo.toml"), "[package]\nname=\"m\"\nversion=\"0.1.0\"\n");
write(&root.join("src/lib.rs"), "pub fn a() {}\n");
crate::gitio::init(&root).unwrap();
crate::gitio::commit_all(&root, "seed").unwrap();
assert!(!crate::gitio::worktree_freshness(&root).unwrap().dirty, "clean baseline");
write(&root.join(".claude/scratch.txt"), "junk\n");
write(&root.join("data.arrows"), "\x00arrow\n");
write(&root.join("src/deep/nested.arrows"), "\x00\n");
assert!(crate::gitio::worktree_freshness(&root).unwrap().dirty);
let repos = vec![("myrepo".to_string(), root.clone())];
let dirty = check_dirty(&repos);
let out = tidy_dirty_repos(&dirty, &repos).unwrap();
assert_eq!(out.removed, 3, "two .arrows files + one .claude dir");
assert_eq!(out.cleaned, vec!["myrepo".to_string()]);
assert!(out.still_dirty.is_empty());
assert!(!root.join(".claude").exists());
assert!(!root.join("data.arrows").exists());
assert!(!crate::gitio::worktree_freshness(&root).unwrap().dirty, "clean after tidy");
write(&root.join("src/new_real.rs"), "pub fn b() {}\n");
write(&root.join("more.arrows"), "\x00\n");
let dirty2 = check_dirty(&repos);
let out2 = tidy_dirty_repos(&dirty2, &repos).unwrap();
assert_eq!(out2.removed, 1, "only the .arrows noise is shed");
assert!(out2.cleaned.is_empty());
assert_eq!(out2.still_dirty, vec!["myrepo".to_string()], "real change remains");
assert!(root.join("src/new_real.rs").exists(), "real file untouched");
}
#[test]
fn cut_cycle_cheap_drops_the_optional_edge_and_scrubs_features() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write(&root.join("repoA/Cargo.toml"), r#"[package]
name = "acrate"
version = "0.1.0"
edition = "2021"
[dependencies]
bcrate = { path = "../repoB" }
"#);
write(&root.join("repoB/Cargo.toml"), r#"[package]
name = "bcrate"
version = "0.1.0"
edition = "2021"
[dependencies]
acrate = { path = "../repoA", optional = true }
[features]
withA = ["dep:acrate"]
"#);
let repos = vec![
("repoA".to_string(), root.join("repoA")),
("repoB".to_string(), root.join("repoB")),
];
let graphs: Vec<RepoGraph> =
repos.iter().map(|(n, p)| gather_repo_graph(n, p).unwrap()).collect();
let sols = cycle_solutions(&graphs);
assert_eq!(sols.len(), 1, "one crate cycle: {sols:#?}");
assert!(sols[0].solutions.first().unwrap().cost <= 1, "cheap (optional) cut");
let out = apply_cheap_cycle_cuts(&repos).unwrap();
assert_eq!(out.cut, vec![("bcrate".to_string(), "acrate".to_string())]);
assert!(out.unresolved.is_empty(), "{:#?}", out.unresolved);
let b = std::fs::read_to_string(root.join("repoB/Cargo.toml")).unwrap();
assert!(!b.contains("acrate = {"), "optional dep removed:\n{b}");
assert!(!b.contains("dep:acrate"), "feature ref scrubbed:\n{b}");
let graphs2: Vec<RepoGraph> =
repos.iter().map(|(n, p)| gather_repo_graph(n, p).unwrap()).collect();
assert!(cycle_solutions(&graphs2).is_empty(), "cycle broken");
}
}
#[cfg(test)]
mod binary_graph_cross_check_tests {
use super::*;
use crate::warehouse::dep_graph::{CrossRepoEdge, WorkspaceGraph};
use std::collections::BTreeSet;
fn adj(pairs: &[(&str, &str)]) -> BTreeMap<String, BTreeSet<String>> {
let mut m: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for (a, b) in pairs {
m.entry(a.to_string()).or_default().insert(b.to_string());
m.entry(b.to_string()).or_default();
}
m
}
#[test]
fn binary_derived_scc_and_mfas_match_source() {
let source = adj(&[("a", "b"), ("b", "a"), ("a", "c")]);
let bin_edges = vec![
CrossRepoEdge::normal("a", "b", ["s_ab".to_string()].into_iter().collect()),
CrossRepoEdge::normal("b", "a", ["s_ba".to_string()].into_iter().collect()),
CrossRepoEdge::normal("a", "c", ["s_ac".to_string()].into_iter().collect()),
];
let wg = WorkspaceGraph::from_query_parts(Default::default(), bin_edges);
let derived = BinaryDerivedGraph::from_workspace(&wg);
assert_eq!(derived.cycles, vec![vec!["a".to_string(), "b".to_string()]]);
let cc = cross_check_source_vs_binary(&source, &derived.adjacency);
assert!(cc.agrees(), "source and ELF graphs agree: {cc:?}");
assert_eq!(cc.shared_cycles, vec![vec!["a".to_string(), "b".to_string()]]);
assert!(cc.source_only_cycles.is_empty());
assert!(cc.binary_only_cycles.is_empty());
assert!(cc.mfas_divergences.is_empty());
}
#[test]
fn binary_derived_divergence_from_source_is_flagged() {
let source = adj(&[("a", "b"), ("b", "c")]);
let bin_edges = vec![
CrossRepoEdge::normal("a", "b", ["s".to_string()].into_iter().collect()),
CrossRepoEdge::normal("b", "c", ["s".to_string()].into_iter().collect()),
CrossRepoEdge::normal("c", "b", ["s".to_string()].into_iter().collect()),
];
let wg = WorkspaceGraph::from_query_parts(Default::default(), bin_edges);
let derived = BinaryDerivedGraph::from_workspace(&wg);
let cc = cross_check_source_vs_binary(&source, &derived.adjacency);
assert!(!cc.agrees(), "a binary-only cycle must NOT pass the cross-check");
assert_eq!(cc.binary_only_cycles, vec![vec!["b".to_string(), "c".to_string()]]);
assert!(cc.source_only_cycles.is_empty());
assert!(cc.shared_cycles.is_empty());
}
#[test]
fn graph_from_binary_reads_a_real_elf() {
let exe = std::env::current_exe().expect("test exe path");
let derived = graph_from_binary(&[exe]).expect("extract ELF graph from the test binary");
assert!(!derived.adjacency.is_empty(), "the ELF object is a graph node");
assert!(
!derived.symbol_rows.is_empty(),
"a dynamically-linked ELF exposes .dynsym symbol rows"
);
}
}