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" | "i" | "add" | "remove") => compress_package(output),
26 Some("test") => compress_test(output),
27 Some("build") => compress_build(output),
28 _ => GenericCompressor::compress_output(output),
29 }
30 }
31
32 fn matches_output(&self, output: &str) -> bool {
33 let mut saw_ran_summary = false;
34 let mut saw_result_marker = false;
35
36 for line in output.lines() {
37 saw_ran_summary |= is_ran_summary_line(line);
38 saw_result_marker |= is_bun_test_result_marker(line);
39
40 if saw_ran_summary && saw_result_marker {
41 return true;
42 }
43 }
44
45 false
46 }
47
48 fn compress_output_match(&self, output: &str) -> String {
49 compress_test(output)
50 }
51}
52
53const BUN_SUBCOMMANDS: &[&str] = &[
63 "install", "i", "add", "remove", "update", "outdated", "link", "unlink", "why", "audit",
64 "patch", "pm", "publish", "pack", "run", "test", "x", "exec", "create", "init", "build",
65 "repl", "upgrade", "help", "info",
66];
67
68fn bun_subcommand(command: &str) -> Option<String> {
78 command
79 .split_whitespace()
80 .skip_while(|token| *token != "bun")
81 .skip(1)
82 .find(|token| BUN_SUBCOMMANDS.contains(token))
83 .map(ToString::to_string)
84}
85
86fn compress_package(output: &str) -> String {
87 let mut result = Vec::new();
88 for line in output.lines() {
89 if is_bun_progress(line) {
90 continue;
91 }
92 let trimmed = line.trim_start();
93 if trimmed.contains("packages installed")
94 || trimmed.contains("package installed")
95 || trimmed.starts_with("error:")
96 || trimmed.starts_with("bun install error:")
97 || trimmed.starts_with("Saved lockfile")
98 {
99 result.push(line.to_string());
100 }
101 }
102 trim_trailing_lines(&result.join("\n"))
103}
104
105fn compress_build(output: &str) -> String {
106 let mut result = Vec::new();
107 let mut timing_seen = 0usize;
108 let mut timing_omitted = 0usize;
109 for line in output.lines() {
110 if is_timing_line(line) {
111 timing_seen += 1;
112 if timing_seen > 10 {
113 timing_omitted += 1;
114 continue;
115 }
116 }
117 result.push(line.to_string());
118 }
119 if timing_omitted > 0 {
120 result.push(format!("... and {timing_omitted} more timing lines"));
121 }
122 trim_trailing_lines(&result.join("\n"))
123}
124
125fn compress_test(output: &str) -> String {
146 let lines: Vec<&str> = output.lines().collect();
147 if lines.is_empty() {
148 return output.to_string();
149 }
150
151 let has_failures = lines.iter().any(|line| is_bun_test_fail_marker(line));
155 if !has_failures {
156 return compress_test_pass_only(&lines);
157 }
158
159 let mut result: Vec<String> = Vec::new();
160 let mut failures_kept = 0usize;
161 let mut failures_dropped = 0usize;
162 let mut index = 0usize;
163 let mut saw_ran_summary = false;
164
165 while index < lines.len() {
166 let line = lines[index];
167
168 if saw_ran_summary {
169 result.push(line.to_string());
175 index += 1;
176 continue;
177 }
178
179 if is_bun_test_header(line) {
181 result.push(line.to_string());
182 index += 1;
183 continue;
184 }
185
186 if is_file_section_header(line) {
190 let next_fail = next_index(&lines, index + 1, is_bun_test_fail_marker);
191 let next_section = next_index(&lines, index + 1, |l| {
192 is_file_section_header(l) || is_summary_line(l)
193 });
194 let keep_section = match (next_fail, next_section) {
195 (Some(fi), Some(si)) => fi < si,
196 (Some(_), None) => true,
197 (None, _) => false,
198 };
199 if keep_section {
200 result.push(line.to_string());
201 }
202 index += 1;
203 continue;
204 }
205
206 if is_summary_line(line) {
208 result.push(line.to_string());
209 if is_ran_summary_line(line) {
214 saw_ran_summary = true;
215 }
216 index += 1;
217 continue;
218 }
219
220 if is_bun_test_error_start(line) || is_bun_test_code_pointer(line) {
225 let block_start = index;
230 let mut block_end = index;
231 while block_end < lines.len() {
232 if is_bun_test_fail_marker(lines[block_end]) {
233 block_end += 1;
234 break;
235 }
236 block_end += 1;
241 }
242
243 failures_kept += 1;
244 if failures_kept <= MAX_FAILURES {
245 for line in &lines[block_start..block_end] {
246 result.push((*line).to_string());
247 }
248 } else {
249 failures_dropped += 1;
250 }
251 index = block_end;
252 continue;
253 }
254
255 index += 1;
257 }
258
259 if failures_dropped > 0 {
260 result.push(format!("+{failures_dropped} more failures"));
261 }
262
263 if result.is_empty() {
266 return GenericCompressor::compress_output(output);
267 }
268 trim_trailing_lines(&result.join("\n"))
269}
270
271fn compress_test_pass_only(lines: &[&str]) -> String {
284 let mut result: Vec<String> = Vec::new();
285 let mut saw_ran_summary = false;
286
287 for line in lines {
288 if saw_ran_summary {
289 result.push((*line).to_string());
291 continue;
292 }
293 if is_bun_test_header(line) || is_summary_line(line) {
294 result.push((*line).to_string());
295 if is_ran_summary_line(line) {
299 saw_ran_summary = true;
300 }
301 }
302 }
303
304 if result.is_empty() {
305 return GenericCompressor::compress_output(&lines.join("\n"));
306 }
307 trim_trailing_lines(&result.join("\n"))
308}
309
310fn next_index<F>(lines: &[&str], start: usize, predicate: F) -> Option<usize>
311where
312 F: Fn(&str) -> bool,
313{
314 lines
315 .iter()
316 .enumerate()
317 .skip(start)
318 .find(|(_, line)| predicate(line))
319 .map(|(i, _)| i)
320}
321
322fn is_bun_test_header(line: &str) -> bool {
323 line.starts_with("bun test v")
324}
325
326fn is_file_section_header(line: &str) -> bool {
327 let trimmed = line.trim_end();
332 if trimmed.starts_with(' ') || !trimmed.ends_with(':') {
333 return false;
334 }
335 let path = &trimmed[..trimmed.len() - 1];
336 if path.is_empty() || path.contains(' ') {
337 return false;
338 }
339 path.contains(".test.")
340 || path.contains(".spec.")
341 || path.contains("_test.")
342 || path.contains("_spec.")
343}
344
345fn is_bun_test_result_marker(line: &str) -> bool {
346 is_bun_test_pass_marker(line) || is_bun_test_fail_marker(line)
347}
348
349fn is_bun_test_pass_marker(line: &str) -> bool {
350 is_bun_test_marker(line, "(pass)")
351}
352
353fn is_bun_test_fail_marker(line: &str) -> bool {
354 is_bun_test_marker(line, "(fail)")
355}
356
357fn is_bun_test_marker(line: &str, marker: &str) -> bool {
358 let trimmed = line.trim();
359 let Some(rest) = trimmed.strip_prefix(marker) else {
360 return false;
361 };
362 if !rest.chars().next().is_some_and(|ch| ch.is_whitespace()) {
363 return false;
364 }
365
366 let name_and_timing = rest.trim_start();
367 let Some((name, timing)) = name_and_timing.rsplit_once(" [") else {
368 return false;
369 };
370 if name.trim().is_empty() {
371 return false;
372 }
373
374 let Some(duration) = timing.strip_suffix(']') else {
375 return false;
376 };
377 is_bun_test_duration(duration)
378}
379
380fn is_bun_test_duration(duration: &str) -> bool {
381 ["ms", "µs", "μs", "us", "ns", "s"]
382 .iter()
383 .any(|unit| duration.strip_suffix(*unit).is_some_and(is_decimal_number))
384}
385
386fn is_decimal_number(value: &str) -> bool {
387 let mut saw_digit = false;
388 let mut saw_dot = false;
389
390 for ch in value.chars() {
391 match ch {
392 '0'..='9' => saw_digit = true,
393 '.' if !saw_dot => saw_dot = true,
394 _ => return false,
395 }
396 }
397
398 saw_digit
399}
400
401fn is_bun_test_error_start(line: &str) -> bool {
402 line.starts_with("error:")
407}
408
409fn is_bun_test_code_pointer(line: &str) -> bool {
410 let trimmed = line.trim_start();
414 if !trimmed.contains(" | ") && !trimmed.contains("| ") {
415 return false;
416 }
417 trimmed
419 .chars()
420 .next()
421 .is_some_and(|char| char.is_ascii_digit())
422}
423
424fn is_ran_summary_line(line: &str) -> bool {
428 let Some(rest) = line.strip_prefix("Ran ") else {
429 return false;
430 };
431 let Some((test_count, rest)) = rest.split_once(" tests across ") else {
432 return false;
433 };
434 if test_count.is_empty() || !test_count.chars().all(|ch| ch.is_ascii_digit()) {
435 return false;
436 }
437 let Some((file_count, rest)) = rest.split_once(" file") else {
438 return false;
439 };
440 if file_count.is_empty() || !file_count.chars().all(|ch| ch.is_ascii_digit()) {
441 return false;
442 }
443 rest.starts_with(". [") || rest.starts_with("s. [")
444}
445
446fn is_summary_line(line: &str) -> bool {
447 let trimmed = line.trim_start();
448 if is_ran_summary_line(trimmed) {
452 return true;
453 }
454 if let Some(first_token) = trimmed.split_whitespace().next() {
455 if first_token.chars().all(|char| char.is_ascii_digit()) {
456 let rest = trimmed[first_token.len()..].trim_start();
457 return rest.starts_with("pass")
458 || rest.starts_with("fail")
459 || rest.starts_with("expect()");
460 }
461 }
462 false
463}
464
465fn is_bun_progress(line: &str) -> bool {
466 let trimmed = line.trim();
467 trimmed == "."
468 || trimmed.chars().all(|char| char == '.')
469 || trimmed.starts_with("Resolving")
470 || trimmed.starts_with("Resolved")
471 || trimmed.starts_with("Downloaded")
472 || trimmed.starts_with("Extracted")
473}
474
475fn is_timing_line(line: &str) -> bool {
476 let trimmed = line.trim_start();
477 trimmed.starts_with('[') && trimmed.contains(" ms]")
478}
479
480fn trim_trailing_lines(input: &str) -> String {
481 input
482 .lines()
483 .map(str::trim_end)
484 .collect::<Vec<_>>()
485 .join("\n")
486}