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        if crate::compress::is_shell_boundary(token) {
60            return None;
61        }
62        return Some(token.to_string());
63    }
64    None
65}
66
67fn compress_build_like(output: &str) -> CompressionResult {
68    let lines: Vec<&str> = output.lines().collect();
69    let has_diagnostic = lines
70        .iter()
71        .any(|line| is_warning_or_error(line) || line.trim_start().starts_with("error["));
72
73    if !has_diagnostic {
74        return CompressionResult::new(output.trim_end().to_string());
75    }
76
77    let mut blocks = Vec::new();
78    let mut index = 0usize;
79
80    while index < lines.len() {
81        let line = lines[index];
82        if is_ignored_progress(line) {
83            index += 1;
84            continue;
85        }
86
87        if is_warning_or_error(line) || line.trim_start().starts_with("error[") {
88            let class = if line.trim_start().starts_with("warning:") {
89                DropClass::Warning
90            } else {
91                DropClass::Error
92            };
93            let start = index;
94            index += 1;
95            while index < lines.len() && !starts_next_build_message(lines[index]) {
96                index += 1;
97            }
98            blocks.push(ClassifiedBlock::new(class, lines[start..index].join("\n")));
99            continue;
100        }
101
102        if is_final_cargo_summary(line) {
103            blocks.push(ClassifiedBlock::unclassified(line.to_string()));
104        }
105        index += 1;
106    }
107
108    let capped = cap_classified_blocks(blocks);
109    CompressionResult::with_class_drops(trim_trailing_lines(&capped.text), capped.dropped_by_class)
110}
111
112fn starts_next_build_message(line: &str) -> bool {
113    is_ignored_progress(line)
114        || is_warning_or_error(line)
115        || line.trim_start().starts_with("error[")
116        || is_final_cargo_summary(line)
117}
118
119fn is_warning_or_error(line: &str) -> bool {
120    let trimmed = line.trim_start();
121    trimmed.starts_with("warning:") || trimmed.starts_with("error:")
122}
123
124fn is_error_diagnostic(line: &str) -> bool {
125    let trimmed = line.trim_start();
126    trimmed.starts_with("error:") || trimmed.starts_with("error[")
127}
128
129fn is_ignored_progress(line: &str) -> bool {
130    let trimmed = line.trim_start();
131    trimmed == "Updating crates.io index" || is_compiling_line(trimmed)
132}
133
134fn is_compiling_line(trimmed: &str) -> bool {
135    let Some(rest) = trimmed.strip_prefix("Compiling ") else {
136        return false;
137    };
138    let mut parts = rest.split_whitespace();
139    let _crate_name = parts.next();
140    parts.next().is_some_and(|part| {
141        part.strip_prefix('v').is_some_and(|version| {
142            version
143                .chars()
144                .all(|char| char.is_ascii_digit() || char == '.')
145        })
146    })
147}
148
149fn is_final_cargo_summary(line: &str) -> bool {
150    let trimmed = line.trim_start();
151    trimmed.starts_with("Finished ")
152        || trimmed.starts_with("error: could not compile")
153        || trimmed.starts_with("test result:")
154}
155
156fn compress_test(output: &str, exit_code: Option<i32>) -> CompressionResult {
157    let lines: Vec<&str> = output.lines().collect();
158    let has_failures = lines.iter().any(|line| line.trim() == "failures:");
159    if !has_failures {
160        let has_error_diagnostic = lines.iter().any(|line| is_error_diagnostic(line));
161        let has_warning_or_error = lines
162            .iter()
163            .any(|line| is_warning_or_error(line) || line.trim_start().starts_with("error["));
164        if has_error_diagnostic
165            || (matches!(exit_code, Some(code) if code != 0) && has_warning_or_error)
166        {
167            return compress_build_like(output);
168        }
169
170        let result: Vec<String> = lines
171            .iter()
172            .filter(|line| {
173                let trimmed = line.trim_start();
174                trimmed.starts_with("running ")
175                    || trimmed.starts_with("test result:")
176                    || is_final_cargo_summary(trimmed)
177            })
178            .map(|line| (*line).to_string())
179            .collect();
180        return CompressionResult::new(trim_trailing_lines(&result.join("\n")));
181    }
182
183    let mut blocks = Vec::new();
184    let mut index = 0usize;
185    while index < lines.len() {
186        let line = lines[index];
187        let trimmed = line.trim_start();
188        if trimmed.starts_with("running ") || trimmed.starts_with("test result:") {
189            blocks.push(ClassifiedBlock::unclassified(line.to_string()));
190            index += 1;
191            continue;
192        }
193
194        if trimmed == "failures:" {
195            let start = index;
196            let mut next = index + 1;
197            while next < lines.len() && lines[next].trim().is_empty() {
198                next += 1;
199            }
200            if next < lines.len() && lines[next].starts_with("---- ") {
201                blocks.push(ClassifiedBlock::unclassified(line.to_string()));
202                index += 1;
203                continue;
204            }
205
206            index += 1;
207            while index < lines.len() && !lines[index].trim_start().starts_with("test result:") {
208                index += 1;
209            }
210            blocks.push(ClassifiedBlock::unclassified(
211                lines[start..index].join("\n"),
212            ));
213            continue;
214        }
215
216        if line.starts_with("---- ") {
217            let start = index;
218            while index < lines.len() {
219                index += 1;
220                if index < lines.len()
221                    && (lines[index].starts_with("---- ")
222                        || lines[index].trim_start().starts_with("test result:")
223                        || lines[index].trim() == "failures:")
224                {
225                    break;
226                }
227            }
228            blocks.push(ClassifiedBlock::new(
229                DropClass::Failure,
230                lines[start..index].join("\n"),
231            ));
232            continue;
233        }
234
235        index += 1;
236    }
237
238    let capped = cap_classified_blocks(blocks);
239    CompressionResult::with_class_drops(trim_trailing_lines(&capped.text), capped.dropped_by_class)
240}
241
242fn trim_trailing_lines(input: &str) -> String {
243    input
244        .lines()
245        .map(str::trim_end)
246        .collect::<Vec<_>>()
247        .join("\n")
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use crate::compress::caps::{DropClass, CAP_ERRORS};
254
255    #[test]
256    fn cargo_test_caps_failure_blocks_after_failures_header() {
257        let mut output = String::from("running 40 tests\n\nfailures:\n\n");
258        for index in 0..40 {
259            output.push_str(&format!(
260                "---- case_{index} stdout ----\nthread 'case_{index}' panicked at src/lib.rs:{index}:1\nstack line {index}\n\n"
261            ));
262        }
263        output.push_str("failures:\n");
264        for index in 0..40 {
265            output.push_str(&format!("    case_{index}\n"));
266        }
267        output.push_str(
268            "\ntest result: FAILED. 0 passed; 40 failed; 0 ignored; 0 measured; 0 filtered out\n",
269        );
270
271        let result = compress_test(&output, None);
272
273        assert_eq!(
274            result.dropped_by_class.get(&DropClass::Failure),
275            Some(&(40 - CAP_ERRORS))
276        );
277        assert_eq!(result.text.matches(" stdout ----").count(), CAP_ERRORS);
278        assert!(result.text.contains("---- case_19 stdout ----"));
279        assert!(!result.text.contains("---- case_20 stdout ----"));
280        assert!(result.had_inner_drop);
281        assert!(!result.offset_hint_eligible);
282    }
283
284    #[test]
285    fn cargo_test_compile_error_preserves_diagnostic_with_unknown_exit() {
286        let output = r#"   Compiling demo v0.1.0 (/tmp/demo)
287error[E0432]: unresolved import `crate::missing`
288 --> src/lib.rs:1:5
289  |
2901 | use crate::missing;
291  |     ^^^^^^^^^^^^^^ no `missing` in the root
292
293error: could not compile `demo` (lib test) due to 1 previous error
294"#;
295
296        let result = compress_test(output, None);
297
298        assert!(result.text.contains("error[E0432]"));
299        assert!(result.text.contains("unresolved import"));
300        assert!(result.text.contains("error: could not compile"));
301    }
302
303    #[test]
304    fn cargo_subcommand_returns_none_for_pipe_before_subcommand() {
305        assert_eq!(cargo_subcommand("cargo --verbose | grep error"), None);
306    }
307
308    #[test]
309    fn cargo_subcommand_returns_subcommand_when_before_pipe() {
310        assert_eq!(
311            cargo_subcommand("cargo test | grep FAIL").as_deref(),
312            Some("test")
313        );
314    }
315
316    #[test]
317    fn cargo_subcommand_unaffected_without_metacharacters() {
318        assert_eq!(
319            cargo_subcommand("cargo test --release").as_deref(),
320            Some("test")
321        );
322    }
323}