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