Skip to main content

aft/compress/
mod.rs

1//! Output compression for hoisted bash.
2//!
3//! Compression has four 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. **Package-manager [`Compressor`] modules** — broad head-token matchers
9//!    (`npm`, `pnpm`, `bun`) that compress unclaimed package-manager output.
10//! 3. **TOML filters** — declarative strip + truncate + cap + shortcircuit
11//!    rules for the long tail of CLI tools. Loaded from builtin / user /
12//!    project sources via [`toml_filter::build_registry`]. See
13//!    [`toml_filter`] and [`trust`] for the trust model.
14//! 4. **[`generic`] fallback** — ANSI strip + consecutive-dedup +
15//!    middle-truncate. Always applies when no Rust module or TOML filter
16//!    matches.
17
18pub mod biome;
19pub mod builtin_filters;
20pub mod bun;
21pub mod cargo;
22pub mod eslint;
23pub mod generic;
24pub mod git;
25pub mod go;
26pub mod mypy;
27pub mod next;
28pub mod npm;
29pub mod playwright;
30pub mod pnpm;
31pub mod prettier;
32pub mod pytest;
33pub mod ruff;
34pub mod toml_filter;
35pub mod trust;
36pub mod tsc;
37pub mod vitest;
38
39use crate::context::AppContext;
40use biome::BiomeCompressor;
41use bun::BunCompressor;
42use cargo::CargoCompressor;
43use eslint::EslintCompressor;
44use generic::{strip_ansi, GenericCompressor};
45use git::GitCompressor;
46use go::{GoCompressor, GolangciLintCompressor};
47use mypy::MypyCompressor;
48use next::NextCompressor;
49use npm::NpmCompressor;
50use playwright::PlaywrightCompressor;
51use pnpm::PnpmCompressor;
52use prettier::PrettierCompressor;
53use pytest::PytestCompressor;
54use ruff::RuffCompressor;
55use std::path::{Path, PathBuf};
56use std::sync::{Arc, RwLock};
57use toml_filter::{apply_filter, FilterRegistry};
58use tsc::TscCompressor;
59use vitest::VitestCompressor;
60
61/// Thread-safe handle to the TOML filter registry. Shared between
62/// `AppContext::filter_registry()` (for direct use in command handlers) and
63/// `BgTaskRegistry`'s output compression closure (for use from the watchdog
64/// thread).
65pub type SharedFilterRegistry = Arc<RwLock<FilterRegistry>>;
66
67/// How specifically a compressor identifies a command.
68///
69/// `Specific` matchers (vitest, eslint, biome, tsc, pytest, cargo, git)
70/// claim a command by recognising a SPECIFIC tool name as a token anywhere
71/// in the command line — `npx vitest`, `pnpm exec eslint --fix`,
72/// `bun run vitest`, etc.
73///
74/// `PackageManager` matchers (npm, pnpm, bun) claim a command by its
75/// HEAD token alone (e.g. `npm`, `bun`) regardless of what subcommand
76/// follows. They are intentionally broad — when a `bun run vitest` is
77/// not claimed by VitestCompressor, BunCompressor still wants the chance
78/// to compress generic bun output for unknown subcommands.
79///
80/// Dispatch order: Specific tier first, then PackageManager tier, then
81/// TOML filters, then GenericCompressor.
82#[derive(Clone, Copy, Debug, PartialEq, Eq)]
83pub enum Specificity {
84    Specific,
85    PackageManager,
86}
87
88/// A `Compressor` knows how to reduce one specific command's output to fewer
89/// tokens while preserving the information the agent needs.
90pub trait Compressor {
91    /// Returns true if this compressor handles the given command head + args.
92    /// Called after generic detection (ANSI strip, dedup) so this is per-command logic only.
93    fn matches(&self, command: &str) -> bool;
94
95    /// Compress the output. Original is left untouched if compression fails.
96    fn compress(&self, command: &str, output: &str) -> String;
97
98    fn specificity(&self) -> Specificity {
99        Specificity::Specific
100    }
101}
102
103/// Top-level dispatch: try specific Rust modules, package-manager modules, TOML filters, then generic fallback.
104///
105/// Convenience wrapper for command handlers that already hold an `AppContext`.
106/// Backs onto [`compress_with_registry`] which is thread-safe for use from the
107/// `BgTaskRegistry` watchdog.
108pub fn compress(command: &str, output: String, ctx: &AppContext) -> String {
109    if !ctx.config().experimental_bash_compress {
110        return output;
111    }
112    let registry_handle = ctx.shared_filter_registry();
113    let guard = match registry_handle.read() {
114        Ok(g) => g,
115        Err(poisoned) => poisoned.into_inner(),
116    };
117    compress_with_registry(command, &output, &guard)
118}
119
120/// Thread-safe dispatch that does not need `AppContext`. Caller is responsible
121/// for the `experimental_bash_compress` gate (the registry has no opinion).
122///
123/// Used from background threads (notably the `BgTaskRegistry` watchdog and
124/// completion-frame emitter) where lock-free access is required.
125pub fn compress_with_registry(command: &str, output: &str, registry: &FilterRegistry) -> String {
126    let stripped_for_generic = strip_ansi(output);
127
128    // Normalize the command so shell-prefix idioms like `cd /path && bun test`,
129    // `env FOO=bar npm install`, `timeout 30 cargo build`, and `(cd /path; cmd)`
130    // don't hide the real command head from per-module matchers. Without this,
131    // BunCompressor/NpmCompressor/PnpmCompressor (which match by head-token)
132    // silently fall through to generic in most agent-issued bash calls.
133    let normalized = normalize_command_for_dispatch(command);
134    let dispatch_cmd = normalized.as_deref().unwrap_or(command);
135
136    let compressors: [&dyn Compressor; 17] = [
137        &GitCompressor,
138        &CargoCompressor,
139        &TscCompressor,
140        &NpmCompressor,
141        &BunCompressor,
142        &PnpmCompressor,
143        &PytestCompressor,
144        &EslintCompressor,
145        &VitestCompressor,
146        &BiomeCompressor,
147        &PrettierCompressor,
148        &RuffCompressor,
149        &MypyCompressor,
150        &GoCompressor,
151        &GolangciLintCompressor,
152        &PlaywrightCompressor,
153        &NextCompressor,
154    ];
155
156    // Tier 1a: Specific compressors win first.
157    for compressor in compressors
158        .iter()
159        .filter(|c| c.specificity() == Specificity::Specific)
160    {
161        if compressor.matches(dispatch_cmd) {
162            return compressor.compress(dispatch_cmd, &stripped_for_generic);
163        }
164    }
165
166    // Tier 1b: PackageManager compressors get unclaimed commands.
167    for compressor in compressors
168        .iter()
169        .filter(|c| c.specificity() == Specificity::PackageManager)
170    {
171        if compressor.matches(dispatch_cmd) {
172            return compressor.compress(dispatch_cmd, &stripped_for_generic);
173        }
174    }
175
176    // Tier 2: TOML filters. Pass raw output so `[ansi].strip = false` filters
177    // can intentionally match escape sequences; `apply_filter` owns ANSI policy.
178    if let Some(filter) = registry.lookup(dispatch_cmd) {
179        return apply_filter(filter, output);
180    }
181
182    // Tier 3: generic fallback.
183    GenericCompressor.compress(command, &stripped_for_generic)
184}
185
186/// Build the registry of TOML filters from the standard sources for the
187/// active context. Called lazily by [`AppContext::filter_registry`].
188///
189/// Layering (highest priority first):
190/// 1. Project filters at `<project_root>/.aft/filters/*.toml` — loaded only
191///    when the project is in the trusted set (see [`trust`]).
192/// 2. User filters at `<storage_dir>/filters/*.toml`.
193/// 3. Builtin filters compiled into the binary via [`builtin_filters`].
194pub fn build_registry_for_context(ctx: &AppContext) -> FilterRegistry {
195    let config = ctx.config();
196    let storage_dir = config.storage_dir.clone();
197    let project_root = config.project_root.clone();
198    drop(config);
199
200    let user_dir = storage_dir.as_ref().map(|d| d.join("filters"));
201    let project_dir = match (project_root.as_ref(), storage_dir.as_ref()) {
202        (Some(root), Some(storage)) => {
203            if trust::is_project_trusted(Some(storage), root) {
204                Some(root.join(".aft").join("filters"))
205            } else {
206                None
207            }
208        }
209        _ => None,
210    };
211
212    toml_filter::build_registry(
213        builtin_filters::ALL,
214        user_dir.as_deref(),
215        project_dir.as_deref(),
216    )
217}
218
219/// Normalize a shell command for compressor dispatch by walking past
220/// common shell-prefix idioms so the REAL command head is what matchers
221/// see. Returns `Some(normalized)` if a prefix was stripped, `None` if
222/// the input was already a bare command.
223///
224/// Handles:
225///   - `cd /path && cmd ...`            → `cmd ...`
226///   - `cd /path; cmd ...`              → `cmd ...`
227///   - `env FOO=bar [BAR=baz ...] cmd`  → `cmd ...`
228///   - `timeout 30 cmd ...`             → `cmd ...`
229///   - `nohup cmd ...`                  → `cmd ...`
230///   - `(cd /path && cmd ...)`          → `cmd ...`   (trailing `)` is kept; harmless for matchers)
231///
232/// Real agent invocations almost always wrap their actual command in
233/// `cd "$ROOT" && ...`. Without this normalization, BunCompressor /
234/// NpmCompressor / PnpmCompressor (head-token matchers) and the
235/// pkg-manager filters silently fall through to GenericCompressor for
236/// the majority of agent bash calls.
237///
238/// The normalizer is conservative: it only strips well-defined idioms
239/// and bails on anything ambiguous, so a malformed command degrades to
240/// the same dispatch behaviour as before this helper existed.
241pub fn normalize_command_for_dispatch(command: &str) -> Option<String> {
242    let trimmed = command.trim_start();
243    if trimmed.is_empty() {
244        return None;
245    }
246
247    // Step 1: peel a leading `(` from group-expression idioms.
248    let (open_paren, after_paren) = if let Some(rest) = trimmed.strip_prefix('(') {
249        (true, rest.trim_start())
250    } else {
251        (false, trimmed)
252    };
253
254    let mut current = after_paren.to_string();
255    let mut changed = open_paren;
256
257    // Step 2: iteratively peel known shell prefixes.
258    loop {
259        let head: String = current.split_whitespace().next().unwrap_or("").to_string();
260
261        // `cd <path> && ...` or `cd <path>; ...`
262        if head == "cd" {
263            // Find the next `&&` or `;` token; everything after that is the real command.
264            // Use char-level scan because `&&` is two chars not separated by whitespace.
265            if let Some(stripped) = strip_cd_prefix(&current) {
266                current = stripped;
267                changed = true;
268                continue;
269            }
270        }
271
272        // `env VAR=val [VAR=val ...] cmd ...`
273        if head == "env" {
274            if let Some(stripped) = strip_env_prefix(&current) {
275                current = stripped;
276                changed = true;
277                continue;
278            }
279        }
280
281        // `timeout <N> cmd ...` or `timeout <duration-with-unit> cmd ...`
282        if head == "timeout" {
283            if let Some(stripped) = strip_timeout_prefix(&current) {
284                current = stripped;
285                changed = true;
286                continue;
287            }
288        }
289
290        // `nohup cmd ...`
291        if head == "nohup" {
292            if let Some(rest) = current.strip_prefix("nohup").and_then(|s| {
293                let trimmed = s.trim_start();
294                if trimmed.is_empty() {
295                    None
296                } else {
297                    Some(trimmed.to_string())
298                }
299            }) {
300                current = rest;
301                changed = true;
302                continue;
303            }
304        }
305
306        break;
307    }
308
309    if changed {
310        Some(current)
311    } else {
312        None
313    }
314}
315
316fn strip_cd_prefix(command: &str) -> Option<String> {
317    // Look for `&&` or `;` outside of quotes.
318    let bytes = command.as_bytes();
319    let mut in_single = false;
320    let mut in_double = false;
321    let mut i = 0;
322    while i < bytes.len() {
323        let ch = bytes[i] as char;
324        if !in_double && ch == '\'' {
325            in_single = !in_single;
326        } else if !in_single && ch == '"' {
327            in_double = !in_double;
328        } else if !in_single && !in_double {
329            if ch == '&' && i + 1 < bytes.len() && bytes[i + 1] as char == '&' {
330                let rest = command[i + 2..].trim_start();
331                if rest.is_empty() {
332                    return None;
333                }
334                return Some(rest.to_string());
335            }
336            if ch == ';' {
337                let rest = command[i + 1..].trim_start();
338                if rest.is_empty() {
339                    return None;
340                }
341                return Some(rest.to_string());
342            }
343        }
344        i += 1;
345    }
346    None
347}
348
349fn strip_env_prefix(command: &str) -> Option<String> {
350    // env <ASSIGN>... <cmd> ...
351    // ASSIGN looks like VAR=value (no leading dash).
352    let rest = command.strip_prefix("env")?.trim_start();
353    let mut tokens = rest.split_whitespace().peekable();
354    let mut consumed = 0usize;
355    while let Some(&token) = tokens.peek() {
356        if !is_env_assignment(token) {
357            break;
358        }
359        consumed += token.len();
360        // count whitespace between this and next
361        tokens.next();
362    }
363    if consumed == 0 {
364        return None;
365    }
366    // Re-walk rest to find the byte offset after the last assignment.
367    let mut idx = 0usize;
368    let mut consumed_now = 0usize;
369    let bytes = rest.as_bytes();
370    while consumed_now < consumed && idx < bytes.len() {
371        // skip whitespace
372        while idx < bytes.len() && (bytes[idx] as char).is_whitespace() {
373            idx += 1;
374        }
375        // consume token
376        let token_start = idx;
377        while idx < bytes.len() && !(bytes[idx] as char).is_whitespace() {
378            idx += 1;
379        }
380        consumed_now += idx - token_start;
381    }
382    let after = rest[idx..].trim_start();
383    if after.is_empty() {
384        None
385    } else {
386        Some(after.to_string())
387    }
388}
389
390fn is_env_assignment(token: &str) -> bool {
391    if token.starts_with('-') {
392        return false;
393    }
394    let Some((name, _value)) = token.split_once('=') else {
395        return false;
396    };
397    !name.is_empty()
398        && name
399            .chars()
400            .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
401}
402
403fn strip_timeout_prefix(command: &str) -> Option<String> {
404    let rest = command.strip_prefix("timeout")?.trim_start();
405    // Next token must look like a duration (digits, optional trailing unit s/m/h).
406    let mut iter = rest.splitn(2, char::is_whitespace);
407    let duration = iter.next()?;
408    let after = iter.next()?.trim_start();
409    if after.is_empty() || !looks_like_duration(duration) {
410        return None;
411    }
412    Some(after.to_string())
413}
414
415fn looks_like_duration(token: &str) -> bool {
416    if token.is_empty() {
417        return false;
418    }
419    let mut chars = token.chars().peekable();
420    let mut saw_digit = false;
421    while let Some(&ch) = chars.peek() {
422        if ch.is_ascii_digit() {
423            saw_digit = true;
424            chars.next();
425        } else {
426            break;
427        }
428    }
429    if !saw_digit {
430        return false;
431    }
432    match chars.next() {
433        None => true,
434        Some(unit) => matches!(unit, 's' | 'm' | 'h' | 'd') && chars.next().is_none(),
435    }
436}
437
438/// Resolve the user-filter directory for an arbitrary storage_dir. Used by
439/// `aft doctor filters` to inspect filters without needing a live AppContext.
440pub fn user_filter_dir(storage_dir: &Path) -> PathBuf {
441    storage_dir.join("filters")
442}
443
444/// Resolve the project-filter directory for an arbitrary project root.
445/// Returns the directory regardless of trust state — caller must check trust
446/// separately if it wants to gate loading.
447pub fn project_filter_dir(project_root: &Path) -> PathBuf {
448    project_root.join(".aft").join("filters")
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454
455    #[test]
456    fn user_and_project_filter_dir_helpers() {
457        let storage = Path::new("/tmp/aft-storage");
458        assert_eq!(
459            user_filter_dir(storage),
460            Path::new("/tmp/aft-storage/filters")
461        );
462
463        let project = Path::new("/repo");
464        assert_eq!(project_filter_dir(project), Path::new("/repo/.aft/filters"));
465    }
466}
467
468#[cfg(test)]
469mod dispatch_specificity_tests {
470    use super::*;
471    use crate::compress::toml_filter::FilterRegistry;
472
473    fn empty_registry() -> FilterRegistry {
474        FilterRegistry::default()
475    }
476
477    /// Helper: assert that a given command would be claimed by a specific
478    /// compressor by reading the output marker the compressor produces.
479    /// (We can't easily compare Compressor instances by identity, so we
480    /// dispatch and check for module-distinctive markers in the output.)
481    fn dispatch(cmd: &str, output: &str) -> String {
482        compress_with_registry(cmd, output, &empty_registry())
483    }
484
485    #[test]
486    fn bun_run_vitest_routes_to_vitest_not_generic() {
487        // VitestCompressor preserves PASS/FAIL markers and "Tests:" summary.
488        // BunCompressor's `Some("run")` arm currently goes to generic which
489        // would middle-truncate. Use a small vitest-shaped output and assert
490        // the vitest formatter's output marker is present.
491        let output = "Test Files  1 passed (1)\n     Tests  4 passed (4)\n  Start at  10:00:00\n  Duration  120ms\n";
492        let compressed = dispatch("bun run vitest", output);
493        // Assert vitest path took it: the vitest text summary keeps "Tests" / "Test Files" lines
494        assert!(compressed.contains("Tests") || compressed.contains("Test Files"));
495    }
496
497    #[test]
498    fn npm_test_routes_to_vitest_when_output_is_vitest_shaped() {
499        // npm test typically runs vitest/jest under the hood. With specificity
500        // dispatch, vitest's matches() returns false for "npm test" alone
501        // (vitest's matcher looks for the token "vitest" or "jest").
502        // So this should fall through to NpmCompressor (PackageManager tier).
503        // This is the correct behavior: npm-managed test output is generic
504        // unless we have explicit token evidence of the runner.
505        let output = "added 100 packages, removed 2 packages\n";
506        let _compressed = dispatch("npm test", output);
507        // Just assert it didn't panic and emitted something. The PackageManager
508        // module's `Some("test") => GenericCompressor` is the right fallback
509        // here because we have no token signal.
510    }
511
512    #[test]
513    fn bun_run_vitest_token_match_wins_over_bun_head_match() {
514        // Concrete proof the new dispatch works: a command where Bun would
515        // otherwise have claimed it.
516        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";
517        let compressed = dispatch("bun run vitest run", output);
518        // Vitest preserves PASS lines and "Tests:" summary.
519        assert!(compressed.contains("Test Files") || compressed.contains("PASS"));
520    }
521
522    #[test]
523    fn bunx_jest_routes_to_vitest_module() {
524        let output = "PASS src/foo.test.js (1.2s)\nTest Suites: 1 passed, 1 total\nTests:       3 passed, 3 total\n";
525        let compressed = dispatch("bunx jest --json", output);
526        assert!(compressed.contains("Tests:") && compressed.contains("Test Suites"));
527    }
528
529    #[test]
530    fn pnpm_run_vitest_routes_to_vitest() {
531        let output = "Test Files  1 passed (1)\n     Tests  10 passed (10)\n";
532        let compressed = dispatch("pnpm run vitest", output);
533        assert!(compressed.contains("Tests") || compressed.contains("Test Files"));
534    }
535
536    #[test]
537    fn npx_eslint_routes_to_eslint_not_generic() {
538        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";
539        let compressed = dispatch("npx eslint .", output);
540        // EslintCompressor preserves rule IDs and the ✖ summary.
541        assert!(compressed.contains("no-unused-vars") || compressed.contains("✖"));
542    }
543
544    #[test]
545    fn npm_run_lint_with_eslint_token_routes_to_eslint() {
546        // `npm run lint` typically runs eslint, but the command string is
547        // just "npm run lint" — no eslint token. This should fall through
548        // to NpmCompressor's PackageManager tier (which then dispatches to
549        // generic for "run"). That's correct: we don't have token evidence.
550        let output = "> my-project@1.0.0 lint\n> eslint .\n\nAll good.\n";
551        let _compressed = dispatch("npm run lint", output);
552        // No assertion needed — just verifying no panic. The behavior is
553        // correctly "fall through to generic" because the command string
554        // has no eslint token.
555    }
556
557    #[test]
558    fn bun_test_still_routes_to_bun_test_compressor() {
559        // Bun.test is the v0.28.2 fix — make sure specificity dispatch
560        // doesn't accidentally break it. The Bun module's `Some("test")`
561        // arm should still claim this when no Specific matcher does.
562        // BunTestCompressor doesn't exist as a separate module — the
563        // BunCompressor.compress() routes Some("test") to its inner
564        // compress_test() function. The relevant assertion: this still
565        // produces bun-test-shaped output, not generic-truncated output.
566        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";
567        let compressed = dispatch("bun test", output);
568        assert!(compressed.contains("(pass)") || compressed.contains("1 pass"));
569    }
570
571    #[test]
572    fn bunx_vitest_routes_to_vitest() {
573        let output = "Test Files  1 passed (1)\n     Tests  3 passed (3)\n";
574        let compressed = dispatch("bunx vitest run", output);
575        assert!(compressed.contains("Tests") || compressed.contains("Test Files"));
576    }
577
578    #[test]
579    fn cargo_test_still_routes_to_cargo() {
580        // Regression: specificity reordering must not break commands that
581        // already worked. Cargo is Specific tier.
582        let output = "running 5 tests\ntest foo ... ok\ntest bar ... FAILED\n\nfailures:\n\ntest result: FAILED. 4 passed; 1 failed\n";
583        let compressed = dispatch("cargo test", output);
584        // Cargo's test compressor preserves PASS/FAIL semantics.
585        assert!(compressed.contains("failed") || compressed.contains("FAILED"));
586    }
587
588    #[test]
589    fn git_status_still_routes_to_git() {
590        // Regression: git is Specific tier.
591        let output =
592            "On branch main\nYour branch is up to date.\n\nnothing to commit, working tree clean\n";
593        let compressed = dispatch("git status", output);
594        assert!(compressed.contains("branch") || compressed.contains("clean"));
595    }
596
597    #[test]
598    fn pnpm_install_still_routes_to_pnpm() {
599        // Regression: pnpm install was handled before this change.
600        let output = "Progress: resolved 100, downloaded 50, added 50\nAdded 50 packages\n";
601        let compressed = dispatch("pnpm install", output);
602        // PnpmCompressor's compress_package keeps "+ pkg" or "Added X packages" type lines.
603        assert!(compressed.contains("Added") || compressed.contains("Progress"));
604    }
605}
606
607#[cfg(test)]
608mod normalize_command_tests {
609    use super::*;
610
611    #[test]
612    fn passes_bare_commands_unchanged() {
613        assert_eq!(normalize_command_for_dispatch("bun test"), None);
614        assert_eq!(normalize_command_for_dispatch("cargo build"), None);
615        assert_eq!(normalize_command_for_dispatch("git status"), None);
616    }
617
618    #[test]
619    fn strips_cd_and_amp_prefix() {
620        assert_eq!(
621            normalize_command_for_dispatch("cd /repo && bun test").as_deref(),
622            Some("bun test")
623        );
624        assert_eq!(
625            normalize_command_for_dispatch("cd /repo/packages/aft && cargo test --release")
626                .as_deref(),
627            Some("cargo test --release")
628        );
629    }
630
631    #[test]
632    fn strips_cd_and_semicolon_prefix() {
633        assert_eq!(
634            normalize_command_for_dispatch("cd /repo; bun test").as_deref(),
635            Some("bun test")
636        );
637    }
638
639    #[test]
640    fn strips_cd_with_quoted_path() {
641        assert_eq!(
642            normalize_command_for_dispatch("cd \"/path with space\" && npm install").as_deref(),
643            Some("npm install")
644        );
645    }
646
647    #[test]
648    fn strips_env_assignments() {
649        assert_eq!(
650            normalize_command_for_dispatch("env FOO=bar npm install").as_deref(),
651            Some("npm install")
652        );
653        assert_eq!(
654            normalize_command_for_dispatch("env FOO=bar BAZ=qux RUST_LOG=info cargo test")
655                .as_deref(),
656            Some("cargo test")
657        );
658    }
659
660    #[test]
661    fn env_without_assignments_returns_none() {
662        // `env` alone is the env-listing command, not a prefix.
663        assert_eq!(
664            normalize_command_for_dispatch("env npm install").as_deref(),
665            None
666        );
667    }
668
669    #[test]
670    fn strips_timeout_prefix() {
671        assert_eq!(
672            normalize_command_for_dispatch("timeout 30 cargo test").as_deref(),
673            Some("cargo test")
674        );
675        assert_eq!(
676            normalize_command_for_dispatch("timeout 5m bun test").as_deref(),
677            Some("bun test")
678        );
679    }
680
681    #[test]
682    fn strips_nohup_prefix() {
683        assert_eq!(
684            normalize_command_for_dispatch("nohup ./long-running-script.sh").as_deref(),
685            Some("./long-running-script.sh")
686        );
687    }
688
689    #[test]
690    fn strips_paren_then_cd_and_amp() {
691        assert_eq!(
692            normalize_command_for_dispatch("(cd /repo && bun test").as_deref(),
693            Some("bun test")
694        );
695    }
696
697    #[test]
698    fn chains_multiple_prefixes() {
699        // env then timeout then real command.
700        assert_eq!(
701            normalize_command_for_dispatch("env FOO=bar timeout 30 cargo test").as_deref(),
702            Some("cargo test")
703        );
704        // cd then env then real command.
705        assert_eq!(
706            normalize_command_for_dispatch("cd /repo && env FOO=bar npm install").as_deref(),
707            Some("npm install")
708        );
709    }
710
711    // -------- end-to-end dispatch via normalize() --------
712
713    fn empty_registry() -> FilterRegistry {
714        FilterRegistry::default()
715    }
716
717    #[test]
718    fn cd_prefix_bun_test_still_routes_to_bun_test() {
719        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";
720        let compressed = compress_with_registry("cd /repo && bun test", output, &empty_registry());
721        // The bun test compressor produces (pass) / "1 pass" / "Ran ..." in
722        // the pass-only path. Generic middle-truncate would drop these and
723        // keep the original. Asserting their presence proves the normalizer
724        // succeeded.
725        assert!(compressed.contains("(pass)") || compressed.contains("1 pass"));
726    }
727
728    #[test]
729    fn cd_prefix_cargo_test_still_routes_to_cargo() {
730        let output = "running 5 tests\ntest foo ... ok\ntest bar ... FAILED\n\nfailures:\n\ntest result: FAILED. 4 passed; 1 failed\n";
731        let compressed =
732            compress_with_registry("cd /repo && cargo test", output, &empty_registry());
733        assert!(compressed.contains("FAILED") || compressed.contains("failed"));
734    }
735
736    #[test]
737    fn env_prefix_npm_install_still_routes_to_npm() {
738        let output = "added 50 packages, and audited 100 packages in 3s\n";
739        let compressed = compress_with_registry(
740            "env NODE_ENV=production npm install",
741            output,
742            &empty_registry(),
743        );
744        // NpmCompressor's install path keeps "added N packages" / "audited" markers.
745        assert!(compressed.contains("added") || compressed.contains("audited"));
746    }
747
748    #[test]
749    fn timeout_prefix_cargo_build_still_routes_to_cargo() {
750        let output =
751            "   Compiling foo v0.1.0\n    Finished `dev` profile [unoptimized] target(s) in 5s\n";
752        let compressed =
753            compress_with_registry("timeout 30 cargo build", output, &empty_registry());
754        // CargoCompressor for build/check/run preserves the structure.
755        assert!(compressed.contains("Compiling") || compressed.contains("Finished"));
756    }
757}