use crate::compress::caps::{cap_classified_blocks, ClassifiedBlock, DropClass};
use crate::compress::generic::GenericCompressor;
use crate::compress::{CompressionResult, Compressor, Specificity};
pub struct BunCompressor;
impl Compressor for BunCompressor {
fn specificity(&self) -> Specificity {
Specificity::PackageManager
}
fn matches(&self, command: &str) -> bool {
command
.split_whitespace()
.next()
.is_some_and(|head| head == "bun")
}
fn compress(&self, command: &str, output: &str) -> CompressionResult {
match bun_subcommand(command).as_deref() {
Some("install" | "i" | "add" | "remove") => compress_package(output).into(),
Some("test") => compress_test(output),
Some("build") => compress_build(output),
_ => GenericCompressor::compress_output(output).into(),
}
}
fn matches_output(&self, output: &str) -> bool {
let mut saw_ran_summary = false;
let mut saw_result_marker = false;
for line in output.lines() {
saw_ran_summary |= is_ran_summary_line(line);
saw_result_marker |= is_bun_test_result_marker(line);
if saw_ran_summary && saw_result_marker {
return true;
}
}
false
}
fn compress_output_match(&self, output: &str) -> CompressionResult {
compress_test(output)
}
}
const BUN_SUBCOMMANDS: &[&str] = &[
"install", "i", "add", "remove", "update", "outdated", "link", "unlink", "why", "audit",
"patch", "pm", "publish", "pack", "run", "test", "x", "exec", "create", "init", "build",
"repl", "upgrade", "help", "info",
];
fn bun_subcommand(command: &str) -> Option<String> {
command
.split_whitespace()
.skip_while(|token| *token != "bun")
.skip(1)
.find(|token| BUN_SUBCOMMANDS.contains(token))
.map(ToString::to_string)
}
fn compress_package(output: &str) -> String {
let mut result = Vec::new();
for line in output.lines() {
if is_bun_progress(line) {
continue;
}
let trimmed = line.trim_start();
if trimmed.contains("packages installed")
|| trimmed.contains("package installed")
|| trimmed.starts_with("error:")
|| trimmed.starts_with("bun install error:")
|| trimmed.starts_with("Saved lockfile")
{
result.push(line.to_string());
}
}
trim_trailing_lines(&result.join("\n"))
}
fn compress_build(output: &str) -> CompressionResult {
let mut blocks = Vec::new();
for line in output.lines() {
if is_timing_line(line) {
blocks.push(ClassifiedBlock::new(DropClass::Timing, line.to_string()));
} else {
blocks.push(ClassifiedBlock::unclassified(line.to_string()));
}
}
let capped = cap_classified_blocks(blocks);
CompressionResult::with_class_drops(trim_trailing_lines(&capped.text), capped.dropped_by_class)
}
fn compress_test(output: &str) -> CompressionResult {
let lines: Vec<&str> = output.lines().collect();
if lines.is_empty() {
return CompressionResult::new(output.to_string());
}
let has_failures = lines.iter().any(|line| is_bun_test_fail_marker(line));
if !has_failures {
return CompressionResult::new(compress_test_pass_only(&lines));
}
let mut blocks: Vec<ClassifiedBlock> = Vec::new();
let mut index = 0usize;
let mut saw_ran_summary = false;
let mut pending_section: Option<String> = None;
while index < lines.len() {
let line = lines[index];
if saw_ran_summary {
blocks.push(ClassifiedBlock::unclassified(line.to_string()));
index += 1;
continue;
}
if is_bun_test_header(line) {
blocks.push(ClassifiedBlock::unclassified(line.to_string()));
index += 1;
continue;
}
if is_file_section_header(line) {
let next_fail = next_index(&lines, index + 1, is_bun_test_fail_marker);
let next_section = next_index(&lines, index + 1, |l| {
is_file_section_header(l) || is_summary_line(l)
});
let keep_section = match (next_fail, next_section) {
(Some(fi), Some(si)) => fi < si,
(Some(_), None) => true,
(None, _) => false,
};
if keep_section {
pending_section = Some(line.to_string());
}
index += 1;
continue;
}
if is_summary_line(line) {
blocks.push(ClassifiedBlock::unclassified(line.to_string()));
if is_ran_summary_line(line) {
saw_ran_summary = true;
}
index += 1;
continue;
}
if is_bun_test_error_start(line) || is_bun_test_code_pointer(line) {
let block_start = index;
let mut block_end = index;
while block_end < lines.len() {
if is_bun_test_fail_marker(lines[block_end]) {
block_end += 1;
break;
}
block_end += 1;
}
let mut block_lines = Vec::new();
if let Some(section) = pending_section.take() {
block_lines.push(section);
}
block_lines.extend(
lines[block_start..block_end]
.iter()
.map(|line| (*line).to_string()),
);
blocks.push(ClassifiedBlock::new(
DropClass::Failure,
block_lines.join("\n"),
));
index = block_end;
continue;
}
index += 1;
}
if blocks.is_empty() {
return GenericCompressor::compress_output(output).into();
}
let capped = cap_classified_blocks(blocks);
CompressionResult::with_class_drops(trim_trailing_lines(&capped.text), capped.dropped_by_class)
}
fn compress_test_pass_only(lines: &[&str]) -> String {
let mut result: Vec<String> = Vec::new();
let mut saw_ran_summary = false;
for line in lines {
if saw_ran_summary {
result.push((*line).to_string());
continue;
}
if is_bun_test_header(line) || is_summary_line(line) {
result.push((*line).to_string());
if is_ran_summary_line(line) {
saw_ran_summary = true;
}
}
}
if result.is_empty() {
return GenericCompressor::compress_output(&lines.join("\n"));
}
trim_trailing_lines(&result.join("\n"))
}
fn next_index<F>(lines: &[&str], start: usize, predicate: F) -> Option<usize>
where
F: Fn(&str) -> bool,
{
lines
.iter()
.enumerate()
.skip(start)
.find(|(_, line)| predicate(line))
.map(|(i, _)| i)
}
fn is_bun_test_header(line: &str) -> bool {
line.starts_with("bun test v")
}
fn is_file_section_header(line: &str) -> bool {
let trimmed = line.trim_end();
if trimmed.starts_with(' ') || !trimmed.ends_with(':') {
return false;
}
let path = &trimmed[..trimmed.len() - 1];
if path.is_empty() || path.contains(' ') {
return false;
}
path.contains(".test.")
|| path.contains(".spec.")
|| path.contains("_test.")
|| path.contains("_spec.")
}
fn is_bun_test_result_marker(line: &str) -> bool {
is_bun_test_pass_marker(line) || is_bun_test_fail_marker(line)
}
fn is_bun_test_pass_marker(line: &str) -> bool {
is_bun_test_marker(line, "(pass)")
}
fn is_bun_test_fail_marker(line: &str) -> bool {
is_bun_test_marker(line, "(fail)")
}
fn is_bun_test_marker(line: &str, marker: &str) -> bool {
let trimmed = line.trim();
let Some(rest) = trimmed.strip_prefix(marker) else {
return false;
};
if !rest.chars().next().is_some_and(|ch| ch.is_whitespace()) {
return false;
}
let name_and_timing = rest.trim_start();
let Some((name, timing)) = name_and_timing.rsplit_once(" [") else {
return false;
};
if name.trim().is_empty() {
return false;
}
let Some(duration) = timing.strip_suffix(']') else {
return false;
};
is_bun_test_duration(duration)
}
fn is_bun_test_duration(duration: &str) -> bool {
["ms", "µs", "μs", "us", "ns", "s"]
.iter()
.any(|unit| duration.strip_suffix(*unit).is_some_and(is_decimal_number))
}
fn is_decimal_number(value: &str) -> bool {
let mut saw_digit = false;
let mut saw_dot = false;
for ch in value.chars() {
match ch {
'0'..='9' => saw_digit = true,
'.' if !saw_dot => saw_dot = true,
_ => return false,
}
}
saw_digit
}
fn is_bun_test_error_start(line: &str) -> bool {
line.starts_with("error:")
}
fn is_bun_test_code_pointer(line: &str) -> bool {
let trimmed = line.trim_start();
if !trimmed.contains(" | ") && !trimmed.contains("| ") {
return false;
}
trimmed
.chars()
.next()
.is_some_and(|char| char.is_ascii_digit())
}
fn is_ran_summary_line(line: &str) -> bool {
let Some(rest) = line.strip_prefix("Ran ") else {
return false;
};
let Some((test_count, rest)) = rest.split_once(" tests across ") else {
return false;
};
if test_count.is_empty() || !test_count.chars().all(|ch| ch.is_ascii_digit()) {
return false;
}
let Some((file_count, rest)) = rest.split_once(" file") else {
return false;
};
if file_count.is_empty() || !file_count.chars().all(|ch| ch.is_ascii_digit()) {
return false;
}
rest.starts_with(". [") || rest.starts_with("s. [")
}
fn is_summary_line(line: &str) -> bool {
let trimmed = line.trim_start();
if is_ran_summary_line(trimmed) {
return true;
}
if let Some(first_token) = trimmed.split_whitespace().next() {
if first_token.chars().all(|char| char.is_ascii_digit()) {
let rest = trimmed[first_token.len()..].trim_start();
return rest.starts_with("pass")
|| rest.starts_with("fail")
|| rest.starts_with("expect()");
}
}
false
}
fn is_bun_progress(line: &str) -> bool {
let trimmed = line.trim();
trimmed == "."
|| trimmed.chars().all(|char| char == '.')
|| trimmed.starts_with("Resolving")
|| trimmed.starts_with("Resolved")
|| trimmed.starts_with("Downloaded")
|| trimmed.starts_with("Extracted")
}
fn is_timing_line(line: &str) -> bool {
let trimmed = line.trim_start();
trimmed.starts_with('[') && trimmed.contains(" ms]")
}
fn trim_trailing_lines(input: &str) -> String {
input
.lines()
.map(str::trim_end)
.collect::<Vec<_>>()
.join("\n")
}