Skip to main content

aft/compress/
bun.rs

1use crate::compress::generic::GenericCompressor;
2use crate::compress::{Compressor, Specificity};
3
4pub struct BunCompressor;
5
6/// Maximum number of failure blocks to preserve in a `bun test` run.
7/// Beyond this, additional failures are dropped with a "+N more failures"
8/// trailer so a catastrophic 1000-failure run still fits in the inline cap.
9const MAX_FAILURES: usize = 25;
10
11impl Compressor for BunCompressor {
12    fn specificity(&self) -> Specificity {
13        Specificity::PackageManager
14    }
15
16    fn matches(&self, command: &str) -> bool {
17        command
18            .split_whitespace()
19            .next()
20            .is_some_and(|head| head == "bun")
21    }
22
23    fn compress(&self, command: &str, output: &str) -> String {
24        match bun_subcommand(command).as_deref() {
25            Some("install" | "i" | "add" | "remove") => compress_package(output),
26            Some("test") => compress_test(output),
27            Some("build") => compress_build(output),
28            _ => GenericCompressor::compress_output(output),
29        }
30    }
31
32    fn matches_output(&self, output: &str) -> bool {
33        let mut saw_ran_summary = false;
34        let mut saw_result_marker = false;
35
36        for line in output.lines() {
37            saw_ran_summary |= is_ran_summary_line(line);
38            saw_result_marker |= is_bun_test_result_marker(line);
39
40            if saw_ran_summary && saw_result_marker {
41                return true;
42            }
43        }
44
45        false
46    }
47
48    fn compress_output_match(&self, output: &str) -> String {
49        compress_test(output)
50    }
51}
52
53/// Known bun subcommands we want to match on. Used by `bun_subcommand`
54/// to safely skip over flag values like `--cwd <dir>` that would
55/// otherwise be misread as the subcommand. Listing only the
56/// subcommands the compressor actually dispatches on plus the most
57/// common bun verbs keeps the set small without missing real cases.
58///
59/// Full bun verb set (per `bun --help`): install, add, remove, update,
60/// outdated, link, unlink, why, audit, patch, pm, publish, pack, run,
61/// test, x, exec, create, init, build, repl, upgrade.
62const BUN_SUBCOMMANDS: &[&str] = &[
63    "install", "i", "add", "remove", "update", "outdated", "link", "unlink", "why", "audit",
64    "patch", "pm", "publish", "pack", "run", "test", "x", "exec", "create", "init", "build",
65    "repl", "upgrade", "help", "info",
66];
67
68/// Detect the bun subcommand from a command line.
69///
70/// Important: previous implementations used `find(!starts_with('-'))`
71/// which broke for `bun --cwd packages/opencode-plugin test` — the
72/// flag's value (`packages/opencode-plugin`) was returned as the
73/// subcommand, causing the bun-test compressor to silently fall
74/// through to the generic compressor and drop per-test failure
75/// blocks. We now match against a whitelist of known bun verbs so
76/// flag values are skipped safely.
77fn bun_subcommand(command: &str) -> Option<String> {
78    command
79        .split_whitespace()
80        .skip_while(|token| *token != "bun")
81        .skip(1)
82        .find(|token| BUN_SUBCOMMANDS.contains(token))
83        .map(ToString::to_string)
84}
85
86fn compress_package(output: &str) -> String {
87    let mut result = Vec::new();
88    for line in output.lines() {
89        if is_bun_progress(line) {
90            continue;
91        }
92        let trimmed = line.trim_start();
93        if trimmed.contains("packages installed")
94            || trimmed.contains("package installed")
95            || trimmed.starts_with("error:")
96            || trimmed.starts_with("bun install error:")
97            || trimmed.starts_with("Saved lockfile")
98        {
99            result.push(line.to_string());
100        }
101    }
102    trim_trailing_lines(&result.join("\n"))
103}
104
105fn compress_build(output: &str) -> String {
106    let mut result = Vec::new();
107    let mut timing_seen = 0usize;
108    let mut timing_omitted = 0usize;
109    for line in output.lines() {
110        if is_timing_line(line) {
111            timing_seen += 1;
112            if timing_seen > 10 {
113                timing_omitted += 1;
114                continue;
115            }
116        }
117        result.push(line.to_string());
118    }
119    if timing_omitted > 0 {
120        result.push(format!("... and {timing_omitted} more timing lines"));
121    }
122    trim_trailing_lines(&result.join("\n"))
123}
124
125/// Compress `bun test` output. Preserves:
126///   - Bun version header (`bun test v1.3.14 ...`)
127///   - File-section headers (`path/to/foo.test.ts:`) that precede a kept failure
128///   - All failure context: `error:` block + diff + source pointer (`  N |`)
129///     + stack frames + the explicit `(fail) test name [Xms]` marker
130///   - Final summary lines: ` N pass`, ` N fail`, ` N expect() calls`, and
131///     `Ran N tests across N files. [Xms]`
132///
133/// Drops:
134///   - `(pass) test name [Xms]` markers (the summary's `N pass` line
135///     already conveys count) — but if no failures exist, returns the
136///     full original output via the generic compressor for safety
137///
138/// Cap: `MAX_FAILURES` failure blocks preserved; further blocks dropped
139/// with a `+N more failures` trailer.
140///
141/// Why this matters: bun test writes failure blocks INLINE between the
142/// header and the final summary. With the 30KB inline cap, large test
143/// runs middle-truncate and lose the failure block entirely — agents
144/// see only the header + summary count and have no debugging context.
145fn compress_test(output: &str) -> String {
146    let lines: Vec<&str> = output.lines().collect();
147    if lines.is_empty() {
148        return output.to_string();
149    }
150
151    // Quick pre-scan: if no failures, defer to generic. This keeps the
152    // pass-only path cheap and avoids touching outputs that don't have
153    // the truncation problem (small all-pass runs are already short).
154    let has_failures = lines.iter().any(|line| is_bun_test_fail_marker(line));
155    if !has_failures {
156        return compress_test_pass_only(&lines);
157    }
158
159    let mut result: Vec<String> = Vec::new();
160    let mut failures_kept = 0usize;
161    let mut failures_dropped = 0usize;
162    let mut index = 0usize;
163    let mut saw_ran_summary = false;
164
165    while index < lines.len() {
166        let line = lines[index];
167
168        if saw_ran_summary {
169            // Past the `Ran N tests across M files. [Xms]` line, output
170            // belongs to a chained command (`; next_cmd`). Pass through
171            // so chains don't silently lose the next command's output.
172            // (Note: `&&` short-circuits on test failure so this is mainly
173            // relevant for `;` separators or `|| fallback_cmd`.)
174            result.push(line.to_string());
175            index += 1;
176            continue;
177        }
178
179        // Bun version header — always keep.
180        if is_bun_test_header(line) {
181            result.push(line.to_string());
182            index += 1;
183            continue;
184        }
185
186        // File-section header (e.g. `src/foo.test.ts:`). Only keep if
187        // a failure block follows before the next file-section header
188        // or summary.
189        if is_file_section_header(line) {
190            let next_fail = next_index(&lines, index + 1, is_bun_test_fail_marker);
191            let next_section = next_index(&lines, index + 1, |l| {
192                is_file_section_header(l) || is_summary_line(l)
193            });
194            let keep_section = match (next_fail, next_section) {
195                (Some(fi), Some(si)) => fi < si,
196                (Some(_), None) => true,
197                (None, _) => false,
198            };
199            if keep_section {
200                result.push(line.to_string());
201            }
202            index += 1;
203            continue;
204        }
205
206        // Summary tail — always keep.
207        if is_summary_line(line) {
208            result.push(line.to_string());
209            // The `Ran N tests across M files. [Xms]` line marks the
210            // boundary between bun-test output and any chained-command
211            // output that follows. (Bun uses `file. [` singular when
212            // M == 1 and `files. [` plural otherwise.)
213            if is_ran_summary_line(line) {
214                saw_ran_summary = true;
215            }
216            index += 1;
217            continue;
218        }
219
220        // Failure block: source pointers, error messages, diff lines,
221        // stack frames, and the explicit `(fail) ...` marker.
222        // Detect block start: an `error:` line, or a code-pointer block
223        // (` N | ...`) that leads into an error.
224        if is_bun_test_error_start(line) || is_bun_test_code_pointer(line) {
225            // Collect lines up to and including the next `(fail) ...`
226            // marker. We rely on the `(fail)` marker as the right edge
227            // because bun always emits one per failed test after the
228            // diagnostic block.
229            let block_start = index;
230            let mut block_end = index;
231            while block_end < lines.len() {
232                if is_bun_test_fail_marker(lines[block_end]) {
233                    block_end += 1;
234                    break;
235                }
236                // Stop early if we hit something that clearly isn't
237                // part of a failure block — but be permissive: source
238                // pointers, error text, stack frames, blank lines all
239                // count as block content.
240                block_end += 1;
241            }
242
243            failures_kept += 1;
244            if failures_kept <= MAX_FAILURES {
245                for line in &lines[block_start..block_end] {
246                    result.push((*line).to_string());
247                }
248            } else {
249                failures_dropped += 1;
250            }
251            index = block_end;
252            continue;
253        }
254
255        // Drop everything else (individual pass lines, blank padding).
256        index += 1;
257    }
258
259    if failures_dropped > 0 {
260        result.push(format!("+{failures_dropped} more failures"));
261    }
262
263    // Safety net: if we somehow stripped everything, fall back so the
264    // agent at least sees the raw bytes truncated by the generic path.
265    if result.is_empty() {
266        return GenericCompressor::compress_output(output);
267    }
268    trim_trailing_lines(&result.join("\n"))
269}
270
271/// All-pass `bun test` output: keep version header + summary + drop the
272/// rest. Bun in default mode doesn't print per-test pass markers, but
273/// `--verbose` does, so we explicitly preserve only header + summary.
274///
275/// IMPORTANT: when `bun test` is part of a shell chain like
276/// `bun test && bun run build`, anything AFTER the
277/// `Ran N tests across M files. [Xms]` line is the chained command's
278/// output. We preserve those trailing lines unchanged so chains don't
279/// silently lose the next command's output. The chained command itself
280/// is generic content from our perspective (we have no signal about
281/// what it is from the bun-test compressor's POV) so we pass it through
282/// verbatim and let the inline cap handle excess size.
283fn compress_test_pass_only(lines: &[&str]) -> String {
284    let mut result: Vec<String> = Vec::new();
285    let mut saw_ran_summary = false;
286
287    for line in lines {
288        if saw_ran_summary {
289            // Everything from here on is chained-command output. Pass through.
290            result.push((*line).to_string());
291            continue;
292        }
293        if is_bun_test_header(line) || is_summary_line(line) {
294            result.push((*line).to_string());
295            // The `Ran N tests across M files. [Xms]` line is the LAST line
296            // bun emits for the test run itself. Everything after must be
297            // from a chained command (`&& other_cmd`).
298            if is_ran_summary_line(line) {
299                saw_ran_summary = true;
300            }
301        }
302    }
303
304    if result.is_empty() {
305        return GenericCompressor::compress_output(&lines.join("\n"));
306    }
307    trim_trailing_lines(&result.join("\n"))
308}
309
310fn next_index<F>(lines: &[&str], start: usize, predicate: F) -> Option<usize>
311where
312    F: Fn(&str) -> bool,
313{
314    lines
315        .iter()
316        .enumerate()
317        .skip(start)
318        .find(|(_, line)| predicate(line))
319        .map(|(i, _)| i)
320}
321
322fn is_bun_test_header(line: &str) -> bool {
323    line.starts_with("bun test v")
324}
325
326fn is_file_section_header(line: &str) -> bool {
327    // File-section headers from bun look like `path/to/foo.test.ts:`
328    // (no leading whitespace, no spaces in the path part, trailing
329    // colon, contains `.test.` or `.spec.` to avoid false-positives on
330    // error-message colons).
331    let trimmed = line.trim_end();
332    if trimmed.starts_with(' ') || !trimmed.ends_with(':') {
333        return false;
334    }
335    let path = &trimmed[..trimmed.len() - 1];
336    if path.is_empty() || path.contains(' ') {
337        return false;
338    }
339    path.contains(".test.")
340        || path.contains(".spec.")
341        || path.contains("_test.")
342        || path.contains("_spec.")
343}
344
345fn is_bun_test_result_marker(line: &str) -> bool {
346    is_bun_test_pass_marker(line) || is_bun_test_fail_marker(line)
347}
348
349fn is_bun_test_pass_marker(line: &str) -> bool {
350    is_bun_test_marker(line, "(pass)")
351}
352
353fn is_bun_test_fail_marker(line: &str) -> bool {
354    is_bun_test_marker(line, "(fail)")
355}
356
357fn is_bun_test_marker(line: &str, marker: &str) -> bool {
358    let trimmed = line.trim();
359    let Some(rest) = trimmed.strip_prefix(marker) else {
360        return false;
361    };
362    if !rest.chars().next().is_some_and(|ch| ch.is_whitespace()) {
363        return false;
364    }
365
366    let name_and_timing = rest.trim_start();
367    let Some((name, timing)) = name_and_timing.rsplit_once(" [") else {
368        return false;
369    };
370    if name.trim().is_empty() {
371        return false;
372    }
373
374    let Some(duration) = timing.strip_suffix(']') else {
375        return false;
376    };
377    is_bun_test_duration(duration)
378}
379
380fn is_bun_test_duration(duration: &str) -> bool {
381    ["ms", "µs", "μs", "us", "ns", "s"]
382        .iter()
383        .any(|unit| duration.strip_suffix(*unit).is_some_and(is_decimal_number))
384}
385
386fn is_decimal_number(value: &str) -> bool {
387    let mut saw_digit = false;
388    let mut saw_dot = false;
389
390    for ch in value.chars() {
391        match ch {
392            '0'..='9' => saw_digit = true,
393            '.' if !saw_dot => saw_dot = true,
394            _ => return false,
395        }
396    }
397
398    saw_digit
399}
400
401fn is_bun_test_error_start(line: &str) -> bool {
402    // Bun emits the failing assertion as `error: expect(...)` (and
403    // sometimes plain `error: ...`). Source pointers preceding this
404    // also need to be kept, but they get caught by the code-pointer
405    // detector — this is the block's primary anchor.
406    line.starts_with("error:")
407}
408
409fn is_bun_test_code_pointer(line: &str) -> bool {
410    // Bun prints the failing line of source code with format `<N> | ...`
411    // where <N> is the line number (with leading space to align).
412    // These appear immediately above and below the `error:` line.
413    let trimmed = line.trim_start();
414    if !trimmed.contains(" | ") && !trimmed.contains("| ") {
415        return false;
416    }
417    // Confirm it starts with a digit (line number).
418    trimmed
419        .chars()
420        .next()
421        .is_some_and(|char| char.is_ascii_digit())
422}
423
424/// Detects the `Ran N tests across M files. [Xms]` final line that bun
425/// emits to mark the end of its own output. Accepts both the singular
426/// (`file. [`) and plural (`files. [`) forms.
427fn is_ran_summary_line(line: &str) -> bool {
428    let Some(rest) = line.strip_prefix("Ran ") else {
429        return false;
430    };
431    let Some((test_count, rest)) = rest.split_once(" tests across ") else {
432        return false;
433    };
434    if test_count.is_empty() || !test_count.chars().all(|ch| ch.is_ascii_digit()) {
435        return false;
436    }
437    let Some((file_count, rest)) = rest.split_once(" file") else {
438        return false;
439    };
440    if file_count.is_empty() || !file_count.chars().all(|ch| ch.is_ascii_digit()) {
441        return false;
442    }
443    rest.starts_with(". [") || rest.starts_with("s. [")
444}
445
446fn is_summary_line(line: &str) -> bool {
447    let trimmed = line.trim_start();
448    // Summary lines come after `[N]ms` markers in counts. Catch:
449    //   " N pass", " N fail", " N expect() calls"
450    //   "Ran N tests across N files. [Xms]"
451    if is_ran_summary_line(trimmed) {
452        return true;
453    }
454    if let Some(first_token) = trimmed.split_whitespace().next() {
455        if first_token.chars().all(|char| char.is_ascii_digit()) {
456            let rest = trimmed[first_token.len()..].trim_start();
457            return rest.starts_with("pass")
458                || rest.starts_with("fail")
459                || rest.starts_with("expect()");
460        }
461    }
462    false
463}
464
465fn is_bun_progress(line: &str) -> bool {
466    let trimmed = line.trim();
467    trimmed == "."
468        || trimmed.chars().all(|char| char == '.')
469        || trimmed.starts_with("Resolving")
470        || trimmed.starts_with("Resolved")
471        || trimmed.starts_with("Downloaded")
472        || trimmed.starts_with("Extracted")
473}
474
475fn is_timing_line(line: &str) -> bool {
476    let trimmed = line.trim_start();
477    trimmed.starts_with('[') && trimmed.contains(" ms]")
478}
479
480fn trim_trailing_lines(input: &str) -> String {
481    input
482        .lines()
483        .map(str::trim_end)
484        .collect::<Vec<_>>()
485        .join("\n")
486}