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