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