Skip to main content

ci_engine/
classify.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Failure classification and bounded excerpt extraction.
3
4use std::sync::OnceLock;
5
6use crypto::FailureClass;
7use regex::Regex;
8
9/// Maximum bytes stored in a verdict failure excerpt.
10pub const EXCERPT_CAP_BYTES: usize = 4096;
11const CONTEXT_LINES: usize = 6;
12
13/// Terminal process disposition before output classification.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum Disposition {
16    /// Process exited successfully.
17    Success,
18    /// Process exited nonzero.
19    Exited(i32),
20    /// Process exceeded its deadline.
21    TimedOut,
22    /// Process could not run or be observed.
23    #[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/// Classify a process result. Disposition always wins over captured text.
54#[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/// Extract the last causal-looking region and cap it without splitting UTF-8.
77#[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}