use std::collections::{BTreeMap, BTreeSet};
use anyhow::{Result, bail};
use regex::Regex;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum FailureSet {
Obtained(BTreeMap<String, String>),
Unobtainable { why: String },
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct RegressionDelta {
pub(crate) new: BTreeSet<String>,
pub(crate) changed: BTreeSet<String>,
pub(crate) fixed: BTreeSet<String>,
pub(crate) persistent: BTreeSet<String>,
}
impl RegressionDelta {
pub(crate) fn halting(&self) -> BTreeSet<String> {
self.new.union(&self.changed).cloned().collect()
}
}
pub(crate) fn diff(baseline: &FailureSet, current: &FailureSet) -> Result<RegressionDelta> {
let (base, cur) = match (baseline, current) {
(FailureSet::Obtained(b), FailureSet::Obtained(c)) => (b, c),
(FailureSet::Unobtainable { why }, _) | (_, FailureSet::Unobtainable { why }) => {
bail!(
"regression diff: a failure-set is unobtainable ({why}) — refusing a silent ∅-pass (INV-5)"
)
}
};
let mut delta = RegressionDelta::default();
for (key, sig) in cur {
match base.get(key) {
None => {
delta.new.insert(key.clone());
}
Some(base_sig) if base_sig == sig => {
delta.persistent.insert(key.clone());
}
Some(_) => {
delta.changed.insert(key.clone());
}
}
}
for key in base.keys() {
if !cur.contains_key(key) {
delta.fixed.insert(key.clone());
}
}
Ok(delta)
}
pub(crate) fn parse_failures(suite_output: &str) -> FailureSet {
let structured = suite_output.contains("test result:")
|| suite_output.contains("\nrunning ")
|| suite_output.starts_with("running ");
if !structured {
return FailureSet::Unobtainable {
why: "suite output carries no `cargo test` structure (run did not complete?)".into(),
};
}
let mut map = BTreeMap::new();
let mut target = String::from("unknown");
let mut lines = suite_output.lines().peekable();
while let Some(line) = lines.next() {
if let Some(t) = running_target(line) {
target = t;
continue;
}
let Some(name) = stdout_marker(line) else {
continue;
};
let mut body: Vec<&str> = Vec::new();
while let Some(&peek) = lines.peek() {
if stdout_marker(peek).is_some()
|| running_target(peek).is_some()
|| peek.trim_start().starts_with("failures:")
|| peek.trim_start().starts_with("test result:")
{
break;
}
lines.next();
let t = peek.trim();
if !t.is_empty() && !t.starts_with("note:") {
body.push(t);
}
}
map.insert(format!("{target}::{name}"), normalise_sig(&body.join(" ")));
}
FailureSet::Obtained(map)
}
fn running_target(line: &str) -> Option<String> {
let rest = line.trim_start().strip_prefix("Running ")?;
let open = rest.rfind('(')?;
let inside = rest[open + 1..].trim_end().trim_end_matches(')');
let base = inside.rsplit('/').next()?; let target = base.rsplit_once('-').map_or(base, |(name, _hash)| name);
Some(target.to_string())
}
fn stdout_marker(line: &str) -> Option<String> {
let rest = line.trim().strip_prefix("---- ")?;
rest.strip_suffix(" stdout ----").map(str::to_string)
}
pub(crate) fn render_delta(delta: &RegressionDelta, base: &str) -> String {
let mut lines: Vec<String> = vec![format!("regression diff vs base {base}:")];
let halting = delta.halting();
if halting.is_empty() {
lines.push(" ✓ no new or changed failures".to_string());
} else {
lines.push(format!(" ✗ {} halting failure(s):", halting.len()));
lines.extend(delta.new.iter().map(|k| format!(" new: {k}")));
lines.extend(delta.changed.iter().map(|k| format!(" changed: {k}")));
}
lines.extend(delta.fixed.iter().map(|k| format!(" fixed: {k}")));
if !delta.persistent.is_empty() {
lines.push(format!(
" ⚠ {} persistent (pre-existing) failure(s) on the coord tree — fix the trunk:",
delta.persistent.len()
));
lines.extend(
delta
.persistent
.iter()
.map(|k| format!(" persistent: {k}")),
);
}
let mut out = lines.join("\n");
out.push('\n');
out
}
fn normalise_sig(raw: &str) -> String {
let subs: &[(&str, &str)] = &[
(r"0x[0-9a-fA-F]+", "0xADDR"), (r"\b\d+(?:\.\d+)?(?:ns|µs|us|ms|s)\b", "DUR"), (r"/tmp/[^\s:)]+", "/tmp/TMP"), (r"target/[^\s:)]+", "target/PATH"), (r"\.rs:\d+:\d+", ".rs:LN:CL"), (r"\b[0-9a-f]{7,}\b", "HASH"), ];
let mut s = raw.trim().to_string();
for (pat, rep) in subs {
if let Ok(re) = Regex::new(pat) {
s = re.replace_all(&s, *rep).into_owned();
}
}
s
}
#[cfg(test)]
mod tests {
use super::*;
fn obtained(pairs: &[(&str, &str)]) -> FailureSet {
FailureSet::Obtained(
pairs
.iter()
.map(|(k, s)| ((*k).to_string(), (*s).to_string()))
.collect(),
)
}
fn keys(set: &BTreeSet<String>) -> Vec<&str> {
set.iter().map(String::as_str).collect()
}
#[test]
fn diff_empty_both_is_green() {
let d = diff(&obtained(&[]), &obtained(&[])).unwrap();
assert_eq!(d, RegressionDelta::default());
assert!(d.halting().is_empty());
}
#[test]
fn diff_full_overlap_same_sig_is_all_persistent() {
let b = obtained(&[("doctrine::a", "s1"), ("doctrine::b", "s2")]);
let d = diff(&b, &b).unwrap();
assert_eq!(keys(&d.persistent), vec!["doctrine::a", "doctrine::b"]);
assert!(d.halting().is_empty());
}
#[test]
fn diff_env_mask_surfaces_only_the_new_failure() {
let base = obtained(&[("t::embed_fail", "e"), ("t::X", "x")]);
let cur = obtained(&[("t::embed_fail", "e"), ("t::X", "x"), ("t::new_fail", "n")]);
let d = diff(&base, &cur).unwrap();
assert_eq!(keys(&d.new), vec!["t::new_fail"]);
assert_eq!(keys(&d.persistent), vec!["t::X", "t::embed_fail"]);
assert!(d.changed.is_empty());
}
#[test]
fn diff_changed_sig_halts_same_sig_persists() {
let base = obtained(&[("t::flaky", "old reason")]);
let cur = obtained(&[("t::flaky", "NEW reason")]);
let d = diff(&base, &cur).unwrap();
assert_eq!(keys(&d.changed), vec!["t::flaky"]);
assert!(d.persistent.is_empty());
assert_eq!(keys(&d.halting()), vec!["t::flaky"]);
}
#[test]
fn diff_fixed_bucket_is_baseline_minus_current() {
let base = obtained(&[("t::gone", "g")]);
let cur = obtained(&[]);
let d = diff(&base, &cur).unwrap();
assert_eq!(keys(&d.fixed), vec!["t::gone"]);
assert!(d.halting().is_empty());
}
#[test]
fn diff_errs_when_baseline_unobtainable() {
let cur = obtained(&[]);
let unob = FailureSet::Unobtainable {
why: "compile error".into(),
};
assert!(diff(&unob, &cur).is_err());
}
#[test]
fn diff_errs_when_current_unobtainable() {
let base = obtained(&[]);
let unob = FailureSet::Unobtainable {
why: "panic mid-run".into(),
};
assert!(diff(&base, &unob).is_err());
}
#[test]
fn parse_section_aware_keys_disambiguate_same_named_tests() {
let out = "\
Running unittests src/main.rs (target/debug/deps/doctrine-aaa111)
failures:
---- roundtrip stdout ----
thread 'roundtrip' panicked at src/plan.rs:10:5:
assertion failed: lhs == rhs
failures:
roundtrip
test result: FAILED. 1 passed; 1 failed; 0 ignored
Running tests/e2e_cli.rs (target/debug/deps/e2e_cli-bbb222)
failures:
---- roundtrip stdout ----
thread 'roundtrip' panicked at tests/e2e_cli.rs:20:5:
the cli exploded
failures:
roundtrip
test result: FAILED. 0 passed; 1 failed; 0 ignored
";
let FailureSet::Obtained(map) = parse_failures(out) else {
panic!("expected Obtained");
};
let keys: Vec<&str> = map.keys().map(String::as_str).collect();
assert_eq!(keys, vec!["doctrine::roundtrip", "e2e_cli::roundtrip"]);
assert_ne!(map["doctrine::roundtrip"], map["e2e_cli::roundtrip"]);
}
#[test]
fn parse_clean_run_is_obtained_empty() {
let out = "\
Running unittests src/main.rs (target/debug/deps/doctrine-aaa111)
test result: ok. 42 passed; 0 failed; 0 ignored
";
assert_eq!(parse_failures(out), FailureSet::Obtained(BTreeMap::new()));
}
#[test]
fn parse_unstructured_output_is_unobtainable() {
let out = "error[E0432]: unresolved import `crate::nope`\n --> src/x.rs:1:5";
assert!(matches!(
parse_failures(out),
FailureSet::Unobtainable { .. }
));
}
#[test]
fn parse_then_diff_volatile_token_only_change_stays_persistent() {
let mk = |addr: &str, dur: &str| {
format!(
"\
Running unittests src/main.rs (target/debug/deps/doctrine-{addr})
failures:
---- t stdout ----
thread 't' panicked at src/x.rs:9:5:
boom at 0x{addr} after {dur}
failures:
t
test result: FAILED. 0 passed; 1 failed; 0 ignored
"
)
};
let base = parse_failures(&mk("deadbeef", "12ms"));
let cur = parse_failures(&mk("cafef00d", "999ms"));
let d = diff(&base, &cur).unwrap();
assert_eq!(keys(&d.persistent), vec!["doctrine::t"]);
assert!(d.halting().is_empty(), "volatile-only diff must not halt");
}
#[test]
fn render_names_the_base_and_the_halting_keys() {
let mut d = RegressionDelta::default();
d.new.insert("t::regressed".into());
let out = render_delta(&d, "B0");
assert!(out.contains("B0"));
assert!(out.contains("t::regressed"));
}
}