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(&self, command: &str, output: &str) -> CompressionResult {
20 match bun_subcommand(command).as_deref() {
21 Some("install" | "i" | "add" | "remove") => compress_package(output).into(),
22 Some("test") => compress_test(output),
23 Some("build") => compress_build(output),
24 _ => GenericCompressor::compress_output(output).into(),
25 }
26 }
27
28 fn matches_output(&self, output: &str) -> bool {
29 let mut saw_ran_summary = false;
30 let mut saw_result_marker = false;
31
32 for line in output.lines() {
33 saw_ran_summary |= is_ran_summary_line(line);
34 saw_result_marker |= is_bun_test_result_marker(line);
35
36 if saw_ran_summary && saw_result_marker {
37 return true;
38 }
39 }
40
41 false
42 }
43
44 fn compress_output_match(&self, output: &str) -> CompressionResult {
45 compress_test(output)
46 }
47}
48
49const BUN_SUBCOMMANDS: &[&str] = &[
59 "install", "i", "add", "remove", "update", "outdated", "link", "unlink", "why", "audit",
60 "patch", "pm", "publish", "pack", "run", "test", "x", "exec", "create", "init", "build",
61 "repl", "upgrade", "help", "info",
62];
63
64fn bun_subcommand(command: &str) -> Option<String> {
74 command
75 .split_whitespace()
76 .skip_while(|token| *token != "bun")
77 .skip(1)
78 .find(|token| BUN_SUBCOMMANDS.contains(token))
79 .map(ToString::to_string)
80}
81
82fn compress_package(output: &str) -> String {
83 let mut result = Vec::new();
84 for line in output.lines() {
85 if is_bun_progress(line) {
86 continue;
87 }
88 let trimmed = line.trim_start();
89 if trimmed.contains("packages installed")
90 || trimmed.contains("package installed")
91 || trimmed.starts_with("error:")
92 || trimmed.starts_with("bun install error:")
93 || trimmed.starts_with("Saved lockfile")
94 {
95 result.push(line.to_string());
96 }
97 }
98 trim_trailing_lines(&result.join("\n"))
99}
100
101fn compress_build(output: &str) -> CompressionResult {
102 let mut blocks = Vec::new();
103 for line in output.lines() {
104 if is_timing_line(line) {
105 blocks.push(ClassifiedBlock::new(DropClass::Timing, line.to_string()));
106 } else {
107 blocks.push(ClassifiedBlock::unclassified(line.to_string()));
108 }
109 }
110 let capped = cap_classified_blocks(blocks);
111 CompressionResult::with_class_drops(trim_trailing_lines(&capped.text), capped.dropped_by_class)
112}
113
114fn compress_test(output: &str) -> CompressionResult {
135 let lines: Vec<&str> = output.lines().collect();
136 if lines.is_empty() {
137 return CompressionResult::new(output.to_string());
138 }
139
140 let has_failures = lines.iter().any(|line| is_bun_test_fail_marker(line));
144 if !has_failures {
145 return CompressionResult::new(compress_test_pass_only(&lines));
146 }
147
148 let mut blocks: Vec<ClassifiedBlock> = Vec::new();
149 let mut index = 0usize;
150 let mut saw_ran_summary = false;
151 let mut pending_section: Option<String> = None;
152
153 while index < lines.len() {
154 let line = lines[index];
155
156 if saw_ran_summary {
157 blocks.push(ClassifiedBlock::unclassified(line.to_string()));
163 index += 1;
164 continue;
165 }
166
167 if is_bun_test_header(line) {
169 blocks.push(ClassifiedBlock::unclassified(render_bun_header(line)));
170 index += 1;
171 continue;
172 }
173
174 if is_file_section_header(line) {
178 let next_fail = next_index(&lines, index + 1, is_bun_test_fail_marker);
179 let next_section = next_index(&lines, index + 1, |l| {
180 is_file_section_header(l) || is_summary_line(l)
181 });
182 let keep_section = match (next_fail, next_section) {
183 (Some(fi), Some(si)) => fi < si,
184 (Some(_), None) => true,
185 (None, _) => false,
186 };
187 if keep_section {
188 pending_section = Some(line.to_string());
189 }
190 index += 1;
191 continue;
192 }
193
194 if is_summary_line(line) {
196 blocks.push(ClassifiedBlock::unclassified(render_summary_line(line)));
197 if is_ran_summary_line(line) {
202 saw_ran_summary = true;
203 }
204 index += 1;
205 continue;
206 }
207
208 if is_bun_test_error_start(line) || is_bun_test_code_pointer(line) {
213 let block_start = index;
218 let mut block_end = index;
219 while block_end < lines.len() {
220 if is_bun_test_fail_marker(lines[block_end]) {
221 block_end += 1;
222 break;
223 }
224 block_end += 1;
229 }
230
231 let mut block_lines = Vec::new();
232 if let Some(section) = pending_section.take() {
233 block_lines.push(section);
234 }
235 block_lines.extend(
236 lines[block_start..block_end]
237 .iter()
238 .map(|line| (*line).to_string()),
239 );
240 blocks.push(ClassifiedBlock::new(
241 DropClass::Failure,
242 block_lines.join("\n"),
243 ));
244 index = block_end;
245 continue;
246 }
247
248 index += 1;
250 }
251
252 if blocks.is_empty() {
255 return GenericCompressor::compress_output(output).into();
256 }
257 let capped = cap_classified_blocks(blocks);
258 CompressionResult::with_class_drops(trim_trailing_lines(&capped.text), capped.dropped_by_class)
259}
260
261fn compress_test_pass_only(lines: &[&str]) -> String {
274 let mut result: Vec<String> = Vec::new();
275 let mut saw_ran_summary = false;
276
277 for line in lines {
278 if saw_ran_summary {
279 result.push((*line).to_string());
281 continue;
282 }
283 if is_bun_test_header(line) {
284 result.push(render_bun_header(line));
285 } else if is_summary_line(line) {
286 result.push(render_summary_line(line));
287 if is_ran_summary_line(line) {
291 saw_ran_summary = true;
292 }
293 }
294 }
295
296 if result.is_empty() {
297 return GenericCompressor::compress_output(&lines.join("\n"));
298 }
299 trim_trailing_lines(&result.join("\n"))
300}
301
302fn next_index<F>(lines: &[&str], start: usize, predicate: F) -> Option<usize>
303where
304 F: Fn(&str) -> bool,
305{
306 lines
307 .iter()
308 .enumerate()
309 .skip(start)
310 .find(|(_, line)| predicate(line))
311 .map(|(i, _)| i)
312}
313
314fn is_bun_test_header(line: &str) -> bool {
315 line.starts_with("bun test v")
316}
317
318fn render_bun_header(line: &str) -> String {
322 match line.find(" v") {
323 Some(idx) => line[..idx].to_string(),
324 None => line.to_string(),
325 }
326}
327
328fn render_summary_line(line: &str) -> String {
333 if is_ran_summary_line(line.trim_start()) {
334 if let Some(idx) = line.rfind(" [") {
335 return line[..idx].to_string();
336 }
337 }
338 line.to_string()
339}
340
341fn is_file_section_header(line: &str) -> bool {
342 let trimmed = line.trim_end();
347 if trimmed.starts_with(' ') || !trimmed.ends_with(':') {
348 return false;
349 }
350 let path = &trimmed[..trimmed.len() - 1];
351 if path.is_empty() || path.contains(' ') {
352 return false;
353 }
354 path.contains(".test.")
355 || path.contains(".spec.")
356 || path.contains("_test.")
357 || path.contains("_spec.")
358}
359
360fn is_bun_test_result_marker(line: &str) -> bool {
361 is_bun_test_pass_marker(line) || is_bun_test_fail_marker(line)
362}
363
364fn is_bun_test_pass_marker(line: &str) -> bool {
365 is_bun_test_marker(line, "(pass)")
366}
367
368fn is_bun_test_fail_marker(line: &str) -> bool {
369 is_bun_test_marker(line, "(fail)")
370}
371
372fn is_bun_test_marker(line: &str, marker: &str) -> bool {
373 let trimmed = line.trim();
374 let Some(rest) = trimmed.strip_prefix(marker) else {
375 return false;
376 };
377 if !rest.chars().next().is_some_and(|ch| ch.is_whitespace()) {
378 return false;
379 }
380
381 let name_and_timing = rest.trim_start();
382 let Some((name, timing)) = name_and_timing.rsplit_once(" [") else {
383 return false;
384 };
385 if name.trim().is_empty() {
386 return false;
387 }
388
389 let Some(duration) = timing.strip_suffix(']') else {
390 return false;
391 };
392 is_bun_test_duration(duration)
393}
394
395fn is_bun_test_duration(duration: &str) -> bool {
396 ["ms", "µs", "μs", "us", "ns", "s"]
397 .iter()
398 .any(|unit| duration.strip_suffix(*unit).is_some_and(is_decimal_number))
399}
400
401fn is_decimal_number(value: &str) -> bool {
402 let mut saw_digit = false;
403 let mut saw_dot = false;
404
405 for ch in value.chars() {
406 match ch {
407 '0'..='9' => saw_digit = true,
408 '.' if !saw_dot => saw_dot = true,
409 _ => return false,
410 }
411 }
412
413 saw_digit
414}
415
416fn is_bun_test_error_start(line: &str) -> bool {
417 line.starts_with("error:")
422}
423
424fn is_bun_test_code_pointer(line: &str) -> bool {
425 let trimmed = line.trim_start();
429 if !trimmed.contains(" | ") && !trimmed.contains("| ") {
430 return false;
431 }
432 trimmed
434 .chars()
435 .next()
436 .is_some_and(|char| char.is_ascii_digit())
437}
438
439fn is_ran_summary_line(line: &str) -> bool {
443 let Some(rest) = line.strip_prefix("Ran ") else {
444 return false;
445 };
446 let Some((test_count, rest)) = rest.split_once(" tests across ") else {
447 return false;
448 };
449 if test_count.is_empty() || !test_count.chars().all(|ch| ch.is_ascii_digit()) {
450 return false;
451 }
452 let Some((file_count, rest)) = rest.split_once(" file") else {
453 return false;
454 };
455 if file_count.is_empty() || !file_count.chars().all(|ch| ch.is_ascii_digit()) {
456 return false;
457 }
458 rest.starts_with(". [") || rest.starts_with("s. [")
459}
460
461fn is_summary_line(line: &str) -> bool {
462 let trimmed = line.trim_start();
463 if is_ran_summary_line(trimmed) {
467 return true;
468 }
469 if let Some(first_token) = trimmed.split_whitespace().next() {
470 if first_token.chars().all(|char| char.is_ascii_digit()) {
471 let rest = trimmed[first_token.len()..].trim_start();
472 return rest.starts_with("pass")
473 || rest.starts_with("fail")
474 || rest.starts_with("expect()");
475 }
476 }
477 false
478}
479
480fn is_bun_progress(line: &str) -> bool {
481 let trimmed = line.trim();
482 trimmed == "."
483 || trimmed.chars().all(|char| char == '.')
484 || trimmed.starts_with("Resolving")
485 || trimmed.starts_with("Resolved")
486 || trimmed.starts_with("Downloaded")
487 || trimmed.starts_with("Extracted")
488}
489
490fn is_timing_line(line: &str) -> bool {
491 let trimmed = line.trim_start();
492 trimmed.starts_with('[') && trimmed.contains(" ms]")
493}
494
495fn trim_trailing_lines(input: &str) -> String {
496 input
497 .lines()
498 .map(str::trim_end)
499 .collect::<Vec<_>>()
500 .join("\n")
501}