1use crate::compress::generic::GenericCompressor;
2use crate::compress::Compressor;
3
4pub struct BunCompressor;
5
6const MAX_FAILURES: usize = 25;
10
11impl Compressor for BunCompressor {
12 fn matches(&self, command: &str) -> bool {
13 command
14 .split_whitespace()
15 .next()
16 .is_some_and(|head| head == "bun")
17 }
18
19 fn compress(&self, command: &str, output: &str) -> String {
20 match bun_subcommand(command).as_deref() {
21 Some("install" | "add" | "remove") => compress_package(output),
22 Some("test") => compress_test(output),
23 Some("run") => GenericCompressor::compress_output(output),
24 Some("build") => compress_build(output),
25 _ => GenericCompressor::compress_output(output),
26 }
27 }
28}
29
30fn bun_subcommand(command: &str) -> Option<String> {
31 command
32 .split_whitespace()
33 .skip_while(|token| *token != "bun")
34 .skip(1)
35 .find(|token| !token.starts_with('-'))
36 .map(ToString::to_string)
37}
38
39fn compress_package(output: &str) -> String {
40 let mut result = Vec::new();
41 for line in output.lines() {
42 if is_bun_progress(line) {
43 continue;
44 }
45 let trimmed = line.trim_start();
46 if trimmed.contains("packages installed")
47 || trimmed.contains("package installed")
48 || trimmed.starts_with("error:")
49 || trimmed.starts_with("bun install error:")
50 || trimmed.starts_with("Saved lockfile")
51 {
52 result.push(line.to_string());
53 }
54 }
55 trim_trailing_lines(&result.join("\n"))
56}
57
58fn compress_build(output: &str) -> String {
59 let mut result = Vec::new();
60 let mut timing_seen = 0usize;
61 let mut timing_omitted = 0usize;
62 for line in output.lines() {
63 if is_timing_line(line) {
64 timing_seen += 1;
65 if timing_seen > 10 {
66 timing_omitted += 1;
67 continue;
68 }
69 }
70 result.push(line.to_string());
71 }
72 if timing_omitted > 0 {
73 result.push(format!("... and {timing_omitted} more timing lines"));
74 }
75 trim_trailing_lines(&result.join("\n"))
76}
77
78fn compress_test(output: &str) -> String {
99 let lines: Vec<&str> = output.lines().collect();
100 if lines.is_empty() {
101 return output.to_string();
102 }
103
104 let has_failures = lines.iter().any(|line| is_bun_test_fail_marker(line));
108 if !has_failures {
109 return compress_test_pass_only(&lines);
110 }
111
112 let mut result: Vec<String> = Vec::new();
113 let mut failures_kept = 0usize;
114 let mut failures_dropped = 0usize;
115 let mut index = 0usize;
116
117 while index < lines.len() {
118 let line = lines[index];
119
120 if is_bun_test_header(line) {
122 result.push(line.to_string());
123 index += 1;
124 continue;
125 }
126
127 if is_file_section_header(line) {
131 let next_fail = next_index(&lines, index + 1, is_bun_test_fail_marker);
132 let next_section = next_index(&lines, index + 1, |l| {
133 is_file_section_header(l) || is_summary_line(l)
134 });
135 let keep_section = match (next_fail, next_section) {
136 (Some(fi), Some(si)) => fi < si,
137 (Some(_), None) => true,
138 (None, _) => false,
139 };
140 if keep_section {
141 result.push(line.to_string());
142 }
143 index += 1;
144 continue;
145 }
146
147 if is_summary_line(line) {
149 result.push(line.to_string());
150 index += 1;
151 continue;
152 }
153
154 if is_bun_test_error_start(line) || is_bun_test_code_pointer(line) {
159 let block_start = index;
164 let mut block_end = index;
165 while block_end < lines.len() {
166 if is_bun_test_fail_marker(lines[block_end]) {
167 block_end += 1;
168 break;
169 }
170 block_end += 1;
175 }
176
177 failures_kept += 1;
178 if failures_kept <= MAX_FAILURES {
179 for line in &lines[block_start..block_end] {
180 result.push((*line).to_string());
181 }
182 } else {
183 failures_dropped += 1;
184 }
185 index = block_end;
186 continue;
187 }
188
189 index += 1;
191 }
192
193 if failures_dropped > 0 {
194 result.push(format!("+{failures_dropped} more failures"));
195 }
196
197 if result.is_empty() {
200 return GenericCompressor::compress_output(output);
201 }
202 trim_trailing_lines(&result.join("\n"))
203}
204
205fn compress_test_pass_only(lines: &[&str]) -> String {
209 let mut result: Vec<String> = Vec::new();
210 for line in lines {
211 if is_bun_test_header(line) || is_summary_line(line) {
212 result.push((*line).to_string());
213 }
214 }
215 if result.is_empty() {
216 return GenericCompressor::compress_output(&lines.join("\n"));
217 }
218 trim_trailing_lines(&result.join("\n"))
219}
220
221fn next_index<F>(lines: &[&str], start: usize, predicate: F) -> Option<usize>
222where
223 F: Fn(&str) -> bool,
224{
225 lines
226 .iter()
227 .enumerate()
228 .skip(start)
229 .find(|(_, line)| predicate(line))
230 .map(|(i, _)| i)
231}
232
233fn is_bun_test_header(line: &str) -> bool {
234 line.starts_with("bun test v")
235}
236
237fn is_file_section_header(line: &str) -> bool {
238 let trimmed = line.trim_end();
243 if trimmed.starts_with(' ') || !trimmed.ends_with(':') {
244 return false;
245 }
246 let path = &trimmed[..trimmed.len() - 1];
247 if path.is_empty() || path.contains(' ') {
248 return false;
249 }
250 path.contains(".test.")
251 || path.contains(".spec.")
252 || path.contains("_test.")
253 || path.contains("_spec.")
254}
255
256fn is_bun_test_fail_marker(line: &str) -> bool {
257 line.trim_start().starts_with("(fail)")
258}
259
260fn is_bun_test_error_start(line: &str) -> bool {
261 line.starts_with("error:")
266}
267
268fn is_bun_test_code_pointer(line: &str) -> bool {
269 let trimmed = line.trim_start();
273 if !trimmed.contains(" | ") && !trimmed.contains("| ") {
274 return false;
275 }
276 trimmed
278 .chars()
279 .next()
280 .is_some_and(|char| char.is_ascii_digit())
281}
282
283fn is_summary_line(line: &str) -> bool {
284 let trimmed = line.trim_start();
285 if trimmed.starts_with("Ran ") && trimmed.contains(" tests") {
289 return true;
290 }
291 if let Some(first_token) = trimmed.split_whitespace().next() {
292 if first_token.chars().all(|char| char.is_ascii_digit()) {
293 let rest = trimmed[first_token.len()..].trim_start();
294 return rest.starts_with("pass")
295 || rest.starts_with("fail")
296 || rest.starts_with("expect()");
297 }
298 }
299 false
300}
301
302fn is_bun_progress(line: &str) -> bool {
303 let trimmed = line.trim();
304 trimmed == "."
305 || trimmed.chars().all(|char| char == '.')
306 || trimmed.starts_with("Resolving")
307 || trimmed.starts_with("Resolved")
308 || trimmed.starts_with("Downloaded")
309 || trimmed.starts_with("Extracted")
310}
311
312fn is_timing_line(line: &str) -> bool {
313 let trimmed = line.trim_start();
314 trimmed.starts_with('[') && trimmed.contains(" ms]")
315}
316
317fn trim_trailing_lines(input: &str) -> String {
318 input
319 .lines()
320 .map(str::trim_end)
321 .collect::<Vec<_>>()
322 .join("\n")
323}