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