1use crate::compress::generic::GenericCompressor;
2use crate::compress::{Compressor, Specificity};
3
4pub struct BunCompressor;
5
6const MAX_FAILURES: usize = 25;
10
11impl Compressor for BunCompressor {
12 fn specificity(&self) -> Specificity {
13 Specificity::PackageManager
14 }
15
16 fn matches(&self, command: &str) -> bool {
17 command
18 .split_whitespace()
19 .next()
20 .is_some_and(|head| head == "bun")
21 }
22
23 fn compress(&self, command: &str, output: &str) -> String {
24 match bun_subcommand(command).as_deref() {
25 Some("install" | "add" | "remove") => compress_package(output),
26 Some("test") => compress_test(output),
27 Some("run") => GenericCompressor::compress_output(output),
28 Some("build") => compress_build(output),
29 _ => GenericCompressor::compress_output(output),
30 }
31 }
32}
33
34fn bun_subcommand(command: &str) -> Option<String> {
35 command
36 .split_whitespace()
37 .skip_while(|token| *token != "bun")
38 .skip(1)
39 .find(|token| !token.starts_with('-'))
40 .map(ToString::to_string)
41}
42
43fn compress_package(output: &str) -> String {
44 let mut result = Vec::new();
45 for line in output.lines() {
46 if is_bun_progress(line) {
47 continue;
48 }
49 let trimmed = line.trim_start();
50 if trimmed.contains("packages installed")
51 || trimmed.contains("package installed")
52 || trimmed.starts_with("error:")
53 || trimmed.starts_with("bun install error:")
54 || trimmed.starts_with("Saved lockfile")
55 {
56 result.push(line.to_string());
57 }
58 }
59 trim_trailing_lines(&result.join("\n"))
60}
61
62fn compress_build(output: &str) -> String {
63 let mut result = Vec::new();
64 let mut timing_seen = 0usize;
65 let mut timing_omitted = 0usize;
66 for line in output.lines() {
67 if is_timing_line(line) {
68 timing_seen += 1;
69 if timing_seen > 10 {
70 timing_omitted += 1;
71 continue;
72 }
73 }
74 result.push(line.to_string());
75 }
76 if timing_omitted > 0 {
77 result.push(format!("... and {timing_omitted} more timing lines"));
78 }
79 trim_trailing_lines(&result.join("\n"))
80}
81
82fn compress_test(output: &str) -> String {
103 let lines: Vec<&str> = output.lines().collect();
104 if lines.is_empty() {
105 return output.to_string();
106 }
107
108 let has_failures = lines.iter().any(|line| is_bun_test_fail_marker(line));
112 if !has_failures {
113 return compress_test_pass_only(&lines);
114 }
115
116 let mut result: Vec<String> = Vec::new();
117 let mut failures_kept = 0usize;
118 let mut failures_dropped = 0usize;
119 let mut index = 0usize;
120 let mut saw_ran_summary = false;
121
122 while index < lines.len() {
123 let line = lines[index];
124
125 if saw_ran_summary {
126 result.push(line.to_string());
132 index += 1;
133 continue;
134 }
135
136 if is_bun_test_header(line) {
138 result.push(line.to_string());
139 index += 1;
140 continue;
141 }
142
143 if is_file_section_header(line) {
147 let next_fail = next_index(&lines, index + 1, is_bun_test_fail_marker);
148 let next_section = next_index(&lines, index + 1, |l| {
149 is_file_section_header(l) || is_summary_line(l)
150 });
151 let keep_section = match (next_fail, next_section) {
152 (Some(fi), Some(si)) => fi < si,
153 (Some(_), None) => true,
154 (None, _) => false,
155 };
156 if keep_section {
157 result.push(line.to_string());
158 }
159 index += 1;
160 continue;
161 }
162
163 if is_summary_line(line) {
165 result.push(line.to_string());
166 if is_ran_summary_line(line) {
171 saw_ran_summary = true;
172 }
173 index += 1;
174 continue;
175 }
176
177 if is_bun_test_error_start(line) || is_bun_test_code_pointer(line) {
182 let block_start = index;
187 let mut block_end = index;
188 while block_end < lines.len() {
189 if is_bun_test_fail_marker(lines[block_end]) {
190 block_end += 1;
191 break;
192 }
193 block_end += 1;
198 }
199
200 failures_kept += 1;
201 if failures_kept <= MAX_FAILURES {
202 for line in &lines[block_start..block_end] {
203 result.push((*line).to_string());
204 }
205 } else {
206 failures_dropped += 1;
207 }
208 index = block_end;
209 continue;
210 }
211
212 index += 1;
214 }
215
216 if failures_dropped > 0 {
217 result.push(format!("+{failures_dropped} more failures"));
218 }
219
220 if result.is_empty() {
223 return GenericCompressor::compress_output(output);
224 }
225 trim_trailing_lines(&result.join("\n"))
226}
227
228fn compress_test_pass_only(lines: &[&str]) -> String {
241 let mut result: Vec<String> = Vec::new();
242 let mut saw_ran_summary = false;
243
244 for line in lines {
245 if saw_ran_summary {
246 result.push((*line).to_string());
248 continue;
249 }
250 if is_bun_test_header(line) || is_summary_line(line) {
251 result.push((*line).to_string());
252 if is_ran_summary_line(line) {
256 saw_ran_summary = true;
257 }
258 }
259 }
260
261 if result.is_empty() {
262 return GenericCompressor::compress_output(&lines.join("\n"));
263 }
264 trim_trailing_lines(&result.join("\n"))
265}
266
267fn next_index<F>(lines: &[&str], start: usize, predicate: F) -> Option<usize>
268where
269 F: Fn(&str) -> bool,
270{
271 lines
272 .iter()
273 .enumerate()
274 .skip(start)
275 .find(|(_, line)| predicate(line))
276 .map(|(i, _)| i)
277}
278
279fn is_bun_test_header(line: &str) -> bool {
280 line.starts_with("bun test v")
281}
282
283fn is_file_section_header(line: &str) -> bool {
284 let trimmed = line.trim_end();
289 if trimmed.starts_with(' ') || !trimmed.ends_with(':') {
290 return false;
291 }
292 let path = &trimmed[..trimmed.len() - 1];
293 if path.is_empty() || path.contains(' ') {
294 return false;
295 }
296 path.contains(".test.")
297 || path.contains(".spec.")
298 || path.contains("_test.")
299 || path.contains("_spec.")
300}
301
302fn is_bun_test_fail_marker(line: &str) -> bool {
303 line.trim_start().starts_with("(fail)")
304}
305
306fn is_bun_test_error_start(line: &str) -> bool {
307 line.starts_with("error:")
312}
313
314fn is_bun_test_code_pointer(line: &str) -> bool {
315 let trimmed = line.trim_start();
319 if !trimmed.contains(" | ") && !trimmed.contains("| ") {
320 return false;
321 }
322 trimmed
324 .chars()
325 .next()
326 .is_some_and(|char| char.is_ascii_digit())
327}
328
329fn is_ran_summary_line(line: &str) -> bool {
333 let trimmed = line.trim_start();
334 trimmed.starts_with("Ran ")
335 && trimmed.contains(" tests")
336 && (trimmed.contains(" files. [") || trimmed.contains(" file. ["))
337}
338
339fn is_summary_line(line: &str) -> bool {
340 let trimmed = line.trim_start();
341 if trimmed.starts_with("Ran ") && trimmed.contains(" tests") {
345 return true;
346 }
347 if let Some(first_token) = trimmed.split_whitespace().next() {
348 if first_token.chars().all(|char| char.is_ascii_digit()) {
349 let rest = trimmed[first_token.len()..].trim_start();
350 return rest.starts_with("pass")
351 || rest.starts_with("fail")
352 || rest.starts_with("expect()");
353 }
354 }
355 false
356}
357
358fn is_bun_progress(line: &str) -> bool {
359 let trimmed = line.trim();
360 trimmed == "."
361 || trimmed.chars().all(|char| char == '.')
362 || trimmed.starts_with("Resolving")
363 || trimmed.starts_with("Resolved")
364 || trimmed.starts_with("Downloaded")
365 || trimmed.starts_with("Extracted")
366}
367
368fn is_timing_line(line: &str) -> bool {
369 let trimmed = line.trim_start();
370 trimmed.starts_with('[') && trimmed.contains(" ms]")
371}
372
373fn trim_trailing_lines(input: &str) -> String {
374 input
375 .lines()
376 .map(str::trim_end)
377 .collect::<Vec<_>>()
378 .join("\n")
379}