Skip to main content

aft/compress/
mod.rs

1//! Output compression for hoisted bash.
2//!
3//! Compression has five tiers, tried in this order:
4//!
5//! 1. **Specific Rust [`Compressor`] modules** — hand-written parsers for
6//!    specific tools identified by tool tokens (for example `vitest`, `eslint`,
7//!    `cargo`, `git`). These win before broad package-manager compressors.
8//! 2. **Output-shape [`Compressor`] sniffers** — inner-tool parsers that can
9//!    recognize their own private summaries even when invoked through wrappers
10//!    such as `npm test`, `make test`, or `./scripts/check.sh`.
11//! 3. **Package-manager [`Compressor`] modules** — broad head-token matchers
12//!    (`npm`, `pnpm`, `bun`) that compress unclaimed package-manager output.
13//! 4. **TOML filters** — declarative strip + truncate + cap + shortcircuit
14//!    rules for the long tail of CLI tools. Loaded from builtin / user /
15//!    project sources via [`toml_filter::build_registry`]. See
16//!    [`toml_filter`] and [`trust`] for the trust model.
17//! 5. **[`generic`] fallback** — ANSI strip + consecutive-dedup. The
18//!    background bash registry owns the shared final output cap.
19
20pub mod biome;
21pub mod builtin_filters;
22pub mod bun;
23pub mod caps;
24pub mod cargo;
25pub mod eslint;
26pub mod find;
27pub mod generic;
28pub mod git;
29pub mod go;
30pub mod listing_fold;
31pub mod ls;
32pub mod mypy;
33pub mod next;
34pub mod npm;
35pub mod playwright;
36pub mod pnpm;
37pub mod prettier;
38pub mod pytest;
39pub mod ruff;
40pub mod toml_filter;
41pub mod tree;
42pub mod trust;
43pub mod tsc;
44pub mod vitest;
45
46use crate::context::AppContext;
47use crate::harness::Harness;
48use biome::BiomeCompressor;
49use bun::BunCompressor;
50use caps::DropClass;
51use cargo::CargoCompressor;
52use eslint::EslintCompressor;
53use find::FindCompressor;
54use generic::{strip_ansi, GenericCompressor};
55use git::GitCompressor;
56use go::{GoCompressor, GolangciLintCompressor};
57use ls::LsCompressor;
58use mypy::MypyCompressor;
59use next::NextCompressor;
60use npm::NpmCompressor;
61use playwright::PlaywrightCompressor;
62use pnpm::PnpmCompressor;
63use prettier::PrettierCompressor;
64use pytest::PytestCompressor;
65use ruff::RuffCompressor;
66use std::cell::OnceCell;
67use std::collections::{BTreeMap, BTreeSet};
68use std::fs;
69use std::path::{Path, PathBuf};
70use std::sync::{Arc, RwLock};
71use toml_filter::{apply_filter_with_exit_code, FilterRegistry};
72use tree::TreeCompressor;
73use tsc::TscCompressor;
74use vitest::VitestCompressor;
75
76/// Thread-safe handle to the TOML filter registry. Shared between
77/// `AppContext::filter_registry()` (for direct use in command handlers) and
78/// `BgTaskRegistry`'s output compression closure (for use from the watchdog
79/// thread).
80pub type SharedFilterRegistry = Arc<RwLock<FilterRegistry>>;
81
82/// How specifically a compressor identifies a command.
83///
84/// `Specific` matchers (vitest, eslint, biome, tsc, pytest, cargo, git)
85/// claim a command by recognising a SPECIFIC tool name as a token anywhere
86/// in the command line — `npx vitest`, `pnpm exec eslint --fix`,
87/// `bun run vitest`, etc.
88///
89/// `PackageManager` matchers (npm, pnpm, bun) claim a command by its
90/// HEAD token alone (e.g. `npm`, `bun`) regardless of what subcommand
91/// follows. They are intentionally broad — when a `bun run vitest` is
92/// not claimed by VitestCompressor, BunCompressor still wants the chance
93/// to compress generic bun output for unknown subcommands.
94///
95/// Dispatch order: Specific command tier first, then output-shape sniffers
96/// (Specific before PackageManager), then PackageManager command tier, then
97/// TOML filters, then GenericCompressor.
98#[derive(Clone, Copy, Debug, PartialEq, Eq)]
99pub enum Specificity {
100    Specific,
101    PackageManager,
102}
103
104/// Shared, per-dispatch view of command output for output-shape matchers.
105///
106/// Several matchers recognize JSON by inspecting different top-level keys. A
107/// probe lets them share one lazy parse while leaving each matcher's decision
108/// and dispatch priority unchanged.
109pub struct OutputProbe<'a> {
110    output: &'a str,
111    json: OnceCell<Option<serde_json::Value>>,
112}
113
114impl<'a> OutputProbe<'a> {
115    pub fn new(output: &'a str) -> Self {
116        Self {
117            output,
118            json: OnceCell::new(),
119        }
120    }
121
122    pub fn output(&self) -> &'a str {
123        self.output
124    }
125
126    /// Parse plausible JSON at most once for all matchers in this dispatch.
127    pub fn json(&self) -> Option<&serde_json::Value> {
128        self.json
129            .get_or_init(|| {
130                let trimmed = self.output.trim_start();
131                if !matches!(trimmed.as_bytes().first(), Some(b'{') | Some(b'[')) {
132                    return None;
133                }
134                record_output_probe_json_parse();
135                serde_json::from_str(trimmed).ok()
136            })
137            .as_ref()
138    }
139}
140
141#[cfg(debug_assertions)]
142thread_local! {
143    static OUTPUT_PROBE_JSON_PARSE_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
144}
145
146#[cfg(debug_assertions)]
147fn record_output_probe_json_parse() {
148    OUTPUT_PROBE_JSON_PARSE_COUNT.with(|count| count.set(count.get() + 1));
149}
150
151#[cfg(not(debug_assertions))]
152fn record_output_probe_json_parse() {}
153
154#[cfg(all(debug_assertions, test))]
155fn reset_output_probe_json_parse_count() {
156    OUTPUT_PROBE_JSON_PARSE_COUNT.with(|count| count.set(0));
157}
158
159#[cfg(all(debug_assertions, test))]
160fn output_probe_json_parse_count() -> usize {
161    OUTPUT_PROBE_JSON_PARSE_COUNT.with(std::cell::Cell::get)
162}
163
164#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct CompressionResult {
166    pub text: String,
167    pub dropped_by_class: BTreeMap<DropClass, usize>,
168    pub had_inner_drop: bool,
169    pub offset_hint_eligible: bool,
170    pub offset_start_line: Option<usize>,
171}
172
173impl CompressionResult {
174    pub fn new(text: impl Into<String>) -> Self {
175        Self {
176            text: text.into(),
177            dropped_by_class: BTreeMap::new(),
178            had_inner_drop: false,
179            offset_hint_eligible: true,
180            offset_start_line: None,
181        }
182    }
183
184    pub fn with_class_drops(
185        text: impl Into<String>,
186        dropped_by_class: BTreeMap<DropClass, usize>,
187    ) -> Self {
188        let had_inner_drop = !dropped_by_class.is_empty();
189        Self {
190            text: text.into(),
191            dropped_by_class,
192            had_inner_drop,
193            offset_hint_eligible: !had_inner_drop,
194            offset_start_line: None,
195        }
196    }
197
198    pub fn with_inner_drop(text: impl Into<String>, offset_hint_eligible: bool) -> Self {
199        Self {
200            text: text.into(),
201            dropped_by_class: BTreeMap::new(),
202            had_inner_drop: true,
203            offset_hint_eligible,
204            offset_start_line: None,
205        }
206    }
207
208    pub fn with_prefix_drop(text: impl Into<String>, offset_start_line: usize) -> Self {
209        Self {
210            text: text.into(),
211            dropped_by_class: BTreeMap::new(),
212            had_inner_drop: true,
213            offset_hint_eligible: true,
214            offset_start_line: Some(offset_start_line),
215        }
216    }
217
218    pub fn has_semantic_drops(&self) -> bool {
219        !self.dropped_by_class.is_empty()
220    }
221
222    pub fn has_any_drop(&self) -> bool {
223        self.had_inner_drop || self.has_semantic_drops()
224    }
225
226    pub fn map_text<F>(mut self, f: F) -> Self
227    where
228        F: FnOnce(&str) -> String,
229    {
230        self.text = f(&self.text);
231        self
232    }
233}
234
235impl std::fmt::Display for CompressionResult {
236    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
237        f.write_str(&self.text)
238    }
239}
240
241impl std::ops::Deref for CompressionResult {
242    type Target = str;
243
244    fn deref(&self) -> &Self::Target {
245        &self.text
246    }
247}
248
249impl PartialEq<&str> for CompressionResult {
250    fn eq(&self, other: &&str) -> bool {
251        self.text == *other
252    }
253}
254
255impl PartialEq<String> for CompressionResult {
256    fn eq(&self, other: &String) -> bool {
257        self.text == *other
258    }
259}
260
261impl From<String> for CompressionResult {
262    fn from(text: String) -> Self {
263        Self::new(text)
264    }
265}
266
267impl From<&str> for CompressionResult {
268    fn from(text: &str) -> Self {
269        Self::new(text)
270    }
271}
272
273/// A `Compressor` knows how to reduce one specific command's output to fewer
274/// tokens while preserving the information the agent needs.
275pub trait Compressor: Send + Sync {
276    /// Returns true if this compressor handles the given command head + args.
277    /// Called after generic detection (ANSI strip, dedup) so this is per-command logic only.
278    fn matches(&self, command: &str) -> bool;
279
280    /// Compress the output when the process exit code is unknown.
281    fn compress(&self, command: &str, output: &str) -> CompressionResult {
282        self.compress_with_exit_code(command, output, None)
283    }
284
285    /// Compress the output. Original is left untouched if compression fails.
286    fn compress_with_exit_code(
287        &self,
288        command: &str,
289        output: &str,
290        exit_code: Option<i32>,
291    ) -> CompressionResult;
292
293    fn specificity(&self) -> Specificity {
294        Specificity::Specific
295    }
296
297    /// Returns true when this compressor recognizes output produced by its
298    /// inner tool even if the command head was a wrapper (`npm test`,
299    /// `make test`, `./scripts/check.sh`, etc.). Wrapper compressors should
300    /// not override this; they remain command-only.
301    fn matches_output(&self, _output: &str) -> bool {
302        false
303    }
304
305    /// Probe-aware output matching. Matchers that need shared derived data can
306    /// override this; all other matchers retain their existing implementation.
307    fn matches_output_probe(&self, probe: &OutputProbe<'_>) -> bool {
308        self.matches_output(probe.output())
309    }
310
311    /// Compress output after an output-shape match when the process exit code is unknown.
312    fn compress_output_match(&self, output: &str) -> CompressionResult {
313        self.compress_output_match_with_exit_code(output, None)
314    }
315
316    /// Compress output after an output-shape match. Compressors that branch by
317    /// subcommand override this to jump directly to the matched branch.
318    fn compress_output_match_with_exit_code(
319        &self,
320        output: &str,
321        exit_code: Option<i32>,
322    ) -> CompressionResult {
323        self.compress_with_exit_code("", output, exit_code)
324    }
325}
326/// Top-level dispatch: try specific Rust modules, output-shape sniffers, package-manager modules, TOML filters, then generic fallback.
327///
328/// Convenience wrapper for command handlers that already hold an `AppContext`.
329/// Backs onto [`compress_with_registry`] which is thread-safe for use from the
330/// `BgTaskRegistry` watchdog.
331pub fn compress(command: &str, output: String, ctx: &AppContext) -> CompressionResult {
332    compress_with_exit_code(command, output, None, ctx)
333}
334
335pub fn compress_with_exit_code(
336    command: &str,
337    output: String,
338    exit_code: Option<i32>,
339    ctx: &AppContext,
340) -> CompressionResult {
341    if !ctx.config().experimental_bash_compress {
342        return CompressionResult::new(output);
343    }
344    let registry_handle = ctx.shared_filter_registry();
345    let guard = match registry_handle.read() {
346        Ok(g) => g,
347        Err(poisoned) => poisoned.into_inner(),
348    };
349    compress_with_registry_exit_code(command, &output, exit_code, &guard)
350}
351
352/// Thread-safe dispatch that does not need `AppContext`. Caller is responsible
353/// for the `experimental_bash_compress` gate (the registry has no opinion).
354///
355/// Used from background threads (notably the `BgTaskRegistry` watchdog and
356/// completion-frame emitter) where lock-free access is required.
357pub fn compress_with_registry(
358    command: &str,
359    output: &str,
360    registry: &FilterRegistry,
361) -> CompressionResult {
362    compress_with_registry_exit_code(command, output, None, registry)
363}
364
365pub fn compress_with_registry_exit_code(
366    command: &str,
367    output: &str,
368    exit_code: Option<i32>,
369    registry: &FilterRegistry,
370) -> CompressionResult {
371    let stripped_for_generic = strip_ansi(output);
372
373    // Resolve what to dispatch on: peel shell-prefix idioms (`cd /path && bun
374    // test`, `env FOO=bar npm install`, `timeout 30 cargo build`, `(cd /path;
375    // cmd)`) so head-token matchers see the real command. Any top-level pipe
376    // must stay generic: the shell already ran the user's pipeline verbatim, so
377    // the captured output belongs to the pipeline result, not necessarily to the
378    // runner that appeared before `|`.
379    let dispatch_owned = match resolve_dispatch_target(command) {
380        DispatchTarget::Pipeline(_) | DispatchTarget::ForceGeneric => {
381            return GenericCompressor.compress_with_exit_code(
382                command,
383                &stripped_for_generic,
384                exit_code,
385            );
386        }
387        DispatchTarget::Command(cmd) => cmd,
388    };
389    let dispatch_cmd = dispatch_owned.as_str();
390
391    let compressors = compressors_in_dispatch_order();
392
393    // Tier 1a: Specific command compressors win first.
394    for compressor in compressors
395        .iter()
396        .filter(|c| c.specificity() == Specificity::Specific)
397    {
398        if compressor.matches(dispatch_cmd) {
399            let result =
400                compressor.compress_with_exit_code(dispatch_cmd, &stripped_for_generic, exit_code);
401            return failure_preserving_result(command, &stripped_for_generic, result, exit_code);
402        }
403    }
404
405    // Tier 1b: Output-shape sniffers handle wrapped inner tools before broad
406    // package managers or TOML filters can consume `npm test`, `make test`,
407    // `just test`, etc. Collision order is deterministic: Specific compressors
408    // in registry order win before PackageManager sniffers (currently Bun's
409    // test-output signature).
410    let output_probe = OutputProbe::new(&stripped_for_generic);
411    for specificity in [Specificity::Specific, Specificity::PackageManager] {
412        for compressor in compressors
413            .iter()
414            .filter(|c| c.specificity() == specificity)
415        {
416            if compressor.matches_output_probe(&output_probe) {
417                let result = compressor
418                    .compress_output_match_with_exit_code(&stripped_for_generic, exit_code);
419                return failure_preserving_result(
420                    command,
421                    &stripped_for_generic,
422                    result,
423                    exit_code,
424                );
425            }
426        }
427    }
428
429    // Tier 1c: PackageManager compressors get unclaimed commands.
430    for compressor in compressors
431        .iter()
432        .filter(|c| c.specificity() == Specificity::PackageManager)
433    {
434        if compressor.matches(dispatch_cmd) {
435            let result =
436                compressor.compress_with_exit_code(dispatch_cmd, &stripped_for_generic, exit_code);
437            return failure_preserving_result(command, &stripped_for_generic, result, exit_code);
438        }
439    }
440
441    // Tier 2: TOML filters. Pass raw output so `[ansi].strip = false` filters
442    // can intentionally match escape sequences; `apply_filter` owns ANSI policy.
443    if let Some(filter) = registry.lookup(dispatch_cmd) {
444        let result = apply_filter_with_exit_code(filter, output, exit_code);
445        return failure_preserving_result(command, &stripped_for_generic, result, exit_code);
446    }
447
448    // Tier 3: generic fallback.
449    GenericCompressor.compress_with_exit_code(command, &stripped_for_generic, exit_code)
450}
451
452fn compressors_in_dispatch_order() -> [&'static dyn Compressor; 20] {
453    [
454        &GitCompressor,
455        &CargoCompressor,
456        &TscCompressor,
457        &NpmCompressor,
458        &BunCompressor,
459        &PnpmCompressor,
460        &PytestCompressor,
461        &EslintCompressor,
462        &VitestCompressor,
463        &BiomeCompressor,
464        &PrettierCompressor,
465        &RuffCompressor,
466        &MypyCompressor,
467        &GoCompressor,
468        &GolangciLintCompressor,
469        &PlaywrightCompressor,
470        &NextCompressor,
471        &LsCompressor,
472        &FindCompressor,
473        &TreeCompressor,
474    ]
475}
476
477fn failure_preserving_result(
478    command: &str,
479    stripped_raw_output: &str,
480    result: CompressionResult,
481    exit_code: Option<i32>,
482) -> CompressionResult {
483    if !matches!(exit_code, Some(code) if code != 0) {
484        return result;
485    }
486
487    if dropped_failure_or_error_blocks(&result)
488        || !text_has_failure_signal(&result.text)
489        || result_looks_successful(&result.text)
490    {
491        return GenericCompressor.compress_with_exit_code(command, stripped_raw_output, exit_code);
492    }
493
494    let missing = missing_raw_failure_signal_lines(stripped_raw_output, &result.text);
495    if missing.is_empty() {
496        result
497    } else {
498        append_missing_failure_lines(result, &missing)
499    }
500}
501
502fn dropped_failure_or_error_blocks(result: &CompressionResult) -> bool {
503    [DropClass::Error, DropClass::Failure]
504        .into_iter()
505        .any(|class| result.dropped_by_class.get(&class).copied().unwrap_or(0) > 0)
506}
507
508fn append_missing_failure_lines(
509    mut result: CompressionResult,
510    missing_failure_lines: &[String],
511) -> CompressionResult {
512    let mut text = result.text.trim_end().to_string();
513    if !text.is_empty() {
514        text.push('\n');
515    }
516    text.push_str("[raw failure lines preserved by AFT]\n");
517    text.push_str(&missing_failure_lines.join("\n"));
518    result.text = text;
519    result
520}
521
522pub(crate) fn missing_raw_failure_signal_lines(
523    raw_output: &str,
524    compressed_text: &str,
525) -> Vec<String> {
526    let compressed_lines: BTreeSet<String> = compressed_text
527        .lines()
528        .map(str::trim)
529        .filter(|line| !line.is_empty())
530        .map(ToString::to_string)
531        .collect();
532    let mut seen = BTreeSet::new();
533    let mut missing = Vec::new();
534
535    for line in raw_output.lines() {
536        let trimmed = line.trim();
537        if trimmed.is_empty() || !line_has_failure_signal(trimmed) {
538            continue;
539        }
540        if compressed_lines.contains(trimmed) || !seen.insert(trimmed.to_string()) {
541            continue;
542        }
543        missing.push(trimmed.to_string());
544    }
545
546    missing
547}
548
549fn result_looks_successful(text: &str) -> bool {
550    let lower = text.to_ascii_lowercase();
551    lower.contains("clean")
552        || lower.contains(" ok")
553        || lower.contains(":ok")
554        || lower.contains(": ok")
555        || lower.contains("passed")
556        || lower.contains("succeeded")
557        || lower.contains("no errors")
558        || lower.contains("0 errors")
559        || lower.contains("no issues")
560        || lower.contains("no diagnostics")
561        || lower.contains("all checks passed")
562        || lower.contains("formatted")
563        || lower.contains("0 fail")
564        || lower.contains("found 0")
565        || lower.contains("up to date")
566        || lower.contains("up-to-date")
567}
568
569pub(crate) fn text_has_failure_signal(text: &str) -> bool {
570    text.lines()
571        .any(|line| line_has_failure_signal(line.trim()))
572}
573
574fn line_has_failure_signal(line: &str) -> bool {
575    let lower = line.to_ascii_lowercase();
576    line.contains("error[")
577        || lower.contains("error:")
578        || line.contains("Error")
579        || line.contains("ERROR")
580        || lower.contains("internalerror")
581        || lower.contains("traceback")
582        || lower.contains("exception")
583        || lower.contains("no module named")
584        || lower.contains("undefined reference")
585        || lower.contains("linker command failed")
586        || lower.contains("undefined:")
587        || lower.contains("expected declaration")
588        || lower.contains("collect2: error")
589        || lower.contains("ld: error")
590        || lower.contains("fatal error")
591        || line.contains("FAILED")
592        || line.contains("FAIL")
593        || contains_nonzero_failure_word(line, "fail")
594        || contains_nonzero_failure_word(line, "failed")
595        || contains_nonzero_failure_word(line, "failure")
596        || contains_nonzero_failure_word(line, "failures")
597        || lower.contains("panic")
598        || lower.contains("cannot find")
599        || lower.contains("not found")
600        || lower.contains("no such")
601}
602
603fn contains_nonzero_failure_word(line: &str, word: &str) -> bool {
604    let lower = line.to_ascii_lowercase();
605    for (index, _) in lower.match_indices(word) {
606        let end = index + word.len();
607        let before_is_word = lower[..index].chars().next_back().is_some_and(is_word_char);
608        let after_is_word = lower[end..].chars().next().is_some_and(is_word_char);
609        if before_is_word || after_is_word {
610            continue;
611        }
612
613        let prefix = lower[..index].trim_end();
614        let digits_start = prefix
615            .char_indices()
616            .rev()
617            .take_while(|(_, ch)| ch.is_ascii_digit())
618            .last()
619            .map(|(idx, _)| idx);
620        let Some(digits_start) = digits_start else {
621            return true;
622        };
623        let digits = &prefix[digits_start..];
624        if digits.parse::<usize>().ok() != Some(0) {
625            return true;
626        }
627    }
628    false
629}
630
631fn is_word_char(ch: char) -> bool {
632    ch.is_ascii_alphanumeric() || ch == '_'
633}
634
635/// Build the registry of TOML filters from the standard sources for the
636/// active context. Called lazily by [`AppContext::filter_registry`].
637///
638/// Layering (highest priority first):
639/// 1. Project filters at `<project_root>/.cortexkit/aft/filters/*.toml` — loaded only
640///    when the project is in the trusted set (see [`trust`]).
641/// 2. User filters at `<storage_dir>/<harness>/filters/*.toml`.
642/// 3. Builtin filters compiled into the binary via [`builtin_filters`].
643pub fn build_registry_for_context(ctx: &AppContext) -> FilterRegistry {
644    let harness = ctx.harness.lock().clone().unwrap_or(Harness::Opencode);
645    let config = ctx.config();
646    let storage_dir = config.storage_dir.clone();
647    let project_root = config.project_root.clone();
648    drop(config);
649
650    let user_dir = storage_dir.as_ref().map(|dir| {
651        repair_legacy_user_filter_dir(dir, harness.clone());
652        user_filter_dir(dir, harness)
653    });
654    let project_dir = match (project_root.as_ref(), storage_dir.as_ref()) {
655        (Some(root), Some(storage)) => {
656            if trust::is_project_trusted(Some(storage), root) {
657                Some(project_filter_dir(root))
658            } else {
659                None
660            }
661        }
662        _ => None,
663    };
664
665    toml_filter::build_registry(
666        builtin_filters::ALL,
667        user_dir.as_deref(),
668        project_dir.as_deref(),
669    )
670}
671
672/// Normalize a shell command for compressor dispatch by walking past
673/// common shell-prefix idioms so the REAL command head is what matchers
674/// see. Returns `Some(normalized)` if a prefix was stripped, `None` if
675/// the input was already a bare command.
676///
677/// Handles:
678///   - `cd /path && cmd ...`            → `cmd ...`
679///   - `cd /path; cmd ...`              → `cmd ...`
680///   - `env FOO=bar [BAR=baz ...] cmd`  → `cmd ...`
681///   - `FOO=bar [BAR=baz ...] cmd`      → `cmd ...`
682///   - `timeout 30 cmd ...`             → `cmd ...`
683///   - `nohup cmd ...`                  → `cmd ...`
684///   - `(cd /path && cmd ...)`          → `cmd ...`   (trailing `)` is kept; harmless for matchers)
685///
686/// Real agent invocations almost always wrap their actual command in
687/// `cd "$ROOT" && ...`. Without this normalization, BunCompressor /
688/// NpmCompressor / PnpmCompressor (head-token matchers) and the
689/// pkg-manager filters silently fall through to GenericCompressor for
690/// the majority of agent bash calls.
691///
692/// The normalizer is conservative: it only strips well-defined idioms
693/// and bails on anything ambiguous, so a malformed command degrades to
694/// the same dispatch behaviour as before this helper existed.
695pub fn normalize_command_for_dispatch(command: &str) -> Option<String> {
696    match resolve_dispatch_target(command) {
697        // Ambiguous or unsafe pipelines must not be claimed by specific
698        // compressors, so return None to make callers use generic dispatch.
699        DispatchTarget::ForceGeneric => None,
700        DispatchTarget::Command(resolved) | DispatchTarget::Pipeline(resolved) => {
701            if resolved == command.trim_start() {
702                None
703            } else {
704                Some(resolved)
705            }
706        }
707    }
708}
709
710/// Normalize commands for structured-output detection, where a top-level pipe
711/// must suppress structured handling instead of falling back to the raw command.
712pub(crate) fn plain_command_for_structured_output(command: &str) -> Option<String> {
713    match resolve_dispatch_target(command) {
714        DispatchTarget::Command(resolved) => Some(resolved),
715        DispatchTarget::Pipeline(_) | DispatchTarget::ForceGeneric => None,
716    }
717}
718
719/// What compressor dispatch should target for a command, after peeling shell
720/// prefixes and resolving any top-level pipeline.
721enum DispatchTarget {
722    /// Match compressors against this command string (peeled, and/or the last
723    /// pipeline stage whose stdout was captured).
724    Command(String),
725    /// A clean top-level pipeline was found. The contained string is the last
726    /// stage for callers that only need a normalized command, but compression
727    /// dispatch treats the original command as generic raw pipeline output.
728    Pipeline(String),
729    /// An unsafe pipeline was detected (a `|` is present but the command could
730    /// not be parsed safely). Skip all specific compressors and use generic —
731    /// a head-token compressor claiming `cargo test | …` would drop the output.
732    ForceGeneric,
733}
734
735fn resolve_dispatch_target(command: &str) -> DispatchTarget {
736    // Strip top-level comments FIRST. A `#` comment's text otherwise reaches the
737    // head-token matchers, which scan the whole string for their tool name — so
738    // `printf keep # cargo test` would let CargoCompressor claim the printf
739    // command's output and drop it (issue #137), with or without a pipe.
740    let decommented = strip_top_level_comment(command);
741    let peeled = peel_shell_prefixes(&decommented);
742    let base = peeled
743        .as_deref()
744        .unwrap_or_else(|| decommented.trim_start());
745    match split_top_level_pipe(base) {
746        PipeSplit::LastStage(last) => DispatchTarget::Pipeline(last),
747        PipeSplit::Unsafe => DispatchTarget::ForceGeneric,
748        PipeSplit::None => DispatchTarget::Command(base.to_string()),
749    }
750}
751
752/// Remove top-level shell comments (`#` to end of line) from a command so the
753/// comment text can't fool head-token compressor matchers (which scan the whole
754/// command string for their tool name). Quote/backtick/substitution aware: a `#`
755/// inside quotes, inside `$(`/`` ` ``, or not at a word boundary is literal.
756/// Copies byte ranges (UTF-8 safe — every decision point is an ASCII byte) and
757/// preserves newlines so any later top-level structure stays visible to the
758/// pipeline scanner.
759fn strip_top_level_comment(command: &str) -> String {
760    let bytes = command.as_bytes();
761    let mut result = String::with_capacity(command.len());
762    let mut seg_start = 0usize;
763    let mut in_single = false;
764    let mut in_double = false;
765    let mut in_backtick = false;
766    let mut paren_depth: u32 = 0;
767    let mut escaped = false;
768    let mut prev = b' '; // start-of-string counts as a word boundary
769
770    let mut i = 0;
771    while i < bytes.len() {
772        let ch = bytes[i];
773        if escaped {
774            escaped = false;
775            prev = ch;
776            i += 1;
777            continue;
778        }
779        if in_single {
780            if ch == b'\'' {
781                in_single = false;
782            }
783            prev = ch;
784            i += 1;
785            continue;
786        }
787        if in_backtick {
788            if ch == b'\\' {
789                escaped = true;
790            } else if ch == b'`' {
791                in_backtick = false;
792            }
793            prev = ch;
794            i += 1;
795            continue;
796        }
797        if ch == b'\\' {
798            escaped = true;
799            prev = ch;
800            i += 1;
801            continue;
802        }
803        if ch == b'`' {
804            in_backtick = true;
805            prev = ch;
806            i += 1;
807            continue;
808        }
809        if ch == b'$' && bytes.get(i + 1) == Some(&b'(') {
810            paren_depth += 1;
811            prev = b'(';
812            i += 2;
813            continue;
814        }
815        if in_double {
816            if ch == b'"' {
817                in_double = false;
818            }
819            prev = ch;
820            i += 1;
821            continue;
822        }
823        if ch == b'#'
824            && paren_depth == 0
825            && matches!(prev, b' ' | b'\t' | b'\n' | b';' | b'&' | b'|' | b'(')
826        {
827            result.push_str(&command[seg_start..i]);
828            while i < bytes.len() && bytes[i] != b'\n' {
829                i += 1;
830            }
831            seg_start = i; // resume at the newline (kept) or EOL
832            prev = b'\n';
833            continue;
834        }
835        match ch {
836            b'\'' => in_single = true,
837            b'"' => in_double = true,
838            b'<' | b'>' if bytes.get(i + 1) == Some(&b'(') => {
839                paren_depth += 1;
840                prev = b'(';
841                i += 2;
842                continue;
843            }
844            b'(' => paren_depth += 1,
845            b')' => paren_depth = paren_depth.saturating_sub(1),
846            _ => {}
847        }
848        prev = ch;
849        i += 1;
850    }
851    result.push_str(&command[seg_start..]);
852    result
853}
854
855/// Peel known shell-prefix idioms (`cd … &&`, `env VAR=v`, `VAR=v`, `timeout N`,
856/// `nohup`, leading `(`) so the REAL command head is exposed to matchers.
857/// Returns `Some(peeled)` when something was stripped, `None` otherwise.
858fn peel_shell_prefixes(command: &str) -> Option<String> {
859    let trimmed = command.trim_start();
860    if trimmed.is_empty() {
861        return None;
862    }
863
864    // Step 1: peel a leading `(` from group-expression idioms.
865    let (open_paren, after_paren) = if let Some(rest) = trimmed.strip_prefix('(') {
866        (true, rest.trim_start())
867    } else {
868        (false, trimmed)
869    };
870
871    let mut current = after_paren.to_string();
872    let mut changed = open_paren;
873
874    // Step 2: iteratively peel known shell prefixes.
875    loop {
876        // `VAR=value cmd ...` (possibly multiple assignment words). This must
877        // run before head-token matching so package-manager/Rust compressors
878        // still see the real command for `NODE_ENV=production npm install`.
879        if let Some(stripped) = strip_leading_assignment_prefix(&current) {
880            current = stripped;
881            changed = true;
882            continue;
883        }
884
885        let head: String = current.split_whitespace().next().unwrap_or("").to_string();
886
887        // `cd <path> && ...` or `cd <path>; ...`
888        if head == "cd" {
889            // Find the next `&&` or `;` token; everything after that is the real command.
890            // Use char-level scan because `&&` is two chars not separated by whitespace.
891            if let Some(stripped) = strip_cd_prefix(&current) {
892                current = stripped;
893                changed = true;
894                continue;
895            }
896        }
897
898        // `env VAR=val [VAR=val ...] cmd ...`
899        if head == "env" {
900            if let Some(stripped) = strip_env_prefix(&current) {
901                current = stripped;
902                changed = true;
903                continue;
904            }
905        }
906
907        // `timeout <N> cmd ...` or `timeout <duration-with-unit> cmd ...`
908        if head == "timeout" {
909            if let Some(stripped) = strip_timeout_prefix(&current) {
910                current = stripped;
911                changed = true;
912                continue;
913            }
914        }
915
916        // `nohup cmd ...`
917        if head == "nohup" {
918            if let Some(rest) = current.strip_prefix("nohup").and_then(|s| {
919                let trimmed = s.trim_start();
920                if trimmed.is_empty() {
921                    None
922                } else {
923                    Some(trimmed.to_string())
924                }
925            }) {
926                current = rest;
927                changed = true;
928                continue;
929            }
930        }
931
932        break;
933    }
934
935    if changed {
936        Some(current)
937    } else {
938        None
939    }
940}
941
942/// Returns true if the token is a shell metacharacter that acts as a
943/// command boundary. Subcommand parsers use this to avoid returning a
944/// redirect/operator token as a subcommand name. Covers control operators
945/// (`|`, `|&`, `;`, `&`, `&&`, `||`), and every redirect shape — bare
946/// (`>`, `>>`, `<`, `<<`, `<<<`, `&>`, `&>>`), fd-prefixed (`2>`, `2>>`,
947/// `2>&1`, `1>&2`), and glued (`>file`, `2>/dev/null`).
948pub fn is_shell_boundary(token: &str) -> bool {
949    matches!(token, "|" | "|&" | ";" | "&" | "&&" | "||" | "&>" | "&>>") || is_redirect_token(token)
950}
951
952/// A redirect operator token: an optional leading fd (`2` in `2>&1`) followed
953/// by a `>`/`<` redirect, or an `&>`/`&>>` merge redirect. Real subcommands
954/// (`test`, `log`, `build`) never match, so this can't suppress a true one.
955fn is_redirect_token(token: &str) -> bool {
956    let rest = token.trim_start_matches(|c: char| c.is_ascii_digit());
957    rest.starts_with('>') || rest.starts_with('<') || rest.starts_with("&>")
958}
959
960/// Outcome of scanning a command for a top-level pipeline.
961#[derive(Debug, PartialEq, Eq)]
962enum PipeSplit {
963    /// No top-level `|` — dispatch on the command as-is.
964    None,
965    /// A top-level pipeline; the captured stdout is this last stage's output.
966    LastStage(String),
967    /// A pipe-like operator is present but the command couldn't be safely
968    /// parsed (unbalanced quotes/parens/backtick). Callers must NOT fall back
969    /// to head-token dispatch — a compressor that claims `cargo test | …`
970    /// would drop the piped output. Force generic instead.
971    Unsafe,
972}
973
974/// Depth-aware pipeline scanner that FAILS CLOSED. Tracks single/double quotes,
975/// backslash escapes, backtick substitution, and `(`/`$(`/`<(`/`>(` nesting so a
976/// `|` inside any of them is not treated as a stage boundary. Splits on a
977/// top-level `|`/`|&` (never `||`) and returns the LAST stage — but ONLY when
978/// the command is a clean single pipeline. The caller captured the WHOLE
979/// command's stdout, so "last stage == captured output" holds only when no other
980/// top-level structure exists; otherwise a head-token compressor could claim the
981/// command and drop output (issue #137). Therefore, whenever a top-level pipe
982/// coexists with ANY of {a top-level separator `;`/`&&`/`||`/bare `&`/newline,
983/// an unbalanced quote/paren/backtick/escape, an unmatched `)`, or an empty
984/// trailing stage}, we return `Unsafe` so the caller forces generic compression.
985/// Top-level comments must already be removed by `strip_top_level_comment`.
986/// Redirects (`>`, `2>&1`, `&>`, …) are NOT separators.
987fn split_top_level_pipe(command: &str) -> PipeSplit {
988    let bytes = command.as_bytes();
989    let mut in_single = false;
990    let mut in_double = false;
991    let mut in_backtick = false;
992    let mut paren_depth: u32 = 0;
993    let mut escaped = false;
994    let mut saw_unmatched_close = false;
995    let mut saw_top_pipe = false;
996    let mut saw_top_separator = false;
997    let mut last_pipe_end: Option<usize> = None;
998
999    let mut i = 0;
1000    while i < bytes.len() {
1001        let ch = bytes[i];
1002
1003        if escaped {
1004            escaped = false;
1005            i += 1;
1006            continue;
1007        }
1008        if in_single {
1009            if ch == b'\'' {
1010                in_single = false;
1011            }
1012            i += 1;
1013            continue;
1014        }
1015        if in_backtick {
1016            // Backtick substitution is opaque for splitting. A backslash still
1017            // escapes the next byte so an escaped backtick doesn't close it.
1018            if ch == b'\\' {
1019                escaped = true;
1020            } else if ch == b'`' {
1021                in_backtick = false;
1022            }
1023            i += 1;
1024            continue;
1025        }
1026        if ch == b'\\' {
1027            escaped = true;
1028            i += 1;
1029            continue;
1030        }
1031        if ch == b'`' {
1032            in_backtick = true;
1033            i += 1;
1034            continue;
1035        }
1036        // `$(` opens command substitution even inside double quotes.
1037        if ch == b'$' && bytes.get(i + 1) == Some(&b'(') {
1038            paren_depth += 1;
1039            i += 2;
1040            continue;
1041        }
1042        if in_double {
1043            if ch == b'"' {
1044                in_double = false;
1045            }
1046            i += 1;
1047            continue;
1048        }
1049
1050        // Below here: outside single/double quotes and backticks. Top-level
1051        // comments are already removed by `strip_top_level_comment` before this
1052        // scanner runs, so no `#` handling is needed here.
1053        let prev_raw = if i > 0 { bytes[i - 1] } else { b' ' };
1054
1055        match ch {
1056            b'\'' => in_single = true,
1057            b'"' => in_double = true,
1058            // process substitution `<(` / `>(`
1059            b'<' | b'>' if bytes.get(i + 1) == Some(&b'(') => {
1060                paren_depth += 1;
1061                i += 2;
1062                continue;
1063            }
1064            b'(' => paren_depth += 1,
1065            b')' => {
1066                if paren_depth == 0 {
1067                    saw_unmatched_close = true;
1068                } else {
1069                    paren_depth -= 1;
1070                }
1071            }
1072            b'|' if paren_depth == 0 => {
1073                if bytes.get(i + 1) == Some(&b'|') {
1074                    saw_top_separator = true; // `||` logical OR
1075                    i += 2;
1076                    continue;
1077                }
1078                saw_top_pipe = true;
1079                if bytes.get(i + 1) == Some(&b'&') {
1080                    last_pipe_end = Some(i + 2); // `|&` (stdout+stderr)
1081                    i += 2;
1082                    continue;
1083                }
1084                last_pipe_end = Some(i + 1);
1085            }
1086            b'&' if paren_depth == 0 => {
1087                if bytes.get(i + 1) == Some(&b'&') {
1088                    saw_top_separator = true; // `&&`
1089                    i += 2;
1090                    continue;
1091                }
1092                // `&>`/`&>>` redirect, or `>&`/`2>&1` fd-dup: NOT a separator.
1093                // A bare `&` is the background control operator.
1094                if bytes.get(i + 1) != Some(&b'>') && prev_raw != b'>' {
1095                    saw_top_separator = true;
1096                }
1097            }
1098            b';' if paren_depth == 0 => saw_top_separator = true,
1099            b'\n' if paren_depth == 0 => saw_top_separator = true,
1100            _ => {}
1101        }
1102        i += 1;
1103    }
1104
1105    let imbalance =
1106        in_single || in_double || in_backtick || escaped || paren_depth != 0 || saw_unmatched_close;
1107
1108    if saw_top_pipe {
1109        // Only a clean single pipeline is safe to last-stage dispatch.
1110        if imbalance || saw_top_separator {
1111            return PipeSplit::Unsafe;
1112        }
1113        match last_pipe_end {
1114            Some(end) => {
1115                let last_stage = command[end..].trim();
1116                if last_stage.is_empty() {
1117                    PipeSplit::Unsafe // trailing empty stage, e.g. `cargo test |`
1118                } else {
1119                    PipeSplit::LastStage(last_stage.to_string())
1120                }
1121            }
1122            None => PipeSplit::Unsafe,
1123        }
1124    } else if imbalance && command.contains('|') {
1125        // No resolvable top-level pipe, but a `|` hides in an unbalanced region.
1126        PipeSplit::Unsafe
1127    } else {
1128        PipeSplit::None
1129    }
1130}
1131
1132fn strip_cd_prefix(command: &str) -> Option<String> {
1133    // Look for `&&` or `;` outside of quotes.
1134    let bytes = command.as_bytes();
1135    let mut in_single = false;
1136    let mut in_double = false;
1137    let mut i = 0;
1138    while i < bytes.len() {
1139        let ch = bytes[i] as char;
1140        if !in_double && ch == '\'' {
1141            in_single = !in_single;
1142        } else if !in_single && ch == '"' {
1143            in_double = !in_double;
1144        } else if !in_single && !in_double {
1145            if ch == '&' && i + 1 < bytes.len() && bytes[i + 1] as char == '&' {
1146                let rest = command[i + 2..].trim_start();
1147                if rest.is_empty() {
1148                    return None;
1149                }
1150                return Some(rest.to_string());
1151            }
1152            if ch == ';' {
1153                let rest = command[i + 1..].trim_start();
1154                if rest.is_empty() {
1155                    return None;
1156                }
1157                return Some(rest.to_string());
1158            }
1159        }
1160        i += 1;
1161    }
1162    None
1163}
1164
1165fn strip_env_prefix(command: &str) -> Option<String> {
1166    // env <ASSIGN>... <cmd> ...
1167    let rest = command.strip_prefix("env")?.trim_start();
1168    strip_leading_assignment_prefix(rest)
1169}
1170
1171fn strip_leading_assignment_prefix(command: &str) -> Option<String> {
1172    let mut index = 0usize;
1173    let mut consumed_assignment = false;
1174
1175    loop {
1176        index = skip_whitespace(command, index);
1177        if index >= command.len() {
1178            break;
1179        }
1180
1181        let word_end = shell_word_end(command, index)?;
1182        if word_end == index {
1183            break;
1184        }
1185
1186        let word = &command[index..word_end];
1187        if !is_env_assignment(word) {
1188            break;
1189        }
1190
1191        consumed_assignment = true;
1192        index = word_end;
1193    }
1194
1195    if !consumed_assignment {
1196        return None;
1197    }
1198
1199    let after = command[index..].trim_start();
1200    if after.is_empty() {
1201        None
1202    } else {
1203        Some(after.to_string())
1204    }
1205}
1206
1207fn skip_whitespace(input: &str, mut index: usize) -> usize {
1208    while index < input.len() {
1209        let Some(ch) = input[index..].chars().next() else {
1210            break;
1211        };
1212        if !ch.is_whitespace() {
1213            break;
1214        }
1215        index += ch.len_utf8();
1216    }
1217    index
1218}
1219
1220fn shell_word_end(command: &str, start: usize) -> Option<usize> {
1221    let mut in_single = false;
1222    let mut in_double = false;
1223    let mut escaped = false;
1224
1225    for (offset, ch) in command[start..].char_indices() {
1226        let index = start + offset;
1227
1228        if escaped {
1229            escaped = false;
1230            continue;
1231        }
1232
1233        if ch == '\\' && !in_single {
1234            escaped = true;
1235            continue;
1236        }
1237
1238        if ch == '\'' && !in_double {
1239            in_single = !in_single;
1240            continue;
1241        }
1242
1243        if ch == '"' && !in_single {
1244            in_double = !in_double;
1245            continue;
1246        }
1247
1248        if !in_single && !in_double && (ch.is_whitespace() || matches!(ch, ';' | '&' | '|')) {
1249            return Some(index);
1250        }
1251    }
1252
1253    if in_single || in_double || escaped {
1254        None
1255    } else {
1256        Some(command.len())
1257    }
1258}
1259
1260fn is_env_assignment(token: &str) -> bool {
1261    if token.starts_with('-') {
1262        return false;
1263    }
1264    let Some((name, _value)) = token.split_once('=') else {
1265        return false;
1266    };
1267    let mut chars = name.chars();
1268    let Some(first) = chars.next() else {
1269        return false;
1270    };
1271    (first.is_ascii_alphabetic() || first == '_')
1272        && chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
1273}
1274
1275fn strip_timeout_prefix(command: &str) -> Option<String> {
1276    let rest = command.strip_prefix("timeout")?.trim_start();
1277    // Next token must look like a duration (digits, optional trailing unit s/m/h).
1278    let mut iter = rest.splitn(2, char::is_whitespace);
1279    let duration = iter.next()?;
1280    let after = iter.next()?.trim_start();
1281    if after.is_empty() || !looks_like_duration(duration) {
1282        return None;
1283    }
1284    Some(after.to_string())
1285}
1286
1287fn looks_like_duration(token: &str) -> bool {
1288    if token.is_empty() {
1289        return false;
1290    }
1291    let mut chars = token.chars().peekable();
1292    let mut saw_digit = false;
1293    while let Some(&ch) = chars.peek() {
1294        if ch.is_ascii_digit() {
1295            saw_digit = true;
1296            chars.next();
1297        } else {
1298            break;
1299        }
1300    }
1301    if !saw_digit {
1302        return false;
1303    }
1304    match chars.next() {
1305        None => true,
1306        Some(unit) => matches!(unit, 's' | 'm' | 'h' | 'd') && chars.next().is_none(),
1307    }
1308}
1309
1310/// Resolve the harness-scoped user-filter directory for an arbitrary storage_dir.
1311/// Used by `aft doctor filters` to inspect filters without needing a live AppContext.
1312pub fn user_filter_dir(storage_dir: &Path, harness: Harness) -> PathBuf {
1313    storage_dir.join(harness.storage_segment()).join("filters")
1314}
1315
1316fn legacy_user_filter_dir(storage_dir: &Path) -> PathBuf {
1317    storage_dir.join("filters")
1318}
1319
1320/// Move filters written by the short-lived root-scoped v0.27 layout into the
1321/// active harness directory. Existing harness files win; colliding root files
1322/// are left in place so we never overwrite user-authored filters.
1323pub(crate) fn repair_legacy_user_filter_dir(storage_dir: &Path, harness: Harness) {
1324    let legacy_dir = legacy_user_filter_dir(storage_dir);
1325    if !legacy_dir.exists() {
1326        return;
1327    }
1328
1329    let entries = match fs::read_dir(&legacy_dir) {
1330        Ok(entries) => entries.filter_map(Result::ok).collect::<Vec<_>>(),
1331        Err(_) => return,
1332    };
1333    if entries.is_empty() {
1334        let _ = fs::remove_dir(&legacy_dir);
1335        return;
1336    }
1337
1338    let harness_dir = user_filter_dir(storage_dir, harness);
1339    if fs::create_dir_all(&harness_dir).is_err() {
1340        return;
1341    }
1342
1343    for entry in entries {
1344        let target = harness_dir.join(entry.file_name());
1345        if target.exists() {
1346            continue;
1347        }
1348        let _ = fs::rename(entry.path(), target);
1349    }
1350
1351    if fs::read_dir(&legacy_dir)
1352        .map(|mut entries| entries.next().is_none())
1353        .unwrap_or(false)
1354    {
1355        let _ = fs::remove_dir(&legacy_dir);
1356    }
1357}
1358
1359/// Resolve the project-filter directory for an arbitrary project root.
1360/// Returns the directory regardless of trust state — caller must check trust
1361/// separately if it wants to gate loading.
1362pub fn project_filter_dir(project_root: &Path) -> PathBuf {
1363    project_root.join(".cortexkit").join("aft").join("filters")
1364}
1365
1366#[cfg(test)]
1367mod tests {
1368    use super::*;
1369
1370    #[test]
1371    fn user_and_project_filter_dir_helpers() {
1372        let storage = Path::new("/tmp/aft-storage");
1373        assert_eq!(
1374            user_filter_dir(storage, Harness::Opencode),
1375            Path::new("/tmp/aft-storage/opencode/filters")
1376        );
1377
1378        let project = Path::new("/repo");
1379        assert_eq!(
1380            project_filter_dir(project),
1381            Path::new("/repo/.cortexkit/aft/filters")
1382        );
1383    }
1384
1385    #[test]
1386    fn repair_legacy_user_filter_dir_moves_root_filters_without_overwrite() {
1387        let temp = tempfile::tempdir().unwrap();
1388        let storage = temp.path();
1389        fs::create_dir_all(storage.join("filters")).unwrap();
1390        fs::create_dir_all(storage.join("opencode/filters")).unwrap();
1391        fs::write(storage.join("filters/root-only.toml"), "root").unwrap();
1392        fs::write(storage.join("filters/collides.toml"), "root").unwrap();
1393        fs::write(storage.join("opencode/filters/collides.toml"), "harness").unwrap();
1394
1395        repair_legacy_user_filter_dir(storage, Harness::Opencode);
1396
1397        assert_eq!(
1398            fs::read_to_string(storage.join("opencode/filters/root-only.toml")).unwrap(),
1399            "root"
1400        );
1401        assert_eq!(
1402            fs::read_to_string(storage.join("opencode/filters/collides.toml")).unwrap(),
1403            "harness"
1404        );
1405        assert_eq!(
1406            fs::read_to_string(storage.join("filters/collides.toml")).unwrap(),
1407            "root"
1408        );
1409        assert!(!storage.join("filters/root-only.toml").exists());
1410    }
1411}
1412
1413#[cfg(test)]
1414mod output_probe_tests {
1415    use super::*;
1416    use serde::Deserialize;
1417
1418    #[derive(Deserialize)]
1419    struct CorpusEntry {
1420        file: String,
1421    }
1422
1423    #[derive(Debug, PartialEq, Eq)]
1424    struct MatchPhaseResult {
1425        selected: Option<usize>,
1426        output: CompressionResult,
1427    }
1428
1429    fn legacy_match_phase(output: &str) -> MatchPhaseResult {
1430        let compressors = compressors_in_dispatch_order();
1431        let selected = [Specificity::Specific, Specificity::PackageManager]
1432            .into_iter()
1433            .find_map(|specificity| {
1434                compressors
1435                    .iter()
1436                    .enumerate()
1437                    .filter(|(_, compressor)| compressor.specificity() == specificity)
1438                    .find_map(|(index, compressor)| {
1439                        compressor.matches_output(output).then_some(index)
1440                    })
1441            });
1442        match_phase_result(&compressors, selected, output)
1443    }
1444
1445    fn probe_match_phase(output: &str) -> MatchPhaseResult {
1446        let compressors = compressors_in_dispatch_order();
1447        let probe = OutputProbe::new(output);
1448        let selected = [Specificity::Specific, Specificity::PackageManager]
1449            .into_iter()
1450            .find_map(|specificity| {
1451                compressors
1452                    .iter()
1453                    .enumerate()
1454                    .filter(|(_, compressor)| compressor.specificity() == specificity)
1455                    .find_map(|(index, compressor)| {
1456                        compressor.matches_output_probe(&probe).then_some(index)
1457                    })
1458            });
1459        match_phase_result(&compressors, selected, output)
1460    }
1461
1462    fn match_phase_result(
1463        compressors: &[&dyn Compressor],
1464        selected: Option<usize>,
1465        output: &str,
1466    ) -> MatchPhaseResult {
1467        let output = selected.map_or_else(
1468            || GenericCompressor.compress_with_exit_code("", output, None),
1469            |index| compressors[index].compress_output_match_with_exit_code(output, None),
1470        );
1471        MatchPhaseResult { selected, output }
1472    }
1473
1474    fn repository_root() -> PathBuf {
1475        Path::new(env!("CARGO_MANIFEST_DIR"))
1476            .parent()
1477            .and_then(Path::parent)
1478            .expect("aft crate should be nested under the repository root")
1479            .to_path_buf()
1480    }
1481
1482    fn benchmark_corpus() -> Vec<(String, String)> {
1483        let fixtures = repository_root().join("benchmarks/compression-tokens/fixtures");
1484        let manifest_text = fs::read_to_string(fixtures.join("manifest.json"))
1485            .expect("compression corpus manifest should be readable");
1486        let manifest: Vec<CorpusEntry> =
1487            serde_json::from_str(&manifest_text).expect("compression corpus manifest should parse");
1488        manifest
1489            .into_iter()
1490            .map(|entry| {
1491                let output = fs::read_to_string(fixtures.join(&entry.file))
1492                    .unwrap_or_else(|error| panic!("failed to read {}: {error}", entry.file));
1493                (format!("compression corpus {}", entry.file), output)
1494            })
1495            .collect()
1496    }
1497
1498    fn integration_filter_inputs() -> Vec<(String, String)> {
1499        let root = Path::new(env!("CARGO_MANIFEST_DIR"))
1500            .join("tests/integration/fixtures/compress_filters");
1501        let mut paths = Vec::new();
1502        collect_named_files(&root, "input.txt", &mut paths);
1503        paths.sort();
1504        paths
1505            .into_iter()
1506            .map(|path| {
1507                let output = fs::read_to_string(&path)
1508                    .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display()));
1509                (format!("filter fixture {}", path.display()), output)
1510            })
1511            .collect()
1512    }
1513
1514    fn collect_named_files(root: &Path, wanted_name: &str, paths: &mut Vec<PathBuf>) {
1515        let entries = fs::read_dir(root)
1516            .unwrap_or_else(|error| panic!("failed to read {}: {error}", root.display()));
1517        for entry in entries {
1518            let path = entry
1519                .expect("fixture directory entry should be readable")
1520                .path();
1521            if path.is_dir() {
1522                collect_named_files(&path, wanted_name, paths);
1523            } else if path.file_name().is_some_and(|name| name == wanted_name) {
1524                paths.push(path);
1525            }
1526        }
1527    }
1528
1529    fn hostile_outputs() -> Vec<(String, String)> {
1530        let huge_json_array = format!(
1531            "[{}]",
1532            (0..20_000)
1533                .map(|index| format!(r#"{{"index":{index},"value":"payload"}}"#))
1534                .collect::<Vec<_>>()
1535                .join(",")
1536        );
1537        let ansi_heavy = (0..2_000)
1538            .map(|index| format!("\u{1b}[31mordinary output {index}\u{1b}[0m"))
1539            .collect::<Vec<_>>()
1540            .join("\n");
1541        vec![
1542            (
1543                "valid non-tool JSON object".to_string(),
1544                r#" {"payload":{"nested":[1,2,3]},"ok":true}"#.to_string(),
1545            ),
1546            ("huge JSON array".to_string(), huge_json_array),
1547            (
1548                "almost JSON".to_string(),
1549                r#"{"payload":[1,2,3],"unterminated":true"#.to_string(),
1550            ),
1551            ("empty output".to_string(), String::new()),
1552            ("ANSI-heavy output".to_string(), ansi_heavy),
1553        ]
1554    }
1555
1556    #[test]
1557    fn shared_probe_preserves_match_selection_and_output_across_corpora() {
1558        let cases = benchmark_corpus()
1559            .into_iter()
1560            .chain(integration_filter_inputs())
1561            .chain(hostile_outputs());
1562
1563        for (label, raw_output) in cases {
1564            let output = strip_ansi(&raw_output);
1565            let legacy = legacy_match_phase(&output);
1566            let probed = probe_match_phase(&output);
1567            assert_eq!(legacy, probed, "output-shape dispatch changed for {label}");
1568        }
1569    }
1570
1571    #[cfg(debug_assertions)]
1572    #[test]
1573    fn unmatched_json_object_is_parsed_once_across_match_phase() {
1574        let output = r#"{"payload":{"nested":[1,2,3]},"ok":true}"#;
1575        reset_output_probe_json_parse_count();
1576
1577        let result = probe_match_phase(output);
1578
1579        assert_eq!(result.selected, None);
1580        assert_eq!(output_probe_json_parse_count(), 1);
1581    }
1582}
1583
1584#[cfg(test)]
1585mod dispatch_specificity_tests {
1586    use super::*;
1587    use crate::compress::toml_filter::FilterRegistry;
1588
1589    fn empty_registry() -> FilterRegistry {
1590        FilterRegistry::default()
1591    }
1592
1593    /// Helper: assert that a given command would be claimed by a specific
1594    /// compressor by reading the output marker the compressor produces.
1595    /// (We can't easily compare Compressor instances by identity, so we
1596    /// dispatch and check for module-distinctive markers in the output.)
1597    fn dispatch(cmd: &str, output: &str) -> String {
1598        compress_with_registry(cmd, output, &empty_registry()).text
1599    }
1600
1601    #[test]
1602    fn generic_dispatch_does_not_classify_error_or_warning_words() {
1603        let result = compress_with_registry(
1604            "unknown-tool",
1605            "error: this is just a log line\nwarning: this too",
1606            &empty_registry(),
1607        );
1608
1609        assert!(result.dropped_by_class.is_empty());
1610        assert!(!result.had_inner_drop);
1611        assert!(result.text.contains("error: this is just a log line"));
1612    }
1613
1614    #[test]
1615    fn bun_run_vitest_routes_to_vitest_not_generic() {
1616        // VitestCompressor preserves PASS/FAIL markers and "Tests:" summary.
1617        // BunCompressor's `Some("run")` arm currently goes to generic which
1618        // would middle-truncate. Use a small vitest-shaped output and assert
1619        // the vitest formatter's output marker is present.
1620        let output = "Test Files  1 passed (1)\n     Tests  4 passed (4)\n  Start at  10:00:00\n  Duration  120ms\n";
1621        let compressed = dispatch("bun run vitest", output);
1622        // Assert vitest path took it: the vitest text summary keeps "Tests" / "Test Files" lines
1623        assert!(compressed.contains("Tests") || compressed.contains("Test Files"));
1624    }
1625
1626    #[test]
1627    fn npm_test_routes_to_vitest_when_output_is_vitest_shaped() {
1628        // `npm test` has no vitest token, so this proves the output-shape
1629        // tier runs before the broad NpmCompressor PackageManager tier.
1630        let output = "RERUN src/foo.test.ts x1\nFAIL src/foo.test.ts\nTest Files  1 failed (1)\nDuration    120ms\n";
1631        let compressed = dispatch("npm test", output);
1632        assert!(compressed.contains("FAIL src/foo.test.ts"));
1633        assert!(compressed.contains("Duration    120ms"));
1634        assert!(!compressed.contains("RERUN"));
1635    }
1636
1637    #[test]
1638    fn bun_run_vitest_token_match_wins_over_bun_head_match() {
1639        // Concrete proof the new dispatch works: a command where Bun would
1640        // otherwise have claimed it.
1641        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";
1642        let compressed = dispatch("bun run vitest run", output);
1643        // Vitest preserves PASS lines and "Tests:" summary.
1644        assert!(compressed.contains("Test Files") || compressed.contains("PASS"));
1645    }
1646
1647    #[test]
1648    fn bunx_jest_routes_to_vitest_module() {
1649        let output = "PASS src/foo.test.js (1.2s)\nTest Suites: 1 passed, 1 total\nTests:       3 passed, 3 total\n";
1650        let compressed = dispatch("bunx jest --json", output);
1651        assert!(compressed.contains("Tests:") && compressed.contains("Test Suites"));
1652    }
1653
1654    #[test]
1655    fn pnpm_run_vitest_routes_to_vitest() {
1656        let output = "Test Files  1 passed (1)\n     Tests  10 passed (10)\n";
1657        let compressed = dispatch("pnpm run vitest", output);
1658        assert!(compressed.contains("Tests") || compressed.contains("Test Files"));
1659    }
1660
1661    #[test]
1662    fn npx_eslint_routes_to_eslint_not_generic() {
1663        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";
1664        let compressed = dispatch("npx eslint .", output);
1665        // EslintCompressor preserves rule IDs and the ✖ summary.
1666        assert!(compressed.contains("no-unused-vars") || compressed.contains("✖"));
1667    }
1668
1669    #[test]
1670    fn npm_run_lint_without_linter_output_shape_falls_back() {
1671        // `npm run lint` has no eslint token, and this output has no eslint
1672        // summary signature, so it should remain package-manager generic.
1673        let output = "> my-project@1.0.0 lint\n> eslint .\n\nAll good.\n";
1674        let compressed = dispatch("npm run lint", output);
1675        assert!(compressed.contains("All good."));
1676    }
1677
1678    #[test]
1679    fn bun_test_still_routes_to_bun_test_compressor() {
1680        // Bun.test is the v0.28.2 fix — make sure specificity dispatch
1681        // doesn't accidentally break it. The Bun module's `Some("test")`
1682        // arm should still claim this when no Specific matcher does.
1683        // BunTestCompressor doesn't exist as a separate module — the
1684        // BunCompressor.compress() routes Some("test") to its inner
1685        // compress_test() function. The relevant assertion: this still
1686        // produces bun-test-shaped output, not generic-truncated output.
1687        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";
1688        let compressed = dispatch("bun test", output);
1689        assert!(compressed.contains("(pass)") || compressed.contains("1 pass"));
1690    }
1691
1692    #[test]
1693    fn bunx_vitest_routes_to_vitest() {
1694        let output = "Test Files  1 passed (1)\n     Tests  3 passed (3)\n";
1695        let compressed = dispatch("bunx vitest run", output);
1696        assert!(compressed.contains("Tests") || compressed.contains("Test Files"));
1697    }
1698
1699    #[test]
1700    fn cargo_test_still_routes_to_cargo() {
1701        // Regression: specificity reordering must not break commands that
1702        // already worked. Cargo is Specific tier.
1703        let output = "running 5 tests\ntest foo ... ok\ntest bar ... FAILED\n\nfailures:\n\ntest result: FAILED. 4 passed; 1 failed\n";
1704        let compressed = dispatch("cargo test", output);
1705        // Cargo's test compressor preserves PASS/FAIL semantics.
1706        assert!(compressed.contains("failed") || compressed.contains("FAILED"));
1707    }
1708
1709    #[test]
1710    fn top_level_piped_cargo_test_uses_generic_output() {
1711        let output = "running 1 test\ntest ok_test ... ok\n\ntest result: ok. 1 passed; 0 failed\n";
1712
1713        let compressed = compress_with_registry("cargo test | cat", output, &empty_registry());
1714
1715        assert!(
1716            compressed.text.contains("test ok_test ... ok"),
1717            "piped cargo output must stay generic/raw, got: {}",
1718            compressed.text
1719        );
1720    }
1721
1722    #[test]
1723    fn non_piped_cargo_test_still_uses_cargo_compressor() {
1724        let output = "running 1 test\ntest ok_test ... ok\n\ntest result: ok. 1 passed; 0 failed\n";
1725
1726        let compressed = compress_with_registry("cargo test", output, &empty_registry());
1727
1728        assert!(compressed.text.contains("running 1 test"));
1729        assert!(compressed.text.contains("test result: ok"));
1730        assert!(
1731            !compressed.text.contains("test ok_test ... ok"),
1732            "non-piped cargo test should keep using the cargo compressor, got: {}",
1733            compressed.text
1734        );
1735    }
1736
1737    #[test]
1738    fn git_status_still_routes_to_git() {
1739        // Regression: git is Specific tier.
1740        let output =
1741            "On branch main\nYour branch is up to date.\n\nnothing to commit, working tree clean\n";
1742        let compressed = dispatch("git status", output);
1743        assert!(compressed.contains("branch") || compressed.contains("clean"));
1744    }
1745
1746    #[test]
1747    fn pnpm_install_still_routes_to_pnpm() {
1748        // Regression: pnpm install was handled before this change.
1749        let output = "Progress: resolved 100, downloaded 50, added 50\nAdded 50 packages\n";
1750        let compressed = dispatch("pnpm install", output);
1751        // PnpmCompressor's compress_package keeps "+ pkg" or "Added X packages" type lines.
1752        assert!(compressed.contains("Added") || compressed.contains("Progress"));
1753    }
1754}
1755
1756#[cfg(test)]
1757mod exit_code_safety_tests {
1758    use super::*;
1759    use crate::compress::toml_filter::{build_registry, FilterRegistry};
1760
1761    fn empty_registry() -> FilterRegistry {
1762        FilterRegistry::default()
1763    }
1764
1765    #[test]
1766    fn go_build_failure_signal_preserved_even_when_exit_zero_masks_failure() {
1767        let output = "go: go.mod file not found in current directory or any parent directory; see 'go help modules'\n";
1768
1769        let failed =
1770            compress_with_registry_exit_code("go build ./...", output, Some(1), &empty_registry());
1771        assert!(!failed.text.contains("go build: ok"));
1772        assert!(failed.text.contains("go.mod file not found"));
1773
1774        let masked =
1775            compress_with_registry_exit_code("go build ./...", output, Some(0), &empty_registry());
1776        assert!(!masked.text.contains("go build: ok"));
1777        assert!(masked.text.contains("go.mod file not found"));
1778    }
1779
1780    #[test]
1781    fn playwright_nonzero_crash_does_not_become_passed_summary() {
1782        let output = r#"Running 4 tests using 2 workers
1783
1784  ✓  1 [chromium] › example.spec.ts:5:1 › has title (2.3s)
1785  ✓  2 [chromium] › example.spec.ts:9:1 › get started link (1.8s)
1786  ✓  3 [chromium] › nav.spec.ts:3:1 › navigates (1.2s)
1787  ✓  4 [chromium] › auth.spec.ts:7:1 › logs out (1.0s)
1788
1789  4 passed (6.3s)
1790Error: browserType.launch: Target page, context or browser has been closed
1791"#;
1792
1793        let failed = compress_with_registry_exit_code(
1794            "npx playwright test",
1795            output,
1796            Some(1),
1797            &empty_registry(),
1798        );
1799        assert!(!failed.text.starts_with("playwright: 4 tests passed"));
1800        assert!(failed.text.contains("browserType.launch"));
1801    }
1802
1803    #[test]
1804    fn cargo_test_compile_error_nonzero_preserves_error_code_diagnostic() {
1805        let output = r#"   Compiling demo v0.1.0 (/tmp/demo)
1806error[E0432]: unresolved import `crate::missing`
1807 --> src/lib.rs:1:5
1808  |
18091 | use crate::missing;
1810  |     ^^^^^^^^^^^^^^ no `missing` in the root
1811
1812error: could not compile `demo` (lib test) due to 1 previous error
1813"#;
1814
1815        let failed =
1816            compress_with_registry_exit_code("cargo test", output, Some(101), &empty_registry());
1817        assert!(failed.text.contains("error[E0432]"));
1818        assert!(failed.text.contains("unresolved import"));
1819        assert!(failed.text.contains("error: could not compile"));
1820    }
1821
1822    #[test]
1823    fn chained_mypy_success_then_later_failure_uses_failure_preserving_output() {
1824        let output = "Success: no issues found in 1 source file\nError: node process exploded\n";
1825
1826        let failed = compress_with_registry_exit_code(
1827            "mypy src && node fail.js",
1828            output,
1829            Some(1),
1830            &empty_registry(),
1831        );
1832        assert_ne!(failed.text, "mypy: clean");
1833        assert!(failed.text.contains("Error: node process exploded"));
1834    }
1835
1836    #[test]
1837    fn toml_shortcircuit_is_skipped_for_nonzero_exit() {
1838        let registry = build_registry(
1839            &[(
1840                "wget",
1841                r#"[filter]
1842matches = ["wget"]
1843
1844[shortcircuit]
1845when = '(?s).*'
1846replacement = "wget: ok"
1847"#,
1848            )],
1849            None,
1850            None,
1851        );
1852        let output = "Connecting to example.invalid\nerror: connection refused\n";
1853
1854        let failed = compress_with_registry_exit_code(
1855            "wget https://example.invalid",
1856            output,
1857            Some(1),
1858            &registry,
1859        );
1860        assert_ne!(failed.text, "wget: ok");
1861        assert!(failed.text.contains("error: connection refused"));
1862    }
1863
1864    #[test]
1865    fn unknown_exit_code_keeps_byte_identical_legacy_compressor_output() {
1866        let output =
1867            "Success: no issues found in 1 source file\nError: later chained command failed\n";
1868
1869        let legacy = compress_with_registry_exit_code(
1870            "mypy src && node fail.js",
1871            output,
1872            None,
1873            &empty_registry(),
1874        );
1875        assert_eq!(legacy.text, "mypy: clean");
1876    }
1877
1878    #[test]
1879    fn killed_exit_sentinel_rejects_clean_legacy_summary() {
1880        let output = "Success: no issues found in 1 source file
1881Error: later chained command failed
1882";
1883
1884        let killed = compress_with_registry_exit_code(
1885            "mypy src && node fail.js",
1886            output,
1887            Some(137),
1888            &empty_registry(),
1889        );
1890        assert_ne!(killed.text, "mypy: clean");
1891        assert!(killed.text.contains("Error: later chained command failed"));
1892    }
1893
1894    #[test]
1895    fn nonzero_clean_eslint_json_summary_falls_back_to_raw_output() {
1896        let output =
1897            r#"[{"filePath":"/repo/src/main.ts","messages":[],"errorCount":0,"warningCount":0}]"#;
1898
1899        let failed = compress_with_registry_exit_code(
1900            "eslint -f json .",
1901            output,
1902            Some(1),
1903            &empty_registry(),
1904        );
1905
1906        assert_ne!(failed.text, "eslint: no issues");
1907        assert!(failed.text.contains(r#""messages":[]"#));
1908    }
1909
1910    #[test]
1911    fn nonzero_appends_distinct_missing_raw_failure_lines() {
1912        let raw = "Error: first failure
1913progress
1914Error: second failure
1915";
1916        let compressed = CompressionResult::new("Error: first failure");
1917
1918        let preserved = failure_preserving_result("tool", raw, compressed, Some(1));
1919
1920        assert!(preserved.text.contains("Error: first failure"));
1921        assert!(preserved.text.contains("Error: second failure"));
1922        assert!(preserved
1923            .text
1924            .contains("[raw failure lines preserved by AFT]"));
1925    }
1926
1927    #[test]
1928    fn nonzero_cargo_failure_class_cap_falls_back_to_all_failures() {
1929        let mut output = String::from(
1930            "running 40 tests
1931
1932failures:
1933
1934",
1935        );
1936        for index in 0..40 {
1937            output.push_str(&format!(
1938                "---- case_{index} stdout ----
1939thread 'case_{index}' panicked at src/lib.rs:{index}:1
1940
1941"
1942            ));
1943        }
1944        output.push_str(
1945            "failures:
1946",
1947        );
1948        for index in 0..40 {
1949            output.push_str(&format!(
1950                "    case_{index}
1951"
1952            ));
1953        }
1954        output.push_str(
1955            "
1956test result: FAILED. 0 passed; 40 failed; 0 ignored; 0 measured; 0 filtered out
1957",
1958        );
1959
1960        let failed =
1961            compress_with_registry_exit_code("cargo test", &output, Some(101), &empty_registry());
1962
1963        assert!(failed.text.contains("---- case_0 stdout ----"));
1964        assert!(failed.text.contains("---- case_39 stdout ----"));
1965        assert!(failed.dropped_by_class.is_empty());
1966    }
1967
1968    #[test]
1969    fn toml_shortcircuit_is_skipped_for_unknown_exit_when_failure_signal_exists() {
1970        let registry = build_registry(
1971            &[(
1972                "make",
1973                r#"[filter]
1974matches = ["make"]
1975
1976[shortcircuit]
1977when = '(?s).*'
1978replacement = "make: ok"
1979"#,
1980            )],
1981            None,
1982            None,
1983        );
1984        let output = "build step
1985ERROR: compiler crashed
1986";
1987
1988        let failed = compress_with_registry_exit_code("make", output, None, &registry);
1989
1990        assert_ne!(failed.text, "make: ok");
1991        assert!(failed.text.contains("ERROR: compiler crashed"));
1992    }
1993
1994    #[test]
1995    fn successful_exit_still_gets_concise_success_summary() {
1996        let output = r#"Running 4 tests using 2 workers
1997
1998  ✓  1 [chromium] › example.spec.ts:5:1 › has title (2.3s)
1999  ✓  2 [chromium] › example.spec.ts:9:1 › get started link (1.8s)
2000  ✓  3 [chromium] › nav.spec.ts:3:1 › navigates (1.2s)
2001  ✓  4 [chromium] › auth.spec.ts:7:1 › logs out (1.0s)
2002
2003  4 passed (6.3s)
2004"#;
2005
2006        let successful =
2007            compress_with_registry_exit_code("playwright test", output, Some(0), &empty_registry());
2008        assert_eq!(successful.text, "playwright: 4 tests passed (6.3s)");
2009    }
2010}
2011
2012#[cfg(test)]
2013mod normalize_command_tests {
2014    use super::*;
2015
2016    #[test]
2017    fn passes_bare_commands_unchanged() {
2018        assert_eq!(normalize_command_for_dispatch("bun test"), None);
2019        assert_eq!(normalize_command_for_dispatch("cargo build"), None);
2020        assert_eq!(normalize_command_for_dispatch("git status"), None);
2021    }
2022
2023    #[test]
2024    fn strips_cd_and_amp_prefix() {
2025        assert_eq!(
2026            normalize_command_for_dispatch("cd /repo && bun test").as_deref(),
2027            Some("bun test")
2028        );
2029        assert_eq!(
2030            normalize_command_for_dispatch("cd /repo/packages/aft && cargo test --release")
2031                .as_deref(),
2032            Some("cargo test --release")
2033        );
2034    }
2035
2036    #[test]
2037    fn strips_cd_and_semicolon_prefix() {
2038        assert_eq!(
2039            normalize_command_for_dispatch("cd /repo; bun test").as_deref(),
2040            Some("bun test")
2041        );
2042    }
2043
2044    #[test]
2045    fn strips_cd_with_quoted_path() {
2046        assert_eq!(
2047            normalize_command_for_dispatch("cd \"/path with space\" && npm install").as_deref(),
2048            Some("npm install")
2049        );
2050    }
2051
2052    #[test]
2053    fn strips_env_assignments() {
2054        assert_eq!(
2055            normalize_command_for_dispatch("env FOO=bar npm install").as_deref(),
2056            Some("npm install")
2057        );
2058        assert_eq!(
2059            normalize_command_for_dispatch("env FOO=bar BAZ=qux RUST_LOG=info cargo test")
2060                .as_deref(),
2061            Some("cargo test")
2062        );
2063    }
2064
2065    #[test]
2066    fn strips_bare_assignment_prefixes() {
2067        assert_eq!(
2068            normalize_command_for_dispatch("NODE_ENV=production npm install").as_deref(),
2069            Some("npm install")
2070        );
2071        assert_eq!(
2072            normalize_command_for_dispatch("FOO=1 BAR=2 cargo test").as_deref(),
2073            Some("cargo test")
2074        );
2075        assert_eq!(
2076            normalize_command_for_dispatch("RUSTFLAGS='-C debug' cargo build").as_deref(),
2077            Some("cargo build")
2078        );
2079    }
2080
2081    #[test]
2082    fn does_not_strip_later_assignment_arguments() {
2083        assert_eq!(normalize_command_for_dispatch("npm install foo=bar"), None);
2084    }
2085
2086    #[test]
2087    fn env_without_assignments_returns_none() {
2088        // `env` alone is the env-listing command, not a prefix.
2089        assert_eq!(
2090            normalize_command_for_dispatch("env npm install").as_deref(),
2091            None
2092        );
2093    }
2094
2095    #[test]
2096    fn strips_timeout_prefix() {
2097        assert_eq!(
2098            normalize_command_for_dispatch("timeout 30 cargo test").as_deref(),
2099            Some("cargo test")
2100        );
2101        assert_eq!(
2102            normalize_command_for_dispatch("timeout 5m bun test").as_deref(),
2103            Some("bun test")
2104        );
2105    }
2106
2107    #[test]
2108    fn strips_nohup_prefix() {
2109        assert_eq!(
2110            normalize_command_for_dispatch("nohup ./long-running-script.sh").as_deref(),
2111            Some("./long-running-script.sh")
2112        );
2113    }
2114
2115    #[test]
2116    fn strips_paren_then_cd_and_amp() {
2117        assert_eq!(
2118            normalize_command_for_dispatch("(cd /repo && bun test").as_deref(),
2119            Some("bun test")
2120        );
2121    }
2122
2123    #[test]
2124    fn chains_multiple_prefixes() {
2125        // env then timeout then real command.
2126        assert_eq!(
2127            normalize_command_for_dispatch("env FOO=bar timeout 30 cargo test").as_deref(),
2128            Some("cargo test")
2129        );
2130        // cd then env then real command.
2131        assert_eq!(
2132            normalize_command_for_dispatch("cd /repo && env FOO=bar npm install").as_deref(),
2133            Some("npm install")
2134        );
2135    }
2136
2137    // -------- end-to-end dispatch via normalize() --------
2138
2139    fn empty_registry() -> FilterRegistry {
2140        FilterRegistry::default()
2141    }
2142
2143    #[test]
2144    fn cd_prefix_bun_test_still_routes_to_bun_test() {
2145        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";
2146        let compressed = compress_with_registry("cd /repo && bun test", output, &empty_registry());
2147        // The bun test compressor produces (pass) / "1 pass" / "Ran ..." in
2148        // the pass-only path. Generic middle-truncate would drop these and
2149        // keep the original. Asserting their presence proves the normalizer
2150        // succeeded.
2151        assert!(compressed.contains("(pass)") || compressed.contains("1 pass"));
2152    }
2153
2154    #[test]
2155    fn cd_prefix_cargo_test_still_routes_to_cargo() {
2156        let output = "running 5 tests\ntest foo ... ok\ntest bar ... FAILED\n\nfailures:\n\ntest result: FAILED. 4 passed; 1 failed\n";
2157        let compressed =
2158            compress_with_registry("cd /repo && cargo test", output, &empty_registry());
2159        assert!(compressed.contains("FAILED") || compressed.contains("failed"));
2160    }
2161
2162    #[test]
2163    fn env_prefix_npm_install_still_routes_to_npm() {
2164        let output = "added 50 packages, and audited 100 packages in 3s\n";
2165        let compressed = compress_with_registry(
2166            "env NODE_ENV=production npm install",
2167            output,
2168            &empty_registry(),
2169        );
2170        // NpmCompressor's install path keeps "added N packages" / "audited" markers.
2171        assert!(compressed.contains("added") || compressed.contains("audited"));
2172    }
2173
2174    #[test]
2175    fn bare_assignment_prefix_npm_install_routes_to_npm() {
2176        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";
2177        let compressed =
2178            compress_with_registry("NODE_ENV=production npm install", output, &empty_registry());
2179        assert!(!compressed.contains("npm http fetch"));
2180        assert!(compressed.contains("audited 100 packages"));
2181    }
2182
2183    #[test]
2184    fn bare_assignment_prefix_cargo_test_routes_to_cargo() {
2185        let output = "running 1 test\ntest foo ... ok\n\ntest result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out\n";
2186        let compressed =
2187            compress_with_registry("FOO=1 BAR=2 cargo test", output, &empty_registry());
2188        assert!(compressed.contains("running 1 test"));
2189        assert!(compressed.contains("test result: ok"));
2190        assert!(!compressed.contains("test foo ... ok"));
2191    }
2192
2193    #[test]
2194    fn quoted_assignment_prefix_cargo_build_routes_to_cargo() {
2195        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";
2196        let compressed = compress_with_registry(
2197            "RUSTFLAGS='-C debug' cargo build",
2198            output,
2199            &empty_registry(),
2200        );
2201        assert!(!compressed.contains("Compiling foo"));
2202        assert!(compressed.contains("warning: unused variable"));
2203        assert!(compressed.contains("Finished `dev` profile"));
2204    }
2205
2206    #[test]
2207    fn timeout_prefix_cargo_build_still_routes_to_cargo() {
2208        let output =
2209            "   Compiling foo v0.1.0\n    Finished `dev` profile [unoptimized] target(s) in 5s\n";
2210        let compressed =
2211            compress_with_registry("timeout 30 cargo build", output, &empty_registry());
2212        // CargoCompressor for build/check/run preserves the structure.
2213        assert!(compressed.contains("Compiling") || compressed.contains("Finished"));
2214    }
2215
2216    #[test]
2217    fn normalize_splits_pipe_and_takes_last_stage() {
2218        assert_eq!(
2219            normalize_command_for_dispatch("git log | grep fix").as_deref(),
2220            Some("grep fix")
2221        );
2222    }
2223
2224    #[test]
2225    fn normalize_cd_prefix_then_pipe_takes_last_stage() {
2226        assert_eq!(
2227            normalize_command_for_dispatch("cd /repo && git log | grep fix").as_deref(),
2228            Some("grep fix")
2229        );
2230    }
2231
2232    #[test]
2233    fn normalize_no_pipe_returns_none() {
2234        assert_eq!(normalize_command_for_dispatch("git log"), None);
2235    }
2236
2237    #[test]
2238    fn normalize_quoted_pipe_not_split() {
2239        assert_eq!(
2240            normalize_command_for_dispatch("grep \"a|b\" file.txt"),
2241            None
2242        );
2243    }
2244
2245    #[test]
2246    fn normalize_balanced_command_substitution_splits_top_level_pipe() {
2247        // The inner `|` is inside $(...) (depth > 0) and must be ignored; the
2248        // real top-level `| grep x` splits to the last stage. The OLD code
2249        // bailed to None here and fell back to head-token dispatch on the full
2250        // command — exactly the data-loss path issue #137 is about.
2251        assert_eq!(
2252            normalize_command_for_dispatch("echo $(cmd | cmd) | grep x").as_deref(),
2253            Some("grep x")
2254        );
2255    }
2256
2257    #[test]
2258    fn normalize_inner_pipe_in_substitution_without_top_level_pipe_is_none() {
2259        // No top-level pipe at all — the only `|` is inside $(...).
2260        assert_eq!(
2261            normalize_command_for_dispatch("echo $(cargo test | cat)"),
2262            None
2263        );
2264    }
2265
2266    #[test]
2267    fn normalize_double_pipe_not_split() {
2268        assert_eq!(normalize_command_for_dispatch("git log || echo fail"), None);
2269    }
2270
2271    #[test]
2272    fn normalize_multi_pipe_returns_last_stage() {
2273        assert_eq!(
2274            normalize_command_for_dispatch("git log | grep fix | head -5").as_deref(),
2275            Some("head -5")
2276        );
2277    }
2278
2279    #[test]
2280    fn normalize_process_substitution_splits_top_level_pipe() {
2281        // `<(...)` inner pipe ignored; top-level `| grep x` splits to last stage.
2282        assert_eq!(
2283            normalize_command_for_dispatch("cat <(echo a | cat) | grep x").as_deref(),
2284            Some("grep x")
2285        );
2286    }
2287
2288    #[test]
2289    fn normalize_pipe_ampersand_splits_last_stage() {
2290        // `|&` pipes stdout+stderr; it is a real pipe boundary, not `|` + `&`.
2291        assert_eq!(
2292            normalize_command_for_dispatch("cargo test |& grep FAIL").as_deref(),
2293            Some("grep FAIL")
2294        );
2295    }
2296
2297    #[test]
2298    fn piped_cargo_test_grep_preserves_failed() {
2299        let grep_output = "test foo ... FAILED\n";
2300        let compressed =
2301            compress_with_registry("cargo test | grep FAIL", grep_output, &empty_registry());
2302        assert!(
2303            compressed.text.contains("FAILED"),
2304            "grep-filtered FAILED must survive, got: {}",
2305            compressed.text
2306        );
2307    }
2308
2309    #[test]
2310    fn unsafe_piped_command_forces_generic_and_preserves_output() {
2311        // Unbalanced quote → the scanner can't trust the parse. A `|` is
2312        // present, so it must force generic rather than let CargoCompressor
2313        // claim `cargo test | …` and drop the single grep-filtered line.
2314        let grep_output = "test foo ... FAILED\n";
2315        let compressed =
2316            compress_with_registry("cargo test | grep \"FAIL", grep_output, &empty_registry());
2317        assert!(
2318            compressed.text.contains("FAILED"),
2319            "unsafe pipe must not drop output, got: {}",
2320            compressed.text
2321        );
2322    }
2323
2324    #[test]
2325    fn split_top_level_pipe_variants() {
2326        assert_eq!(split_top_level_pipe("git log"), PipeSplit::None);
2327        assert_eq!(
2328            split_top_level_pipe("git log | grep fix"),
2329            PipeSplit::LastStage("grep fix".to_string())
2330        );
2331        // `||` is logical-or, not a pipe.
2332        assert_eq!(split_top_level_pipe("a || b"), PipeSplit::None);
2333        // inner pipe inside a subshell is not a top-level boundary.
2334        assert_eq!(split_top_level_pipe("(a | b)"), PipeSplit::None);
2335        // inner pipe inside $() is not a top-level boundary.
2336        assert_eq!(split_top_level_pipe("echo $(a | b)"), PipeSplit::None);
2337        // unbalanced quote with a pipe present → unsafe.
2338        assert_eq!(split_top_level_pipe("a | grep \"x"), PipeSplit::Unsafe);
2339        // unbalanced paren with a pipe present → unsafe.
2340        assert_eq!(split_top_level_pipe("$(a | b | grep x"), PipeSplit::Unsafe);
2341        // FAIL-CLOSED cases (Oracle findings) — a pipe must never be last-staged
2342        // when other top-level structure could mean the captured output isn't
2343        // the last stage's:
2344        // trailing empty stage
2345        assert_eq!(split_top_level_pipe("cargo test |"), PipeSplit::Unsafe);
2346        assert_eq!(split_top_level_pipe("cargo test |&"), PipeSplit::Unsafe);
2347        // pipe coexisting with a top-level separator
2348        assert_eq!(
2349            split_top_level_pipe("true | cargo test --quiet ; printf X"),
2350            PipeSplit::Unsafe
2351        );
2352        assert_eq!(
2353            split_top_level_pipe("true | cargo test && echo done"),
2354            PipeSplit::Unsafe
2355        );
2356        // unmatched close paren with a pipe
2357        assert_eq!(
2358            split_top_level_pipe("echo ) | cargo test"),
2359            PipeSplit::Unsafe
2360        );
2361        // bare `&` background is a separator; `2>&1` / `&>` redirects are not
2362        assert_eq!(split_top_level_pipe("a | b & c"), PipeSplit::Unsafe);
2363        assert_eq!(
2364            split_top_level_pipe("cargo test 2>&1 | grep FAIL"),
2365            PipeSplit::LastStage("grep FAIL".to_string())
2366        );
2367    }
2368
2369    #[test]
2370    fn strip_top_level_comment_removes_only_real_comments() {
2371        assert_eq!(
2372            strip_top_level_comment("printf keep # | cargo test"),
2373            "printf keep "
2374        );
2375        assert_eq!(
2376            strip_top_level_comment("printf keep # cargo test"),
2377            "printf keep "
2378        );
2379        // `#` not at a word boundary is literal (e.g. a fragment/anchor).
2380        assert_eq!(
2381            strip_top_level_comment("curl http://x/y#frag"),
2382            "curl http://x/y#frag"
2383        );
2384        // `#` inside quotes is literal.
2385        assert_eq!(
2386            strip_top_level_comment("grep \"# not a comment\" f"),
2387            "grep \"# not a comment\" f"
2388        );
2389        assert_eq!(
2390            strip_top_level_comment("echo '# literal'"),
2391            "echo '# literal'"
2392        );
2393        // no comment → unchanged.
2394        assert_eq!(
2395            strip_top_level_comment("git log | grep fix"),
2396            "git log | grep fix"
2397        );
2398    }
2399
2400    #[test]
2401    fn commented_command_does_not_misdispatch_and_preserves_output() {
2402        // The `# cargo test` comment must not let CargoCompressor claim this
2403        // printf command's output and drop it — with OR without a pipe.
2404        for cmd in ["printf keep # | cargo test", "printf keep # cargo test"] {
2405            let compressed = compress_with_registry(cmd, "keep\n", &empty_registry());
2406            assert!(
2407                compressed.text.contains("keep"),
2408                "comment must not drop output for {cmd:?}, got: {}",
2409                compressed.text
2410            );
2411        }
2412    }
2413
2414    #[test]
2415    fn pipe_with_trailing_command_chain_preserves_sentinel() {
2416        // `true | cargo test ; printf SENTINEL` — captured output includes
2417        // SENTINEL; cargo must not claim it and drop the sentinel line.
2418        let compressed = compress_with_registry(
2419            "true | cargo test --quiet ; printf SENTINEL",
2420            "SENTINEL\n",
2421            &empty_registry(),
2422        );
2423        assert!(
2424            compressed.text.contains("SENTINEL"),
2425            "trailing-chain output must survive, got: {}",
2426            compressed.text
2427        );
2428    }
2429
2430    #[test]
2431    fn is_shell_boundary_covers_redirects_and_operators() {
2432        for tok in [
2433            "|",
2434            "|&",
2435            ";",
2436            "&",
2437            "&&",
2438            "||",
2439            ">",
2440            ">>",
2441            "<",
2442            "<<",
2443            "<<<",
2444            "&>",
2445            "&>>",
2446            "2>",
2447            "2>>",
2448            "2>&1",
2449            "1>&2",
2450            ">/dev/null",
2451            "2>/dev/null",
2452        ] {
2453            assert!(is_shell_boundary(tok), "{tok} should be a boundary");
2454        }
2455        for tok in ["test", "log", "build", "--release", "-v", "file.txt"] {
2456            assert!(!is_shell_boundary(tok), "{tok} must not be a boundary");
2457        }
2458    }
2459}