use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::Result;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ForbiddenDep {
pub crate_name: String,
pub version: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DepPolicy {
pub forbidden: Vec<ForbiddenDep>,
}
#[derive(Debug, Clone)]
pub struct RepoExternals {
pub repo: String,
pub deps: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum SkewStatus {
Ok,
Behind,
Forbidden,
}
#[derive(Debug, Clone, Serialize)]
pub struct RepoCrateStatus {
pub repo: String,
pub version: String,
pub status: SkewStatus,
}
#[derive(Debug, Clone, Serialize)]
pub struct CrateSkew {
pub crate_name: String,
pub target: String,
pub diverged: bool,
pub entries: Vec<RepoCrateStatus>,
}
impl CrateSkew {
pub fn bump_repos(&self) -> Vec<&str> {
self.entries
.iter()
.filter(|e| e.status != SkewStatus::Ok)
.map(|e| e.repo.as_str())
.collect()
}
}
fn version_key(s: &str) -> (u64, u64, u64) {
let cleaned = s.trim().trim_start_matches(|c: char| !c.is_ascii_digit());
let mut it = cleaned.split('.').map(|p| {
p.chars()
.take_while(|c| c.is_ascii_digit())
.collect::<String>()
.parse::<u64>()
.unwrap_or(0)
});
(it.next().unwrap_or(0), it.next().unwrap_or(0), it.next().unwrap_or(0))
}
fn is_forbidden(crate_name: &str, version: &str, policy: &DepPolicy) -> bool {
let major = version_key(version).0;
policy
.forbidden
.iter()
.any(|f| f.crate_name == crate_name && version_key(&f.version).0 == major)
}
pub fn analyze_skew(repos: &[RepoExternals], policy: &DepPolicy) -> Vec<CrateSkew> {
let mut by_crate: BTreeMap<&str, Vec<(&str, &str)>> = BTreeMap::new();
for r in repos {
for (c, v) in &r.deps {
by_crate.entry(c.as_str()).or_default().push((r.repo.as_str(), v.as_str()));
}
}
let mut out = Vec::new();
for (crate_name, mut uses) in by_crate {
let has_forbidden = uses.iter().any(|(_, v)| is_forbidden(crate_name, v, policy));
let distinct: std::collections::BTreeSet<(u64, u64, u64)> =
uses.iter().map(|(_, v)| version_key(v)).collect();
let diverged = distinct.len() >= 2;
if !diverged && !has_forbidden {
continue;
}
uses.sort_by(|a, b| a.0.cmp(b.0)); let target_key = uses.iter().map(|(_, v)| version_key(v)).max().unwrap();
let target = uses
.iter()
.find(|(_, v)| version_key(v) == target_key)
.map(|(_, v)| v.to_string())
.unwrap();
let entries = uses
.iter()
.map(|(repo, v)| {
let status = if is_forbidden(crate_name, v, policy) {
SkewStatus::Forbidden
} else if version_key(v) < target_key {
SkewStatus::Behind
} else {
SkewStatus::Ok
};
RepoCrateStatus { repo: repo.to_string(), version: v.to_string(), status }
})
.collect();
out.push(CrateSkew { crate_name: crate_name.to_string(), target, diverged, entries });
}
out
}
#[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 !p.is_dir() {
continue;
}
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name == "target" || name.starts_with('.') {
continue;
}
stack.push((p, depth + 1));
}
}
out
}
#[derive(Debug, Clone)]
pub struct RepoGraph {
pub repo: String,
pub produces: std::collections::BTreeSet<String>,
pub deps: std::collections::BTreeSet<String>,
}
pub fn gather_repo_graph(repo: &str, root: &Path) -> Result<RepoGraph> {
use std::collections::BTreeSet;
let mut produces = BTreeSet::new();
let mut deps = BTreeSet::new();
for toml_path in find_cargo_tomls(root, 4) {
let Ok(txt) = std::fs::read_to_string(&toml_path) else { continue };
let Ok(doc) = txt.parse::<toml::Value>() else { continue };
let package = doc.get("package");
let publishable = package
.and_then(|p| p.get("publish"))
.and_then(|v| v.as_bool())
.unwrap_or(true);
if !publishable {
continue;
}
if let Some(n) = package.and_then(|p| p.get("name")).and_then(|n| n.as_str()) {
produces.insert(n.to_string());
}
for key in ["dependencies", "build-dependencies"] {
if let Some(t) = doc.get(key).and_then(|t| t.as_table()) {
for (name, spec) in t {
let optional = spec
.as_table()
.and_then(|d| d.get("optional"))
.and_then(|o| o.as_bool())
.unwrap_or(false);
if !optional {
deps.insert(name.clone());
}
}
}
}
}
Ok(RepoGraph { repo: repo.to_string(), produces, deps })
}
fn repo_edges(graphs: &[RepoGraph]) -> BTreeMap<String, std::collections::BTreeSet<String>> {
let mut out: BTreeMap<String, std::collections::BTreeSet<String>> = BTreeMap::new();
for a in graphs {
let set = out.entry(a.repo.clone()).or_default();
for b in graphs {
if a.repo != b.repo && b.produces.iter().any(|c| a.deps.contains(c)) {
set.insert(b.repo.clone());
}
}
}
out
}
#[derive(Debug, Clone, Serialize)]
pub struct TopoReport {
pub order: Vec<String>,
pub cycle: Vec<String>,
}
pub fn publish_order(graphs: &[RepoGraph]) -> TopoReport {
let deps_on = repo_edges(graphs);
let mut indeg: BTreeMap<String, usize> =
deps_on.iter().map(|(r, d)| (r.clone(), d.len())).collect();
let mut dependents: BTreeMap<String, Vec<String>> = BTreeMap::new();
for (a, ds) in &deps_on {
for b in ds {
dependents.entry(b.clone()).or_default().push(a.clone());
}
}
let mut ready: std::collections::BTreeSet<String> =
indeg.iter().filter(|(_, &d)| d == 0).map(|(r, _)| r.clone()).collect();
let mut order = Vec::new();
while let Some(n) = ready.iter().next().cloned() {
ready.remove(&n);
order.push(n.clone());
if let Some(deps) = dependents.get(&n) {
for a in deps {
if let Some(d) = indeg.get_mut(a) {
*d -= 1;
if *d == 0 {
ready.insert(a.clone());
}
}
}
}
}
let cycle: Vec<String> =
indeg.keys().filter(|r| !order.contains(r)).cloned().collect();
TopoReport { order, cycle }
}
pub fn blast_radius(graphs: &[RepoGraph], repo: &str) -> Vec<String> {
let deps_on = repo_edges(graphs);
let mut result = std::collections::BTreeSet::new();
let mut stack = vec![repo.to_string()];
while let Some(cur) = stack.pop() {
for (a, ds) in &deps_on {
if ds.contains(&cur) && result.insert(a.clone()) {
stack.push(a.clone());
}
}
}
result.into_iter().collect()
}
#[derive(Debug, Clone, Serialize)]
pub struct DoctorReport {
pub dirty: Vec<RepoDirty>,
pub skew: Vec<CrateSkew>,
pub topo: TopoReport,
pub blast: BTreeMap<String, Vec<String>>,
}
pub fn run(repos: &[(String, PathBuf)], policy: &DepPolicy) -> Result<DoctorReport> {
let externals = repos
.iter()
.map(|(name, path)| gather_repo_externals(name, path))
.collect::<Result<Vec<_>>>()?;
let graphs = repos
.iter()
.map(|(name, path)| gather_repo_graph(name, path))
.collect::<Result<Vec<_>>>()?;
let dirty = check_dirty(repos);
let blast = dirty
.iter()
.filter(|d| d.dirty)
.map(|d| (d.repo.clone(), blast_radius(&graphs, &d.repo)))
.collect();
Ok(DoctorReport {
dirty,
skew: analyze_skew(&externals, policy),
topo: publish_order(&graphs),
blast,
})
}
pub fn format_report(report: &DoctorReport) -> String {
let mut s = String::new();
s.push_str("nornir release doctor — advisory\n\n");
s.push_str("Working trees:\n");
let dirty: Vec<_> = report.dirty.iter().filter(|d| d.dirty).collect();
if dirty.is_empty() {
s.push_str(" ✅ all clean\n");
} else {
for d in &dirty {
s.push_str(&format!(" 🟡 {} — uncommitted changes\n", d.repo));
}
}
for d in report.dirty.iter().filter(|d| d.error.is_some()) {
s.push_str(&format!(" ⚠ {} — {}\n", d.repo, d.error.as_deref().unwrap_or("")));
}
s.push_str("\nExternal dependency skew:\n");
if report.skew.is_empty() {
s.push_str(" ✅ no divergence\n");
} else {
for c in &report.skew {
let forbidden = c.entries.iter().any(|e| e.status == SkewStatus::Forbidden);
s.push_str(&format!(" {} (target {})", c.crate_name, c.target));
if forbidden {
s.push_str(" ⚠ FORBIDDEN version present");
}
s.push('\n');
for e in &c.entries {
let mark = match e.status {
SkewStatus::Ok => "✓",
SkewStatus::Behind => "·",
SkewStatus::Forbidden => "⚠",
};
s.push_str(&format!(" {} {} {}\n", mark, e.repo, e.version));
}
let bump = c.bump_repos();
if !bump.is_empty() {
s.push_str(&format!(" 💡 bump → {}: {}\n", c.target, bump.join(", ")));
}
}
}
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(", ")));
}
let blast: Vec<_> = report.blast.iter().filter(|(_, d)| !d.is_empty()).collect();
if !blast.is_empty() {
s.push_str("\nBlast radius of dirty repos (re-validate on change):\n");
for (repo, deps) in blast {
s.push_str(&format!(" {} → {}\n", repo, deps.join(", ")));
}
}
s
}
#[cfg(test)]
mod tests {
use super::*;
fn repo(name: &str, deps: &[(&str, &str)]) -> RepoExternals {
RepoExternals {
repo: name.to_string(),
deps: deps.iter().map(|(c, v)| (c.to_string(), v.to_string())).collect(),
}
}
#[test]
fn arrow58_case_matches_hand_analysis() {
let repos = [
repo("znippy", &[("arrow", "58.3.0"), ("serde", "1")]),
repo("skade", &[("arrow", "57.1"), ("serde", "1")]),
repo("nornir", &[("arrow", "57"), ("serde", "1")]),
repo("knut", &[("arrow", "57"), ("serde", "1")]),
repo("facett", &[("arrow", "56"), ("serde", "1")]),
repo("korp", &[("arrow", "56"), ("serde", "1")]),
];
let policy = DepPolicy {
forbidden: vec![ForbiddenDep { crate_name: "arrow".into(), version: "56".into() }],
};
let skew = analyze_skew(&repos, &policy);
assert_eq!(skew.len(), 1, "only arrow should be flagged");
let arrow = &skew[0];
assert_eq!(arrow.crate_name, "arrow");
assert_eq!(arrow.target, "58.3.0", "target = highest declared (znippy)");
assert!(arrow.diverged);
let status = |r: &str| {
arrow.entries.iter().find(|e| e.repo == r).map(|e| e.status.clone()).unwrap()
};
assert_eq!(status("znippy"), SkewStatus::Ok);
assert_eq!(status("skade"), SkewStatus::Behind);
assert_eq!(status("nornir"), SkewStatus::Behind);
assert_eq!(status("knut"), SkewStatus::Behind);
assert_eq!(status("facett"), SkewStatus::Forbidden);
assert_eq!(status("korp"), SkewStatus::Forbidden);
let mut bump = arrow.bump_repos();
bump.sort();
assert_eq!(bump, vec!["facett", "knut", "korp", "nornir", "skade"]);
}
#[test]
fn no_skew_when_all_agree() {
let repos = [repo("a", &[("arrow", "58")]), repo("b", &[("arrow", "58")])];
assert!(analyze_skew(&repos, &DepPolicy::default()).is_empty());
}
#[test]
fn forbidden_surfaces_even_without_divergence() {
let repos = [repo("a", &[("arrow", "56")]), repo("b", &[("arrow", "56")])];
let policy = DepPolicy {
forbidden: vec![ForbiddenDep { crate_name: "arrow".into(), version: "56".into() }],
};
let skew = analyze_skew(&repos, &policy);
assert_eq!(skew.len(), 1);
assert!(skew[0].entries.iter().all(|e| e.status == SkewStatus::Forbidden));
}
#[test]
fn version_key_tolerates_partials_and_operators() {
assert_eq!(version_key("58.3.0"), (58, 3, 0));
assert_eq!(version_key("57"), (57, 0, 0));
assert_eq!(version_key("^1.2"), (1, 2, 0));
assert_eq!(version_key(">=0.9.0"), (0, 9, 0));
assert_eq!(version_key("=56.2.1"), (56, 2, 1));
}
#[test]
fn gatherer_reads_external_versions_and_skips_path_deps() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
r#"
[package]
name = "demo"
[dependencies]
arrow = "58.3.0"
serde = { version = "1.0", features = ["derive"] }
znippy-common = { version = "0.9.4", path = "../znippy-common" }
gitdep = { git = "https://example.com/x" }
"#,
)
.unwrap();
let ext = gather_repo_externals("demo", dir.path()).unwrap();
assert_eq!(ext.deps.get("arrow").map(String::as_str), Some("58.3.0"));
assert_eq!(ext.deps.get("serde").map(String::as_str), Some("1.0"));
assert!(!ext.deps.contains_key("znippy-common"), "path dep is workspace-internal");
assert!(!ext.deps.contains_key("gitdep"), "git dep has no version");
}
fn graph(repo: &str, produces: &[&str], deps: &[&str]) -> RepoGraph {
RepoGraph {
repo: repo.to_string(),
produces: produces.iter().map(|s| s.to_string()).collect(),
deps: deps.iter().map(|s| s.to_string()).collect(),
}
}
#[test]
fn publish_order_is_dependencies_first() {
let graphs = [
graph("znippy", &["znippy-common", "lgz"], &["serde"]),
graph("skade", &["skade-katalog"], &["arrow"]),
graph("nornir", &["nornir"], &["znippy-common", "skade-katalog", "serde"]),
];
let topo = publish_order(&graphs);
assert!(topo.cycle.is_empty(), "clean DAG");
let pos = |r: &str| topo.order.iter().position(|x| x == r).unwrap();
assert!(pos("znippy") < pos("nornir"), "znippy before nornir");
assert!(pos("skade") < pos("nornir"), "skade before nornir");
assert_eq!(topo.order.len(), 3);
}
#[test]
fn blast_radius_is_transitive_dependents() {
let graphs = [
graph("skade", &["skade-katalog"], &[]),
graph("nornir", &["nornir"], &["skade-katalog"]),
graph("cli", &["cli"], &["nornir"]),
];
let mut radius = blast_radius(&graphs, "skade");
radius.sort();
assert_eq!(radius, vec!["cli", "nornir"], "changing skade re-validates nornir + cli");
}
#[test]
fn publish_order_flags_cycle() {
let graphs = [
graph("a", &["a-crate"], &["b-crate"]),
graph("b", &["b-crate"], &["a-crate"]),
];
let topo = publish_order(&graphs);
assert!(topo.order.is_empty(), "all on a cycle → none ordered");
assert_eq!(topo.cycle.len(), 2);
}
}