use std::collections::{BTreeMap, BTreeSet};
use crate::warehouse::dep_graph::WorkspaceGraph;
#[derive(Debug, Clone)]
pub struct FailedCrate {
pub name: String,
pub reason: String,
pub blames: Vec<String>,
}
impl FailedCrate {
pub fn new(name: impl Into<String>, reason: impl Into<String>) -> Self {
Self { name: name.into(), reason: reason.into(), blames: Vec::new() }
}
pub fn from_issue(issue: &crate::release::preflight::Issue) -> Self {
Self {
name: issue.krate.clone(),
reason: issue.detail.clone(),
blames: issue.blames.clone(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RootGroup {
pub root: String,
pub reason: String,
pub unblocks: Vec<String>,
}
#[derive(Debug, Clone, Default)]
pub struct RootCauseSummary {
pub roots: Vec<RootGroup>,
pub downstream: Vec<String>,
}
const INFERRED_ROOT_REASON: &str =
"edited with a new API but not published (skipped as AlreadyPublished) — \
dependents can't build against the published version";
pub fn classify_roots(failures: &[FailedCrate], graph: &WorkspaceGraph) -> Vec<RootGroup> {
analyze(failures, graph).roots
}
pub fn analyze(failures: &[FailedCrate], graph: &WorkspaceGraph) -> RootCauseSummary {
let mut reason: BTreeMap<String, String> = BTreeMap::new();
let mut blame_edges: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for f in failures {
reason.entry(f.name.clone()).or_insert_with(|| f.reason.clone());
blame_edges
.entry(f.name.clone())
.or_default()
.extend(f.blames.iter().cloned());
}
let mut nodes: BTreeSet<String> = reason.keys().cloned().collect();
for f in failures {
for b in &f.blames {
nodes.insert(b.clone());
}
}
let tdeps: BTreeMap<String, BTreeSet<String>> = nodes
.iter()
.map(|n| (n.clone(), transitive_failed_deps(n, &nodes, graph, &blame_edges)))
.collect();
let roots: BTreeSet<String> = nodes
.iter()
.filter(|n| tdeps[*n].is_empty())
.cloned()
.collect();
let downstream: Vec<String> =
nodes.iter().filter(|n| !roots.contains(*n)).cloned().collect();
let mut unblocks: BTreeMap<String, BTreeSet<String>> =
roots.iter().map(|r| (r.clone(), BTreeSet::new())).collect();
for d in &downstream {
for r in tdeps[d].intersection(&roots) {
unblocks.get_mut(r).unwrap().insert(d.clone());
}
}
let mut groups: Vec<RootGroup> = roots
.iter()
.map(|r| RootGroup {
root: r.clone(),
reason: reason
.get(r)
.cloned()
.unwrap_or_else(|| INFERRED_ROOT_REASON.to_string()),
unblocks: unblocks[r].iter().cloned().collect(),
})
.collect();
groups.sort_by(|a, b| {
b.unblocks
.len()
.cmp(&a.unblocks.len())
.then_with(|| a.root.cmp(&b.root))
});
RootCauseSummary { roots: groups, downstream }
}
fn transitive_failed_deps(
start: &str,
nodes: &BTreeSet<String>,
graph: &WorkspaceGraph,
blame_edges: &BTreeMap<String, BTreeSet<String>>,
) -> BTreeSet<String> {
let mut reached: BTreeSet<String> = BTreeSet::new();
if graph.has_component(start) {
reached.extend(graph.deps_transitive(start));
}
let mut stack: Vec<String> =
blame_edges.get(start).into_iter().flatten().cloned().collect();
while let Some(cur) = stack.pop() {
if !reached.insert(cur.clone()) {
continue;
}
if graph.has_component(&cur) {
reached.extend(graph.deps_transitive(&cur));
}
if let Some(bs) = blame_edges.get(&cur) {
stack.extend(bs.iter().cloned());
}
}
reached.remove(start);
reached.intersection(nodes).cloned().collect()
}
impl RootCauseSummary {
pub fn format(&self) -> String {
let explaining: Vec<&RootGroup> =
self.roots.iter().filter(|g| !g.unblocks.is_empty()).collect();
if explaining.is_empty() {
return String::new();
}
let mut s = String::from("\nROOT CAUSE(S) — fix these, in order:\n");
for g in &explaining {
s.push_str(&format!(
" \u{26D4} {} — {} → unblocks: {}\n",
g.root,
g.reason,
g.unblocks.join(", ")
));
}
s.push_str(&format!(
"({} downstream failure{} explained by {} root{})\n",
self.downstream.len(),
if self.downstream.len() == 1 { "" } else { "s" },
explaining.len(),
if explaining.len() == 1 { "" } else { "s" }
));
s
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::warehouse::dep_graph::{CrossRepoEdge, WorkspaceGraph};
fn graph(edges: &[(&str, &str)]) -> WorkspaceGraph {
let e: Vec<CrossRepoEdge> = edges
.iter()
.map(|(from, to)| {
let mut via = BTreeSet::new();
via.insert((*to).to_string());
CrossRepoEdge::normal(*from, *to, via)
})
.collect();
WorkspaceGraph::from_query_parts(Default::default(), e)
}
fn names(v: &[String]) -> Vec<&str> {
v.iter().map(String::as_str).collect()
}
#[test]
fn one_root_many_downstream() {
let g = graph(&[
("lbzip2", "gatling"),
("lgz", "gatling"),
("ljar", "gatling"),
("lzip-parallel", "gatling"),
("znippy-zoomies", "gatling"),
("osm-katana", "gatling"),
]);
let failures = vec![
FailedCrate::new("gatling", "already-published: new API never uploaded"),
FailedCrate::new("lbzip2", "verify: uses API not in published gatling"),
FailedCrate::new("lgz", "verify"),
FailedCrate::new("ljar", "verify"),
FailedCrate::new("lzip-parallel", "verify"),
FailedCrate::new("znippy-zoomies", "verify"),
FailedCrate::new("osm-katana", "verify"),
];
let summary = analyze(&failures, &g);
assert_eq!(summary.roots.len(), 1, "exactly one root");
let root = &summary.roots[0];
assert_eq!(root.root, "gatling");
assert_eq!(
names(&root.unblocks),
vec!["lbzip2", "lgz", "ljar", "lzip-parallel", "osm-katana", "znippy-zoomies"]
);
assert_eq!(summary.downstream.len(), 6);
let text = summary.format();
assert!(text.contains("ROOT CAUSE(S)"), "{text}");
assert!(text.contains("6 downstream failures explained by 1 root"), "{text}");
}
#[test]
fn inferred_root_from_blames_when_root_was_skipped() {
let g = graph(&[("lbzip2", "gatling"), ("lgz", "gatling")]);
let failures = vec![
FailedCrate { name: "lbzip2".into(), reason: "verify".into(), blames: vec!["gatling".into()] },
FailedCrate { name: "lgz".into(), reason: "verify".into(), blames: vec!["gatling".into()] },
];
let summary = analyze(&failures, &g);
assert_eq!(summary.roots.len(), 1);
assert_eq!(summary.roots[0].root, "gatling");
assert_eq!(names(&summary.roots[0].unblocks), vec!["lbzip2", "lgz"]);
assert!(summary.roots[0].reason.contains("AlreadyPublished"));
assert_eq!(names(&summary.downstream), vec!["lbzip2", "lgz"]);
}
#[test]
fn two_independent_roots() {
let g = graph(&[
("app-a", "core-x"),
("lib-a", "core-x"),
("app-b", "core-y"),
]);
let failures = vec![
FailedCrate::new("core-x", "dirty tree"),
FailedCrate::new("app-a", "verify"),
FailedCrate::new("lib-a", "verify"),
FailedCrate::new("core-y", "missing metadata"),
FailedCrate::new("app-b", "verify"),
];
let roots = classify_roots(&failures, &g);
assert_eq!(roots.len(), 2, "two roots");
assert_eq!(roots[0].root, "core-x");
assert_eq!(names(&roots[0].unblocks), vec!["app-a", "lib-a"]);
assert_eq!(roots[0].reason, "dirty tree");
assert_eq!(roots[1].root, "core-y");
assert_eq!(names(&roots[1].unblocks), vec!["app-b"]);
let summary = analyze(&failures, &g);
assert!(
summary.format().contains("3 downstream failures explained by 2 roots"),
"{}",
summary.format()
);
}
#[test]
fn root_downstream_of_non_failed_crate_is_still_root() {
let g = graph(&[
("gatling", "serde"), ("lbzip2", "gatling"),
]);
let failures = vec![
FailedCrate::new("gatling", "already-published"),
FailedCrate::new("lbzip2", "verify"),
];
let roots = classify_roots(&failures, &g);
assert_eq!(roots.len(), 1, "serde is not failed ⇒ gatling is the sole root");
assert_eq!(roots[0].root, "gatling");
assert_eq!(names(&roots[0].unblocks), vec!["lbzip2"]);
}
#[test]
fn transitive_chain_attributes_all_to_the_deepest_root() {
let g = graph(&[("leaf", "mid"), ("mid", "root")]);
let failures = vec![
FailedCrate::new("root", "dirty"),
FailedCrate::new("mid", "verify"),
FailedCrate::new("leaf", "verify"),
];
let roots = classify_roots(&failures, &g);
assert_eq!(roots.len(), 1);
assert_eq!(roots[0].root, "root");
assert_eq!(names(&roots[0].unblocks), vec!["leaf", "mid"]);
}
#[test]
fn all_independent_failures_produce_no_summary() {
let g = graph(&[]);
let failures = vec![
FailedCrate::new("a", "metadata"),
FailedCrate::new("b", "metadata"),
];
let summary = analyze(&failures, &g);
assert_eq!(summary.roots.len(), 2, "both are roots");
assert!(summary.downstream.is_empty());
assert_eq!(summary.format(), "", "nothing to explain ⇒ no block");
}
}