Skip to main content

aft/compress/
cargo.rs

1use crate::compress::caps::{cap_classified_blocks, ClassifiedBlock, DropClass};
2use crate::compress::generic::GenericCompressor;
3use crate::compress::{CompressionResult, Compressor};
4
5pub struct CargoCompressor;
6
7impl Compressor for CargoCompressor {
8    fn matches(&self, command: &str) -> bool {
9        command
10            .split_whitespace()
11            .next()
12            .is_some_and(|head| head == "cargo")
13    }
14
15    fn compress_with_exit_code(
16        &self,
17        command: &str,
18        output: &str,
19        exit_code: Option<i32>,
20    ) -> CompressionResult {
21        match cargo_subcommand(command).as_deref() {
22            Some("build" | "check" | "clippy") => compress_build_like(output),
23            Some("test") => compress_test(output, exit_code),
24            _ => GenericCompressor::compress_output(output).into(),
25        }
26    }
27
28    fn matches_output(&self, output: &str) -> bool {
29        output.lines().any(is_cargo_test_signature_line)
30    }
31
32    fn compress_output_match_with_exit_code(
33        &self,
34        output: &str,
35        exit_code: Option<i32>,
36    ) -> CompressionResult {
37        compress_test(output, exit_code)
38    }
39}
40
41fn is_cargo_test_signature_line(line: &str) -> bool {
42    line.starts_with("test result:")
43        || line.starts_with("failures:")
44        || (line.starts_with("---- ") && line.ends_with(" stdout ----"))
45}
46
47fn cargo_subcommand(command: &str) -> Option<String> {
48    let mut seen_cargo = false;
49    for token in command.split_whitespace() {
50        if !seen_cargo {
51            if token == "cargo" {
52                seen_cargo = true;
53            }
54            continue;
55        }
56        if token.starts_with('-') {
57            continue;
58        }
59        return Some(token.to_string());
60    }
61    None
62}
63
64fn compress_build_like(output: &str) -> CompressionResult {
65    let lines: Vec<&str> = output.lines().collect();
66    let has_diagnostic = lines
67        .iter()
68        .any(|line| is_warning_or_error(line) || line.trim_start().starts_with("error["));
69
70    if !has_diagnostic {
71        return CompressionResult::new(output.trim_end().to_string());
72    }
73
74    let mut blocks = Vec::new();
75    let mut index = 0usize;
76
77    while index < lines.len() {
78        let line = lines[index];
79        if is_ignored_progress(line) {
80            index += 1;
81            continue;
82        }
83
84        if is_warning_or_error(line) || line.trim_start().starts_with("error[") {
85            let class = if line.trim_start().starts_with("warning:") {
86                DropClass::Warning
87            } else {
88                DropClass::Error
89            };
90            let start = index;
91            index += 1;
92            while index < lines.len() && !starts_next_build_message(lines[index]) {
93                index += 1;
94            }
95            blocks.push(ClassifiedBlock::new(class, lines[start..index].join("\n")));
96            continue;
97        }
98
99        if is_final_cargo_summary(line) {
100            blocks.push(ClassifiedBlock::unclassified(line.to_string()));
101        }
102        index += 1;
103    }
104
105    let capped = cap_classified_blocks(blocks);
106    CompressionResult::with_class_drops(trim_trailing_lines(&capped.text), capped.dropped_by_class)
107}
108
109fn starts_next_build_message(line: &str) -> bool {
110    is_ignored_progress(line)
111        || is_warning_or_error(line)
112        || line.trim_start().starts_with("error[")
113        || is_final_cargo_summary(line)
114}
115
116fn is_warning_or_error(line: &str) -> bool {
117    let trimmed = line.trim_start();
118    trimmed.starts_with("warning:") || trimmed.starts_with("error:")
119}
120
121fn is_ignored_progress(line: &str) -> bool {
122    let trimmed = line.trim_start();
123    trimmed == "Updating crates.io index" || is_compiling_line(trimmed)
124}
125
126fn is_compiling_line(trimmed: &str) -> bool {
127    let Some(rest) = trimmed.strip_prefix("Compiling ") else {
128        return false;
129    };
130    let mut parts = rest.split_whitespace();
131    let _crate_name = parts.next();
132    parts.next().is_some_and(|part| {
133        part.strip_prefix('v').is_some_and(|version| {
134            version
135                .chars()
136                .all(|char| char.is_ascii_digit() || char == '.')
137        })
138    })
139}
140
141fn is_final_cargo_summary(line: &str) -> bool {
142    let trimmed = line.trim_start();
143    trimmed.starts_with("Finished ")
144        || trimmed.starts_with("error: could not compile")
145        || trimmed.starts_with("test result:")
146}
147
148fn compress_test(output: &str, exit_code: Option<i32>) -> CompressionResult {
149    let lines: Vec<&str> = output.lines().collect();
150    let has_failures = lines.iter().any(|line| line.trim() == "failures:");
151    if !has_failures {
152        if matches!(exit_code, Some(code) if code != 0)
153            && lines
154                .iter()
155                .any(|line| is_warning_or_error(line) || line.trim_start().starts_with("error["))
156        {
157            return compress_build_like(output);
158        }
159
160        let result: Vec<String> = lines
161            .iter()
162            .filter(|line| {
163                let trimmed = line.trim_start();
164                trimmed.starts_with("running ")
165                    || trimmed.starts_with("test result:")
166                    || is_final_cargo_summary(trimmed)
167            })
168            .map(|line| (*line).to_string())
169            .collect();
170        return CompressionResult::new(trim_trailing_lines(&result.join("\n")));
171    }
172
173    let mut blocks = Vec::new();
174    let mut index = 0usize;
175    while index < lines.len() {
176        let line = lines[index];
177        let trimmed = line.trim_start();
178        if trimmed.starts_with("running ") || trimmed.starts_with("test result:") {
179            blocks.push(ClassifiedBlock::unclassified(line.to_string()));
180            index += 1;
181            continue;
182        }
183
184        if trimmed == "failures:" {
185            let start = index;
186            let mut next = index + 1;
187            while next < lines.len() && lines[next].trim().is_empty() {
188                next += 1;
189            }
190            if next < lines.len() && lines[next].starts_with("---- ") {
191                blocks.push(ClassifiedBlock::unclassified(line.to_string()));
192                index += 1;
193                continue;
194            }
195
196            index += 1;
197            while index < lines.len() && !lines[index].trim_start().starts_with("test result:") {
198                index += 1;
199            }
200            blocks.push(ClassifiedBlock::unclassified(
201                lines[start..index].join("\n"),
202            ));
203            continue;
204        }
205
206        if line.starts_with("---- ") {
207            let start = index;
208            while index < lines.len() {
209                index += 1;
210                if index < lines.len()
211                    && (lines[index].starts_with("---- ")
212                        || lines[index].trim_start().starts_with("test result:")
213                        || lines[index].trim() == "failures:")
214                {
215                    break;
216                }
217            }
218            blocks.push(ClassifiedBlock::new(
219                DropClass::Failure,
220                lines[start..index].join("\n"),
221            ));
222            continue;
223        }
224
225        index += 1;
226    }
227
228    let capped = cap_classified_blocks(blocks);
229    CompressionResult::with_class_drops(trim_trailing_lines(&capped.text), capped.dropped_by_class)
230}
231
232fn trim_trailing_lines(input: &str) -> String {
233    input
234        .lines()
235        .map(str::trim_end)
236        .collect::<Vec<_>>()
237        .join("\n")
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use crate::compress::caps::{DropClass, CAP_ERRORS};
244
245    #[test]
246    fn cargo_test_caps_failure_blocks_after_failures_header() {
247        let mut output = String::from("running 40 tests\n\nfailures:\n\n");
248        for index in 0..40 {
249            output.push_str(&format!(
250                "---- case_{index} stdout ----\nthread 'case_{index}' panicked at src/lib.rs:{index}:1\nstack line {index}\n\n"
251            ));
252        }
253        output.push_str("failures:\n");
254        for index in 0..40 {
255            output.push_str(&format!("    case_{index}\n"));
256        }
257        output.push_str(
258            "\ntest result: FAILED. 0 passed; 40 failed; 0 ignored; 0 measured; 0 filtered out\n",
259        );
260
261        let result = compress_test(&output, None);
262
263        assert_eq!(
264            result.dropped_by_class.get(&DropClass::Failure),
265            Some(&(40 - CAP_ERRORS))
266        );
267        assert_eq!(result.text.matches(" stdout ----").count(), CAP_ERRORS);
268        assert!(result.text.contains("---- case_19 stdout ----"));
269        assert!(!result.text.contains("---- case_20 stdout ----"));
270        assert!(result.had_inner_drop);
271        assert!(!result.offset_hint_eligible);
272    }
273}