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