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") => compress_test(output),
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 compress_test(output)
55 }
56}
57
58const BUN_SUBCOMMANDS: &[&str] = &[
68 "install", "i", "add", "remove", "update", "outdated", "link", "unlink", "why", "audit",
69 "patch", "pm", "publish", "pack", "run", "test", "x", "exec", "create", "init", "build",
70 "repl", "upgrade", "help", "info",
71];
72
73fn bun_subcommand(command: &str) -> Option<String> {
83 command
84 .split_whitespace()
85 .skip_while(|token| *token != "bun")
86 .skip(1)
87 .find(|token| BUN_SUBCOMMANDS.contains(token))
88 .map(ToString::to_string)
89}
90
91fn compress_package(output: &str) -> String {
92 let mut result = Vec::new();
93 for line in output.lines() {
94 if is_bun_progress(line) {
95 continue;
96 }
97 let trimmed = line.trim_start();
98 if trimmed.contains("packages installed")
99 || trimmed.contains("package installed")
100 || trimmed.starts_with("error:")
101 || trimmed.starts_with("bun install error:")
102 || trimmed.starts_with("Saved lockfile")
103 {
104 result.push(line.to_string());
105 }
106 }
107 trim_trailing_lines(&result.join("\n"))
108}
109
110fn compress_build(output: &str) -> CompressionResult {
111 let mut blocks = Vec::new();
112 for line in output.lines() {
113 if is_timing_line(line) {
114 blocks.push(ClassifiedBlock::new(DropClass::Timing, line.to_string()));
115 } else {
116 blocks.push(ClassifiedBlock::unclassified(line.to_string()));
117 }
118 }
119 let capped = cap_classified_blocks(blocks);
120 CompressionResult::with_class_drops(trim_trailing_lines(&capped.text), capped.dropped_by_class)
121}
122
123fn compress_test(output: &str) -> CompressionResult {
144 let lines: Vec<&str> = output.lines().collect();
145 if lines.is_empty() {
146 return CompressionResult::new(output.to_string());
147 }
148
149 let has_failures = lines.iter().any(|line| is_bun_test_fail_marker(line));
153 if !has_failures {
154 return CompressionResult::new(compress_test_pass_only(&lines));
155 }
156
157 let mut blocks: Vec<ClassifiedBlock> = Vec::new();
158 let mut index = 0usize;
159 let mut saw_ran_summary = false;
160 let mut pending_section: Option<String> = None;
161
162 while index < lines.len() {
163 let line = lines[index];
164
165 if saw_ran_summary {
166 blocks.push(ClassifiedBlock::unclassified(line.to_string()));
172 index += 1;
173 continue;
174 }
175
176 if is_bun_test_header(line) {
178 blocks.push(ClassifiedBlock::unclassified(render_bun_header(line)));
179 index += 1;
180 continue;
181 }
182
183 if is_file_section_header(line) {
187 let next_fail = next_index(&lines, index + 1, is_bun_test_fail_marker);
188 let next_section = next_index(&lines, index + 1, |l| {
189 is_file_section_header(l) || is_summary_line(l)
190 });
191 let keep_section = match (next_fail, next_section) {
192 (Some(fi), Some(si)) => fi < si,
193 (Some(_), None) => true,
194 (None, _) => false,
195 };
196 if keep_section {
197 pending_section = Some(line.to_string());
198 }
199 index += 1;
200 continue;
201 }
202
203 if is_summary_line(line) {
205 blocks.push(ClassifiedBlock::unclassified(render_summary_line(line)));
206 if is_ran_summary_line(line) {
211 saw_ran_summary = true;
212 }
213 index += 1;
214 continue;
215 }
216
217 if is_bun_test_error_start(line) || is_bun_test_code_pointer(line) {
222 let block_start = index;
227 let mut block_end = index;
228 while block_end < lines.len() {
229 if is_bun_test_fail_marker(lines[block_end]) {
230 block_end += 1;
231 break;
232 }
233 block_end += 1;
238 }
239
240 let mut block_lines = Vec::new();
241 if let Some(section) = pending_section.take() {
242 block_lines.push(section);
243 }
244 block_lines.extend(
245 lines[block_start..block_end]
246 .iter()
247 .map(|line| (*line).to_string()),
248 );
249 blocks.push(ClassifiedBlock::new(
250 DropClass::Failure,
251 block_lines.join("\n"),
252 ));
253 index = block_end;
254 continue;
255 }
256
257 index += 1;
259 }
260
261 if blocks.is_empty() {
264 return GenericCompressor::compress_output(output).into();
265 }
266 let capped = cap_classified_blocks(blocks);
267 CompressionResult::with_class_drops(trim_trailing_lines(&capped.text), capped.dropped_by_class)
268}
269
270fn compress_test_pass_only(lines: &[&str]) -> String {
283 let mut result: Vec<String> = Vec::new();
284 let mut saw_ran_summary = false;
285
286 for line in lines {
287 if saw_ran_summary {
288 result.push((*line).to_string());
290 continue;
291 }
292 if is_bun_test_header(line) {
293 result.push(render_bun_header(line));
294 } else if is_summary_line(line) {
295 result.push(render_summary_line(line));
296 if is_ran_summary_line(line) {
300 saw_ran_summary = true;
301 }
302 }
303 }
304
305 if result.is_empty() {
306 return GenericCompressor::compress_output(&lines.join("\n"));
307 }
308 trim_trailing_lines(&result.join("\n"))
309}
310
311fn next_index<F>(lines: &[&str], start: usize, predicate: F) -> Option<usize>
312where
313 F: Fn(&str) -> bool,
314{
315 lines
316 .iter()
317 .enumerate()
318 .skip(start)
319 .find(|(_, line)| predicate(line))
320 .map(|(i, _)| i)
321}
322
323fn is_bun_test_header(line: &str) -> bool {
324 line.starts_with("bun test v")
325}
326
327fn render_bun_header(line: &str) -> String {
331 match line.find(" v") {
332 Some(idx) => line[..idx].to_string(),
333 None => line.to_string(),
334 }
335}
336
337fn render_summary_line(line: &str) -> String {
342 if is_ran_summary_line(line.trim_start()) {
343 if let Some(idx) = line.rfind(" [") {
344 return line[..idx].to_string();
345 }
346 }
347 line.to_string()
348}
349
350fn is_file_section_header(line: &str) -> bool {
351 let trimmed = line.trim_end();
356 if trimmed.starts_with(' ') || !trimmed.ends_with(':') {
357 return false;
358 }
359 let path = &trimmed[..trimmed.len() - 1];
360 if path.is_empty() || path.contains(' ') {
361 return false;
362 }
363 path.contains(".test.")
364 || path.contains(".spec.")
365 || path.contains("_test.")
366 || path.contains("_spec.")
367}
368
369fn is_bun_test_result_marker(line: &str) -> bool {
370 is_bun_test_pass_marker(line) || is_bun_test_fail_marker(line)
371}
372
373fn is_bun_test_pass_marker(line: &str) -> bool {
374 is_bun_test_marker(line, "(pass)")
375}
376
377fn is_bun_test_fail_marker(line: &str) -> bool {
378 is_bun_test_marker(line, "(fail)")
379}
380
381fn is_bun_test_marker(line: &str, marker: &str) -> bool {
382 let trimmed = line.trim();
383 let Some(rest) = trimmed.strip_prefix(marker) else {
384 return false;
385 };
386 if !rest.chars().next().is_some_and(|ch| ch.is_whitespace()) {
387 return false;
388 }
389
390 let name_and_timing = rest.trim_start();
391 let Some((name, timing)) = name_and_timing.rsplit_once(" [") else {
392 return false;
393 };
394 if name.trim().is_empty() {
395 return false;
396 }
397
398 let Some(duration) = timing.strip_suffix(']') else {
399 return false;
400 };
401 is_bun_test_duration(duration)
402}
403
404fn is_bun_test_duration(duration: &str) -> bool {
405 ["ms", "µs", "μs", "us", "ns", "s"]
406 .iter()
407 .any(|unit| duration.strip_suffix(*unit).is_some_and(is_decimal_number))
408}
409
410fn is_decimal_number(value: &str) -> bool {
411 let mut saw_digit = false;
412 let mut saw_dot = false;
413
414 for ch in value.chars() {
415 match ch {
416 '0'..='9' => saw_digit = true,
417 '.' if !saw_dot => saw_dot = true,
418 _ => return false,
419 }
420 }
421
422 saw_digit
423}
424
425fn is_bun_test_error_start(line: &str) -> bool {
426 line.starts_with("error:")
431}
432
433fn is_bun_test_code_pointer(line: &str) -> bool {
434 let trimmed = line.trim_start();
438 if !trimmed.contains(" | ") && !trimmed.contains("| ") {
439 return false;
440 }
441 trimmed
443 .chars()
444 .next()
445 .is_some_and(|char| char.is_ascii_digit())
446}
447
448fn is_ran_summary_line(line: &str) -> bool {
452 let Some(rest) = line.strip_prefix("Ran ") else {
453 return false;
454 };
455 let Some((test_count, rest)) = rest.split_once(" tests across ") else {
456 return false;
457 };
458 if test_count.is_empty() || !test_count.chars().all(|ch| ch.is_ascii_digit()) {
459 return false;
460 }
461 let Some((file_count, rest)) = rest.split_once(" file") else {
462 return false;
463 };
464 if file_count.is_empty() || !file_count.chars().all(|ch| ch.is_ascii_digit()) {
465 return false;
466 }
467 rest.starts_with(". [") || rest.starts_with("s. [")
468}
469
470fn is_summary_line(line: &str) -> bool {
471 let trimmed = line.trim_start();
472 if is_ran_summary_line(trimmed) {
476 return true;
477 }
478 if let Some(first_token) = trimmed.split_whitespace().next() {
479 if first_token.chars().all(|char| char.is_ascii_digit()) {
480 let rest = trimmed[first_token.len()..].trim_start();
481 return rest.starts_with("pass")
482 || rest.starts_with("fail")
483 || rest.starts_with("expect()");
484 }
485 }
486 false
487}
488
489fn is_bun_progress(line: &str) -> bool {
490 let trimmed = line.trim();
491 trimmed == "."
492 || trimmed.chars().all(|char| char == '.')
493 || trimmed.starts_with("Resolving")
494 || trimmed.starts_with("Resolved")
495 || trimmed.starts_with("Downloaded")
496 || trimmed.starts_with("Extracted")
497}
498
499fn is_timing_line(line: &str) -> bool {
500 let trimmed = line.trim_start();
501 trimmed.starts_with('[') && trimmed.contains(" ms]")
502}
503
504fn trim_trailing_lines(input: &str) -> String {
505 input
506 .lines()
507 .map(str::trim_end)
508 .collect::<Vec<_>>()
509 .join("\n")
510}