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(line.to_string()));
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(line.to_string()));
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) || is_summary_line(line) {
284 result.push((*line).to_string());
285 if is_ran_summary_line(line) {
289 saw_ran_summary = true;
290 }
291 }
292 }
293
294 if result.is_empty() {
295 return GenericCompressor::compress_output(&lines.join("\n"));
296 }
297 trim_trailing_lines(&result.join("\n"))
298}
299
300fn next_index<F>(lines: &[&str], start: usize, predicate: F) -> Option<usize>
301where
302 F: Fn(&str) -> bool,
303{
304 lines
305 .iter()
306 .enumerate()
307 .skip(start)
308 .find(|(_, line)| predicate(line))
309 .map(|(i, _)| i)
310}
311
312fn is_bun_test_header(line: &str) -> bool {
313 line.starts_with("bun test v")
314}
315
316fn is_file_section_header(line: &str) -> bool {
317 let trimmed = line.trim_end();
322 if trimmed.starts_with(' ') || !trimmed.ends_with(':') {
323 return false;
324 }
325 let path = &trimmed[..trimmed.len() - 1];
326 if path.is_empty() || path.contains(' ') {
327 return false;
328 }
329 path.contains(".test.")
330 || path.contains(".spec.")
331 || path.contains("_test.")
332 || path.contains("_spec.")
333}
334
335fn is_bun_test_result_marker(line: &str) -> bool {
336 is_bun_test_pass_marker(line) || is_bun_test_fail_marker(line)
337}
338
339fn is_bun_test_pass_marker(line: &str) -> bool {
340 is_bun_test_marker(line, "(pass)")
341}
342
343fn is_bun_test_fail_marker(line: &str) -> bool {
344 is_bun_test_marker(line, "(fail)")
345}
346
347fn is_bun_test_marker(line: &str, marker: &str) -> bool {
348 let trimmed = line.trim();
349 let Some(rest) = trimmed.strip_prefix(marker) else {
350 return false;
351 };
352 if !rest.chars().next().is_some_and(|ch| ch.is_whitespace()) {
353 return false;
354 }
355
356 let name_and_timing = rest.trim_start();
357 let Some((name, timing)) = name_and_timing.rsplit_once(" [") else {
358 return false;
359 };
360 if name.trim().is_empty() {
361 return false;
362 }
363
364 let Some(duration) = timing.strip_suffix(']') else {
365 return false;
366 };
367 is_bun_test_duration(duration)
368}
369
370fn is_bun_test_duration(duration: &str) -> bool {
371 ["ms", "µs", "μs", "us", "ns", "s"]
372 .iter()
373 .any(|unit| duration.strip_suffix(*unit).is_some_and(is_decimal_number))
374}
375
376fn is_decimal_number(value: &str) -> bool {
377 let mut saw_digit = false;
378 let mut saw_dot = false;
379
380 for ch in value.chars() {
381 match ch {
382 '0'..='9' => saw_digit = true,
383 '.' if !saw_dot => saw_dot = true,
384 _ => return false,
385 }
386 }
387
388 saw_digit
389}
390
391fn is_bun_test_error_start(line: &str) -> bool {
392 line.starts_with("error:")
397}
398
399fn is_bun_test_code_pointer(line: &str) -> bool {
400 let trimmed = line.trim_start();
404 if !trimmed.contains(" | ") && !trimmed.contains("| ") {
405 return false;
406 }
407 trimmed
409 .chars()
410 .next()
411 .is_some_and(|char| char.is_ascii_digit())
412}
413
414fn is_ran_summary_line(line: &str) -> bool {
418 let Some(rest) = line.strip_prefix("Ran ") else {
419 return false;
420 };
421 let Some((test_count, rest)) = rest.split_once(" tests across ") else {
422 return false;
423 };
424 if test_count.is_empty() || !test_count.chars().all(|ch| ch.is_ascii_digit()) {
425 return false;
426 }
427 let Some((file_count, rest)) = rest.split_once(" file") else {
428 return false;
429 };
430 if file_count.is_empty() || !file_count.chars().all(|ch| ch.is_ascii_digit()) {
431 return false;
432 }
433 rest.starts_with(". [") || rest.starts_with("s. [")
434}
435
436fn is_summary_line(line: &str) -> bool {
437 let trimmed = line.trim_start();
438 if is_ran_summary_line(trimmed) {
442 return true;
443 }
444 if let Some(first_token) = trimmed.split_whitespace().next() {
445 if first_token.chars().all(|char| char.is_ascii_digit()) {
446 let rest = trimmed[first_token.len()..].trim_start();
447 return rest.starts_with("pass")
448 || rest.starts_with("fail")
449 || rest.starts_with("expect()");
450 }
451 }
452 false
453}
454
455fn is_bun_progress(line: &str) -> bool {
456 let trimmed = line.trim();
457 trimmed == "."
458 || trimmed.chars().all(|char| char == '.')
459 || trimmed.starts_with("Resolving")
460 || trimmed.starts_with("Resolved")
461 || trimmed.starts_with("Downloaded")
462 || trimmed.starts_with("Extracted")
463}
464
465fn is_timing_line(line: &str) -> bool {
466 let trimmed = line.trim_start();
467 trimmed.starts_with('[') && trimmed.contains(" ms]")
468}
469
470fn trim_trailing_lines(input: &str) -> String {
471 input
472 .lines()
473 .map(str::trim_end)
474 .collect::<Vec<_>>()
475 .join("\n")
476}