Skip to main content

aft/compress/
bun.rs

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