use crate::events::{BuildEvent, EventResult};
use std::collections::{BTreeMap, HashSet};
const MAX_DEPTH: usize = 12;
const MAX_NODES: usize = 64;
#[derive(Debug, Clone, PartialEq)]
pub struct ChangedDep {
pub name: String,
pub from: Option<String>,
pub to: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Hop {
pub crate_name: String,
pub via: ChangedDep,
}
#[derive(Debug, Clone, PartialEq)]
pub enum RootKind {
Groups(Vec<String>),
NothingRecorded,
NoMissRecorded,
NoBaseline,
NoDiffableHistory,
LimitReached,
}
impl RootKind {
pub fn is_resolved(&self) -> bool {
matches!(self, RootKind::Groups(_) | RootKind::NothingRecorded)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct PassthroughGroup {
pub reason: String,
pub count: usize,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Root {
pub crate_name: String,
pub kind: RootKind,
pub passthroughs: Vec<PassthroughGroup>,
pub branches: usize,
pub path: Vec<Hop>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Chain {
pub roots: Vec<Root>,
pub direct: Vec<ChangedDep>,
pub truncated: Option<&'static str>,
}
impl Chain {
pub fn has_resolved_root(&self) -> bool {
self.roots.iter().any(|r| r.kind.is_resolved())
}
}
pub fn analyze(events: &[BuildEvent], miss_index: usize) -> Option<Chain> {
let miss = events.get(miss_index)?;
if !miss.key_externs_recorded || miss.root.is_empty() {
return None;
}
let direct = changed_deps_at(events, miss_index)?;
if direct.is_empty() {
return None;
}
let mut queue: Vec<(usize, Vec<Hop>)> = vec![(miss_index, Vec::new())];
let mut seen: HashSet<String> = HashSet::from([miss.crate_name.clone()]);
let mut reached: BTreeMap<String, usize> = BTreeMap::new();
let mut roots: Vec<Root> = Vec::new();
let mut nodes = 0usize;
let mut truncated = None;
while let Some((index, path)) = queue.pop() {
let changed = match changed_deps_at(events, index) {
Some(changed) => changed,
None => continue,
};
for dep in changed {
if nodes >= MAX_NODES {
truncated = Some("too many changed dependencies to follow");
break;
}
nodes += 1;
*reached.entry(dep.name.clone()).or_default() += 1;
let mut next_path = path.clone();
next_path.push(Hop {
crate_name: events[index].crate_name.clone(),
via: dep.clone(),
});
if path.iter().any(|hop| hop.crate_name == dep.name) || dep.name == miss.crate_name {
truncated = Some("cycle in recorded dependency digests");
continue;
}
if !seen.insert(dep.name.clone()) {
continue;
}
let Some(dep_index) =
last_compiled_index(events, &dep.name, &events[index].root, index)
else {
roots.push(unresolved(dep.name, RootKind::NoMissRecorded, next_path));
continue;
};
match changed_deps_at(events, dep_index) {
Some(next) if !next.is_empty() => {
if next_path.len() >= MAX_DEPTH {
truncated = Some("chain longer than the walk limit");
roots.push(unresolved(dep.name, RootKind::LimitReached, next_path));
continue;
}
queue.push((dep_index, next_path));
}
Some(_) => roots.push(classify_at(events, dep_index, next_path)),
None => {
let mut root = unresolved(dep.name, RootKind::NoDiffableHistory, next_path);
root.passthroughs = passthroughs_for(events, &root.crate_name, dep_index);
roots.push(root);
}
}
}
if truncated == Some("too many changed dependencies to follow") {
break;
}
}
for root in &mut roots {
root.branches = reached.get(&root.crate_name).copied().unwrap_or(1);
}
roots.sort_by(|a, b| {
b.branches
.cmp(&a.branches)
.then_with(|| a.path.len().cmp(&b.path.len()))
.then_with(|| a.crate_name.cmp(&b.crate_name))
});
Some(Chain {
roots,
direct,
truncated,
})
}
fn unresolved(crate_name: String, kind: RootKind, path: Vec<Hop>) -> Root {
Root {
crate_name,
kind,
passthroughs: Vec::new(),
branches: 1,
path,
}
}
fn changed_deps_at(events: &[BuildEvent], index: usize) -> Option<Vec<ChangedDep>> {
let compiled = events.get(index)?;
if !compiled.key_externs_recorded {
return None;
}
let baseline = last_baseline_index(events, &compiled.crate_name, &compiled.root, index)?;
Some(diff_externs(
&events[baseline].key_externs,
&compiled.key_externs,
))
}
fn diff_externs(
before: &BTreeMap<String, String>,
after: &BTreeMap<String, String>,
) -> Vec<ChangedDep> {
let mut out = Vec::new();
for (name, to) in after {
match before.get(name) {
Some(from) if from == to => {}
from => out.push(ChangedDep {
name: name.clone(),
from: from.cloned(),
to: Some(to.clone()),
}),
}
}
for (name, from) in before {
if !after.contains_key(name) {
out.push(ChangedDep {
name: name.clone(),
from: Some(from.clone()),
to: None,
});
}
}
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
fn classify_at(events: &[BuildEvent], index: usize, path: Vec<Hop>) -> Root {
let compiled = &events[index];
let passthroughs = passthroughs_for(events, &compiled.crate_name, index);
let Some(baseline) = last_baseline_index(events, &compiled.crate_name, &compiled.root, index)
else {
return Root {
crate_name: compiled.crate_name.clone(),
kind: RootKind::NoBaseline,
passthroughs,
branches: 1,
path,
};
};
let mut groups = changed_groups(&events[baseline].key_fields, &compiled.key_fields);
if groups.is_empty() && !compiled.key_diff.is_empty() {
groups = compiled.key_diff.clone();
}
groups.retain(|g| g != "externs");
groups.sort();
groups.dedup();
let kind = if groups.is_empty() {
RootKind::NothingRecorded
} else {
RootKind::Groups(groups)
};
Root {
crate_name: compiled.crate_name.clone(),
kind,
passthroughs,
branches: 1,
path,
}
}
fn changed_groups(
before: &BTreeMap<String, String>,
after: &BTreeMap<String, String>,
) -> Vec<String> {
let mut out: Vec<String> = after
.iter()
.filter(|(group, digest)| before.get(*group) != Some(digest))
.map(|(group, _)| group.clone())
.collect();
out.extend(before.keys().filter(|g| !after.contains_key(*g)).cloned());
out
}
fn passthroughs_for(
events: &[BuildEvent],
crate_name: &str,
before: usize,
) -> Vec<PassthroughGroup> {
let needle = crate_name.replace('_', "-");
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
for event in &events[..before.min(events.len())] {
if event.result != EventResult::Passthrough || event.root.is_empty() {
continue;
}
if !package_dir_matches(&event.root, &needle) {
continue;
}
let reason = if event.passthrough_reason.is_empty() {
"(no reason recorded)".to_string()
} else {
event.passthrough_reason.clone()
};
*counts.entry(reason).or_default() += 1;
}
let mut out: Vec<PassthroughGroup> = counts
.into_iter()
.map(|(reason, count)| PassthroughGroup { reason, count })
.collect();
out.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.reason.cmp(&b.reason)));
out.truncate(4);
out
}
fn package_dir_matches(root: &str, needle: &str) -> bool {
root.split(['/', '\\']).any(|component| {
component == needle
|| component
.strip_prefix(needle)
.and_then(|rest| rest.strip_prefix('-'))
.is_some_and(looks_like_semver)
})
}
fn looks_like_semver(value: &str) -> bool {
let core = value.split(['-', '+']).next().unwrap_or_default();
let parts: Vec<&str> = core.split('.').collect();
parts.len() == 3
&& parts
.iter()
.all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit()))
}
fn last_compiled_index(
events: &[BuildEvent],
crate_name: &str,
root: &str,
before: usize,
) -> Option<usize> {
events[..before.min(events.len())].iter().rposition(|e| {
e.crate_name == crate_name
&& same_root(e, root)
&& matches!(e.result, EventResult::Miss | EventResult::Dup)
})
}
fn last_baseline_index(
events: &[BuildEvent],
crate_name: &str,
root: &str,
before: usize,
) -> Option<usize> {
events[..before.min(events.len())].iter().rposition(|e| {
e.crate_name == crate_name
&& same_root(e, root)
&& e.key_externs_recorded
&& matches!(
e.result,
EventResult::LocalHit
| EventResult::PrefetchHit
| EventResult::RemoteHit
| EventResult::Miss
| EventResult::Dup
)
})
}
fn same_root(event: &BuildEvent, root: &str) -> bool {
!root.is_empty() && event.root == root
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{DateTime, TimeZone, Utc};
fn ts(secs: i64) -> DateTime<Utc> {
Utc.timestamp_opt(1_700_000_000 + secs, 0).unwrap()
}
fn event(
crate_name: &str,
result: EventResult,
at: i64,
externs: &[(&str, &str)],
) -> BuildEvent {
let mut e = BuildEvent::new_for_test(crate_name, result);
e.ts = ts(at);
e.root = "/w".to_string();
e.key_externs = externs
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect();
e.key_externs_recorded = true;
e
}
fn unrecorded(crate_name: &str, result: EventResult, at: i64) -> BuildEvent {
let mut e = event(crate_name, result, at, &[]);
e.key_externs_recorded = false;
e
}
fn with_fields(mut e: BuildEvent, fields: &[(&str, &str)]) -> BuildEvent {
e.key_fields = fields
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect();
e
}
fn analyze_last(events: &[BuildEvent]) -> Option<Chain> {
analyze(events, events.len() - 1)
}
#[test]
fn walks_a_cascade_to_the_leaf_that_changed() {
let events = vec![
event(
"rig_core",
EventResult::LocalHit,
0,
&[("aws_lc_rs", "aaaa")],
),
event(
"aws_lc_rs",
EventResult::LocalHit,
1,
&[("aws_lc_sys", "bbbb")],
),
with_fields(
event("aws_lc_sys", EventResult::LocalHit, 2, &[("libc", "cccc")]),
&[("sources", "1111")],
),
with_fields(
event("aws_lc_sys", EventResult::Miss, 10, &[("libc", "cccc")]),
&[("sources", "2222")],
),
event(
"aws_lc_rs",
EventResult::Miss,
11,
&[("aws_lc_sys", "dddd")],
),
event("rig_core", EventResult::Miss, 12, &[("aws_lc_rs", "eeee")]),
];
let chain = analyze_last(&events).expect("cascade should be reported");
assert_eq!(chain.roots.len(), 1);
let root = &chain.roots[0];
assert_eq!(root.crate_name, "aws_lc_sys");
assert_eq!(root.kind, RootKind::Groups(vec!["sources".to_string()]));
assert_eq!(
root.path
.iter()
.map(|h| (h.crate_name.as_str(), h.via.name.as_str()))
.collect::<Vec<_>>(),
vec![("rig_core", "aws_lc_rs"), ("aws_lc_rs", "aws_lc_sys")]
);
assert!(chain.truncated.is_none());
assert!(chain.has_resolved_root());
}
#[test]
fn ranks_the_root_that_most_branches_converge_on() {
let events = vec![
event(
"top",
EventResult::LocalHit,
0,
&[("a", "1111"), ("b", "2222")],
),
event("a", EventResult::LocalHit, 1, &[("leaf", "5555")]),
event("b", EventResult::LocalHit, 2, &[("leaf", "5555")]),
with_fields(
event("leaf", EventResult::LocalHit, 3, &[("libc", "9999")]),
&[("sources", "1111")],
),
with_fields(
event("leaf", EventResult::Miss, 10, &[("libc", "9999")]),
&[("sources", "2222")],
),
event("a", EventResult::Miss, 11, &[("leaf", "6666")]),
event("b", EventResult::Miss, 12, &[("leaf", "6666")]),
event(
"top",
EventResult::Miss,
13,
&[("a", "3333"), ("b", "4444")],
),
];
let chain = analyze_last(&events).unwrap();
assert_eq!(chain.direct.len(), 2, "both direct dependencies moved");
let leaf = &chain.roots[0];
assert_eq!(leaf.crate_name, "leaf");
assert_eq!(leaf.branches, 2, "reached via both a and b");
assert_eq!(leaf.kind, RootKind::Groups(vec!["sources".to_string()]));
}
#[test]
fn undiffable_history_is_not_reported_as_a_root() {
let events = vec![
unrecorded("b", EventResult::LocalHit, 0),
with_fields(
event("b", EventResult::Miss, 1, &[("c", "1111")]),
&[("sources", "2222")],
),
event("a", EventResult::LocalHit, 2, &[("b", "aaaa")]),
event("a", EventResult::Miss, 3, &[("b", "bbbb")]),
];
let chain = analyze_last(&events).unwrap();
assert_eq!(chain.roots.len(), 1);
assert_eq!(chain.roots[0].crate_name, "b");
assert_eq!(chain.roots[0].kind, RootKind::NoDiffableHistory);
assert!(
!chain.has_resolved_root(),
"an undiffable endpoint must not read as an explanation"
);
}
#[test]
fn baseline_is_the_previous_recorded_state_not_the_last_hit() {
let events = vec![
event(
"b",
EventResult::LocalHit,
0,
&[("c", "1111"), ("d", "2222")],
),
event("b", EventResult::Miss, 1, &[("c", "9999"), ("d", "2222")]),
event("b", EventResult::Miss, 2, &[("c", "9999"), ("d", "8888")]),
];
let chain = analyze_last(&events).unwrap();
assert_eq!(
chain
.direct
.iter()
.map(|d| d.name.as_str())
.collect::<Vec<_>>(),
vec!["d"],
"diffing against the last hit would wrongly also report c"
);
}
#[test]
fn dependency_lookup_cannot_select_a_later_compile() {
let events = vec![
with_fields(
event("c", EventResult::LocalHit, 0, &[("d", "1111")]),
&[("sources", "aaaa")],
),
event("b", EventResult::LocalHit, 1, &[("c", "5555")]),
event("a", EventResult::LocalHit, 2, &[("b", "7777")]),
with_fields(
event("c", EventResult::Miss, 10, &[("d", "2222")]),
&[("sources", "aaaa")],
),
event("b", EventResult::Miss, 11, &[("c", "6666")]),
with_fields(
event("c", EventResult::Miss, 12, &[("d", "2222")]),
&[("sources", "bbbb")],
),
event("a", EventResult::Miss, 13, &[("b", "8888")]),
];
let chain = analyze_last(&events).unwrap();
assert_eq!(chain.roots[0].crate_name, "d");
assert_eq!(chain.roots[0].kind, RootKind::NoMissRecorded);
}
#[test]
fn reports_nothing_when_dependencies_are_stable() {
let events = vec![
event("foo", EventResult::LocalHit, 0, &[("bar", "aaaa")]),
event("foo", EventResult::Miss, 1, &[("bar", "aaaa")]),
];
assert!(analyze_last(&events).is_none());
}
#[test]
fn reports_nothing_without_recorded_digests() {
let events = vec![
unrecorded("foo", EventResult::LocalHit, 0),
unrecorded("foo", EventResult::Miss, 1),
];
assert!(analyze_last(&events).is_none());
}
#[test]
fn rootless_events_are_not_wildcards() {
let mut legacy = event("app", EventResult::LocalHit, 0, &[("dep", "1111")]);
legacy.root = String::new();
let events = vec![
legacy,
event("app", EventResult::Miss, 1, &[("dep", "2222")]),
];
assert!(
analyze_last(&events).is_none(),
"an unrelated rootless event must not seed a cascade"
);
}
#[test]
fn terminates_on_a_cycle_in_recorded_digests() {
let events = vec![
event("a", EventResult::LocalHit, 0, &[("b", "1111")]),
event("b", EventResult::LocalHit, 1, &[("a", "3333")]),
event("b", EventResult::Miss, 10, &[("a", "4444")]),
event("a", EventResult::Miss, 11, &[("b", "2222")]),
];
let chain = analyze_last(&events).unwrap();
assert_eq!(
chain.truncated,
Some("cycle in recorded dependency digests")
);
assert!(
!chain.has_resolved_root(),
"a cycle must not yield a confident root: {:?}",
chain.roots
);
assert!(chain.roots.iter().all(|r| r.path.len() <= MAX_DEPTH));
}
#[test]
fn a_chain_at_the_depth_limit_still_resolves() {
let mut events = Vec::new();
let names: Vec<String> = (0..=MAX_DEPTH).map(|i| format!("c{i}")).collect();
let last = names.len() - 1;
let deps_of = |i: usize, digest: &'static str| -> Vec<(String, &'static str)> {
match names.get(i + 1) {
Some(next) => vec![(next.clone(), digest)],
None => vec![("libc".to_string(), "stable")],
}
};
for (i, name) in names.iter().enumerate() {
let deps = deps_of(i, "old");
let deps: Vec<(&str, &str)> = deps.iter().map(|(n, d)| (n.as_str(), *d)).collect();
events.push(with_fields(
event(name, EventResult::LocalHit, i as i64, &deps),
&[("sources", "1111")],
));
}
for (i, name) in names.iter().enumerate().rev() {
let deps = deps_of(i, "new");
let deps: Vec<(&str, &str)> = deps.iter().map(|(n, d)| (n.as_str(), *d)).collect();
events.push(with_fields(
event(
name,
EventResult::Miss,
100 + (names.len() - i) as i64,
&deps,
),
&[("sources", if i == last { "2222" } else { "1111" })],
));
}
let start = events
.iter()
.rposition(|e| e.crate_name == "c0" && e.result == EventResult::Miss)
.unwrap();
let chain = analyze(&events, start).unwrap();
assert_eq!(chain.roots[0].crate_name, format!("c{MAX_DEPTH}"));
assert!(
chain.roots[0].kind.is_resolved(),
"a root exactly at the limit must still resolve: {:?}",
chain.roots[0].kind
);
assert!(chain.truncated.is_none());
}
#[test]
fn diff_externs_reports_added_and_removed() {
let before: BTreeMap<String, String> = [
("keep".to_string(), "1".to_string()),
("gone".to_string(), "2".to_string()),
]
.into_iter()
.collect();
let after: BTreeMap<String, String> = [
("keep".to_string(), "1".to_string()),
("new".to_string(), "3".to_string()),
]
.into_iter()
.collect();
assert_eq!(
diff_externs(&before, &after),
vec![
ChangedDep {
name: "gone".to_string(),
from: Some("2".to_string()),
to: None
},
ChangedDep {
name: "new".to_string(),
from: None,
to: Some("3".to_string())
},
]
);
}
#[test]
fn package_dir_match_is_component_and_version_aware() {
assert!(package_dir_matches(
"/home/u/.cargo/registry/src/idx/aws-lc-sys-0.43.0",
"aws-lc-sys"
));
assert!(package_dir_matches("/src/aws-lc-sys", "aws-lc-sys"));
assert!(package_dir_matches(
"/registry/src/idx/serde-1.0.0-alpha.1",
"serde"
));
assert!(!package_dir_matches(
"/home/u/.cargo/registry/src/idx/aws-lc-sys-0.43.0",
"aws-lc"
));
assert!(!package_dir_matches(
"/src/my-aws-lc-sys-fork",
"aws-lc-sys"
));
assert!(!package_dir_matches("/src/foo-2-helper-0.1.0", "foo"));
assert!(package_dir_matches(
r"C:\Users\u\.cargo\registry\src\idx\aws-lc-sys-0.43.0",
"aws-lc-sys"
));
}
#[test]
fn attributes_passthroughs_to_the_root_package_dir() {
let mut pt = BuildEvent::new_for_test("bcm.c", EventResult::Passthrough);
pt.ts = ts(5);
pt.root = "/home/u/.cargo/registry/src/idx/aws-lc-sys-0.43.0".to_string();
pt.passthrough_reason = "cc unsupported flag(s): --include=... not yet".to_string();
let mut unrelated = pt.clone();
unrelated.root = "/home/u/.cargo/registry/src/idx/ring-0.17.8".to_string();
let events = vec![
with_fields(
event("aws_lc_sys", EventResult::LocalHit, 0, &[("libc", "cccc")]),
&[("sources", "1111")],
),
pt.clone(),
pt,
unrelated,
with_fields(
event("aws_lc_sys", EventResult::Miss, 10, &[("libc", "cccc")]),
&[("sources", "2222")],
),
];
let root = classify_at(&events, events.len() - 1, Vec::new());
assert_eq!(root.passthroughs.len(), 1);
assert_eq!(root.passthroughs[0].count, 2);
assert!(root.passthroughs[0].reason.contains("--include="));
}
#[test]
fn passthrough_attribution_is_bounded_to_earlier_events() {
let mut pt = BuildEvent::new_for_test("bcm.c", EventResult::Passthrough);
pt.root = "/registry/src/idx/aws-lc-sys-0.43.0".to_string();
pt.passthrough_reason = "later build".to_string();
let events = vec![
event("aws_lc_sys", EventResult::Miss, 0, &[("libc", "cccc")]),
pt,
];
assert!(passthroughs_for(&events, "aws_lc_sys", 1).is_empty());
}
}