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