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