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