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