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 `|` and no top-level separator — dispatch on the
964    /// command as-is.
965    None,
966    /// A top-level pipeline; the captured stdout is this last stage's output.
967    LastStage(String),
968    /// The command cannot be safely dispatched to a head-token compressor.
969    /// Either a pipe coexists with other top-level structure (so the
970    /// captured output isn't just the last stage's), or a top-level
971    /// separator (`;`, `&&`, `||`, bare `&`, newline) means multiple
972    /// commands' output is interleaved — a head-token compressor would
973    /// delete the other commands' output. Force generic instead.
974    Unsafe,
975}
976
977/// Depth-aware pipeline scanner that FAILS CLOSED. Tracks single/double quotes,
978/// backslash escapes, backtick substitution, and `(`/`$(`/`<(`/`>(` nesting so a
979/// `|` inside any of them is not treated as a stage boundary. Splits on a
980/// top-level `|`/`|&` (never `||`) and returns the LAST stage — but ONLY when
981/// the command is a clean single pipeline. The caller captured the WHOLE
982/// command's stdout, so "last stage == captured output" holds only when no other
983/// top-level structure exists; otherwise a head-token compressor could claim the
984/// command and drop output (issue #137). Therefore, whenever a top-level pipe
985/// coexists with ANY of {a top-level separator `;`/`&&`/`||`/bare `&`/newline,
986/// an unbalanced quote/paren/backtick/escape, an unmatched `)`, or an empty
987/// trailing stage}, we return `Unsafe` so the caller forces generic compression.
988/// The same `Unsafe` result applies when there is NO pipe but a top-level
989/// separator IS present: the captured transcript interleaves multiple commands'
990/// output, so per-head specialized compression would delete the other commands'
991/// output.
992/// Top-level comments must already be removed by `strip_top_level_comment`.
993/// Redirects (`>`, `2>&1`, `&>`, …) are NOT separators.
994fn split_top_level_pipe(command: &str) -> PipeSplit {
995    let bytes = command.as_bytes();
996    let mut in_single = false;
997    let mut in_double = false;
998    let mut in_backtick = false;
999    let mut paren_depth: u32 = 0;
1000    let mut escaped = false;
1001    let mut saw_unmatched_close = false;
1002    let mut saw_top_pipe = false;
1003    let mut saw_top_separator = false;
1004    let mut last_pipe_end: Option<usize> = None;
1005
1006    let mut i = 0;
1007    while i < bytes.len() {
1008        let ch = bytes[i];
1009
1010        if escaped {
1011            escaped = false;
1012            i += 1;
1013            continue;
1014        }
1015        if in_single {
1016            if ch == b'\'' {
1017                in_single = false;
1018            }
1019            i += 1;
1020            continue;
1021        }
1022        if in_backtick {
1023            // Backtick substitution is opaque for splitting. A backslash still
1024            // escapes the next byte so an escaped backtick doesn't close it.
1025            if ch == b'\\' {
1026                escaped = true;
1027            } else if ch == b'`' {
1028                in_backtick = false;
1029            }
1030            i += 1;
1031            continue;
1032        }
1033        if ch == b'\\' {
1034            escaped = true;
1035            i += 1;
1036            continue;
1037        }
1038        if ch == b'`' {
1039            in_backtick = true;
1040            i += 1;
1041            continue;
1042        }
1043        // `$(` opens command substitution even inside double quotes.
1044        if ch == b'$' && bytes.get(i + 1) == Some(&b'(') {
1045            paren_depth += 1;
1046            i += 2;
1047            continue;
1048        }
1049        if in_double {
1050            if ch == b'"' {
1051                in_double = false;
1052            }
1053            i += 1;
1054            continue;
1055        }
1056
1057        // Below here: outside single/double quotes and backticks. Top-level
1058        // comments are already removed by `strip_top_level_comment` before this
1059        // scanner runs, so no `#` handling is needed here.
1060        let prev_raw = if i > 0 { bytes[i - 1] } else { b' ' };
1061
1062        match ch {
1063            b'\'' => in_single = true,
1064            b'"' => in_double = true,
1065            // process substitution `<(` / `>(`
1066            b'<' | b'>' if bytes.get(i + 1) == Some(&b'(') => {
1067                paren_depth += 1;
1068                i += 2;
1069                continue;
1070            }
1071            b'(' => paren_depth += 1,
1072            b')' => {
1073                if paren_depth == 0 {
1074                    saw_unmatched_close = true;
1075                } else {
1076                    paren_depth -= 1;
1077                }
1078            }
1079            b'|' if paren_depth == 0 => {
1080                if bytes.get(i + 1) == Some(&b'|') {
1081                    saw_top_separator = true; // `||` logical OR
1082                    i += 2;
1083                    continue;
1084                }
1085                saw_top_pipe = true;
1086                if bytes.get(i + 1) == Some(&b'&') {
1087                    last_pipe_end = Some(i + 2); // `|&` (stdout+stderr)
1088                    i += 2;
1089                    continue;
1090                }
1091                last_pipe_end = Some(i + 1);
1092            }
1093            b'&' if paren_depth == 0 => {
1094                if bytes.get(i + 1) == Some(&b'&') {
1095                    saw_top_separator = true; // `&&`
1096                    i += 2;
1097                    continue;
1098                }
1099                // `&>`/`&>>` redirect, or `>&`/`2>&1` fd-dup: NOT a separator.
1100                // A bare `&` is the background control operator.
1101                if bytes.get(i + 1) != Some(&b'>') && prev_raw != b'>' {
1102                    saw_top_separator = true;
1103                }
1104            }
1105            b';' if paren_depth == 0 => saw_top_separator = true,
1106            b'\n' if paren_depth == 0 => saw_top_separator = true,
1107            _ => {}
1108        }
1109        i += 1;
1110    }
1111
1112    let imbalance =
1113        in_single || in_double || in_backtick || escaped || paren_depth != 0 || saw_unmatched_close;
1114
1115    if saw_top_pipe {
1116        // Only a clean single pipeline is safe to last-stage dispatch.
1117        if imbalance || saw_top_separator {
1118            return PipeSplit::Unsafe;
1119        }
1120        match last_pipe_end {
1121            Some(end) => {
1122                let last_stage = command[end..].trim();
1123                if last_stage.is_empty() {
1124                    PipeSplit::Unsafe // trailing empty stage, e.g. `cargo test |`
1125                } else {
1126                    PipeSplit::LastStage(last_stage.to_string())
1127                }
1128            }
1129            None => PipeSplit::Unsafe,
1130        }
1131    } else if imbalance && command.contains('|') {
1132        // No resolvable top-level pipe, but a `|` hides in an unbalanced region.
1133        PipeSplit::Unsafe
1134    } else if saw_top_separator {
1135        // A top-level separator (`;`, `&&`, `||`, bare `&`, newline) without a
1136        // pipe means multiple commands' output is interleaved in the captured
1137        // transcript. Per-head specialized compression would delete the other
1138        // commands' output — same rationale as the pipe Unsafe rule.
1139        PipeSplit::Unsafe
1140    } else {
1141        PipeSplit::None
1142    }
1143}
1144
1145fn strip_cd_prefix(command: &str) -> Option<String> {
1146    // Look for `&&` or `;` outside of quotes.
1147    let bytes = command.as_bytes();
1148    let mut in_single = false;
1149    let mut in_double = false;
1150    let mut i = 0;
1151    while i < bytes.len() {
1152        let ch = bytes[i] as char;
1153        if !in_double && ch == '\'' {
1154            in_single = !in_single;
1155        } else if !in_single && ch == '"' {
1156            in_double = !in_double;
1157        } else if !in_single && !in_double {
1158            if ch == '&' && i + 1 < bytes.len() && bytes[i + 1] as char == '&' {
1159                let rest = command[i + 2..].trim_start();
1160                if rest.is_empty() {
1161                    return None;
1162                }
1163                return Some(rest.to_string());
1164            }
1165            if ch == ';' {
1166                let rest = command[i + 1..].trim_start();
1167                if rest.is_empty() {
1168                    return None;
1169                }
1170                return Some(rest.to_string());
1171            }
1172        }
1173        i += 1;
1174    }
1175    None
1176}
1177
1178fn strip_env_prefix(command: &str) -> Option<String> {
1179    // env <ASSIGN>... <cmd> ...
1180    let rest = command.strip_prefix("env")?.trim_start();
1181    strip_leading_assignment_prefix(rest)
1182}
1183
1184fn strip_leading_assignment_prefix(command: &str) -> Option<String> {
1185    let mut index = 0usize;
1186    let mut consumed_assignment = false;
1187
1188    loop {
1189        index = skip_whitespace(command, index);
1190        if index >= command.len() {
1191            break;
1192        }
1193
1194        let word_end = shell_word_end(command, index)?;
1195        if word_end == index {
1196            break;
1197        }
1198
1199        let word = &command[index..word_end];
1200        if !is_env_assignment(word) {
1201            break;
1202        }
1203
1204        consumed_assignment = true;
1205        index = word_end;
1206    }
1207
1208    if !consumed_assignment {
1209        return None;
1210    }
1211
1212    let after = command[index..].trim_start();
1213    if after.is_empty() {
1214        None
1215    } else {
1216        Some(after.to_string())
1217    }
1218}
1219
1220fn skip_whitespace(input: &str, mut index: usize) -> usize {
1221    while index < input.len() {
1222        let Some(ch) = input[index..].chars().next() else {
1223            break;
1224        };
1225        if !ch.is_whitespace() {
1226            break;
1227        }
1228        index += ch.len_utf8();
1229    }
1230    index
1231}
1232
1233fn shell_word_end(command: &str, start: usize) -> Option<usize> {
1234    let mut in_single = false;
1235    let mut in_double = false;
1236    let mut escaped = false;
1237
1238    for (offset, ch) in command[start..].char_indices() {
1239        let index = start + offset;
1240
1241        if escaped {
1242            escaped = false;
1243            continue;
1244        }
1245
1246        if ch == '\\' && !in_single {
1247            escaped = true;
1248            continue;
1249        }
1250
1251        if ch == '\'' && !in_double {
1252            in_single = !in_single;
1253            continue;
1254        }
1255
1256        if ch == '"' && !in_single {
1257            in_double = !in_double;
1258            continue;
1259        }
1260
1261        if !in_single && !in_double && (ch.is_whitespace() || matches!(ch, ';' | '&' | '|')) {
1262            return Some(index);
1263        }
1264    }
1265
1266    if in_single || in_double || escaped {
1267        None
1268    } else {
1269        Some(command.len())
1270    }
1271}
1272
1273fn is_env_assignment(token: &str) -> bool {
1274    if token.starts_with('-') {
1275        return false;
1276    }
1277    let Some((name, _value)) = token.split_once('=') else {
1278        return false;
1279    };
1280    let mut chars = name.chars();
1281    let Some(first) = chars.next() else {
1282        return false;
1283    };
1284    (first.is_ascii_alphabetic() || first == '_')
1285        && chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
1286}
1287
1288fn strip_timeout_prefix(command: &str) -> Option<String> {
1289    let rest = command.strip_prefix("timeout")?.trim_start();
1290    // Next token must look like a duration (digits, optional trailing unit s/m/h).
1291    let mut iter = rest.splitn(2, char::is_whitespace);
1292    let duration = iter.next()?;
1293    let after = iter.next()?.trim_start();
1294    if after.is_empty() || !looks_like_duration(duration) {
1295        return None;
1296    }
1297    Some(after.to_string())
1298}
1299
1300fn looks_like_duration(token: &str) -> bool {
1301    if token.is_empty() {
1302        return false;
1303    }
1304    let mut chars = token.chars().peekable();
1305    let mut saw_digit = false;
1306    while let Some(&ch) = chars.peek() {
1307        if ch.is_ascii_digit() {
1308            saw_digit = true;
1309            chars.next();
1310        } else {
1311            break;
1312        }
1313    }
1314    if !saw_digit {
1315        return false;
1316    }
1317    match chars.next() {
1318        None => true,
1319        Some(unit) => matches!(unit, 's' | 'm' | 'h' | 'd') && chars.next().is_none(),
1320    }
1321}
1322
1323/// Resolve the harness-scoped user-filter directory for an arbitrary storage_dir.
1324/// Used by `aft doctor filters` to inspect filters without needing a live AppContext.
1325pub fn user_filter_dir(storage_dir: &Path, harness: Harness) -> PathBuf {
1326    storage_dir.join(harness.storage_segment()).join("filters")
1327}
1328
1329fn legacy_user_filter_dir(storage_dir: &Path) -> PathBuf {
1330    storage_dir.join("filters")
1331}
1332
1333/// Move filters written by the short-lived root-scoped v0.27 layout into the
1334/// active harness directory. Existing harness files win; colliding root files
1335/// are left in place so we never overwrite user-authored filters.
1336pub(crate) fn repair_legacy_user_filter_dir(storage_dir: &Path, harness: Harness) {
1337    let legacy_dir = legacy_user_filter_dir(storage_dir);
1338    if !legacy_dir.exists() {
1339        return;
1340    }
1341
1342    let entries = match fs::read_dir(&legacy_dir) {
1343        Ok(entries) => entries.filter_map(Result::ok).collect::<Vec<_>>(),
1344        Err(_) => return,
1345    };
1346    if entries.is_empty() {
1347        let _ = fs::remove_dir(&legacy_dir);
1348        return;
1349    }
1350
1351    let harness_dir = user_filter_dir(storage_dir, harness);
1352    if fs::create_dir_all(&harness_dir).is_err() {
1353        return;
1354    }
1355
1356    for entry in entries {
1357        let target = harness_dir.join(entry.file_name());
1358        if target.exists() {
1359            continue;
1360        }
1361        let _ = fs::rename(entry.path(), target);
1362    }
1363
1364    if fs::read_dir(&legacy_dir)
1365        .map(|mut entries| entries.next().is_none())
1366        .unwrap_or(false)
1367    {
1368        let _ = fs::remove_dir(&legacy_dir);
1369    }
1370}
1371
1372/// Resolve the project-filter directory for an arbitrary project root.
1373/// Returns the directory regardless of trust state — caller must check trust
1374/// separately if it wants to gate loading.
1375pub fn project_filter_dir(project_root: &Path) -> PathBuf {
1376    project_root.join(".cortexkit").join("aft").join("filters")
1377}
1378
1379#[cfg(test)]
1380mod tests {
1381    use super::*;
1382
1383    #[test]
1384    fn user_and_project_filter_dir_helpers() {
1385        let storage = Path::new("/tmp/aft-storage");
1386        assert_eq!(
1387            user_filter_dir(storage, Harness::Opencode),
1388            Path::new("/tmp/aft-storage/opencode/filters")
1389        );
1390
1391        let project = Path::new("/repo");
1392        assert_eq!(
1393            project_filter_dir(project),
1394            Path::new("/repo/.cortexkit/aft/filters")
1395        );
1396    }
1397
1398    #[test]
1399    fn repair_legacy_user_filter_dir_moves_root_filters_without_overwrite() {
1400        let temp = tempfile::tempdir().unwrap();
1401        let storage = temp.path();
1402        fs::create_dir_all(storage.join("filters")).unwrap();
1403        fs::create_dir_all(storage.join("opencode/filters")).unwrap();
1404        fs::write(storage.join("filters/root-only.toml"), "root").unwrap();
1405        fs::write(storage.join("filters/collides.toml"), "root").unwrap();
1406        fs::write(storage.join("opencode/filters/collides.toml"), "harness").unwrap();
1407
1408        repair_legacy_user_filter_dir(storage, Harness::Opencode);
1409
1410        assert_eq!(
1411            fs::read_to_string(storage.join("opencode/filters/root-only.toml")).unwrap(),
1412            "root"
1413        );
1414        assert_eq!(
1415            fs::read_to_string(storage.join("opencode/filters/collides.toml")).unwrap(),
1416            "harness"
1417        );
1418        assert_eq!(
1419            fs::read_to_string(storage.join("filters/collides.toml")).unwrap(),
1420            "root"
1421        );
1422        assert!(!storage.join("filters/root-only.toml").exists());
1423    }
1424}
1425
1426#[cfg(test)]
1427mod output_probe_tests {
1428    use super::*;
1429    use serde::Deserialize;
1430
1431    #[derive(Deserialize)]
1432    struct CorpusEntry {
1433        file: String,
1434    }
1435
1436    #[derive(Debug, PartialEq, Eq)]
1437    struct MatchPhaseResult {
1438        selected: Option<usize>,
1439        output: CompressionResult,
1440    }
1441
1442    fn legacy_match_phase(output: &str) -> MatchPhaseResult {
1443        let compressors = compressors_in_dispatch_order();
1444        let selected = [Specificity::Specific, Specificity::PackageManager]
1445            .into_iter()
1446            .find_map(|specificity| {
1447                compressors
1448                    .iter()
1449                    .enumerate()
1450                    .filter(|(_, compressor)| compressor.specificity() == specificity)
1451                    .find_map(|(index, compressor)| {
1452                        compressor.matches_output(output).then_some(index)
1453                    })
1454            });
1455        match_phase_result(&compressors, selected, output)
1456    }
1457
1458    fn probe_match_phase(output: &str) -> MatchPhaseResult {
1459        let compressors = compressors_in_dispatch_order();
1460        let probe = OutputProbe::new(output);
1461        let selected = [Specificity::Specific, Specificity::PackageManager]
1462            .into_iter()
1463            .find_map(|specificity| {
1464                compressors
1465                    .iter()
1466                    .enumerate()
1467                    .filter(|(_, compressor)| compressor.specificity() == specificity)
1468                    .find_map(|(index, compressor)| {
1469                        compressor.matches_output_probe(&probe).then_some(index)
1470                    })
1471            });
1472        match_phase_result(&compressors, selected, output)
1473    }
1474
1475    fn match_phase_result(
1476        compressors: &[&dyn Compressor],
1477        selected: Option<usize>,
1478        output: &str,
1479    ) -> MatchPhaseResult {
1480        let output = selected.map_or_else(
1481            || GenericCompressor.compress_with_exit_code("", output, None),
1482            |index| compressors[index].compress_output_match_with_exit_code(output, None),
1483        );
1484        MatchPhaseResult { selected, output }
1485    }
1486
1487    fn repository_root() -> PathBuf {
1488        Path::new(env!("CARGO_MANIFEST_DIR"))
1489            .parent()
1490            .and_then(Path::parent)
1491            .expect("aft crate should be nested under the repository root")
1492            .to_path_buf()
1493    }
1494
1495    fn benchmark_corpus() -> Vec<(String, String)> {
1496        let fixtures = repository_root().join("benchmarks/compression-tokens/fixtures");
1497        let manifest_text = fs::read_to_string(fixtures.join("manifest.json"))
1498            .expect("compression corpus manifest should be readable");
1499        let manifest: Vec<CorpusEntry> =
1500            serde_json::from_str(&manifest_text).expect("compression corpus manifest should parse");
1501        manifest
1502            .into_iter()
1503            .map(|entry| {
1504                let output = fs::read_to_string(fixtures.join(&entry.file))
1505                    .unwrap_or_else(|error| panic!("failed to read {}: {error}", entry.file));
1506                (format!("compression corpus {}", entry.file), output)
1507            })
1508            .collect()
1509    }
1510
1511    fn integration_filter_inputs() -> Vec<(String, String)> {
1512        let root = Path::new(env!("CARGO_MANIFEST_DIR"))
1513            .join("tests/integration/fixtures/compress_filters");
1514        let mut paths = Vec::new();
1515        collect_named_files(&root, "input.txt", &mut paths);
1516        paths.sort();
1517        paths
1518            .into_iter()
1519            .map(|path| {
1520                let output = fs::read_to_string(&path)
1521                    .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display()));
1522                (format!("filter fixture {}", path.display()), output)
1523            })
1524            .collect()
1525    }
1526
1527    fn collect_named_files(root: &Path, wanted_name: &str, paths: &mut Vec<PathBuf>) {
1528        let entries = fs::read_dir(root)
1529            .unwrap_or_else(|error| panic!("failed to read {}: {error}", root.display()));
1530        for entry in entries {
1531            let path = entry
1532                .expect("fixture directory entry should be readable")
1533                .path();
1534            if path.is_dir() {
1535                collect_named_files(&path, wanted_name, paths);
1536            } else if path.file_name().is_some_and(|name| name == wanted_name) {
1537                paths.push(path);
1538            }
1539        }
1540    }
1541
1542    fn hostile_outputs() -> Vec<(String, String)> {
1543        let huge_json_array = format!(
1544            "[{}]",
1545            (0..20_000)
1546                .map(|index| format!(r#"{{"index":{index},"value":"payload"}}"#))
1547                .collect::<Vec<_>>()
1548                .join(",")
1549        );
1550        let ansi_heavy = (0..2_000)
1551            .map(|index| format!("\u{1b}[31mordinary output {index}\u{1b}[0m"))
1552            .collect::<Vec<_>>()
1553            .join("\n");
1554        vec![
1555            (
1556                "valid non-tool JSON object".to_string(),
1557                r#" {"payload":{"nested":[1,2,3]},"ok":true}"#.to_string(),
1558            ),
1559            ("huge JSON array".to_string(), huge_json_array),
1560            (
1561                "almost JSON".to_string(),
1562                r#"{"payload":[1,2,3],"unterminated":true"#.to_string(),
1563            ),
1564            ("empty output".to_string(), String::new()),
1565            ("ANSI-heavy output".to_string(), ansi_heavy),
1566        ]
1567    }
1568
1569    #[test]
1570    fn shared_probe_preserves_match_selection_and_output_across_corpora() {
1571        let cases = benchmark_corpus()
1572            .into_iter()
1573            .chain(integration_filter_inputs())
1574            .chain(hostile_outputs());
1575
1576        for (label, raw_output) in cases {
1577            let output = strip_ansi(&raw_output);
1578            let legacy = legacy_match_phase(&output);
1579            let probed = probe_match_phase(&output);
1580            assert_eq!(legacy, probed, "output-shape dispatch changed for {label}");
1581        }
1582    }
1583
1584    #[cfg(debug_assertions)]
1585    #[test]
1586    fn unmatched_json_object_is_parsed_once_across_match_phase() {
1587        let output = r#"{"payload":{"nested":[1,2,3]},"ok":true}"#;
1588        reset_output_probe_json_parse_count();
1589
1590        let result = probe_match_phase(output);
1591
1592        assert_eq!(result.selected, None);
1593        assert_eq!(output_probe_json_parse_count(), 1);
1594    }
1595}
1596
1597#[cfg(test)]
1598mod dispatch_specificity_tests {
1599    use super::*;
1600    use crate::compress::toml_filter::FilterRegistry;
1601
1602    fn empty_registry() -> FilterRegistry {
1603        FilterRegistry::default()
1604    }
1605
1606    /// Helper: assert that a given command would be claimed by a specific
1607    /// compressor by reading the output marker the compressor produces.
1608    /// (We can't easily compare Compressor instances by identity, so we
1609    /// dispatch and check for module-distinctive markers in the output.)
1610    fn dispatch(cmd: &str, output: &str) -> String {
1611        compress_with_registry(cmd, output, &empty_registry()).text
1612    }
1613
1614    #[test]
1615    fn generic_dispatch_does_not_classify_error_or_warning_words() {
1616        let result = compress_with_registry(
1617            "unknown-tool",
1618            "error: this is just a log line\nwarning: this too",
1619            &empty_registry(),
1620        );
1621
1622        assert!(result.dropped_by_class.is_empty());
1623        assert!(!result.had_inner_drop);
1624        assert!(result.text.contains("error: this is just a log line"));
1625    }
1626
1627    #[test]
1628    fn bun_run_vitest_routes_to_vitest_not_generic() {
1629        // VitestCompressor preserves PASS/FAIL markers and "Tests:" summary.
1630        // BunCompressor's `Some("run")` arm currently goes to generic which
1631        // would middle-truncate. Use a small vitest-shaped output and assert
1632        // the vitest formatter's output marker is present.
1633        let output = "Test Files  1 passed (1)\n     Tests  4 passed (4)\n  Start at  10:00:00\n  Duration  120ms\n";
1634        let compressed = dispatch("bun run vitest", output);
1635        // Assert vitest path took it: the vitest text summary keeps "Tests" / "Test Files" lines
1636        assert!(compressed.contains("Tests") || compressed.contains("Test Files"));
1637    }
1638
1639    #[test]
1640    fn npm_test_routes_to_vitest_when_output_is_vitest_shaped() {
1641        // `npm test` has no vitest token, so this proves the output-shape
1642        // tier runs before the broad NpmCompressor PackageManager tier.
1643        let output = "RERUN src/foo.test.ts x1\nFAIL src/foo.test.ts\nTest Files  1 failed (1)\nDuration    120ms\n";
1644        let compressed = dispatch("npm test", output);
1645        assert!(compressed.contains("FAIL src/foo.test.ts"));
1646        assert!(compressed.contains("Duration    120ms"));
1647        assert!(!compressed.contains("RERUN"));
1648    }
1649
1650    #[test]
1651    fn bun_run_vitest_token_match_wins_over_bun_head_match() {
1652        // Concrete proof the new dispatch works: a command where Bun would
1653        // otherwise have claimed it.
1654        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";
1655        let compressed = dispatch("bun run vitest run", output);
1656        // Vitest preserves PASS lines and "Tests:" summary.
1657        assert!(compressed.contains("Test Files") || compressed.contains("PASS"));
1658    }
1659
1660    #[test]
1661    fn bunx_jest_routes_to_vitest_module() {
1662        let output = "PASS src/foo.test.js (1.2s)\nTest Suites: 1 passed, 1 total\nTests:       3 passed, 3 total\n";
1663        let compressed = dispatch("bunx jest --json", output);
1664        assert!(compressed.contains("Tests:") && compressed.contains("Test Suites"));
1665    }
1666
1667    #[test]
1668    fn pnpm_run_vitest_routes_to_vitest() {
1669        let output = "Test Files  1 passed (1)\n     Tests  10 passed (10)\n";
1670        let compressed = dispatch("pnpm run vitest", output);
1671        assert!(compressed.contains("Tests") || compressed.contains("Test Files"));
1672    }
1673
1674    #[test]
1675    fn npx_eslint_routes_to_eslint_not_generic() {
1676        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";
1677        let compressed = dispatch("npx eslint .", output);
1678        // EslintCompressor preserves rule IDs and the ✖ summary.
1679        assert!(compressed.contains("no-unused-vars") || compressed.contains("✖"));
1680    }
1681
1682    #[test]
1683    fn npm_run_lint_without_linter_output_shape_falls_back() {
1684        // `npm run lint` has no eslint token, and this output has no eslint
1685        // summary signature, so it should remain package-manager generic.
1686        let output = "> my-project@1.0.0 lint\n> eslint .\n\nAll good.\n";
1687        let compressed = dispatch("npm run lint", output);
1688        assert!(compressed.contains("All good."));
1689    }
1690
1691    #[test]
1692    fn bun_test_still_routes_to_bun_test_compressor() {
1693        // Bun.test is the v0.28.2 fix — make sure specificity dispatch
1694        // doesn't accidentally break it. The Bun module's `Some("test")`
1695        // arm should still claim this when no Specific matcher does.
1696        // BunTestCompressor doesn't exist as a separate module — the
1697        // BunCompressor.compress() routes Some("test") to its inner
1698        // compress_test() function. The relevant assertion: this still
1699        // produces bun-test-shaped output, not generic-truncated output.
1700        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";
1701        let compressed = dispatch("bun test", output);
1702        assert!(compressed.contains("(pass)") || compressed.contains("1 pass"));
1703    }
1704
1705    #[test]
1706    fn bunx_vitest_routes_to_vitest() {
1707        let output = "Test Files  1 passed (1)\n     Tests  3 passed (3)\n";
1708        let compressed = dispatch("bunx vitest run", output);
1709        assert!(compressed.contains("Tests") || compressed.contains("Test Files"));
1710    }
1711
1712    #[test]
1713    fn cargo_test_still_routes_to_cargo() {
1714        // Regression: specificity reordering must not break commands that
1715        // already worked. Cargo is Specific tier.
1716        let output = "running 5 tests\ntest foo ... ok\ntest bar ... FAILED\n\nfailures:\n\ntest result: FAILED. 4 passed; 1 failed\n";
1717        let compressed = dispatch("cargo test", output);
1718        // Cargo's test compressor preserves PASS/FAIL semantics.
1719        assert!(compressed.contains("failed") || compressed.contains("FAILED"));
1720    }
1721
1722    #[test]
1723    fn top_level_piped_cargo_test_uses_generic_output() {
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 | cat", output, &empty_registry());
1727
1728        assert!(
1729            compressed.text.contains("test ok_test ... ok"),
1730            "piped cargo output must stay generic/raw, got: {}",
1731            compressed.text
1732        );
1733    }
1734
1735    #[test]
1736    fn non_piped_cargo_test_still_uses_cargo_compressor() {
1737        let output = "running 1 test\ntest ok_test ... ok\n\ntest result: ok. 1 passed; 0 failed\n";
1738
1739        let compressed = compress_with_registry("cargo test", output, &empty_registry());
1740
1741        assert!(compressed.text.contains("running 1 test"));
1742        assert!(compressed.text.contains("test result: ok"));
1743        assert!(
1744            !compressed.text.contains("test ok_test ... ok"),
1745            "non-piped cargo test should keep using the cargo compressor, got: {}",
1746            compressed.text
1747        );
1748    }
1749
1750    #[test]
1751    fn git_status_still_routes_to_git() {
1752        // Regression: git is Specific tier.
1753        let output =
1754            "On branch main\nYour branch is up to date.\n\nnothing to commit, working tree clean\n";
1755        let compressed = dispatch("git status", output);
1756        assert!(compressed.contains("branch") || compressed.contains("clean"));
1757    }
1758
1759    #[test]
1760    fn pnpm_install_still_routes_to_pnpm() {
1761        // Regression: pnpm install was handled before this change.
1762        let output = "Progress: resolved 100, downloaded 50, added 50\nAdded 50 packages\n";
1763        let compressed = dispatch("pnpm install", output);
1764        // PnpmCompressor's compress_package keeps "+ pkg" or "Added X packages" type lines.
1765        assert!(compressed.contains("Added") || compressed.contains("Progress"));
1766    }
1767}
1768
1769#[cfg(test)]
1770mod exit_code_safety_tests {
1771    use super::*;
1772    use crate::compress::toml_filter::{build_registry, FilterRegistry};
1773
1774    fn empty_registry() -> FilterRegistry {
1775        FilterRegistry::default()
1776    }
1777
1778    #[test]
1779    fn go_build_failure_signal_preserved_even_when_exit_zero_masks_failure() {
1780        let output = "go: go.mod file not found in current directory or any parent directory; see 'go help modules'\n";
1781
1782        let failed =
1783            compress_with_registry_exit_code("go build ./...", output, Some(1), &empty_registry());
1784        assert!(!failed.text.contains("go build: ok"));
1785        assert!(failed.text.contains("go.mod file not found"));
1786
1787        let masked =
1788            compress_with_registry_exit_code("go build ./...", output, Some(0), &empty_registry());
1789        assert!(!masked.text.contains("go build: ok"));
1790        assert!(masked.text.contains("go.mod file not found"));
1791    }
1792
1793    #[test]
1794    fn playwright_nonzero_crash_does_not_become_passed_summary() {
1795        let output = r#"Running 4 tests using 2 workers
1796
1797  ✓  1 [chromium] › example.spec.ts:5:1 › has title (2.3s)
1798  ✓  2 [chromium] › example.spec.ts:9:1 › get started link (1.8s)
1799  ✓  3 [chromium] › nav.spec.ts:3:1 › navigates (1.2s)
1800  ✓  4 [chromium] › auth.spec.ts:7:1 › logs out (1.0s)
1801
1802  4 passed (6.3s)
1803Error: browserType.launch: Target page, context or browser has been closed
1804"#;
1805
1806        let failed = compress_with_registry_exit_code(
1807            "npx playwright test",
1808            output,
1809            Some(1),
1810            &empty_registry(),
1811        );
1812        assert!(!failed.text.starts_with("playwright: 4 tests passed"));
1813        assert!(failed.text.contains("browserType.launch"));
1814    }
1815
1816    #[test]
1817    fn cargo_test_compile_error_nonzero_preserves_error_code_diagnostic() {
1818        let output = r#"   Compiling demo v0.1.0 (/tmp/demo)
1819error[E0432]: unresolved import `crate::missing`
1820 --> src/lib.rs:1:5
1821  |
18221 | use crate::missing;
1823  |     ^^^^^^^^^^^^^^ no `missing` in the root
1824
1825error: could not compile `demo` (lib test) due to 1 previous error
1826"#;
1827
1828        let failed =
1829            compress_with_registry_exit_code("cargo test", output, Some(101), &empty_registry());
1830        assert!(failed.text.contains("error[E0432]"));
1831        assert!(failed.text.contains("unresolved import"));
1832        assert!(failed.text.contains("error: could not compile"));
1833    }
1834
1835    #[test]
1836    fn chained_mypy_success_then_later_failure_uses_failure_preserving_output() {
1837        let output = "Success: no issues found in 1 source file\nError: node process exploded\n";
1838
1839        let failed = compress_with_registry_exit_code(
1840            "mypy src && node fail.js",
1841            output,
1842            Some(1),
1843            &empty_registry(),
1844        );
1845        assert_ne!(failed.text, "mypy: clean");
1846        assert!(failed.text.contains("Error: node process exploded"));
1847    }
1848
1849    #[test]
1850    fn toml_shortcircuit_is_skipped_for_nonzero_exit() {
1851        let registry = build_registry(
1852            &[(
1853                "wget",
1854                r#"[filter]
1855matches = ["wget"]
1856
1857[shortcircuit]
1858when = '(?s).*'
1859replacement = "wget: ok"
1860"#,
1861            )],
1862            None,
1863            None,
1864        );
1865        let output = "Connecting to example.invalid\nerror: connection refused\n";
1866
1867        let failed = compress_with_registry_exit_code(
1868            "wget https://example.invalid",
1869            output,
1870            Some(1),
1871            &registry,
1872        );
1873        assert_ne!(failed.text, "wget: ok");
1874        assert!(failed.text.contains("error: connection refused"));
1875    }
1876
1877    #[test]
1878    fn unknown_exit_code_keeps_byte_identical_legacy_compressor_output() {
1879        // Use a clean single command (no separator) so this tests the
1880        // unknown-exit-code path, not the separator-forces-generic path.
1881        let output = "Success: no issues found in 1 source file\n";
1882
1883        let legacy = compress_with_registry_exit_code("mypy src", output, None, &empty_registry());
1884        assert_eq!(legacy.text, "mypy: clean");
1885    }
1886
1887    #[test]
1888    fn killed_exit_sentinel_rejects_clean_legacy_summary() {
1889        let output = "Success: no issues found in 1 source file
1890Error: later chained command failed
1891";
1892
1893        let killed = compress_with_registry_exit_code(
1894            "mypy src && node fail.js",
1895            output,
1896            Some(137),
1897            &empty_registry(),
1898        );
1899        assert_ne!(killed.text, "mypy: clean");
1900        assert!(killed.text.contains("Error: later chained command failed"));
1901    }
1902
1903    #[test]
1904    fn nonzero_clean_eslint_json_summary_falls_back_to_raw_output() {
1905        let output =
1906            r#"[{"filePath":"/repo/src/main.ts","messages":[],"errorCount":0,"warningCount":0}]"#;
1907
1908        let failed = compress_with_registry_exit_code(
1909            "eslint -f json .",
1910            output,
1911            Some(1),
1912            &empty_registry(),
1913        );
1914
1915        assert_ne!(failed.text, "eslint: no issues");
1916        assert!(failed.text.contains(r#""messages":[]"#));
1917    }
1918
1919    #[test]
1920    fn nonzero_appends_distinct_missing_raw_failure_lines() {
1921        let raw = "Error: first failure
1922progress
1923Error: second failure
1924";
1925        let compressed = CompressionResult::new("Error: first failure");
1926
1927        let preserved = failure_preserving_result("tool", raw, compressed, Some(1));
1928
1929        assert!(preserved.text.contains("Error: first failure"));
1930        assert!(preserved.text.contains("Error: second failure"));
1931        assert!(preserved
1932            .text
1933            .contains("[raw failure lines preserved by AFT]"));
1934    }
1935
1936    #[test]
1937    fn nonzero_cargo_failure_class_cap_falls_back_to_all_failures() {
1938        let mut output = String::from(
1939            "running 40 tests
1940
1941failures:
1942
1943",
1944        );
1945        for index in 0..40 {
1946            output.push_str(&format!(
1947                "---- case_{index} stdout ----
1948thread 'case_{index}' panicked at src/lib.rs:{index}:1
1949
1950"
1951            ));
1952        }
1953        output.push_str(
1954            "failures:
1955",
1956        );
1957        for index in 0..40 {
1958            output.push_str(&format!(
1959                "    case_{index}
1960"
1961            ));
1962        }
1963        output.push_str(
1964            "
1965test result: FAILED. 0 passed; 40 failed; 0 ignored; 0 measured; 0 filtered out
1966",
1967        );
1968
1969        let failed =
1970            compress_with_registry_exit_code("cargo test", &output, Some(101), &empty_registry());
1971
1972        assert!(failed.text.contains("---- case_0 stdout ----"));
1973        assert!(failed.text.contains("---- case_39 stdout ----"));
1974        assert!(failed.dropped_by_class.is_empty());
1975    }
1976
1977    #[test]
1978    fn toml_shortcircuit_is_skipped_for_unknown_exit_when_failure_signal_exists() {
1979        let registry = build_registry(
1980            &[(
1981                "make",
1982                r#"[filter]
1983matches = ["make"]
1984
1985[shortcircuit]
1986when = '(?s).*'
1987replacement = "make: ok"
1988"#,
1989            )],
1990            None,
1991            None,
1992        );
1993        let output = "build step
1994ERROR: compiler crashed
1995";
1996
1997        let failed = compress_with_registry_exit_code("make", output, None, &registry);
1998
1999        assert_ne!(failed.text, "make: ok");
2000        assert!(failed.text.contains("ERROR: compiler crashed"));
2001    }
2002
2003    #[test]
2004    fn successful_exit_still_gets_concise_success_summary() {
2005        let output = r#"Running 4 tests using 2 workers
2006
2007  ✓  1 [chromium] › example.spec.ts:5:1 › has title (2.3s)
2008  ✓  2 [chromium] › example.spec.ts:9:1 › get started link (1.8s)
2009  ✓  3 [chromium] › nav.spec.ts:3:1 › navigates (1.2s)
2010  ✓  4 [chromium] › auth.spec.ts:7:1 › logs out (1.0s)
2011
2012  4 passed (6.3s)
2013"#;
2014
2015        let successful =
2016            compress_with_registry_exit_code("playwright test", output, Some(0), &empty_registry());
2017        assert_eq!(successful.text, "playwright: 4 tests passed (6.3s)");
2018    }
2019}
2020
2021#[cfg(test)]
2022mod normalize_command_tests {
2023    use super::*;
2024
2025    #[test]
2026    fn passes_bare_commands_unchanged() {
2027        assert_eq!(normalize_command_for_dispatch("bun test"), None);
2028        assert_eq!(normalize_command_for_dispatch("cargo build"), None);
2029        assert_eq!(normalize_command_for_dispatch("git status"), None);
2030    }
2031
2032    #[test]
2033    fn strips_cd_and_amp_prefix() {
2034        assert_eq!(
2035            normalize_command_for_dispatch("cd /repo && bun test").as_deref(),
2036            Some("bun test")
2037        );
2038        assert_eq!(
2039            normalize_command_for_dispatch("cd /repo/packages/aft && cargo test --release")
2040                .as_deref(),
2041            Some("cargo test --release")
2042        );
2043    }
2044
2045    #[test]
2046    fn strips_cd_and_semicolon_prefix() {
2047        assert_eq!(
2048            normalize_command_for_dispatch("cd /repo; bun test").as_deref(),
2049            Some("bun test")
2050        );
2051    }
2052
2053    #[test]
2054    fn strips_cd_with_quoted_path() {
2055        assert_eq!(
2056            normalize_command_for_dispatch("cd \"/path with space\" && npm install").as_deref(),
2057            Some("npm install")
2058        );
2059    }
2060
2061    #[test]
2062    fn strips_env_assignments() {
2063        assert_eq!(
2064            normalize_command_for_dispatch("env FOO=bar npm install").as_deref(),
2065            Some("npm install")
2066        );
2067        assert_eq!(
2068            normalize_command_for_dispatch("env FOO=bar BAZ=qux RUST_LOG=info cargo test")
2069                .as_deref(),
2070            Some("cargo test")
2071        );
2072    }
2073
2074    #[test]
2075    fn strips_bare_assignment_prefixes() {
2076        assert_eq!(
2077            normalize_command_for_dispatch("NODE_ENV=production npm install").as_deref(),
2078            Some("npm install")
2079        );
2080        assert_eq!(
2081            normalize_command_for_dispatch("FOO=1 BAR=2 cargo test").as_deref(),
2082            Some("cargo test")
2083        );
2084        assert_eq!(
2085            normalize_command_for_dispatch("RUSTFLAGS='-C debug' cargo build").as_deref(),
2086            Some("cargo build")
2087        );
2088    }
2089
2090    #[test]
2091    fn does_not_strip_later_assignment_arguments() {
2092        assert_eq!(normalize_command_for_dispatch("npm install foo=bar"), None);
2093    }
2094
2095    #[test]
2096    fn env_without_assignments_returns_none() {
2097        // `env` alone is the env-listing command, not a prefix.
2098        assert_eq!(
2099            normalize_command_for_dispatch("env npm install").as_deref(),
2100            None
2101        );
2102    }
2103
2104    #[test]
2105    fn strips_timeout_prefix() {
2106        assert_eq!(
2107            normalize_command_for_dispatch("timeout 30 cargo test").as_deref(),
2108            Some("cargo test")
2109        );
2110        assert_eq!(
2111            normalize_command_for_dispatch("timeout 5m bun test").as_deref(),
2112            Some("bun test")
2113        );
2114    }
2115
2116    #[test]
2117    fn strips_nohup_prefix() {
2118        assert_eq!(
2119            normalize_command_for_dispatch("nohup ./long-running-script.sh").as_deref(),
2120            Some("./long-running-script.sh")
2121        );
2122    }
2123
2124    #[test]
2125    fn strips_paren_then_cd_and_amp() {
2126        assert_eq!(
2127            normalize_command_for_dispatch("(cd /repo && bun test").as_deref(),
2128            Some("bun test")
2129        );
2130    }
2131
2132    #[test]
2133    fn chains_multiple_prefixes() {
2134        // env then timeout then real command.
2135        assert_eq!(
2136            normalize_command_for_dispatch("env FOO=bar timeout 30 cargo test").as_deref(),
2137            Some("cargo test")
2138        );
2139        // cd then env then real command.
2140        assert_eq!(
2141            normalize_command_for_dispatch("cd /repo && env FOO=bar npm install").as_deref(),
2142            Some("npm install")
2143        );
2144    }
2145
2146    // -------- end-to-end dispatch via normalize() --------
2147
2148    fn empty_registry() -> FilterRegistry {
2149        FilterRegistry::default()
2150    }
2151
2152    #[test]
2153    fn cd_prefix_bun_test_still_routes_to_bun_test() {
2154        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";
2155        let compressed = compress_with_registry("cd /repo && bun test", output, &empty_registry());
2156        // The bun test compressor produces (pass) / "1 pass" / "Ran ..." in
2157        // the pass-only path. Generic middle-truncate would drop these and
2158        // keep the original. Asserting their presence proves the normalizer
2159        // succeeded.
2160        assert!(compressed.contains("(pass)") || compressed.contains("1 pass"));
2161    }
2162
2163    #[test]
2164    fn cd_prefix_cargo_test_still_routes_to_cargo() {
2165        let output = "running 5 tests\ntest foo ... ok\ntest bar ... FAILED\n\nfailures:\n\ntest result: FAILED. 4 passed; 1 failed\n";
2166        let compressed =
2167            compress_with_registry("cd /repo && cargo test", output, &empty_registry());
2168        assert!(compressed.contains("FAILED") || compressed.contains("failed"));
2169    }
2170
2171    #[test]
2172    fn env_prefix_npm_install_still_routes_to_npm() {
2173        let output = "added 50 packages, and audited 100 packages in 3s\n";
2174        let compressed = compress_with_registry(
2175            "env NODE_ENV=production npm install",
2176            output,
2177            &empty_registry(),
2178        );
2179        // NpmCompressor's install path keeps "added N packages" / "audited" markers.
2180        assert!(compressed.contains("added") || compressed.contains("audited"));
2181    }
2182
2183    #[test]
2184    fn bare_assignment_prefix_npm_install_routes_to_npm() {
2185        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";
2186        let compressed =
2187            compress_with_registry("NODE_ENV=production npm install", output, &empty_registry());
2188        assert!(!compressed.contains("npm http fetch"));
2189        assert!(compressed.contains("audited 100 packages"));
2190    }
2191
2192    #[test]
2193    fn bare_assignment_prefix_cargo_test_routes_to_cargo() {
2194        let output = "running 1 test\ntest foo ... ok\n\ntest result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out\n";
2195        let compressed =
2196            compress_with_registry("FOO=1 BAR=2 cargo test", output, &empty_registry());
2197        assert!(compressed.contains("running 1 test"));
2198        assert!(compressed.contains("test result: ok"));
2199        assert!(!compressed.contains("test foo ... ok"));
2200    }
2201
2202    #[test]
2203    fn quoted_assignment_prefix_cargo_build_routes_to_cargo() {
2204        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";
2205        let compressed = compress_with_registry(
2206            "RUSTFLAGS='-C debug' cargo build",
2207            output,
2208            &empty_registry(),
2209        );
2210        assert!(!compressed.contains("Compiling foo"));
2211        assert!(compressed.contains("warning: unused variable"));
2212        assert!(compressed.contains("Finished `dev` profile"));
2213    }
2214
2215    #[test]
2216    fn timeout_prefix_cargo_build_still_routes_to_cargo() {
2217        let output =
2218            "   Compiling foo v0.1.0\n    Finished `dev` profile [unoptimized] target(s) in 5s\n";
2219        let compressed =
2220            compress_with_registry("timeout 30 cargo build", output, &empty_registry());
2221        // CargoCompressor for build/check/run preserves the structure.
2222        assert!(compressed.contains("Compiling") || compressed.contains("Finished"));
2223    }
2224
2225    #[test]
2226    fn normalize_splits_pipe_and_takes_last_stage() {
2227        assert_eq!(
2228            normalize_command_for_dispatch("git log | grep fix").as_deref(),
2229            Some("grep fix")
2230        );
2231    }
2232
2233    #[test]
2234    fn normalize_cd_prefix_then_pipe_takes_last_stage() {
2235        assert_eq!(
2236            normalize_command_for_dispatch("cd /repo && git log | grep fix").as_deref(),
2237            Some("grep fix")
2238        );
2239    }
2240
2241    #[test]
2242    fn normalize_no_pipe_returns_none() {
2243        assert_eq!(normalize_command_for_dispatch("git log"), None);
2244    }
2245
2246    #[test]
2247    fn normalize_quoted_pipe_not_split() {
2248        assert_eq!(
2249            normalize_command_for_dispatch("grep \"a|b\" file.txt"),
2250            None
2251        );
2252    }
2253
2254    #[test]
2255    fn normalize_balanced_command_substitution_splits_top_level_pipe() {
2256        // The inner `|` is inside $(...) (depth > 0) and must be ignored; the
2257        // real top-level `| grep x` splits to the last stage. The OLD code
2258        // bailed to None here and fell back to head-token dispatch on the full
2259        // command — exactly the data-loss path issue #137 is about.
2260        assert_eq!(
2261            normalize_command_for_dispatch("echo $(cmd | cmd) | grep x").as_deref(),
2262            Some("grep x")
2263        );
2264    }
2265
2266    #[test]
2267    fn normalize_inner_pipe_in_substitution_without_top_level_pipe_is_none() {
2268        // No top-level pipe at all — the only `|` is inside $(...).
2269        assert_eq!(
2270            normalize_command_for_dispatch("echo $(cargo test | cat)"),
2271            None
2272        );
2273    }
2274
2275    #[test]
2276    fn normalize_double_pipe_not_split() {
2277        assert_eq!(normalize_command_for_dispatch("git log || echo fail"), None);
2278    }
2279
2280    #[test]
2281    fn normalize_multi_pipe_returns_last_stage() {
2282        assert_eq!(
2283            normalize_command_for_dispatch("git log | grep fix | head -5").as_deref(),
2284            Some("head -5")
2285        );
2286    }
2287
2288    #[test]
2289    fn normalize_process_substitution_splits_top_level_pipe() {
2290        // `<(...)` inner pipe ignored; top-level `| grep x` splits to last stage.
2291        assert_eq!(
2292            normalize_command_for_dispatch("cat <(echo a | cat) | grep x").as_deref(),
2293            Some("grep x")
2294        );
2295    }
2296
2297    #[test]
2298    fn normalize_pipe_ampersand_splits_last_stage() {
2299        // `|&` pipes stdout+stderr; it is a real pipe boundary, not `|` + `&`.
2300        assert_eq!(
2301            normalize_command_for_dispatch("cargo test |& grep FAIL").as_deref(),
2302            Some("grep FAIL")
2303        );
2304    }
2305
2306    #[test]
2307    fn piped_cargo_test_grep_preserves_failed() {
2308        let grep_output = "test foo ... FAILED\n";
2309        let compressed =
2310            compress_with_registry("cargo test | grep FAIL", grep_output, &empty_registry());
2311        assert!(
2312            compressed.text.contains("FAILED"),
2313            "grep-filtered FAILED must survive, got: {}",
2314            compressed.text
2315        );
2316    }
2317
2318    #[test]
2319    fn unsafe_piped_command_forces_generic_and_preserves_output() {
2320        // Unbalanced quote → the scanner can't trust the parse. A `|` is
2321        // present, so it must force generic rather than let CargoCompressor
2322        // claim `cargo test | …` and drop the single grep-filtered line.
2323        let grep_output = "test foo ... FAILED\n";
2324        let compressed =
2325            compress_with_registry("cargo test | grep \"FAIL", grep_output, &empty_registry());
2326        assert!(
2327            compressed.text.contains("FAILED"),
2328            "unsafe pipe must not drop output, got: {}",
2329            compressed.text
2330        );
2331    }
2332
2333    #[test]
2334    fn split_top_level_pipe_variants() {
2335        assert_eq!(split_top_level_pipe("git log"), PipeSplit::None);
2336        assert_eq!(
2337            split_top_level_pipe("git log | grep fix"),
2338            PipeSplit::LastStage("grep fix".to_string())
2339        );
2340        // `||` is logical-or, not a pipe — but it IS a top-level separator,
2341        // so the no-pipe exit forces generic (multiple commands' output).
2342        assert_eq!(split_top_level_pipe("a || b"), PipeSplit::Unsafe);
2343        // inner pipe inside a subshell is not a top-level boundary.
2344        assert_eq!(split_top_level_pipe("(a | b)"), PipeSplit::None);
2345        // inner pipe inside $() is not a top-level boundary.
2346        assert_eq!(split_top_level_pipe("echo $(a | b)"), PipeSplit::None);
2347        // unbalanced quote with a pipe present → unsafe.
2348        assert_eq!(split_top_level_pipe("a | grep \"x"), PipeSplit::Unsafe);
2349        // unbalanced paren with a pipe present → unsafe.
2350        assert_eq!(split_top_level_pipe("$(a | b | grep x"), PipeSplit::Unsafe);
2351        // FAIL-CLOSED cases (Oracle findings) — a pipe must never be last-staged
2352        // when other top-level structure could mean the captured output isn't
2353        // the last stage's:
2354        // trailing empty stage
2355        assert_eq!(split_top_level_pipe("cargo test |"), PipeSplit::Unsafe);
2356        assert_eq!(split_top_level_pipe("cargo test |&"), PipeSplit::Unsafe);
2357        // pipe coexisting with a top-level separator
2358        assert_eq!(
2359            split_top_level_pipe("true | cargo test --quiet ; printf X"),
2360            PipeSplit::Unsafe
2361        );
2362        assert_eq!(
2363            split_top_level_pipe("true | cargo test && echo done"),
2364            PipeSplit::Unsafe
2365        );
2366        // unmatched close paren with a pipe
2367        assert_eq!(
2368            split_top_level_pipe("echo ) | cargo test"),
2369            PipeSplit::Unsafe
2370        );
2371        // bare `&` background is a separator; `2>&1` / `&>` redirects are not
2372        assert_eq!(split_top_level_pipe("a | b & c"), PipeSplit::Unsafe);
2373        assert_eq!(
2374            split_top_level_pipe("cargo test 2>&1 | grep FAIL"),
2375            PipeSplit::LastStage("grep FAIL".to_string())
2376        );
2377        // No-pipe separator cases: a top-level separator without a pipe still
2378        // forces generic so a head-token compressor can't drop later commands'
2379        // output.
2380        assert_eq!(
2381            split_top_level_pipe("cargo test ; printf SENTINEL"),
2382            PipeSplit::Unsafe
2383        );
2384        assert_eq!(
2385            split_top_level_pipe("cargo test && echo done"),
2386            PipeSplit::Unsafe
2387        );
2388        assert_eq!(
2389            split_top_level_pipe("cargo test\necho done"),
2390            PipeSplit::Unsafe
2391        );
2392        // Redirects are NOT separators — a single command with a redirect must
2393        // still dispatch to its head-token compressor.
2394        assert_eq!(split_top_level_pipe("cargo test 2>&1"), PipeSplit::None);
2395        assert_eq!(
2396            split_top_level_pipe("cargo test &> /dev/null"),
2397            PipeSplit::None
2398        );
2399    }
2400
2401    #[test]
2402    fn strip_top_level_comment_removes_only_real_comments() {
2403        assert_eq!(
2404            strip_top_level_comment("printf keep # | cargo test"),
2405            "printf keep "
2406        );
2407        assert_eq!(
2408            strip_top_level_comment("printf keep # cargo test"),
2409            "printf keep "
2410        );
2411        // `#` not at a word boundary is literal (e.g. a fragment/anchor).
2412        assert_eq!(
2413            strip_top_level_comment("curl http://x/y#frag"),
2414            "curl http://x/y#frag"
2415        );
2416        // `#` inside quotes is literal.
2417        assert_eq!(
2418            strip_top_level_comment("grep \"# not a comment\" f"),
2419            "grep \"# not a comment\" f"
2420        );
2421        assert_eq!(
2422            strip_top_level_comment("echo '# literal'"),
2423            "echo '# literal'"
2424        );
2425        // no comment → unchanged.
2426        assert_eq!(
2427            strip_top_level_comment("git log | grep fix"),
2428            "git log | grep fix"
2429        );
2430    }
2431
2432    #[test]
2433    fn commented_command_does_not_misdispatch_and_preserves_output() {
2434        // The `# cargo test` comment must not let CargoCompressor claim this
2435        // printf command's output and drop it — with OR without a pipe.
2436        for cmd in ["printf keep # | cargo test", "printf keep # cargo test"] {
2437            let compressed = compress_with_registry(cmd, "keep\n", &empty_registry());
2438            assert!(
2439                compressed.text.contains("keep"),
2440                "comment must not drop output for {cmd:?}, got: {}",
2441                compressed.text
2442            );
2443        }
2444    }
2445
2446    #[test]
2447    fn pipe_with_trailing_command_chain_preserves_sentinel() {
2448        // `true | cargo test ; printf SENTINEL` — captured output includes
2449        // SENTINEL; cargo must not claim it and drop the sentinel line.
2450        let compressed = compress_with_registry(
2451            "true | cargo test --quiet ; printf SENTINEL",
2452            "SENTINEL\n",
2453            &empty_registry(),
2454        );
2455        assert!(
2456            compressed.text.contains("SENTINEL"),
2457            "trailing-chain output must survive, got: {}",
2458            compressed.text
2459        );
2460    }
2461
2462    /// MUTATION CONTROL: reverting the no-pipe exit in `split_top_level_pipe`
2463    /// to ignore `saw_top_separator` (returning `PipeSplit::None` unconditionally)
2464    /// makes this test fail — cargo.rs claims the list and drops the sentinel.
2465    #[test]
2466    fn separator_list_forces_generic_and_preserves_sentinel() {
2467        // `cargo test ; printf SENTINEL` — the captured transcript contains both
2468        // cargo noise and the sentinel. A head-token cargo compressor would keep
2469        // only cargo-shaped lines and silently delete the sentinel. The no-pipe
2470        // separator must force generic compression so all output survives.
2471        let cargo_noise =
2472            "running 1 test\ntest ok_test ... ok\n\ntest result: ok. 1 passed; 0 failed\n";
2473        let transcript = format!("{cargo_noise}SENTINEL\n");
2474        let compressed = compress_with_registry(
2475            "cargo test ; printf SENTINEL",
2476            &transcript,
2477            &empty_registry(),
2478        );
2479        assert!(
2480            compressed.text.contains("SENTINEL"),
2481            "separator-list output must survive generic compression, got: {}",
2482            compressed.text
2483        );
2484    }
2485
2486    #[test]
2487    fn cd_prefix_peel_leaves_no_residual_separator_for_cargo() {
2488        // `cd /x && cargo test` — the `cd /x &&` prefix is peeled, leaving a
2489        // clean `cargo test` with NO residual separator. Specialized cargo
2490        // compression must still engage (not force-generic).
2491        let output = "running 1 test\ntest ok_test ... ok\n\ntest result: ok. 1 passed; 0 failed\n";
2492        let compressed = compress_with_registry("cd /x && cargo test", output, &empty_registry());
2493        // Cargo compressor drops individual "test ... ok" lines in its summary.
2494        assert!(
2495            !compressed.text.contains("test ok_test ... ok"),
2496            "cd-peeled cargo test must still use the cargo compressor, got: {}",
2497            compressed.text
2498        );
2499        assert!(compressed.text.contains("test result: ok"));
2500    }
2501
2502    #[test]
2503    fn clean_single_cargo_test_dispatch_byte_identical() {
2504        // Control: a clean single `cargo test` (no separator, no pipe) must
2505        // continue dispatching to the specialized cargo compressor and produce
2506        // the same output as the original specialized-compression behavior.
2507        let output = "running 1 test\ntest ok_test ... ok\n\ntest result: ok. 1 passed; 0 failed\n";
2508        let compressed = compress_with_registry("cargo test", output, &empty_registry());
2509        assert!(compressed.text.contains("running 1 test"));
2510        assert!(compressed.text.contains("test result: ok"));
2511        assert!(
2512            !compressed.text.contains("test ok_test ... ok"),
2513            "clean cargo test must keep using the cargo compressor, got: {}",
2514            compressed.text
2515        );
2516    }
2517
2518    #[test]
2519    fn is_shell_boundary_covers_redirects_and_operators() {
2520        for tok in [
2521            "|",
2522            "|&",
2523            ";",
2524            "&",
2525            "&&",
2526            "||",
2527            ">",
2528            ">>",
2529            "<",
2530            "<<",
2531            "<<<",
2532            "&>",
2533            "&>>",
2534            "2>",
2535            "2>>",
2536            "2>&1",
2537            "1>&2",
2538            ">/dev/null",
2539            "2>/dev/null",
2540        ] {
2541            assert!(is_shell_boundary(tok), "{tok} should be a boundary");
2542        }
2543        for tok in ["test", "log", "build", "--release", "-v", "file.txt"] {
2544            assert!(!is_shell_boundary(tok), "{tok} must not be a boundary");
2545        }
2546    }
2547}