1use serde::{Deserialize, Serialize};
9
10use crate::verify::{CheckStatus, VerifyReport};
11
12const EXCERPT_LINES: usize = 40;
14const EXCERPT_BYTES: usize = 4000;
16const MARKERS: [&str; 5] = ["error", "panicked", "assert", "failed", "fail"];
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
21pub struct FailedCheck {
22 pub name: String,
24 pub argv: Vec<String>,
26 pub status: CheckStatus,
28 pub failing_tests: Vec<String>,
30 pub excerpt: String,
32 #[serde(default, skip_serializing_if = "crate::verify::Denials::is_empty")]
34 pub sandbox: crate::verify::Denials,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
39pub struct Digest {
40 pub attempt: u32,
42 pub reasons: Vec<String>,
44 pub checks: Vec<FailedCheck>,
46 pub discarded: Vec<String>,
48 #[serde(default, skip_serializing_if = "Vec::is_empty")]
51 pub uncollected: Vec<String>,
52}
53
54#[must_use]
58pub fn held_out_reason(failed: &[crate::spec::ScenarioRef]) -> String {
59 let mut failed: Vec<&crate::spec::ScenarioRef> = failed.iter().collect();
60 failed.sort_by(|a, b| a.id.cmp(&b.id));
61 failed.dedup_by(|a, b| a.id == b.id);
62 let parts: Vec<String> = failed
63 .iter()
64 .map(|s| {
65 format!(
66 "{} (\"{}\")",
67 s.id,
68 s.text.split_whitespace().collect::<Vec<_>>().join(" ")
69 )
70 })
71 .collect();
72 format!("held-out acceptance failed: {}", parts.join(", "))
73}
74
75#[must_use]
78pub fn infrastructure(report: &VerifyReport) -> Option<String> {
79 report
80 .checks
81 .iter()
82 .find(|c| c.status == CheckStatus::Error)
83 .map(|c| {
84 format!(
85 "check {} could not run{}",
86 c.name,
87 c.note
88 .as_deref()
89 .map(|n| format!(": {n}"))
90 .unwrap_or_default()
91 )
92 })
93}
94
95#[must_use]
97pub fn digest(
98 attempt: u32,
99 reasons: &[String],
100 report: &VerifyReport,
101 discarded: &[String],
102) -> Digest {
103 let checks = report
104 .checks
105 .iter()
106 .filter(|c| !matches!(c.status, CheckStatus::Passed | CheckStatus::Skipped))
107 .map(|c| FailedCheck {
108 name: c.name.clone(),
109 argv: c.argv.clone(),
110 status: c.status,
111 failing_tests: c
112 .tests
113 .iter()
114 .flatten()
115 .filter(|t| t.outcome.is_failing())
116 .map(|t| t.id.clone())
117 .collect(),
118 excerpt: excerpt(&output(&c.stderr_tail, &c.stdout_tail)),
119 sandbox: c.sandbox.clone(),
120 })
121 .collect();
122 Digest {
123 attempt,
124 reasons: reasons.to_vec(),
125 checks,
126 discarded: discarded.to_vec(),
127 uncollected: Vec::new(),
128 }
129}
130
131#[must_use]
135pub fn first_cause(c: &crate::verify::CheckResult) -> Option<String> {
136 let failing: Vec<&str> = c
137 .tests
138 .iter()
139 .flatten()
140 .filter(|t| t.outcome.is_failing())
141 .map(|t| t.id.as_str())
142 .collect();
143 let said = match failing.as_slice() {
144 [] => {
145 let text = output(&c.stderr_tail, &c.stdout_tail);
146 let lines = || {
147 text.lines()
148 .map(str::trim)
149 .filter(|l| !l.is_empty() && !l.starts_with("===") && !l.starts_with("___"))
150 };
151 lines()
152 .find(|l| is_error_line(l))
153 .or_else(|| lines().next())?
154 .to_owned()
155 }
156 [one] => format!("{one} fails"),
157 [first, rest @ ..] => format!("{first} and {} more fail", rest.len()),
158 };
159 let cut: String = said.chars().take(160).collect();
160 Some(if cut.len() < said.len() {
161 format!("{cut}…")
162 } else {
163 cut
164 })
165}
166
167#[must_use]
170pub fn fingerprint(digest: &Digest) -> String {
171 let mut parts: Vec<String> = digest
172 .checks
173 .iter()
174 .map(|c| format!("{}: {}", c.name, normalise(first_error(&c.excerpt))))
175 .collect();
176 parts.sort();
177 let joined = if parts.is_empty() {
178 let mut reasons: Vec<String> = digest.reasons.iter().map(|r| normalise(r)).collect();
179 reasons.sort();
180 reasons.join(" | ")
181 } else {
182 parts.join(" | ")
183 };
184 let mut paths: Vec<&str> = digest
186 .uncollected
187 .iter()
188 .filter_map(|l| l.split('`').nth(1))
189 .collect();
190 paths.sort_unstable();
191 paths.dedup();
192 if paths.is_empty() {
193 joined
194 } else {
195 format!("{joined} | uncollected: {}", paths.join(", "))
196 }
197}
198
199fn output(stderr: &str, stdout: &str) -> String {
200 match (stderr.trim().is_empty(), stdout.trim().is_empty()) {
201 (false, false) => format!("{stderr}\n{stdout}"),
202 (false, true) => stderr.to_owned(),
203 _ => stdout.to_owned(),
204 }
205}
206
207fn is_error_line(line: &str) -> bool {
208 let lower = line.to_lowercase();
209 MARKERS.iter().any(|m| lower.contains(m))
210}
211
212fn first_error(text: &str) -> &str {
213 text.lines()
214 .find(|l| is_error_line(l))
215 .or_else(|| text.lines().find(|l| !l.trim().is_empty()))
216 .unwrap_or_default()
217}
218
219fn excerpt(text: &str) -> String {
222 let lines: Vec<&str> = text.lines().collect();
223 let start = lines
224 .iter()
225 .position(|l| is_error_line(l))
226 .unwrap_or_else(|| lines.len().saturating_sub(EXCERPT_LINES));
227 let mut out = lines
228 .iter()
229 .skip(start)
230 .take(EXCERPT_LINES)
231 .copied()
232 .collect::<Vec<_>>()
233 .join("\n");
234 if out.len() > EXCERPT_BYTES {
235 let mut end = EXCERPT_BYTES;
236 while !out.is_char_boundary(end) {
237 end -= 1;
238 }
239 out.truncate(end);
240 out.push_str("\n…");
241 }
242 out
243}
244
245fn normalise(line: &str) -> String {
247 let mapped: String = line
248 .chars()
249 .map(|c| if c.is_ascii_digit() { '#' } else { c })
250 .collect();
251 mapped
252 .split_whitespace()
253 .collect::<Vec<_>>()
254 .join(" ")
255 .chars()
256 .take(200)
257 .collect()
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263
264 #[test]
265 fn a_held_out_failure_names_the_requirement_only() {
266 use crate::spec::ScenarioRef;
267 let failed = [
268 ScenarioRef {
269 id: "US1-2".to_owned(),
270 text: "**Given** a name,\n **When** greeted".to_owned(),
271 },
272 ScenarioRef {
273 id: "US1-1".to_owned(),
274 text: "x".to_owned(),
275 },
276 ];
277 let reason = held_out_reason(&failed);
278 assert_eq!(
279 reason,
280 "held-out acceptance failed: US1-1 (\"x\"), US1-2 (\"**Given** a name, **When** greeted\")"
281 );
282 let report = VerifyReport {
283 root: String::new(),
284 tier: "fast".to_owned(),
285 checks: Vec::new(),
286 passed: true,
287 comparison: None,
288 };
289 let d = digest(2, std::slice::from_ref(&reason), &report, &[]);
290 assert!(d.checks.is_empty(), "no check entry for the held-out run");
291 assert_eq!(fingerprint(&d), normalise(&reason));
292 }
293 use crate::verify::{CheckResult, TestOutcome, TestResult};
294
295 fn check(name: &str, status: CheckStatus, stderr: &str) -> CheckResult {
296 CheckResult {
297 name: name.to_owned(),
298 argv: vec!["cargo".to_owned(), "test".to_owned()],
299 status,
300 exit_code: Some(101),
301 duration_ms: 10,
302 junit: false,
303 tests: None,
304 stdout_tail: String::new(),
305 stderr_tail: stderr.to_owned(),
306 truncated: false,
307 note: None,
308 flaky: false,
309 sandbox: Default::default(),
310 }
311 }
312
313 #[test]
316 fn denials_reach_the_digest_and_old_reports_still_read() {
317 let plain = check("test", CheckStatus::Failed, "error: x");
318 let json = serde_json::to_value(&plain).unwrap();
319 assert!(json.get("sandbox").is_none(), "{json}");
320 let back: CheckResult = serde_json::from_value(json).unwrap();
321 assert_eq!(back, plain);
322 let mut denied = plain.clone();
323 denied.sandbox = crate::verify::Denials {
324 denied: vec!["git: file-write-data /w/.git/config — read-only".into()],
325 more: 2,
326 note: None,
327 };
328 let d = digest(1, &[], &report(vec![denied.clone()]), &[]);
329 assert_eq!(d.checks[0].sandbox, denied.sandbox);
330 let json = serde_json::to_value(&d.checks[0]).unwrap();
331 assert_eq!(json["sandbox"]["more"], 2);
332 let plain_digest = digest(1, &[], &report(vec![plain]), &[]);
333 assert!(
334 serde_json::to_value(&plain_digest.checks[0])
335 .unwrap()
336 .get("sandbox")
337 .is_none()
338 );
339 }
340
341 fn report(checks: Vec<CheckResult>) -> VerifyReport {
342 VerifyReport {
343 root: ".".to_owned(),
344 tier: "fast".to_owned(),
345 checks,
346 comparison: None,
347 passed: false,
348 }
349 }
350
351 #[test]
352 fn the_digest_keeps_failing_checks_from_the_first_error() {
353 let mut failing = check(
354 "test",
355 CheckStatus::Failed,
356 " Compiling x v0.1.0\nerror[E0425]: cannot find function `add`\n --> src/lib.rs:3:5\n",
357 );
358 failing.tests = Some(vec![
359 TestResult {
360 id: "adds".to_owned(),
361 outcome: TestOutcome::Failed,
362 },
363 TestResult {
364 id: "ok".to_owned(),
365 outcome: TestOutcome::Passed,
366 },
367 ]);
368 let r = report(vec![check("fmt", CheckStatus::Passed, ""), failing]);
369 let d = digest(
370 1,
371 &["check test failed".to_owned()],
372 &r,
373 &["src/lib.rs".to_owned()],
374 );
375 assert_eq!(d.checks.len(), 1);
376 assert_eq!(d.checks[0].failing_tests, ["adds"]);
377 assert!(
378 d.checks[0].excerpt.starts_with("error[E0425]"),
379 "{}",
380 d.checks[0].excerpt
381 );
382 assert!(!d.checks[0].excerpt.contains("Compiling"));
383 assert_eq!(d.discarded, ["src/lib.rs"]);
384 }
385
386 #[test]
387 fn fingerprints_ignore_numbers_and_order() {
388 let a = report(vec![
389 check("b", CheckStatus::Failed, "error at line 12"),
390 check("a", CheckStatus::Failed, "assertion failed: 4 == 5"),
391 ]);
392 let b = report(vec![
393 check("a", CheckStatus::Failed, "assertion failed: 3 == 5"),
394 check("b", CheckStatus::Failed, "error at line 40"),
395 ]);
396 let c = report(vec![check(
397 "a",
398 CheckStatus::Failed,
399 "error: something else",
400 )]);
401 let fp = |r: &VerifyReport| fingerprint(&digest(1, &[], r, &[]));
402 assert_eq!(fp(&a), fp(&b));
403 assert_ne!(fp(&a), fp(&c));
404 let none = report(vec![]);
406 let x = fingerprint(&digest(1, &["regressed: t1".to_owned()], &none, &[]));
407 assert_eq!(x, "regressed: t#");
408 }
409
410 #[test]
411 fn a_check_that_could_not_run_is_infrastructure() {
412 let mut broken = check("lint", CheckStatus::Error, "");
413 broken.note = Some("command not found".to_owned());
414 assert_eq!(
415 infrastructure(&report(vec![broken])).as_deref(),
416 Some("check lint could not run: command not found")
417 );
418 assert!(infrastructure(&report(vec![check("t", CheckStatus::Failed, "x")])).is_none());
419 }
420
421 #[test]
422 fn long_output_is_capped_on_a_char_boundary() {
423 let long = format!("error: {}", "é".repeat(5000));
424 let e = excerpt(&long);
425 assert!(e.len() <= EXCERPT_BYTES + "\n…".len());
426 assert!(e.ends_with('…'));
427 }
428}