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}