Skip to main content

aft/compress/
bun.rs

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