1use crate::compress::caps::{cap_classified_blocks, ClassifiedBlock, DropClass};
2use crate::compress::generic::GenericCompressor;
3use crate::compress::{CompressionResult, Compressor, Specificity};
4
5pub struct BunCompressor;
6
7impl Compressor for BunCompressor {
8 fn specificity(&self) -> Specificity {
9 Specificity::PackageManager
10 }
11
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_with_exit_code(
20 &self,
21 command: &str,
22 output: &str,
23 exit_code: Option<i32>,
24 ) -> CompressionResult {
25 match bun_subcommand(command).as_deref() {
26 Some("install" | "i" | "add" | "remove") => compress_package(output).into(),
27 Some("test") => preserve_bun_failure(output, compress_test(output), exit_code),
28 Some("build") => compress_build(output),
29 _ => GenericCompressor::compress_output(output).into(),
30 }
31 }
32
33 fn matches_output(&self, output: &str) -> bool {
34 let mut saw_ran_summary = false;
35 let mut saw_result_marker = false;
36
37 for line in output.lines() {
38 saw_ran_summary |= is_ran_summary_line(line);
39 saw_result_marker |= is_bun_test_result_marker(line);
40
41 if saw_ran_summary && saw_result_marker {
42 return true;
43 }
44 }
45
46 false
47 }
48
49 fn compress_output_match_with_exit_code(
50 &self,
51 output: &str,
52 exit_code: Option<i32>,
53 ) -> CompressionResult {
54 preserve_bun_failure(output, compress_test(output), exit_code)
55 }
56}
57
58fn preserve_bun_failure(
59 output: &str,
60 compressed: CompressionResult,
61 exit_code: Option<i32>,
62) -> CompressionResult {
63 let stripped_failure =
64 compressed.text.trim().is_empty() || !super::text_has_failure_signal(&compressed.text);
65 if !output.trim().is_empty()
66 && stripped_failure
67 && (matches!(exit_code, Some(code) if code != 0) || super::text_has_failure_signal(output))
68 {
69 GenericCompressor::compress_output(output).into()
70 } else {
71 compressed
72 }
73}
74
75const BUN_SUBCOMMANDS: &[&str] = &[
85 "install", "i", "add", "remove", "update", "outdated", "link", "unlink", "why", "audit",
86 "patch", "pm", "publish", "pack", "run", "test", "x", "exec", "create", "init", "build",
87 "repl", "upgrade", "help", "info",
88];
89
90fn bun_subcommand(command: &str) -> Option<String> {
100 command
101 .split_whitespace()
102 .skip_while(|token| *token != "bun")
103 .skip(1)
104 .find(|token| BUN_SUBCOMMANDS.contains(token))
105 .map(ToString::to_string)
106}
107
108fn compress_package(output: &str) -> String {
109 let mut result = Vec::new();
110 for line in output.lines() {
111 if is_bun_progress(line) {
112 continue;
113 }
114 let trimmed = line.trim_start();
115 if trimmed.contains("packages installed")
116 || trimmed.contains("package installed")
117 || trimmed.starts_with("error:")
118 || trimmed.starts_with("bun install error:")
119 || trimmed.starts_with("Saved lockfile")
120 {
121 result.push(line.to_string());
122 }
123 }
124 trim_trailing_lines(&result.join("\n"))
125}
126
127fn compress_build(output: &str) -> CompressionResult {
128 let mut blocks = Vec::new();
129 for line in output.lines() {
130 if is_timing_line(line) {
131 blocks.push(ClassifiedBlock::new(DropClass::Timing, line.to_string()));
132 } else {
133 blocks.push(ClassifiedBlock::unclassified(line.to_string()));
134 }
135 }
136 let capped = cap_classified_blocks(blocks);
137 CompressionResult::with_class_drops(trim_trailing_lines(&capped.text), capped.dropped_by_class)
138}
139
140fn compress_test(output: &str) -> CompressionResult {
161 let lines: Vec<&str> = output.lines().collect();
162 if lines.is_empty() {
163 return CompressionResult::new(output.to_string());
164 }
165
166 let has_failures = lines.iter().any(|line| is_bun_test_fail_marker(line));
170 if !has_failures {
171 return CompressionResult::new(compress_test_pass_only(&lines));
172 }
173
174 let mut blocks: Vec<ClassifiedBlock> = Vec::new();
175 let mut index = 0usize;
176 let mut saw_ran_summary = false;
177 let mut pending_section: Option<String> = None;
178
179 while index < lines.len() {
180 let line = lines[index];
181
182 if saw_ran_summary {
183 blocks.push(ClassifiedBlock::unclassified(line.to_string()));
189 index += 1;
190 continue;
191 }
192
193 if is_bun_test_header(line) {
195 blocks.push(ClassifiedBlock::unclassified(render_bun_header(line)));
196 index += 1;
197 continue;
198 }
199
200 if is_file_section_header(line) {
204 let next_fail = next_index(&lines, index + 1, is_bun_test_fail_marker);
205 let next_section = next_index(&lines, index + 1, |l| {
206 is_file_section_header(l) || is_summary_line(l)
207 });
208 let keep_section = match (next_fail, next_section) {
209 (Some(fi), Some(si)) => fi < si,
210 (Some(_), None) => true,
211 (None, _) => false,
212 };
213 if keep_section {
214 pending_section = Some(line.to_string());
215 }
216 index += 1;
217 continue;
218 }
219
220 if is_summary_line(line) {
222 blocks.push(ClassifiedBlock::unclassified(render_summary_line(line)));
223 if is_ran_summary_line(line) {
228 saw_ran_summary = true;
229 }
230 index += 1;
231 continue;
232 }
233
234 if is_bun_test_error_start(line) || is_bun_test_code_pointer(line) {
239 let block_start = index;
244 let mut block_end = index;
245 while block_end < lines.len() {
246 if is_bun_test_fail_marker(lines[block_end]) {
247 block_end += 1;
248 break;
249 }
250 block_end += 1;
255 }
256
257 let mut block_lines = Vec::new();
258 if let Some(section) = pending_section.take() {
259 block_lines.push(section);
260 }
261 block_lines.extend(
262 lines[block_start..block_end]
263 .iter()
264 .map(|line| (*line).to_string()),
265 );
266 blocks.push(ClassifiedBlock::new(
267 DropClass::Failure,
268 block_lines.join("\n"),
269 ));
270 index = block_end;
271 continue;
272 }
273
274 index += 1;
276 }
277
278 if blocks.is_empty() {
281 return GenericCompressor::compress_output(output).into();
282 }
283 let capped = cap_classified_blocks(blocks);
284 CompressionResult::with_class_drops(trim_trailing_lines(&capped.text), capped.dropped_by_class)
285}
286
287fn compress_test_pass_only(lines: &[&str]) -> String {
300 let mut result: Vec<String> = Vec::new();
301 let mut saw_ran_summary = false;
302
303 for line in lines {
304 if saw_ran_summary {
305 result.push((*line).to_string());
307 continue;
308 }
309 if is_bun_test_header(line) {
310 result.push(render_bun_header(line));
311 } else if is_summary_line(line) {
312 result.push(render_summary_line(line));
313 if is_ran_summary_line(line) {
317 saw_ran_summary = true;
318 }
319 }
320 }
321
322 if result.is_empty() {
323 return GenericCompressor::compress_output(&lines.join("\n"));
324 }
325 trim_trailing_lines(&result.join("\n"))
326}
327
328fn next_index<F>(lines: &[&str], start: usize, predicate: F) -> Option<usize>
329where
330 F: Fn(&str) -> bool,
331{
332 lines
333 .iter()
334 .enumerate()
335 .skip(start)
336 .find(|(_, line)| predicate(line))
337 .map(|(i, _)| i)
338}
339
340fn is_bun_test_header(line: &str) -> bool {
341 line.starts_with("bun test v")
342}
343
344fn render_bun_header(line: &str) -> String {
348 match line.find(" v") {
349 Some(idx) => line[..idx].to_string(),
350 None => line.to_string(),
351 }
352}
353
354fn render_summary_line(line: &str) -> String {
359 if is_ran_summary_line(line.trim_start()) {
360 if let Some(idx) = line.rfind(" [") {
361 return line[..idx].to_string();
362 }
363 }
364 line.to_string()
365}
366
367fn is_file_section_header(line: &str) -> bool {
368 let trimmed = line.trim_end();
373 if trimmed.starts_with(' ') || !trimmed.ends_with(':') {
374 return false;
375 }
376 let path = &trimmed[..trimmed.len() - 1];
377 if path.is_empty() || path.contains(' ') {
378 return false;
379 }
380 path.contains(".test.")
381 || path.contains(".spec.")
382 || path.contains("_test.")
383 || path.contains("_spec.")
384}
385
386fn is_bun_test_result_marker(line: &str) -> bool {
387 is_bun_test_pass_marker(line) || is_bun_test_fail_marker(line)
388}
389
390fn is_bun_test_pass_marker(line: &str) -> bool {
391 is_bun_test_marker(line, "(pass)")
392}
393
394fn is_bun_test_fail_marker(line: &str) -> bool {
395 is_bun_test_marker(line, "(fail)")
396}
397
398fn is_bun_test_marker(line: &str, marker: &str) -> bool {
399 let trimmed = line.trim();
400 let Some(rest) = trimmed.strip_prefix(marker) else {
401 return false;
402 };
403 if !rest.chars().next().is_some_and(|ch| ch.is_whitespace()) {
404 return false;
405 }
406
407 let name_and_timing = rest.trim_start();
408 let Some((name, timing)) = name_and_timing.rsplit_once(" [") else {
409 return false;
410 };
411 if name.trim().is_empty() {
412 return false;
413 }
414
415 let Some(duration) = timing.strip_suffix(']') else {
416 return false;
417 };
418 is_bun_test_duration(duration)
419}
420
421fn is_bun_test_duration(duration: &str) -> bool {
422 ["ms", "µs", "μs", "us", "ns", "s"]
423 .iter()
424 .any(|unit| duration.strip_suffix(*unit).is_some_and(is_decimal_number))
425}
426
427fn is_decimal_number(value: &str) -> bool {
428 let mut saw_digit = false;
429 let mut saw_dot = false;
430
431 for ch in value.chars() {
432 match ch {
433 '0'..='9' => saw_digit = true,
434 '.' if !saw_dot => saw_dot = true,
435 _ => return false,
436 }
437 }
438
439 saw_digit
440}
441
442fn is_bun_test_error_start(line: &str) -> bool {
443 line.starts_with("error:")
448}
449
450fn is_bun_test_code_pointer(line: &str) -> bool {
451 let trimmed = line.trim_start();
455 if !trimmed.contains(" | ") && !trimmed.contains("| ") {
456 return false;
457 }
458 trimmed
460 .chars()
461 .next()
462 .is_some_and(|char| char.is_ascii_digit())
463}
464
465fn is_ran_summary_line(line: &str) -> bool {
469 let Some(rest) = line.strip_prefix("Ran ") else {
470 return false;
471 };
472 let Some((test_count, rest)) = rest.split_once(" tests across ") else {
473 return false;
474 };
475 if test_count.is_empty() || !test_count.chars().all(|ch| ch.is_ascii_digit()) {
476 return false;
477 }
478 let Some((file_count, rest)) = rest.split_once(" file") else {
479 return false;
480 };
481 if file_count.is_empty() || !file_count.chars().all(|ch| ch.is_ascii_digit()) {
482 return false;
483 }
484 rest.starts_with(". [") || rest.starts_with("s. [")
485}
486
487fn is_summary_line(line: &str) -> bool {
488 let trimmed = line.trim_start();
489 if is_ran_summary_line(trimmed) {
493 return true;
494 }
495 if let Some(first_token) = trimmed.split_whitespace().next() {
496 if first_token.chars().all(|char| char.is_ascii_digit()) {
497 let rest = trimmed[first_token.len()..].trim_start();
498 return rest.starts_with("pass")
499 || rest.starts_with("fail")
500 || rest.starts_with("expect()");
501 }
502 }
503 false
504}
505
506fn is_bun_progress(line: &str) -> bool {
507 let trimmed = line.trim();
508 trimmed == "."
509 || trimmed.chars().all(|char| char == '.')
510 || trimmed.starts_with("Resolving")
511 || trimmed.starts_with("Resolved")
512 || trimmed.starts_with("Downloaded")
513 || trimmed.starts_with("Extracted")
514}
515
516fn is_timing_line(line: &str) -> bool {
517 let trimmed = line.trim_start();
518 trimmed.starts_with('[') && trimmed.contains(" ms]")
519}
520
521fn trim_trailing_lines(input: &str) -> String {
522 input
523 .lines()
524 .map(str::trim_end)
525 .collect::<Vec<_>>()
526 .join("\n")
527}
528
529#[cfg(test)]
530mod tests {
531 use super::*;
532
533 #[test]
534 fn bun_test_compile_error_falls_back_when_exit_is_unknown() {
535 let output = "bun test v1.3.14 (0d9b296a)\n\nerror: Cannot find module './missing' from 'src/app.test.ts'\n\nRan 0 tests across 1 file. [12.00ms]\n";
536
537 let compressed = BunCompressor.compress("bun test", output);
538
539 assert!(compressed.text.contains("Cannot find module"));
540 assert!(compressed.text.contains("Ran 0 tests"));
541 assert_ne!(compressed.text, "bun test\nRan 0 tests across 1 file.");
542 }
543}