Skip to main content

zeph_tools/filter/
mod.rs

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