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}