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}