Skip to main content

aft/compress/
mod.rs

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