1use std::sync::OnceLock;
5
6use crypto::FailureClass;
7use regex::Regex;
8
9pub const EXCERPT_CAP_BYTES: usize = 4096;
11const CONTEXT_LINES: usize = 6;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum Disposition {
16 Success,
18 Exited(i32),
20 TimedOut,
22 #[default]
24 InfraError,
25}
26
27fn typed_build_error() -> &'static Regex {
28 static REGEX: OnceLock<Regex> = OnceLock::new();
29 REGEX.get_or_init(|| Regex::new(r"error\[E\d+\]").expect("valid static regex"))
30}
31
32fn could_not_compile() -> &'static Regex {
33 static REGEX: OnceLock<Regex> = OnceLock::new();
34 REGEX.get_or_init(|| Regex::new(r"error: could not compile").expect("valid static regex"))
35}
36
37fn test_failure() -> &'static Regex {
38 static REGEX: OnceLock<Regex> = OnceLock::new();
39 REGEX.get_or_init(|| {
40 Regex::new(r"test result: FAILED|panicked at|assertion .*failed|\bFAILED\b")
41 .expect("valid static regex")
42 })
43}
44
45fn lint_failure() -> &'static Regex {
46 static REGEX: OnceLock<Regex> = OnceLock::new();
47 REGEX.get_or_init(|| {
48 Regex::new(r"-D warnings|clippy::[a-z_]+|requested on the command line")
49 .expect("valid static regex")
50 })
51}
52
53#[must_use]
55pub fn classify(disposition: Disposition, output: &str) -> Option<FailureClass> {
56 match disposition {
57 Disposition::Success => None,
58 Disposition::TimedOut => Some(FailureClass::Timeout),
59 Disposition::InfraError => Some(FailureClass::Infra),
60 Disposition::Exited(_) => Some(classify_output(output)),
61 }
62}
63
64fn classify_output(output: &str) -> FailureClass {
65 if typed_build_error().is_match(output) {
66 FailureClass::Build
67 } else if lint_failure().is_match(output) {
68 FailureClass::Lint
69 } else if could_not_compile().is_match(output) {
70 FailureClass::Build
71 } else {
72 FailureClass::Test
73 }
74}
75
76#[must_use]
78pub fn extract_excerpt(output: &str, class: FailureClass) -> String {
79 let lines: Vec<_> = output.lines().collect();
80 let anchor = last_anchor(&lines, class);
81 let excerpt = match anchor {
82 Some(index) => {
83 let start = index.saturating_sub(2);
84 let end = (index + CONTEXT_LINES + 1).min(lines.len());
85 lines[start..end].join("\n")
86 }
87 None => lines[lines.len().saturating_sub(CONTEXT_LINES + 1)..].join("\n"),
88 };
89 cap_bytes(&excerpt, EXCERPT_CAP_BYTES)
90}
91
92fn last_anchor(lines: &[&str], class: FailureClass) -> Option<usize> {
93 let matches_class = |line: &&str| match class {
94 FailureClass::Build => {
95 typed_build_error().is_match(line) || could_not_compile().is_match(line)
96 }
97 FailureClass::Lint => lint_failure().is_match(line),
98 _ => test_failure().is_match(line),
99 };
100 lines
101 .iter()
102 .rposition(matches_class)
103 .or_else(|| lines.iter().rposition(|line| line.contains("error")))
104}
105
106pub(crate) fn cap_bytes(value: &str, cap: usize) -> String {
107 if value.len() <= cap {
108 return value.to_string();
109 }
110 const MARKER: &str = "\n… [excerpt truncated]";
111 let mut end = cap.saturating_sub(MARKER.len());
112 while end > 0 && !value.is_char_boundary(end) {
113 end -= 1;
114 }
115 format!("{}{MARKER}", &value[..end])
116}