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