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