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