Skip to main content

aft/compress/
mod.rs

1//! Output compression for hoisted bash.
2//!
3//! Compression has five tiers, tried in this order:
4//!
5//! 1. **Specific Rust [`Compressor`] modules** — hand-written parsers for
6//!    specific tools identified by tool tokens (for example `vitest`, `eslint`,
7//!    `cargo`, `git`). These win before broad package-manager compressors.
8//! 2. **Output-shape [`Compressor`] sniffers** — inner-tool parsers that can
9//!    recognize their own private summaries even when invoked through wrappers
10//!    such as `npm test`, `make test`, or `./scripts/check.sh`.
11//! 3. **Package-manager [`Compressor`] modules** — broad head-token matchers
12//!    (`npm`, `pnpm`, `bun`) that compress unclaimed package-manager output.
13//! 4. **TOML filters** — declarative strip + truncate + cap + shortcircuit
14//!    rules for the long tail of CLI tools. Loaded from builtin / user /
15//!    project sources via [`toml_filter::build_registry`]. See
16//!    [`toml_filter`] and [`trust`] for the trust model.
17//! 5. **[`generic`] fallback** — ANSI strip + consecutive-dedup. The
18//!    background bash registry owns the shared final output cap.
19
20pub mod biome;
21pub mod builtin_filters;
22pub mod bun;
23pub mod caps;
24pub mod cargo;
25pub mod eslint;
26pub mod find;
27pub mod generic;
28pub mod git;
29pub mod go;
30pub mod listing_fold;
31pub mod ls;
32pub mod mypy;
33pub mod next;
34pub mod npm;
35pub mod playwright;
36pub mod pnpm;
37pub mod prettier;
38pub mod pytest;
39pub mod ruff;
40pub mod toml_filter;
41pub mod trust;
42pub mod tsc;
43pub mod vitest;
44
45use crate::context::AppContext;
46use crate::harness::Harness;
47use biome::BiomeCompressor;
48use bun::BunCompressor;
49use caps::DropClass;
50use cargo::CargoCompressor;
51use eslint::EslintCompressor;
52use find::FindCompressor;
53use generic::{strip_ansi, GenericCompressor};
54use git::GitCompressor;
55use go::{GoCompressor, GolangciLintCompressor};
56use ls::LsCompressor;
57use mypy::MypyCompressor;
58use next::NextCompressor;
59use npm::NpmCompressor;
60use playwright::PlaywrightCompressor;
61use pnpm::PnpmCompressor;
62use prettier::PrettierCompressor;
63use pytest::PytestCompressor;
64use ruff::RuffCompressor;
65use std::collections::BTreeMap;
66use std::fs;
67use std::path::{Path, PathBuf};
68use std::sync::{Arc, RwLock};
69use toml_filter::{apply_filter_with_exit_code, FilterRegistry};
70use tsc::TscCompressor;
71use vitest::VitestCompressor;
72
73/// Thread-safe handle to the TOML filter registry. Shared between
74/// `AppContext::filter_registry()` (for direct use in command handlers) and
75/// `BgTaskRegistry`'s output compression closure (for use from the watchdog
76/// thread).
77pub type SharedFilterRegistry = Arc<RwLock<FilterRegistry>>;
78
79/// How specifically a compressor identifies a command.
80///
81/// `Specific` matchers (vitest, eslint, biome, tsc, pytest, cargo, git)
82/// claim a command by recognising a SPECIFIC tool name as a token anywhere
83/// in the command line — `npx vitest`, `pnpm exec eslint --fix`,
84/// `bun run vitest`, etc.
85///
86/// `PackageManager` matchers (npm, pnpm, bun) claim a command by its
87/// HEAD token alone (e.g. `npm`, `bun`) regardless of what subcommand
88/// follows. They are intentionally broad — when a `bun run vitest` is
89/// not claimed by VitestCompressor, BunCompressor still wants the chance
90/// to compress generic bun output for unknown subcommands.
91///
92/// Dispatch order: Specific command tier first, then output-shape sniffers
93/// (Specific before PackageManager), then PackageManager command tier, then
94/// TOML filters, then GenericCompressor.
95#[derive(Clone, Copy, Debug, PartialEq, Eq)]
96pub enum Specificity {
97    Specific,
98    PackageManager,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct CompressionResult {
103    pub text: String,
104    pub dropped_by_class: BTreeMap<DropClass, usize>,
105    pub had_inner_drop: bool,
106    pub offset_hint_eligible: bool,
107    pub offset_start_line: Option<usize>,
108}
109
110impl CompressionResult {
111    pub fn new(text: impl Into<String>) -> Self {
112        Self {
113            text: text.into(),
114            dropped_by_class: BTreeMap::new(),
115            had_inner_drop: false,
116            offset_hint_eligible: true,
117            offset_start_line: None,
118        }
119    }
120
121    pub fn with_class_drops(
122        text: impl Into<String>,
123        dropped_by_class: BTreeMap<DropClass, usize>,
124    ) -> Self {
125        let had_inner_drop = !dropped_by_class.is_empty();
126        Self {
127            text: text.into(),
128            dropped_by_class,
129            had_inner_drop,
130            offset_hint_eligible: !had_inner_drop,
131            offset_start_line: None,
132        }
133    }
134
135    pub fn with_inner_drop(text: impl Into<String>, offset_hint_eligible: bool) -> Self {
136        Self {
137            text: text.into(),
138            dropped_by_class: BTreeMap::new(),
139            had_inner_drop: true,
140            offset_hint_eligible,
141            offset_start_line: None,
142        }
143    }
144
145    pub fn with_prefix_drop(text: impl Into<String>, offset_start_line: usize) -> Self {
146        Self {
147            text: text.into(),
148            dropped_by_class: BTreeMap::new(),
149            had_inner_drop: true,
150            offset_hint_eligible: true,
151            offset_start_line: Some(offset_start_line),
152        }
153    }
154
155    pub fn has_semantic_drops(&self) -> bool {
156        !self.dropped_by_class.is_empty()
157    }
158
159    pub fn has_any_drop(&self) -> bool {
160        self.had_inner_drop || self.has_semantic_drops()
161    }
162
163    pub fn map_text<F>(mut self, f: F) -> Self
164    where
165        F: FnOnce(&str) -> String,
166    {
167        self.text = f(&self.text);
168        self
169    }
170}
171
172impl std::fmt::Display for CompressionResult {
173    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
174        f.write_str(&self.text)
175    }
176}
177
178impl std::ops::Deref for CompressionResult {
179    type Target = str;
180
181    fn deref(&self) -> &Self::Target {
182        &self.text
183    }
184}
185
186impl PartialEq<&str> for CompressionResult {
187    fn eq(&self, other: &&str) -> bool {
188        self.text == *other
189    }
190}
191
192impl PartialEq<String> for CompressionResult {
193    fn eq(&self, other: &String) -> bool {
194        self.text == *other
195    }
196}
197
198impl From<String> for CompressionResult {
199    fn from(text: String) -> Self {
200        Self::new(text)
201    }
202}
203
204impl From<&str> for CompressionResult {
205    fn from(text: &str) -> Self {
206        Self::new(text)
207    }
208}
209
210/// A `Compressor` knows how to reduce one specific command's output to fewer
211/// tokens while preserving the information the agent needs.
212pub trait Compressor: Send + Sync {
213    /// Returns true if this compressor handles the given command head + args.
214    /// Called after generic detection (ANSI strip, dedup) so this is per-command logic only.
215    fn matches(&self, command: &str) -> bool;
216
217    /// Compress the output when the process exit code is unknown.
218    fn compress(&self, command: &str, output: &str) -> CompressionResult {
219        self.compress_with_exit_code(command, output, None)
220    }
221
222    /// Compress the output. Original is left untouched if compression fails.
223    fn compress_with_exit_code(
224        &self,
225        command: &str,
226        output: &str,
227        exit_code: Option<i32>,
228    ) -> CompressionResult;
229
230    fn specificity(&self) -> Specificity {
231        Specificity::Specific
232    }
233
234    /// Returns true when this compressor recognizes output produced by its
235    /// inner tool even if the command head was a wrapper (`npm test`,
236    /// `make test`, `./scripts/check.sh`, etc.). Wrapper compressors should
237    /// not override this; they remain command-only.
238    fn matches_output(&self, _output: &str) -> bool {
239        false
240    }
241
242    /// Compress output after an output-shape match when the process exit code is unknown.
243    fn compress_output_match(&self, output: &str) -> CompressionResult {
244        self.compress_output_match_with_exit_code(output, None)
245    }
246
247    /// Compress output after an output-shape match. Compressors that branch by
248    /// subcommand override this to jump directly to the matched branch.
249    fn compress_output_match_with_exit_code(
250        &self,
251        output: &str,
252        exit_code: Option<i32>,
253    ) -> CompressionResult {
254        self.compress_with_exit_code("", output, exit_code)
255    }
256}
257/// Top-level dispatch: try specific Rust modules, output-shape sniffers, package-manager modules, TOML filters, then generic fallback.
258///
259/// Convenience wrapper for command handlers that already hold an `AppContext`.
260/// Backs onto [`compress_with_registry`] which is thread-safe for use from the
261/// `BgTaskRegistry` watchdog.
262pub fn compress(command: &str, output: String, ctx: &AppContext) -> CompressionResult {
263    compress_with_exit_code(command, output, None, ctx)
264}
265
266pub fn compress_with_exit_code(
267    command: &str,
268    output: String,
269    exit_code: Option<i32>,
270    ctx: &AppContext,
271) -> CompressionResult {
272    if !ctx.config().experimental_bash_compress {
273        return CompressionResult::new(output);
274    }
275    let registry_handle = ctx.shared_filter_registry();
276    let guard = match registry_handle.read() {
277        Ok(g) => g,
278        Err(poisoned) => poisoned.into_inner(),
279    };
280    compress_with_registry_exit_code(command, &output, exit_code, &guard)
281}
282
283/// Thread-safe dispatch that does not need `AppContext`. Caller is responsible
284/// for the `experimental_bash_compress` gate (the registry has no opinion).
285///
286/// Used from background threads (notably the `BgTaskRegistry` watchdog and
287/// completion-frame emitter) where lock-free access is required.
288pub fn compress_with_registry(
289    command: &str,
290    output: &str,
291    registry: &FilterRegistry,
292) -> CompressionResult {
293    compress_with_registry_exit_code(command, output, None, registry)
294}
295
296pub fn compress_with_registry_exit_code(
297    command: &str,
298    output: &str,
299    exit_code: Option<i32>,
300    registry: &FilterRegistry,
301) -> CompressionResult {
302    let stripped_for_generic = strip_ansi(output);
303
304    // Normalize the command so shell-prefix idioms like `cd /path && bun test`,
305    // `env FOO=bar npm install`, `timeout 30 cargo build`, and `(cd /path; cmd)`
306    // don't hide the real command head from per-module matchers. Without this,
307    // BunCompressor/NpmCompressor/PnpmCompressor (which match by head-token)
308    // silently fall through to generic in most agent-issued bash calls.
309    let normalized = normalize_command_for_dispatch(command);
310    let dispatch_cmd = normalized.as_deref().unwrap_or(command);
311
312    let compressors: [&dyn Compressor; 19] = [
313        &GitCompressor,
314        &CargoCompressor,
315        &TscCompressor,
316        &NpmCompressor,
317        &BunCompressor,
318        &PnpmCompressor,
319        &PytestCompressor,
320        &EslintCompressor,
321        &VitestCompressor,
322        &BiomeCompressor,
323        &PrettierCompressor,
324        &RuffCompressor,
325        &MypyCompressor,
326        &GoCompressor,
327        &GolangciLintCompressor,
328        &PlaywrightCompressor,
329        &NextCompressor,
330        &LsCompressor,
331        &FindCompressor,
332    ];
333
334    // Tier 1a: Specific command compressors win first.
335    for compressor in compressors
336        .iter()
337        .filter(|c| c.specificity() == Specificity::Specific)
338    {
339        if compressor.matches(dispatch_cmd) {
340            let result =
341                compressor.compress_with_exit_code(dispatch_cmd, &stripped_for_generic, exit_code);
342            return failure_preserving_result(command, &stripped_for_generic, result, exit_code);
343        }
344    }
345
346    // Tier 1b: Output-shape sniffers handle wrapped inner tools before broad
347    // package managers or TOML filters can consume `npm test`, `make test`,
348    // `just test`, etc. Collision order is deterministic: Specific compressors
349    // in registry order win before PackageManager sniffers (currently Bun's
350    // test-output signature).
351    for specificity in [Specificity::Specific, Specificity::PackageManager] {
352        for compressor in compressors
353            .iter()
354            .filter(|c| c.specificity() == specificity)
355        {
356            if compressor.matches_output(&stripped_for_generic) {
357                let result = compressor
358                    .compress_output_match_with_exit_code(&stripped_for_generic, exit_code);
359                return failure_preserving_result(
360                    command,
361                    &stripped_for_generic,
362                    result,
363                    exit_code,
364                );
365            }
366        }
367    }
368
369    // Tier 1c: PackageManager compressors get unclaimed commands.
370    for compressor in compressors
371        .iter()
372        .filter(|c| c.specificity() == Specificity::PackageManager)
373    {
374        if compressor.matches(dispatch_cmd) {
375            let result =
376                compressor.compress_with_exit_code(dispatch_cmd, &stripped_for_generic, exit_code);
377            return failure_preserving_result(command, &stripped_for_generic, result, exit_code);
378        }
379    }
380
381    // Tier 2: TOML filters. Pass raw output so `[ansi].strip = false` filters
382    // can intentionally match escape sequences; `apply_filter` owns ANSI policy.
383    if let Some(filter) = registry.lookup(dispatch_cmd) {
384        let result = apply_filter_with_exit_code(filter, output, exit_code);
385        return failure_preserving_result(command, &stripped_for_generic, result, exit_code);
386    }
387
388    // Tier 3: generic fallback.
389    GenericCompressor.compress_with_exit_code(command, &stripped_for_generic, exit_code)
390}
391
392fn failure_preserving_result(
393    command: &str,
394    stripped_raw_output: &str,
395    result: CompressionResult,
396    exit_code: Option<i32>,
397) -> CompressionResult {
398    if !matches!(exit_code, Some(code) if code != 0) {
399        return result;
400    }
401
402    if result_looks_successful(&result.text)
403        || raw_failure_signal_absent_from_compressed(stripped_raw_output, &result.text)
404    {
405        GenericCompressor.compress_with_exit_code(command, stripped_raw_output, exit_code)
406    } else {
407        result
408    }
409}
410
411fn raw_failure_signal_absent_from_compressed(raw_output: &str, compressed_text: &str) -> bool {
412    let mut saw_signal_line = false;
413    for line in raw_output.lines() {
414        let trimmed = line.trim();
415        if trimmed.is_empty() || !line_has_failure_signal(trimmed) {
416            continue;
417        }
418        saw_signal_line = true;
419        if compressed_text.contains(trimmed) {
420            return false;
421        }
422    }
423
424    saw_signal_line && !text_has_failure_signal(compressed_text)
425}
426
427fn result_looks_successful(text: &str) -> bool {
428    let lower = text.to_ascii_lowercase();
429    !text_has_failure_signal(text)
430        && (lower.contains("clean")
431            || lower.contains(" ok")
432            || lower.contains(":ok")
433            || lower.contains(": ok")
434            || lower.contains("passed")
435            || lower.contains("no errors")
436            || lower.contains("all checks passed")
437            || lower.contains("formatted")
438            || lower.contains("0 fail")
439            || lower.contains("found 0")
440            || lower.contains("up to date")
441            || lower.contains("up-to-date"))
442}
443
444fn text_has_failure_signal(text: &str) -> bool {
445    text.lines()
446        .any(|line| line_has_failure_signal(line.trim()))
447}
448
449fn line_has_failure_signal(line: &str) -> bool {
450    line.contains("error[")
451        || line.contains("error:")
452        || line.contains("Error")
453        || line.contains("FAILED")
454        || line.contains("FAIL")
455        || contains_nonzero_failure_word(line, "failed")
456        || contains_nonzero_failure_word(line, "failure")
457        || contains_nonzero_failure_word(line, "failures")
458        || line.contains("panic")
459        || line.contains("cannot find")
460        || line.contains("not found")
461        || line.contains("no such")
462}
463
464fn contains_nonzero_failure_word(line: &str, word: &str) -> bool {
465    let lower = line.to_ascii_lowercase();
466    for (index, _) in lower.match_indices(word) {
467        let prefix = lower[..index].trim_end();
468        let digits_start = prefix
469            .char_indices()
470            .rev()
471            .take_while(|(_, ch)| ch.is_ascii_digit())
472            .last()
473            .map(|(idx, _)| idx);
474        let Some(digits_start) = digits_start else {
475            return true;
476        };
477        let digits = &prefix[digits_start..];
478        if digits.parse::<usize>().ok() != Some(0) {
479            return true;
480        }
481    }
482    false
483}
484
485/// Build the registry of TOML filters from the standard sources for the
486/// active context. Called lazily by [`AppContext::filter_registry`].
487///
488/// Layering (highest priority first):
489/// 1. Project filters at `<project_root>/.aft/filters/*.toml` — loaded only
490///    when the project is in the trusted set (see [`trust`]).
491/// 2. User filters at `<storage_dir>/<harness>/filters/*.toml`.
492/// 3. Builtin filters compiled into the binary via [`builtin_filters`].
493pub fn build_registry_for_context(ctx: &AppContext) -> FilterRegistry {
494    let harness = ctx.harness.borrow().unwrap_or(Harness::Opencode);
495    let config = ctx.config();
496    let storage_dir = config.storage_dir.clone();
497    let project_root = config.project_root.clone();
498    drop(config);
499
500    let user_dir = storage_dir.as_ref().map(|dir| {
501        repair_legacy_user_filter_dir(dir, harness);
502        user_filter_dir(dir, harness)
503    });
504    let project_dir = match (project_root.as_ref(), storage_dir.as_ref()) {
505        (Some(root), Some(storage)) => {
506            if trust::is_project_trusted(Some(storage), root) {
507                Some(root.join(".aft").join("filters"))
508            } else {
509                None
510            }
511        }
512        _ => None,
513    };
514
515    toml_filter::build_registry(
516        builtin_filters::ALL,
517        user_dir.as_deref(),
518        project_dir.as_deref(),
519    )
520}
521
522/// Normalize a shell command for compressor dispatch by walking past
523/// common shell-prefix idioms so the REAL command head is what matchers
524/// see. Returns `Some(normalized)` if a prefix was stripped, `None` if
525/// the input was already a bare command.
526///
527/// Handles:
528///   - `cd /path && cmd ...`            → `cmd ...`
529///   - `cd /path; cmd ...`              → `cmd ...`
530///   - `env FOO=bar [BAR=baz ...] cmd`  → `cmd ...`
531///   - `FOO=bar [BAR=baz ...] cmd`      → `cmd ...`
532///   - `timeout 30 cmd ...`             → `cmd ...`
533///   - `nohup cmd ...`                  → `cmd ...`
534///   - `(cd /path && cmd ...)`          → `cmd ...`   (trailing `)` is kept; harmless for matchers)
535///
536/// Real agent invocations almost always wrap their actual command in
537/// `cd "$ROOT" && ...`. Without this normalization, BunCompressor /
538/// NpmCompressor / PnpmCompressor (head-token matchers) and the
539/// pkg-manager filters silently fall through to GenericCompressor for
540/// the majority of agent bash calls.
541///
542/// The normalizer is conservative: it only strips well-defined idioms
543/// and bails on anything ambiguous, so a malformed command degrades to
544/// the same dispatch behaviour as before this helper existed.
545pub fn normalize_command_for_dispatch(command: &str) -> Option<String> {
546    let trimmed = command.trim_start();
547    if trimmed.is_empty() {
548        return None;
549    }
550
551    // Step 1: peel a leading `(` from group-expression idioms.
552    let (open_paren, after_paren) = if let Some(rest) = trimmed.strip_prefix('(') {
553        (true, rest.trim_start())
554    } else {
555        (false, trimmed)
556    };
557
558    let mut current = after_paren.to_string();
559    let mut changed = open_paren;
560
561    // Step 2: iteratively peel known shell prefixes.
562    loop {
563        // `VAR=value cmd ...` (possibly multiple assignment words). This must
564        // run before head-token matching so package-manager/Rust compressors
565        // still see the real command for `NODE_ENV=production npm install`.
566        if let Some(stripped) = strip_leading_assignment_prefix(&current) {
567            current = stripped;
568            changed = true;
569            continue;
570        }
571
572        let head: String = current.split_whitespace().next().unwrap_or("").to_string();
573
574        // `cd <path> && ...` or `cd <path>; ...`
575        if head == "cd" {
576            // Find the next `&&` or `;` token; everything after that is the real command.
577            // Use char-level scan because `&&` is two chars not separated by whitespace.
578            if let Some(stripped) = strip_cd_prefix(&current) {
579                current = stripped;
580                changed = true;
581                continue;
582            }
583        }
584
585        // `env VAR=val [VAR=val ...] cmd ...`
586        if head == "env" {
587            if let Some(stripped) = strip_env_prefix(&current) {
588                current = stripped;
589                changed = true;
590                continue;
591            }
592        }
593
594        // `timeout <N> cmd ...` or `timeout <duration-with-unit> cmd ...`
595        if head == "timeout" {
596            if let Some(stripped) = strip_timeout_prefix(&current) {
597                current = stripped;
598                changed = true;
599                continue;
600            }
601        }
602
603        // `nohup cmd ...`
604        if head == "nohup" {
605            if let Some(rest) = current.strip_prefix("nohup").and_then(|s| {
606                let trimmed = s.trim_start();
607                if trimmed.is_empty() {
608                    None
609                } else {
610                    Some(trimmed.to_string())
611                }
612            }) {
613                current = rest;
614                changed = true;
615                continue;
616            }
617        }
618
619        break;
620    }
621
622    if changed {
623        Some(current)
624    } else {
625        None
626    }
627}
628
629fn strip_cd_prefix(command: &str) -> Option<String> {
630    // Look for `&&` or `;` outside of quotes.
631    let bytes = command.as_bytes();
632    let mut in_single = false;
633    let mut in_double = false;
634    let mut i = 0;
635    while i < bytes.len() {
636        let ch = bytes[i] as char;
637        if !in_double && ch == '\'' {
638            in_single = !in_single;
639        } else if !in_single && ch == '"' {
640            in_double = !in_double;
641        } else if !in_single && !in_double {
642            if ch == '&' && i + 1 < bytes.len() && bytes[i + 1] as char == '&' {
643                let rest = command[i + 2..].trim_start();
644                if rest.is_empty() {
645                    return None;
646                }
647                return Some(rest.to_string());
648            }
649            if ch == ';' {
650                let rest = command[i + 1..].trim_start();
651                if rest.is_empty() {
652                    return None;
653                }
654                return Some(rest.to_string());
655            }
656        }
657        i += 1;
658    }
659    None
660}
661
662fn strip_env_prefix(command: &str) -> Option<String> {
663    // env <ASSIGN>... <cmd> ...
664    let rest = command.strip_prefix("env")?.trim_start();
665    strip_leading_assignment_prefix(rest)
666}
667
668fn strip_leading_assignment_prefix(command: &str) -> Option<String> {
669    let mut index = 0usize;
670    let mut consumed_assignment = false;
671
672    loop {
673        index = skip_whitespace(command, index);
674        if index >= command.len() {
675            break;
676        }
677
678        let word_end = shell_word_end(command, index)?;
679        if word_end == index {
680            break;
681        }
682
683        let word = &command[index..word_end];
684        if !is_env_assignment(word) {
685            break;
686        }
687
688        consumed_assignment = true;
689        index = word_end;
690    }
691
692    if !consumed_assignment {
693        return None;
694    }
695
696    let after = command[index..].trim_start();
697    if after.is_empty() {
698        None
699    } else {
700        Some(after.to_string())
701    }
702}
703
704fn skip_whitespace(input: &str, mut index: usize) -> usize {
705    while index < input.len() {
706        let Some(ch) = input[index..].chars().next() else {
707            break;
708        };
709        if !ch.is_whitespace() {
710            break;
711        }
712        index += ch.len_utf8();
713    }
714    index
715}
716
717fn shell_word_end(command: &str, start: usize) -> Option<usize> {
718    let mut in_single = false;
719    let mut in_double = false;
720    let mut escaped = false;
721
722    for (offset, ch) in command[start..].char_indices() {
723        let index = start + offset;
724
725        if escaped {
726            escaped = false;
727            continue;
728        }
729
730        if ch == '\\' && !in_single {
731            escaped = true;
732            continue;
733        }
734
735        if ch == '\'' && !in_double {
736            in_single = !in_single;
737            continue;
738        }
739
740        if ch == '"' && !in_single {
741            in_double = !in_double;
742            continue;
743        }
744
745        if !in_single && !in_double && (ch.is_whitespace() || matches!(ch, ';' | '&' | '|')) {
746            return Some(index);
747        }
748    }
749
750    if in_single || in_double || escaped {
751        None
752    } else {
753        Some(command.len())
754    }
755}
756
757fn is_env_assignment(token: &str) -> bool {
758    if token.starts_with('-') {
759        return false;
760    }
761    let Some((name, _value)) = token.split_once('=') else {
762        return false;
763    };
764    let mut chars = name.chars();
765    let Some(first) = chars.next() else {
766        return false;
767    };
768    (first.is_ascii_alphabetic() || first == '_')
769        && chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
770}
771
772fn strip_timeout_prefix(command: &str) -> Option<String> {
773    let rest = command.strip_prefix("timeout")?.trim_start();
774    // Next token must look like a duration (digits, optional trailing unit s/m/h).
775    let mut iter = rest.splitn(2, char::is_whitespace);
776    let duration = iter.next()?;
777    let after = iter.next()?.trim_start();
778    if after.is_empty() || !looks_like_duration(duration) {
779        return None;
780    }
781    Some(after.to_string())
782}
783
784fn looks_like_duration(token: &str) -> bool {
785    if token.is_empty() {
786        return false;
787    }
788    let mut chars = token.chars().peekable();
789    let mut saw_digit = false;
790    while let Some(&ch) = chars.peek() {
791        if ch.is_ascii_digit() {
792            saw_digit = true;
793            chars.next();
794        } else {
795            break;
796        }
797    }
798    if !saw_digit {
799        return false;
800    }
801    match chars.next() {
802        None => true,
803        Some(unit) => matches!(unit, 's' | 'm' | 'h' | 'd') && chars.next().is_none(),
804    }
805}
806
807/// Resolve the harness-scoped user-filter directory for an arbitrary storage_dir.
808/// Used by `aft doctor filters` to inspect filters without needing a live AppContext.
809pub fn user_filter_dir(storage_dir: &Path, harness: Harness) -> PathBuf {
810    storage_dir.join(harness.as_str()).join("filters")
811}
812
813fn legacy_user_filter_dir(storage_dir: &Path) -> PathBuf {
814    storage_dir.join("filters")
815}
816
817/// Move filters written by the short-lived root-scoped v0.27 layout into the
818/// active harness directory. Existing harness files win; colliding root files
819/// are left in place so we never overwrite user-authored filters.
820pub(crate) fn repair_legacy_user_filter_dir(storage_dir: &Path, harness: Harness) {
821    let legacy_dir = legacy_user_filter_dir(storage_dir);
822    if !legacy_dir.exists() {
823        return;
824    }
825
826    let entries = match fs::read_dir(&legacy_dir) {
827        Ok(entries) => entries.filter_map(Result::ok).collect::<Vec<_>>(),
828        Err(_) => return,
829    };
830    if entries.is_empty() {
831        let _ = fs::remove_dir(&legacy_dir);
832        return;
833    }
834
835    let harness_dir = user_filter_dir(storage_dir, harness);
836    if fs::create_dir_all(&harness_dir).is_err() {
837        return;
838    }
839
840    for entry in entries {
841        let target = harness_dir.join(entry.file_name());
842        if target.exists() {
843            continue;
844        }
845        let _ = fs::rename(entry.path(), target);
846    }
847
848    if fs::read_dir(&legacy_dir)
849        .map(|mut entries| entries.next().is_none())
850        .unwrap_or(false)
851    {
852        let _ = fs::remove_dir(&legacy_dir);
853    }
854}
855
856/// Resolve the project-filter directory for an arbitrary project root.
857/// Returns the directory regardless of trust state — caller must check trust
858/// separately if it wants to gate loading.
859pub fn project_filter_dir(project_root: &Path) -> PathBuf {
860    project_root.join(".aft").join("filters")
861}
862
863#[cfg(test)]
864mod tests {
865    use super::*;
866
867    #[test]
868    fn user_and_project_filter_dir_helpers() {
869        let storage = Path::new("/tmp/aft-storage");
870        assert_eq!(
871            user_filter_dir(storage, Harness::Opencode),
872            Path::new("/tmp/aft-storage/opencode/filters")
873        );
874
875        let project = Path::new("/repo");
876        assert_eq!(project_filter_dir(project), Path::new("/repo/.aft/filters"));
877    }
878
879    #[test]
880    fn repair_legacy_user_filter_dir_moves_root_filters_without_overwrite() {
881        let temp = tempfile::tempdir().unwrap();
882        let storage = temp.path();
883        fs::create_dir_all(storage.join("filters")).unwrap();
884        fs::create_dir_all(storage.join("opencode/filters")).unwrap();
885        fs::write(storage.join("filters/root-only.toml"), "root").unwrap();
886        fs::write(storage.join("filters/collides.toml"), "root").unwrap();
887        fs::write(storage.join("opencode/filters/collides.toml"), "harness").unwrap();
888
889        repair_legacy_user_filter_dir(storage, Harness::Opencode);
890
891        assert_eq!(
892            fs::read_to_string(storage.join("opencode/filters/root-only.toml")).unwrap(),
893            "root"
894        );
895        assert_eq!(
896            fs::read_to_string(storage.join("opencode/filters/collides.toml")).unwrap(),
897            "harness"
898        );
899        assert_eq!(
900            fs::read_to_string(storage.join("filters/collides.toml")).unwrap(),
901            "root"
902        );
903        assert!(!storage.join("filters/root-only.toml").exists());
904    }
905}
906
907#[cfg(test)]
908mod dispatch_specificity_tests {
909    use super::*;
910    use crate::compress::toml_filter::FilterRegistry;
911
912    fn empty_registry() -> FilterRegistry {
913        FilterRegistry::default()
914    }
915
916    /// Helper: assert that a given command would be claimed by a specific
917    /// compressor by reading the output marker the compressor produces.
918    /// (We can't easily compare Compressor instances by identity, so we
919    /// dispatch and check for module-distinctive markers in the output.)
920    fn dispatch(cmd: &str, output: &str) -> String {
921        compress_with_registry(cmd, output, &empty_registry()).text
922    }
923
924    #[test]
925    fn generic_dispatch_does_not_classify_error_or_warning_words() {
926        let result = compress_with_registry(
927            "unknown-tool",
928            "error: this is just a log line\nwarning: this too",
929            &empty_registry(),
930        );
931
932        assert!(result.dropped_by_class.is_empty());
933        assert!(!result.had_inner_drop);
934        assert!(result.text.contains("error: this is just a log line"));
935    }
936
937    #[test]
938    fn bun_run_vitest_routes_to_vitest_not_generic() {
939        // VitestCompressor preserves PASS/FAIL markers and "Tests:" summary.
940        // BunCompressor's `Some("run")` arm currently goes to generic which
941        // would middle-truncate. Use a small vitest-shaped output and assert
942        // the vitest formatter's output marker is present.
943        let output = "Test Files  1 passed (1)\n     Tests  4 passed (4)\n  Start at  10:00:00\n  Duration  120ms\n";
944        let compressed = dispatch("bun run vitest", output);
945        // Assert vitest path took it: the vitest text summary keeps "Tests" / "Test Files" lines
946        assert!(compressed.contains("Tests") || compressed.contains("Test Files"));
947    }
948
949    #[test]
950    fn npm_test_routes_to_vitest_when_output_is_vitest_shaped() {
951        // `npm test` has no vitest token, so this proves the output-shape
952        // tier runs before the broad NpmCompressor PackageManager tier.
953        let output = "RERUN src/foo.test.ts x1\nFAIL src/foo.test.ts\nTest Files  1 failed (1)\nDuration    120ms\n";
954        let compressed = dispatch("npm test", output);
955        assert!(compressed.contains("FAIL src/foo.test.ts"));
956        assert!(compressed.contains("Duration    120ms"));
957        assert!(!compressed.contains("RERUN"));
958    }
959
960    #[test]
961    fn bun_run_vitest_token_match_wins_over_bun_head_match() {
962        // Concrete proof the new dispatch works: a command where Bun would
963        // otherwise have claimed it.
964        let output = "PASS src/a.test.ts (1)\n PASS src/b.test.ts (1)\nTest Files  2 passed (2)\n     Tests  4 passed (4)\n";
965        let compressed = dispatch("bun run vitest run", output);
966        // Vitest preserves PASS lines and "Tests:" summary.
967        assert!(compressed.contains("Test Files") || compressed.contains("PASS"));
968    }
969
970    #[test]
971    fn bunx_jest_routes_to_vitest_module() {
972        let output = "PASS src/foo.test.js (1.2s)\nTest Suites: 1 passed, 1 total\nTests:       3 passed, 3 total\n";
973        let compressed = dispatch("bunx jest --json", output);
974        assert!(compressed.contains("Tests:") && compressed.contains("Test Suites"));
975    }
976
977    #[test]
978    fn pnpm_run_vitest_routes_to_vitest() {
979        let output = "Test Files  1 passed (1)\n     Tests  10 passed (10)\n";
980        let compressed = dispatch("pnpm run vitest", output);
981        assert!(compressed.contains("Tests") || compressed.contains("Test Files"));
982    }
983
984    #[test]
985    fn npx_eslint_routes_to_eslint_not_generic() {
986        let output = "\n/tmp/a.js\n  1:1  error  'foo' is defined but never used  no-unused-vars\n\n✖ 1 problem (1 error, 0 warnings)\n";
987        let compressed = dispatch("npx eslint .", output);
988        // EslintCompressor preserves rule IDs and the ✖ summary.
989        assert!(compressed.contains("no-unused-vars") || compressed.contains("✖"));
990    }
991
992    #[test]
993    fn npm_run_lint_without_linter_output_shape_falls_back() {
994        // `npm run lint` has no eslint token, and this output has no eslint
995        // summary signature, so it should remain package-manager generic.
996        let output = "> my-project@1.0.0 lint\n> eslint .\n\nAll good.\n";
997        let compressed = dispatch("npm run lint", output);
998        assert!(compressed.contains("All good."));
999    }
1000
1001    #[test]
1002    fn bun_test_still_routes_to_bun_test_compressor() {
1003        // Bun.test is the v0.28.2 fix — make sure specificity dispatch
1004        // doesn't accidentally break it. The Bun module's `Some("test")`
1005        // arm should still claim this when no Specific matcher does.
1006        // BunTestCompressor doesn't exist as a separate module — the
1007        // BunCompressor.compress() routes Some("test") to its inner
1008        // compress_test() function. The relevant assertion: this still
1009        // produces bun-test-shaped output, not generic-truncated output.
1010        let output = "bun test v1.3.14\n\nsrc/foo.test.ts:\n(pass) my test [0.5ms]\n\n 1 pass\n 0 fail\n 1 expect() calls\nRan 1 tests across 1 files. [1.00ms]\n";
1011        let compressed = dispatch("bun test", output);
1012        assert!(compressed.contains("(pass)") || compressed.contains("1 pass"));
1013    }
1014
1015    #[test]
1016    fn bunx_vitest_routes_to_vitest() {
1017        let output = "Test Files  1 passed (1)\n     Tests  3 passed (3)\n";
1018        let compressed = dispatch("bunx vitest run", output);
1019        assert!(compressed.contains("Tests") || compressed.contains("Test Files"));
1020    }
1021
1022    #[test]
1023    fn cargo_test_still_routes_to_cargo() {
1024        // Regression: specificity reordering must not break commands that
1025        // already worked. Cargo is Specific tier.
1026        let output = "running 5 tests\ntest foo ... ok\ntest bar ... FAILED\n\nfailures:\n\ntest result: FAILED. 4 passed; 1 failed\n";
1027        let compressed = dispatch("cargo test", output);
1028        // Cargo's test compressor preserves PASS/FAIL semantics.
1029        assert!(compressed.contains("failed") || compressed.contains("FAILED"));
1030    }
1031
1032    #[test]
1033    fn git_status_still_routes_to_git() {
1034        // Regression: git is Specific tier.
1035        let output =
1036            "On branch main\nYour branch is up to date.\n\nnothing to commit, working tree clean\n";
1037        let compressed = dispatch("git status", output);
1038        assert!(compressed.contains("branch") || compressed.contains("clean"));
1039    }
1040
1041    #[test]
1042    fn pnpm_install_still_routes_to_pnpm() {
1043        // Regression: pnpm install was handled before this change.
1044        let output = "Progress: resolved 100, downloaded 50, added 50\nAdded 50 packages\n";
1045        let compressed = dispatch("pnpm install", output);
1046        // PnpmCompressor's compress_package keeps "+ pkg" or "Added X packages" type lines.
1047        assert!(compressed.contains("Added") || compressed.contains("Progress"));
1048    }
1049}
1050
1051#[cfg(test)]
1052mod exit_code_safety_tests {
1053    use super::*;
1054    use crate::compress::toml_filter::{build_registry, FilterRegistry};
1055
1056    fn empty_registry() -> FilterRegistry {
1057        FilterRegistry::default()
1058    }
1059
1060    #[test]
1061    fn go_build_nonzero_preserves_missing_module_error_but_zero_keeps_old_summary() {
1062        let output = "go: go.mod file not found in current directory or any parent directory; see 'go help modules'\n";
1063
1064        let failed =
1065            compress_with_registry_exit_code("go build ./...", output, Some(1), &empty_registry());
1066        assert!(!failed.text.contains("go build: ok"));
1067        assert!(failed.text.contains("go.mod file not found"));
1068
1069        let successful =
1070            compress_with_registry_exit_code("go build ./...", output, Some(0), &empty_registry());
1071        assert_eq!(successful.text, "go build: ok");
1072    }
1073
1074    #[test]
1075    fn playwright_nonzero_crash_does_not_become_passed_summary() {
1076        let output = r#"Running 4 tests using 2 workers
1077
1078  ✓  1 [chromium] › example.spec.ts:5:1 › has title (2.3s)
1079  ✓  2 [chromium] › example.spec.ts:9:1 › get started link (1.8s)
1080  ✓  3 [chromium] › nav.spec.ts:3:1 › navigates (1.2s)
1081  ✓  4 [chromium] › auth.spec.ts:7:1 › logs out (1.0s)
1082
1083  4 passed (6.3s)
1084Error: browserType.launch: Target page, context or browser has been closed
1085"#;
1086
1087        let failed = compress_with_registry_exit_code(
1088            "npx playwright test",
1089            output,
1090            Some(1),
1091            &empty_registry(),
1092        );
1093        assert!(!failed.text.starts_with("playwright: 4 tests passed"));
1094        assert!(failed.text.contains("browserType.launch"));
1095    }
1096
1097    #[test]
1098    fn cargo_test_compile_error_nonzero_preserves_error_code_diagnostic() {
1099        let output = r#"   Compiling demo v0.1.0 (/tmp/demo)
1100error[E0432]: unresolved import `crate::missing`
1101 --> src/lib.rs:1:5
1102  |
11031 | use crate::missing;
1104  |     ^^^^^^^^^^^^^^ no `missing` in the root
1105
1106error: could not compile `demo` (lib test) due to 1 previous error
1107"#;
1108
1109        let failed =
1110            compress_with_registry_exit_code("cargo test", output, Some(101), &empty_registry());
1111        assert!(failed.text.contains("error[E0432]"));
1112        assert!(failed.text.contains("unresolved import"));
1113        assert!(failed.text.contains("error: could not compile"));
1114    }
1115
1116    #[test]
1117    fn chained_mypy_success_then_later_failure_uses_failure_preserving_output() {
1118        let output = "Success: no issues found in 1 source file\nError: node process exploded\n";
1119
1120        let failed = compress_with_registry_exit_code(
1121            "mypy src && node fail.js",
1122            output,
1123            Some(1),
1124            &empty_registry(),
1125        );
1126        assert_ne!(failed.text, "mypy: clean");
1127        assert!(failed.text.contains("Error: node process exploded"));
1128    }
1129
1130    #[test]
1131    fn toml_shortcircuit_is_skipped_for_nonzero_exit() {
1132        let registry = build_registry(
1133            &[(
1134                "wget",
1135                r#"[filter]
1136matches = ["wget"]
1137
1138[shortcircuit]
1139when = '(?s).*'
1140replacement = "wget: ok"
1141"#,
1142            )],
1143            None,
1144            None,
1145        );
1146        let output = "Connecting to example.invalid\nerror: connection refused\n";
1147
1148        let failed = compress_with_registry_exit_code(
1149            "wget https://example.invalid",
1150            output,
1151            Some(1),
1152            &registry,
1153        );
1154        assert_ne!(failed.text, "wget: ok");
1155        assert!(failed.text.contains("error: connection refused"));
1156    }
1157
1158    #[test]
1159    fn unknown_exit_code_keeps_byte_identical_legacy_compressor_output() {
1160        let output =
1161            "Success: no issues found in 1 source file\nError: later chained command failed\n";
1162
1163        let legacy = compress_with_registry_exit_code(
1164            "mypy src && node fail.js",
1165            output,
1166            None,
1167            &empty_registry(),
1168        );
1169        assert_eq!(legacy.text, "mypy: clean");
1170    }
1171
1172    #[test]
1173    fn successful_exit_still_gets_concise_success_summary() {
1174        let output = r#"Running 4 tests using 2 workers
1175
1176  ✓  1 [chromium] › example.spec.ts:5:1 › has title (2.3s)
1177  ✓  2 [chromium] › example.spec.ts:9:1 › get started link (1.8s)
1178  ✓  3 [chromium] › nav.spec.ts:3:1 › navigates (1.2s)
1179  ✓  4 [chromium] › auth.spec.ts:7:1 › logs out (1.0s)
1180
1181  4 passed (6.3s)
1182"#;
1183
1184        let successful =
1185            compress_with_registry_exit_code("playwright test", output, Some(0), &empty_registry());
1186        assert_eq!(successful.text, "playwright: 4 tests passed (6.3s)");
1187    }
1188}
1189
1190#[cfg(test)]
1191mod normalize_command_tests {
1192    use super::*;
1193
1194    #[test]
1195    fn passes_bare_commands_unchanged() {
1196        assert_eq!(normalize_command_for_dispatch("bun test"), None);
1197        assert_eq!(normalize_command_for_dispatch("cargo build"), None);
1198        assert_eq!(normalize_command_for_dispatch("git status"), None);
1199    }
1200
1201    #[test]
1202    fn strips_cd_and_amp_prefix() {
1203        assert_eq!(
1204            normalize_command_for_dispatch("cd /repo && bun test").as_deref(),
1205            Some("bun test")
1206        );
1207        assert_eq!(
1208            normalize_command_for_dispatch("cd /repo/packages/aft && cargo test --release")
1209                .as_deref(),
1210            Some("cargo test --release")
1211        );
1212    }
1213
1214    #[test]
1215    fn strips_cd_and_semicolon_prefix() {
1216        assert_eq!(
1217            normalize_command_for_dispatch("cd /repo; bun test").as_deref(),
1218            Some("bun test")
1219        );
1220    }
1221
1222    #[test]
1223    fn strips_cd_with_quoted_path() {
1224        assert_eq!(
1225            normalize_command_for_dispatch("cd \"/path with space\" && npm install").as_deref(),
1226            Some("npm install")
1227        );
1228    }
1229
1230    #[test]
1231    fn strips_env_assignments() {
1232        assert_eq!(
1233            normalize_command_for_dispatch("env FOO=bar npm install").as_deref(),
1234            Some("npm install")
1235        );
1236        assert_eq!(
1237            normalize_command_for_dispatch("env FOO=bar BAZ=qux RUST_LOG=info cargo test")
1238                .as_deref(),
1239            Some("cargo test")
1240        );
1241    }
1242
1243    #[test]
1244    fn strips_bare_assignment_prefixes() {
1245        assert_eq!(
1246            normalize_command_for_dispatch("NODE_ENV=production npm install").as_deref(),
1247            Some("npm install")
1248        );
1249        assert_eq!(
1250            normalize_command_for_dispatch("FOO=1 BAR=2 cargo test").as_deref(),
1251            Some("cargo test")
1252        );
1253        assert_eq!(
1254            normalize_command_for_dispatch("RUSTFLAGS='-C debug' cargo build").as_deref(),
1255            Some("cargo build")
1256        );
1257    }
1258
1259    #[test]
1260    fn does_not_strip_later_assignment_arguments() {
1261        assert_eq!(normalize_command_for_dispatch("npm install foo=bar"), None);
1262    }
1263
1264    #[test]
1265    fn env_without_assignments_returns_none() {
1266        // `env` alone is the env-listing command, not a prefix.
1267        assert_eq!(
1268            normalize_command_for_dispatch("env npm install").as_deref(),
1269            None
1270        );
1271    }
1272
1273    #[test]
1274    fn strips_timeout_prefix() {
1275        assert_eq!(
1276            normalize_command_for_dispatch("timeout 30 cargo test").as_deref(),
1277            Some("cargo test")
1278        );
1279        assert_eq!(
1280            normalize_command_for_dispatch("timeout 5m bun test").as_deref(),
1281            Some("bun test")
1282        );
1283    }
1284
1285    #[test]
1286    fn strips_nohup_prefix() {
1287        assert_eq!(
1288            normalize_command_for_dispatch("nohup ./long-running-script.sh").as_deref(),
1289            Some("./long-running-script.sh")
1290        );
1291    }
1292
1293    #[test]
1294    fn strips_paren_then_cd_and_amp() {
1295        assert_eq!(
1296            normalize_command_for_dispatch("(cd /repo && bun test").as_deref(),
1297            Some("bun test")
1298        );
1299    }
1300
1301    #[test]
1302    fn chains_multiple_prefixes() {
1303        // env then timeout then real command.
1304        assert_eq!(
1305            normalize_command_for_dispatch("env FOO=bar timeout 30 cargo test").as_deref(),
1306            Some("cargo test")
1307        );
1308        // cd then env then real command.
1309        assert_eq!(
1310            normalize_command_for_dispatch("cd /repo && env FOO=bar npm install").as_deref(),
1311            Some("npm install")
1312        );
1313    }
1314
1315    // -------- end-to-end dispatch via normalize() --------
1316
1317    fn empty_registry() -> FilterRegistry {
1318        FilterRegistry::default()
1319    }
1320
1321    #[test]
1322    fn cd_prefix_bun_test_still_routes_to_bun_test() {
1323        let output = "bun test v1.3.14\n\nsrc/a.test.ts:\n(pass) ok [0.1ms]\n\n 1 pass\n 0 fail\n 1 expect() calls\nRan 1 tests across 1 files. [1.00ms]\n";
1324        let compressed = compress_with_registry("cd /repo && bun test", output, &empty_registry());
1325        // The bun test compressor produces (pass) / "1 pass" / "Ran ..." in
1326        // the pass-only path. Generic middle-truncate would drop these and
1327        // keep the original. Asserting their presence proves the normalizer
1328        // succeeded.
1329        assert!(compressed.contains("(pass)") || compressed.contains("1 pass"));
1330    }
1331
1332    #[test]
1333    fn cd_prefix_cargo_test_still_routes_to_cargo() {
1334        let output = "running 5 tests\ntest foo ... ok\ntest bar ... FAILED\n\nfailures:\n\ntest result: FAILED. 4 passed; 1 failed\n";
1335        let compressed =
1336            compress_with_registry("cd /repo && cargo test", output, &empty_registry());
1337        assert!(compressed.contains("FAILED") || compressed.contains("failed"));
1338    }
1339
1340    #[test]
1341    fn env_prefix_npm_install_still_routes_to_npm() {
1342        let output = "added 50 packages, and audited 100 packages in 3s\n";
1343        let compressed = compress_with_registry(
1344            "env NODE_ENV=production npm install",
1345            output,
1346            &empty_registry(),
1347        );
1348        // NpmCompressor's install path keeps "added N packages" / "audited" markers.
1349        assert!(compressed.contains("added") || compressed.contains("audited"));
1350    }
1351
1352    #[test]
1353    fn bare_assignment_prefix_npm_install_routes_to_npm() {
1354        let output = "npm http fetch GET 200 https://registry.npmjs.org/foo 123ms\nnpm WARN deprecated old-pkg@1.0.0: use new-pkg instead\n\nadded 42 packages in 2s\n\naudited 100 packages in 2s\n\nfound 0 vulnerabilities\n";
1355        let compressed =
1356            compress_with_registry("NODE_ENV=production npm install", output, &empty_registry());
1357        assert!(!compressed.contains("npm http fetch"));
1358        assert!(compressed.contains("audited 100 packages"));
1359    }
1360
1361    #[test]
1362    fn bare_assignment_prefix_cargo_test_routes_to_cargo() {
1363        let output = "running 1 test\ntest foo ... ok\n\ntest result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out\n";
1364        let compressed =
1365            compress_with_registry("FOO=1 BAR=2 cargo test", output, &empty_registry());
1366        assert!(compressed.contains("running 1 test"));
1367        assert!(compressed.contains("test result: ok"));
1368        assert!(!compressed.contains("test foo ... ok"));
1369    }
1370
1371    #[test]
1372    fn quoted_assignment_prefix_cargo_build_routes_to_cargo() {
1373        let output = "   Compiling foo v0.1.0\nwarning: unused variable: `x`\n --> src/lib.rs:1:9\n  |\n1 |     let x = 1;\n  |         ^ help: if this is intentional, prefix it with an underscore: `_x`\n\n    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.12s\n";
1374        let compressed = compress_with_registry(
1375            "RUSTFLAGS='-C debug' cargo build",
1376            output,
1377            &empty_registry(),
1378        );
1379        assert!(!compressed.contains("Compiling foo"));
1380        assert!(compressed.contains("warning: unused variable"));
1381        assert!(compressed.contains("Finished `dev` profile"));
1382    }
1383
1384    #[test]
1385    fn timeout_prefix_cargo_build_still_routes_to_cargo() {
1386        let output =
1387            "   Compiling foo v0.1.0\n    Finished `dev` profile [unoptimized] target(s) in 5s\n";
1388        let compressed =
1389            compress_with_registry("timeout 30 cargo build", output, &empty_registry());
1390        // CargoCompressor for build/check/run preserves the structure.
1391        assert!(compressed.contains("Compiling") || compressed.contains("Finished"));
1392    }
1393}