use serde::{Deserialize, Serialize};
use crate::verify::{CheckStatus, VerifyReport};
const EXCERPT_LINES: usize = 40;
const EXCERPT_BYTES: usize = 4000;
const MARKERS: [&str; 5] = ["error", "panicked", "assert", "failed", "fail"];
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct FailedCheck {
pub name: String,
pub argv: Vec<String>,
pub status: CheckStatus,
pub failing_tests: Vec<String>,
pub excerpt: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct Digest {
pub attempt: u32,
pub reasons: Vec<String>,
pub checks: Vec<FailedCheck>,
pub discarded: Vec<String>,
}
#[must_use]
pub fn held_out_reason(failed: &[crate::spec::ScenarioRef]) -> String {
let mut failed: Vec<&crate::spec::ScenarioRef> = failed.iter().collect();
failed.sort_by(|a, b| a.id.cmp(&b.id));
failed.dedup_by(|a, b| a.id == b.id);
let parts: Vec<String> = failed
.iter()
.map(|s| {
format!(
"{} (\"{}\")",
s.id,
s.text.split_whitespace().collect::<Vec<_>>().join(" ")
)
})
.collect();
format!("held-out acceptance failed: {}", parts.join(", "))
}
#[must_use]
pub fn infrastructure(report: &VerifyReport) -> Option<String> {
report
.checks
.iter()
.find(|c| c.status == CheckStatus::Error)
.map(|c| {
format!(
"check {} could not run{}",
c.name,
c.note
.as_deref()
.map(|n| format!(": {n}"))
.unwrap_or_default()
)
})
}
#[must_use]
pub fn digest(
attempt: u32,
reasons: &[String],
report: &VerifyReport,
discarded: &[String],
) -> Digest {
let checks = report
.checks
.iter()
.filter(|c| !matches!(c.status, CheckStatus::Passed | CheckStatus::Skipped))
.map(|c| FailedCheck {
name: c.name.clone(),
argv: c.argv.clone(),
status: c.status,
failing_tests: c
.tests
.iter()
.flatten()
.filter(|t| t.outcome.is_failing())
.map(|t| t.id.clone())
.collect(),
excerpt: excerpt(&output(&c.stderr_tail, &c.stdout_tail)),
})
.collect();
Digest {
attempt,
reasons: reasons.to_vec(),
checks,
discarded: discarded.to_vec(),
}
}
#[must_use]
pub fn fingerprint(digest: &Digest) -> String {
let mut parts: Vec<String> = digest
.checks
.iter()
.map(|c| format!("{}: {}", c.name, normalise(first_error(&c.excerpt))))
.collect();
parts.sort();
if parts.is_empty() {
let mut reasons: Vec<String> = digest.reasons.iter().map(|r| normalise(r)).collect();
reasons.sort();
return reasons.join(" | ");
}
parts.join(" | ")
}
fn output(stderr: &str, stdout: &str) -> String {
match (stderr.trim().is_empty(), stdout.trim().is_empty()) {
(false, false) => format!("{stderr}\n{stdout}"),
(false, true) => stderr.to_owned(),
_ => stdout.to_owned(),
}
}
fn is_error_line(line: &str) -> bool {
let lower = line.to_lowercase();
MARKERS.iter().any(|m| lower.contains(m))
}
fn first_error(text: &str) -> &str {
text.lines()
.find(|l| is_error_line(l))
.or_else(|| text.lines().find(|l| !l.trim().is_empty()))
.unwrap_or_default()
}
fn excerpt(text: &str) -> String {
let lines: Vec<&str> = text.lines().collect();
let start = lines
.iter()
.position(|l| is_error_line(l))
.unwrap_or_else(|| lines.len().saturating_sub(EXCERPT_LINES));
let mut out = lines
.iter()
.skip(start)
.take(EXCERPT_LINES)
.copied()
.collect::<Vec<_>>()
.join("\n");
if out.len() > EXCERPT_BYTES {
let mut end = EXCERPT_BYTES;
while !out.is_char_boundary(end) {
end -= 1;
}
out.truncate(end);
out.push_str("\n…");
}
out
}
fn normalise(line: &str) -> String {
let mapped: String = line
.chars()
.map(|c| if c.is_ascii_digit() { '#' } else { c })
.collect();
mapped
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.chars()
.take(200)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_held_out_failure_names_the_requirement_only() {
use crate::spec::ScenarioRef;
let failed = [
ScenarioRef {
id: "US1-2".to_owned(),
text: "**Given** a name,\n **When** greeted".to_owned(),
},
ScenarioRef {
id: "US1-1".to_owned(),
text: "x".to_owned(),
},
];
let reason = held_out_reason(&failed);
assert_eq!(
reason,
"held-out acceptance failed: US1-1 (\"x\"), US1-2 (\"**Given** a name, **When** greeted\")"
);
let report = VerifyReport {
root: String::new(),
tier: "fast".to_owned(),
checks: Vec::new(),
passed: true,
comparison: None,
};
let d = digest(2, std::slice::from_ref(&reason), &report, &[]);
assert!(d.checks.is_empty(), "no check entry for the held-out run");
assert_eq!(fingerprint(&d), normalise(&reason));
}
use crate::verify::{CheckResult, TestOutcome, TestResult};
fn check(name: &str, status: CheckStatus, stderr: &str) -> CheckResult {
CheckResult {
name: name.to_owned(),
argv: vec!["cargo".to_owned(), "test".to_owned()],
status,
exit_code: Some(101),
duration_ms: 10,
junit: false,
tests: None,
stdout_tail: String::new(),
stderr_tail: stderr.to_owned(),
truncated: false,
note: None,
flaky: false,
}
}
fn report(checks: Vec<CheckResult>) -> VerifyReport {
VerifyReport {
root: ".".to_owned(),
tier: "fast".to_owned(),
checks,
comparison: None,
passed: false,
}
}
#[test]
fn the_digest_keeps_failing_checks_from_the_first_error() {
let mut failing = check(
"test",
CheckStatus::Failed,
" Compiling x v0.1.0\nerror[E0425]: cannot find function `add`\n --> src/lib.rs:3:5\n",
);
failing.tests = Some(vec![
TestResult {
id: "adds".to_owned(),
outcome: TestOutcome::Failed,
},
TestResult {
id: "ok".to_owned(),
outcome: TestOutcome::Passed,
},
]);
let r = report(vec![check("fmt", CheckStatus::Passed, ""), failing]);
let d = digest(
1,
&["check test failed".to_owned()],
&r,
&["src/lib.rs".to_owned()],
);
assert_eq!(d.checks.len(), 1);
assert_eq!(d.checks[0].failing_tests, ["adds"]);
assert!(
d.checks[0].excerpt.starts_with("error[E0425]"),
"{}",
d.checks[0].excerpt
);
assert!(!d.checks[0].excerpt.contains("Compiling"));
assert_eq!(d.discarded, ["src/lib.rs"]);
}
#[test]
fn fingerprints_ignore_numbers_and_order() {
let a = report(vec![
check("b", CheckStatus::Failed, "error at line 12"),
check("a", CheckStatus::Failed, "assertion failed: 4 == 5"),
]);
let b = report(vec![
check("a", CheckStatus::Failed, "assertion failed: 3 == 5"),
check("b", CheckStatus::Failed, "error at line 40"),
]);
let c = report(vec![check(
"a",
CheckStatus::Failed,
"error: something else",
)]);
let fp = |r: &VerifyReport| fingerprint(&digest(1, &[], r, &[]));
assert_eq!(fp(&a), fp(&b));
assert_ne!(fp(&a), fp(&c));
let none = report(vec![]);
let x = fingerprint(&digest(1, &["regressed: t1".to_owned()], &none, &[]));
assert_eq!(x, "regressed: t#");
}
#[test]
fn a_check_that_could_not_run_is_infrastructure() {
let mut broken = check("lint", CheckStatus::Error, "");
broken.note = Some("command not found".to_owned());
assert_eq!(
infrastructure(&report(vec![broken])).as_deref(),
Some("check lint could not run: command not found")
);
assert!(infrastructure(&report(vec![check("t", CheckStatus::Failed, "x")])).is_none());
}
#[test]
fn long_output_is_capped_on_a_char_boundary() {
let long = format!("error: {}", "é".repeat(5000));
let e = excerpt(&long);
assert!(e.len() <= EXCERPT_BYTES + "\n…".len());
assert!(e.ends_with('…'));
}
}