Skip to main content

zeph_tools/filter/
mod.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Command-aware output filtering pipeline.
5
6pub(crate) mod declarative;
7pub mod security;
8
9use std::path::PathBuf;
10use std::sync::LazyLock;
11
12use parking_lot::Mutex;
13
14use regex::Regex;
15use serde::{Deserialize, Serialize};
16
17// ---------------------------------------------------------------------------
18// FilterConfidence (#440)
19// ---------------------------------------------------------------------------
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22pub enum FilterConfidence {
23    Full,
24    Partial,
25    Fallback,
26}
27
28// ---------------------------------------------------------------------------
29// FilterResult
30// ---------------------------------------------------------------------------
31
32/// Result of applying a filter to tool output.
33pub struct FilterResult {
34    pub output: String,
35    pub raw_chars: usize,
36    pub filtered_chars: usize,
37    pub raw_lines: usize,
38    pub filtered_lines: usize,
39    pub confidence: FilterConfidence,
40    /// 0-indexed line indices from raw output that the filter considers informative.
41    pub kept_lines: Vec<usize>,
42}
43
44impl FilterResult {
45    #[must_use]
46    #[allow(clippy::cast_precision_loss)]
47    pub fn savings_pct(&self) -> f64 {
48        if self.raw_chars == 0 {
49            return 0.0;
50        }
51        (1.0 - self.filtered_chars as f64 / self.raw_chars as f64) * 100.0
52    }
53}
54
55// ---------------------------------------------------------------------------
56// CommandMatcher (#439)
57// ---------------------------------------------------------------------------
58
59pub enum CommandMatcher {
60    Exact(&'static str),
61    Prefix(&'static str),
62    Regex(regex::Regex),
63    #[cfg(test)]
64    Custom(Box<dyn Fn(&str) -> bool + Send + Sync>),
65}
66
67impl CommandMatcher {
68    #[must_use]
69    pub fn matches(&self, command: &str) -> bool {
70        self.matches_single(command)
71            || extract_last_command(command).is_some_and(|last| self.matches_single(last))
72    }
73
74    fn matches_single(&self, command: &str) -> bool {
75        match self {
76            Self::Exact(s) => command == *s,
77            Self::Prefix(s) => command.starts_with(s),
78            Self::Regex(re) => re.is_match(command),
79            #[cfg(test)]
80            Self::Custom(f) => f(command),
81        }
82    }
83}
84
85/// Extract the last command segment from compound shell expressions
86/// like `cd /path && cargo test` or `cmd1 ; cmd2`. Strips trailing
87/// redirections and pipes (e.g. `2>&1 | tail -50`).
88fn extract_last_command(command: &str) -> Option<&str> {
89    let last = command
90        .rsplit("&&")
91        .next()
92        .or_else(|| command.rsplit(';').next())?;
93    let last = last.trim();
94    if last == command.trim() {
95        return None;
96    }
97    // Strip trailing pipe chain and redirections: take content before first `|` or `2>`
98    let last = last.split('|').next().unwrap_or(last);
99    let last = last.split("2>").next().unwrap_or(last);
100    let trimmed = last.trim();
101    if trimmed.is_empty() {
102        None
103    } else {
104        Some(trimmed)
105    }
106}
107
108impl std::fmt::Debug for CommandMatcher {
109    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        match self {
111            Self::Exact(s) => write!(f, "Exact({s:?})"),
112            Self::Prefix(s) => write!(f, "Prefix({s:?})"),
113            Self::Regex(re) => write!(f, "Regex({:?})", re.as_str()),
114            #[cfg(test)]
115            Self::Custom(_) => write!(f, "Custom(...)"),
116        }
117    }
118}
119
120// ---------------------------------------------------------------------------
121// OutputFilter trait
122// ---------------------------------------------------------------------------
123
124/// Command-aware output filter.
125pub trait OutputFilter: Send + Sync {
126    fn name(&self) -> &'static str;
127    fn matcher(&self) -> &CommandMatcher;
128    fn filter(&self, command: &str, raw_output: &str, exit_code: i32) -> FilterResult;
129}
130
131// ---------------------------------------------------------------------------
132// FilterPipeline (#441)
133// ---------------------------------------------------------------------------
134
135#[derive(Default)]
136pub struct FilterPipeline<'a> {
137    stages: Vec<&'a dyn OutputFilter>,
138}
139
140impl<'a> FilterPipeline<'a> {
141    #[must_use]
142    pub fn new() -> Self {
143        Self::default()
144    }
145
146    pub fn push(&mut self, filter: &'a dyn OutputFilter) {
147        self.stages.push(filter);
148    }
149
150    #[must_use]
151    pub fn run(&self, command: &str, output: &str, exit_code: i32) -> FilterResult {
152        let initial_len = output.len();
153        let mut current = output.to_owned();
154        let mut worst = FilterConfidence::Full;
155        let mut kept_lines: Vec<usize> = Vec::new();
156
157        for stage in &self.stages {
158            let result = stage.filter(command, &current, exit_code);
159            worst = worse_confidence(worst, result.confidence);
160            if !result.kept_lines.is_empty() {
161                kept_lines.clone_from(&result.kept_lines);
162            }
163            current = result.output;
164        }
165
166        FilterResult {
167            raw_chars: initial_len,
168            filtered_chars: current.len(),
169            raw_lines: count_lines(output),
170            filtered_lines: count_lines(&current),
171            output: current,
172            confidence: worst,
173            kept_lines,
174        }
175    }
176}
177
178#[must_use]
179pub fn worse_confidence(a: FilterConfidence, b: FilterConfidence) -> FilterConfidence {
180    match (a, b) {
181        (FilterConfidence::Fallback, _) | (_, FilterConfidence::Fallback) => {
182            FilterConfidence::Fallback
183        }
184        (FilterConfidence::Partial, _) | (_, FilterConfidence::Partial) => {
185            FilterConfidence::Partial
186        }
187        _ => FilterConfidence::Full,
188    }
189}
190
191// ---------------------------------------------------------------------------
192// FilterMetrics (#442)
193// ---------------------------------------------------------------------------
194
195#[derive(Debug, Clone)]
196pub struct FilterMetrics {
197    pub total_commands: u64,
198    pub filtered_commands: u64,
199    pub skipped_commands: u64,
200    pub raw_chars_total: u64,
201    pub filtered_chars_total: u64,
202    pub confidence_counts: [u64; 3],
203}
204
205impl FilterMetrics {
206    #[must_use]
207    pub fn new() -> Self {
208        Self {
209            total_commands: 0,
210            filtered_commands: 0,
211            skipped_commands: 0,
212            raw_chars_total: 0,
213            filtered_chars_total: 0,
214            confidence_counts: [0; 3],
215        }
216    }
217
218    pub fn record(&mut self, result: &FilterResult) {
219        self.total_commands += 1;
220        if result.filtered_chars < result.raw_chars {
221            self.filtered_commands += 1;
222        } else {
223            self.skipped_commands += 1;
224        }
225        self.raw_chars_total += result.raw_chars as u64;
226        self.filtered_chars_total += result.filtered_chars as u64;
227        let idx = match result.confidence {
228            FilterConfidence::Full => 0,
229            FilterConfidence::Partial => 1,
230            FilterConfidence::Fallback => 2,
231        };
232        self.confidence_counts[idx] += 1;
233    }
234
235    #[must_use]
236    #[allow(clippy::cast_precision_loss)]
237    pub fn savings_pct(&self) -> f64 {
238        if self.raw_chars_total == 0 {
239            return 0.0;
240        }
241        (1.0 - self.filtered_chars_total as f64 / self.raw_chars_total as f64) * 100.0
242    }
243}
244
245impl Default for FilterMetrics {
246    fn default() -> Self {
247        Self::new()
248    }
249}
250
251// ---------------------------------------------------------------------------
252// FilterConfig (#444)
253// ---------------------------------------------------------------------------
254
255pub(crate) fn default_true() -> bool {
256    true
257}
258
259/// Configuration for output filters.
260#[derive(Debug, Clone, Deserialize, Serialize)]
261pub struct FilterConfig {
262    #[serde(default = "default_true")]
263    pub enabled: bool,
264
265    #[serde(default)]
266    pub security: SecurityFilterConfig,
267
268    /// Directory containing a `filters.toml` override file.
269    /// Falls back to embedded defaults when `None` or when the file is absent.
270    #[serde(default, skip_serializing_if = "Option::is_none")]
271    pub filters_path: Option<PathBuf>,
272}
273
274impl Default for FilterConfig {
275    fn default() -> Self {
276        Self {
277            enabled: true,
278            security: SecurityFilterConfig::default(),
279            filters_path: None,
280        }
281    }
282}
283
284#[derive(Debug, Clone, Deserialize, Serialize)]
285pub struct SecurityFilterConfig {
286    #[serde(default = "default_true")]
287    pub enabled: bool,
288    #[serde(default)]
289    pub extra_patterns: Vec<String>,
290}
291
292impl Default for SecurityFilterConfig {
293    fn default() -> Self {
294        Self {
295            enabled: true,
296            extra_patterns: Vec::new(),
297        }
298    }
299}
300
301// ---------------------------------------------------------------------------
302// OutputFilterRegistry
303// ---------------------------------------------------------------------------
304
305/// Registry of filters with pipeline support, security whitelist, and metrics.
306pub struct OutputFilterRegistry {
307    filters: Vec<Box<dyn OutputFilter>>,
308    enabled: bool,
309    security_enabled: bool,
310    extra_security_patterns: Vec<regex::Regex>,
311    metrics: Mutex<FilterMetrics>,
312}
313
314impl std::fmt::Debug for OutputFilterRegistry {
315    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
316        f.debug_struct("OutputFilterRegistry")
317            .field("enabled", &self.enabled)
318            .field("filter_count", &self.filters.len())
319            .finish_non_exhaustive()
320    }
321}
322
323impl OutputFilterRegistry {
324    #[must_use]
325    pub fn new(enabled: bool) -> Self {
326        Self {
327            filters: Vec::new(),
328            enabled,
329            security_enabled: true,
330            extra_security_patterns: Vec::new(),
331            metrics: Mutex::new(FilterMetrics::new()),
332        }
333    }
334
335    pub fn register(&mut self, filter: Box<dyn OutputFilter>) {
336        self.filters.push(filter);
337    }
338
339    #[must_use]
340    pub fn default_filters(config: &FilterConfig) -> Self {
341        let mut r = Self {
342            filters: Vec::new(),
343            enabled: config.enabled,
344            security_enabled: config.security.enabled,
345            extra_security_patterns: security::compile_extra_patterns(
346                &config.security.extra_patterns,
347            ),
348            metrics: Mutex::new(FilterMetrics::new()),
349        };
350        for f in declarative::load_declarative_filters(config.filters_path.as_deref()) {
351            r.register(f);
352        }
353        r
354    }
355
356    #[must_use]
357    pub fn apply(&self, command: &str, raw_output: &str, exit_code: i32) -> Option<FilterResult> {
358        if !self.enabled {
359            return None;
360        }
361
362        let matching: Vec<&dyn OutputFilter> = self
363            .filters
364            .iter()
365            .filter(|f| f.matcher().matches(command))
366            .map(AsRef::as_ref)
367            .collect();
368
369        if matching.is_empty() {
370            return None;
371        }
372
373        let mut result = if matching.len() == 1 {
374            matching[0].filter(command, raw_output, exit_code)
375        } else {
376            let mut pipeline = FilterPipeline::new();
377            for f in &matching {
378                pipeline.push(*f);
379            }
380            pipeline.run(command, raw_output, exit_code)
381        };
382
383        if self.security_enabled {
384            security::append_security_warnings(
385                &mut result.output,
386                raw_output,
387                &self.extra_security_patterns,
388            );
389        }
390
391        self.record_metrics(&result);
392        Some(result)
393    }
394
395    fn record_metrics(&self, result: &FilterResult) {
396        let mut m = self.metrics.lock();
397        m.record(result);
398        if m.total_commands.is_multiple_of(50) {
399            tracing::debug!(
400                total = m.total_commands,
401                filtered = m.filtered_commands,
402                savings_pct = format!("{:.1}", m.savings_pct()),
403                "filter metrics"
404            );
405        }
406    }
407
408    #[must_use]
409    pub fn metrics(&self) -> FilterMetrics {
410        self.metrics.lock().clone()
411    }
412}
413
414// ---------------------------------------------------------------------------
415// Helpers
416// ---------------------------------------------------------------------------
417
418static ANSI_RE: LazyLock<Regex> =
419    LazyLock::new(|| Regex::new(r"\x1b\[[0-9;]*[a-zA-Z]|\x1b[()][A-B0-2]").unwrap());
420
421/// Strip only ANSI escape sequences, preserving newlines and whitespace.
422#[must_use]
423pub fn strip_ansi(raw: &str) -> String {
424    ANSI_RE.replace_all(raw, "").into_owned()
425}
426
427/// Strip ANSI escape sequences, carriage-return progress bars, and collapse blank lines.
428#[must_use]
429pub fn sanitize_output(raw: &str) -> String {
430    let no_ansi = ANSI_RE.replace_all(raw, "");
431
432    let mut result = String::with_capacity(no_ansi.len());
433    let mut prev_blank = false;
434
435    for line in no_ansi.lines() {
436        let clean = if line.contains('\r') {
437            line.rsplit('\r').next().unwrap_or("")
438        } else {
439            line
440        };
441
442        let is_blank = clean.trim().is_empty();
443        if is_blank && prev_blank {
444            continue;
445        }
446        prev_blank = is_blank;
447
448        if !result.is_empty() {
449            result.push('\n');
450        }
451        result.push_str(clean);
452    }
453    result
454}
455
456fn count_lines(s: &str) -> usize {
457    if s.is_empty() { 0 } else { s.lines().count() }
458}
459
460fn make_result(
461    raw: &str,
462    output: String,
463    confidence: FilterConfidence,
464    kept_lines: Vec<usize>,
465) -> FilterResult {
466    let filtered_chars = output.len();
467    FilterResult {
468        raw_lines: count_lines(raw),
469        filtered_lines: count_lines(&output),
470        output,
471        raw_chars: raw.len(),
472        filtered_chars,
473        confidence,
474        kept_lines,
475    }
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481
482    #[test]
483    fn sanitize_strips_ansi() {
484        let input = "\x1b[32mOK\x1b[0m test passed";
485        assert_eq!(sanitize_output(input), "OK test passed");
486    }
487
488    #[test]
489    fn sanitize_strips_cr_progress() {
490        let input = "Downloading... 50%\rDownloading... 100%";
491        assert_eq!(sanitize_output(input), "Downloading... 100%");
492    }
493
494    #[test]
495    fn sanitize_collapses_blank_lines() {
496        let input = "line1\n\n\n\nline2";
497        assert_eq!(sanitize_output(input), "line1\n\nline2");
498    }
499
500    #[test]
501    fn sanitize_preserves_crlf_content() {
502        let input = "line1\r\nline2\r\n";
503        let result = sanitize_output(input);
504        assert!(result.contains("line1"));
505        assert!(result.contains("line2"));
506    }
507
508    #[test]
509    fn filter_result_savings_pct() {
510        let r = FilterResult {
511            output: String::new(),
512            raw_chars: 1000,
513            filtered_chars: 200,
514            raw_lines: 0,
515            filtered_lines: 0,
516            confidence: FilterConfidence::Full,
517            kept_lines: vec![],
518        };
519        assert!((r.savings_pct() - 80.0).abs() < 0.01);
520    }
521
522    #[test]
523    fn filter_result_savings_pct_zero_raw() {
524        let r = FilterResult {
525            output: String::new(),
526            raw_chars: 0,
527            filtered_chars: 0,
528            raw_lines: 0,
529            filtered_lines: 0,
530            confidence: FilterConfidence::Full,
531            kept_lines: vec![],
532        };
533        assert!((r.savings_pct()).abs() < 0.01);
534    }
535
536    #[test]
537    fn count_lines_helper() {
538        assert_eq!(count_lines(""), 0);
539        assert_eq!(count_lines("one"), 1);
540        assert_eq!(count_lines("one\ntwo\nthree"), 3);
541        assert_eq!(count_lines("trailing\n"), 1);
542    }
543
544    #[test]
545    fn make_result_counts_lines() {
546        let raw = "line1\nline2\nline3\nline4\nline5";
547        let filtered = "line1\nline3".to_owned();
548        let r = make_result(raw, filtered, FilterConfidence::Full, vec![]);
549        assert_eq!(r.raw_lines, 5);
550        assert_eq!(r.filtered_lines, 2);
551    }
552
553    #[test]
554    fn registry_disabled_returns_none() {
555        let r = OutputFilterRegistry::new(false);
556        assert!(r.apply("cargo test", "output", 0).is_none());
557    }
558
559    #[test]
560    fn registry_no_match_returns_none() {
561        let r = OutputFilterRegistry::new(true);
562        assert!(r.apply("some-unknown-cmd", "output", 0).is_none());
563    }
564
565    #[test]
566    fn registry_default_has_filters() {
567        let r = OutputFilterRegistry::default_filters(&FilterConfig::default());
568        assert!(
569            r.apply(
570                "cargo test",
571                "test result: ok. 5 passed; 0 failed; 0 ignored; 0 filtered out",
572                0
573            )
574            .is_some()
575        );
576    }
577
578    #[test]
579    fn filter_config_default_enabled() {
580        let c = FilterConfig::default();
581        assert!(c.enabled);
582    }
583
584    #[test]
585    fn filter_config_deserialize() {
586        let toml_str = "enabled = false";
587        let c: FilterConfig = toml::from_str(toml_str).unwrap();
588        assert!(!c.enabled);
589    }
590
591    #[test]
592    fn filter_config_deserialize_minimal() {
593        let toml_str = "enabled = true";
594        let c: FilterConfig = toml::from_str(toml_str).unwrap();
595        assert!(c.enabled);
596        assert!(c.security.enabled);
597    }
598
599    #[test]
600    fn filter_config_deserialize_security() {
601        let toml_str = r#"
602enabled = true
603
604[security]
605enabled = true
606extra_patterns = ["TODO: security review"]
607"#;
608        let c: FilterConfig = toml::from_str(toml_str).unwrap();
609        assert!(c.enabled);
610        assert_eq!(c.security.extra_patterns, vec!["TODO: security review"]);
611    }
612
613    // CommandMatcher tests
614    #[test]
615    fn command_matcher_exact() {
616        let m = CommandMatcher::Exact("ls");
617        assert!(m.matches("ls"));
618        assert!(!m.matches("ls -la"));
619    }
620
621    #[test]
622    fn command_matcher_prefix() {
623        let m = CommandMatcher::Prefix("git ");
624        assert!(m.matches("git status"));
625        assert!(!m.matches("github"));
626    }
627
628    #[test]
629    fn command_matcher_regex() {
630        let m = CommandMatcher::Regex(Regex::new(r"^cargo\s+test").unwrap());
631        assert!(m.matches("cargo test"));
632        assert!(m.matches("cargo test --lib"));
633        assert!(!m.matches("cargo build"));
634    }
635
636    #[test]
637    fn command_matcher_custom() {
638        let m = CommandMatcher::Custom(Box::new(|cmd| cmd.contains("hello")));
639        assert!(m.matches("say hello world"));
640        assert!(!m.matches("goodbye"));
641    }
642
643    #[test]
644    fn command_matcher_compound_cd_and() {
645        let m = CommandMatcher::Prefix("cargo ");
646        assert!(m.matches("cd /some/path && cargo test --workspace --lib"));
647        assert!(m.matches("cd /path && cargo clippy --workspace -- -D warnings 2>&1"));
648    }
649
650    #[test]
651    fn command_matcher_compound_with_pipe() {
652        let m = CommandMatcher::Custom(Box::new(|cmd| cmd.split_whitespace().any(|t| t == "test")));
653        assert!(m.matches("cd /path && cargo test --workspace --lib 2>&1 | tail -80"));
654    }
655
656    #[test]
657    fn command_matcher_compound_no_false_positive() {
658        let m = CommandMatcher::Exact("ls");
659        assert!(!m.matches("cd /path && cargo test"));
660    }
661
662    #[test]
663    fn extract_last_command_basic() {
664        assert_eq!(
665            extract_last_command("cd /path && cargo test --lib"),
666            Some("cargo test --lib")
667        );
668        assert_eq!(
669            extract_last_command("cd /p && cargo clippy 2>&1 | tail -20"),
670            Some("cargo clippy")
671        );
672        assert!(extract_last_command("cargo test").is_none());
673    }
674
675    // FilterConfidence derives
676    #[test]
677    fn filter_confidence_derives() {
678        let a = FilterConfidence::Full;
679        let b = a;
680        assert_eq!(a, b);
681        let _ = format!("{a:?}");
682        let mut set = std::collections::HashSet::new();
683        set.insert(a);
684    }
685
686    // FilterMetrics tests
687    #[test]
688    fn filter_metrics_new_zeros() {
689        let m = FilterMetrics::new();
690        assert_eq!(m.total_commands, 0);
691        assert_eq!(m.filtered_commands, 0);
692        assert_eq!(m.skipped_commands, 0);
693        assert_eq!(m.confidence_counts, [0; 3]);
694    }
695
696    #[test]
697    fn filter_metrics_record() {
698        let mut m = FilterMetrics::new();
699        let r = FilterResult {
700            output: "short".into(),
701            raw_chars: 100,
702            filtered_chars: 5,
703            raw_lines: 10,
704            filtered_lines: 1,
705            confidence: FilterConfidence::Full,
706            kept_lines: vec![],
707        };
708        m.record(&r);
709        assert_eq!(m.total_commands, 1);
710        assert_eq!(m.filtered_commands, 1);
711        assert_eq!(m.skipped_commands, 0);
712        assert_eq!(m.confidence_counts[0], 1);
713    }
714
715    #[test]
716    fn filter_metrics_savings_pct() {
717        let mut m = FilterMetrics::new();
718        m.raw_chars_total = 1000;
719        m.filtered_chars_total = 200;
720        assert!((m.savings_pct() - 80.0).abs() < 0.01);
721    }
722
723    #[test]
724    fn registry_metrics_updated() {
725        let r = OutputFilterRegistry::default_filters(&FilterConfig::default());
726        let _ = r.apply(
727            "cargo test",
728            "test result: ok. 5 passed; 0 failed; 0 ignored; 0 filtered out",
729            0,
730        );
731        let m = r.metrics();
732        assert_eq!(m.total_commands, 1);
733    }
734
735    // Pipeline tests
736    #[test]
737    fn confidence_aggregation() {
738        assert_eq!(
739            worse_confidence(FilterConfidence::Full, FilterConfidence::Partial),
740            FilterConfidence::Partial
741        );
742        assert_eq!(
743            worse_confidence(FilterConfidence::Full, FilterConfidence::Fallback),
744            FilterConfidence::Fallback
745        );
746        assert_eq!(
747            worse_confidence(FilterConfidence::Partial, FilterConfidence::Fallback),
748            FilterConfidence::Fallback
749        );
750        assert_eq!(
751            worse_confidence(FilterConfidence::Full, FilterConfidence::Full),
752            FilterConfidence::Full
753        );
754    }
755
756    // Helper filter for pipeline integration test: replaces a word.
757    struct ReplaceFilter {
758        from: &'static str,
759        to: &'static str,
760        confidence: FilterConfidence,
761    }
762
763    static MATCH_ALL: LazyLock<CommandMatcher> =
764        LazyLock::new(|| CommandMatcher::Custom(Box::new(|_| true)));
765
766    impl OutputFilter for ReplaceFilter {
767        fn name(&self) -> &'static str {
768            "replace"
769        }
770        fn matcher(&self) -> &CommandMatcher {
771            &MATCH_ALL
772        }
773        fn filter(&self, _cmd: &str, raw: &str, _exit: i32) -> FilterResult {
774            let output = raw.replace(self.from, self.to);
775            make_result(raw, output, self.confidence, vec![])
776        }
777    }
778
779    #[test]
780    fn pipeline_multi_stage_chains_and_aggregates() {
781        let f1 = ReplaceFilter {
782            from: "hello",
783            to: "world",
784            confidence: FilterConfidence::Full,
785        };
786        let f2 = ReplaceFilter {
787            from: "world",
788            to: "DONE",
789            confidence: FilterConfidence::Partial,
790        };
791
792        let mut pipeline = FilterPipeline::new();
793        pipeline.push(&f1);
794        pipeline.push(&f2);
795
796        let result = pipeline.run("test", "say hello there", 0);
797        // f1: "hello" -> "world", f2: "world" -> "DONE"
798        assert_eq!(result.output, "say DONE there");
799        assert_eq!(result.confidence, FilterConfidence::Partial);
800        assert_eq!(result.raw_chars, "say hello there".len());
801        assert_eq!(result.filtered_chars, "say DONE there".len());
802    }
803
804    use proptest::prelude::*;
805
806    proptest! {
807        #[test]
808        fn filter_pipeline_run_never_panics(cmd in ".*", output in ".*", exit_code in -1i32..=255) {
809            let pipeline = FilterPipeline::new();
810            let _ = pipeline.run(&cmd, &output, exit_code);
811        }
812
813        #[test]
814        fn output_filter_registry_apply_never_panics(cmd in ".*", output in ".*", exit_code in -1i32..=255) {
815            let reg = OutputFilterRegistry::new(true);
816            let _ = reg.apply(&cmd, &output, exit_code);
817        }
818    }
819
820    #[test]
821    fn registry_pipeline_with_two_matching_filters() {
822        let mut reg = OutputFilterRegistry::new(true);
823        reg.register(Box::new(ReplaceFilter {
824            from: "aaa",
825            to: "bbb",
826            confidence: FilterConfidence::Full,
827        }));
828        reg.register(Box::new(ReplaceFilter {
829            from: "bbb",
830            to: "ccc",
831            confidence: FilterConfidence::Fallback,
832        }));
833
834        let result = reg.apply("test", "aaa", 0).unwrap();
835        // Both match "test" via MATCH_ALL. Pipeline: "aaa" -> "bbb" -> "ccc"
836        assert_eq!(result.output, "ccc");
837        assert_eq!(result.confidence, FilterConfidence::Fallback);
838    }
839}