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#[non_exhaustive]
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21pub enum FilterConfidence {
22    Full,
23    Partial,
24    Fallback,
25}
26
27// ---------------------------------------------------------------------------
28// FilterResult
29// ---------------------------------------------------------------------------
30
31/// Result of applying a filter to tool output.
32pub struct FilterResult {
33    pub output: String,
34    pub raw_chars: usize,
35    pub filtered_chars: usize,
36    pub raw_lines: usize,
37    pub filtered_lines: usize,
38    pub confidence: FilterConfidence,
39    /// 0-indexed line indices from raw output that the filter considers informative.
40    pub kept_lines: Vec<usize>,
41}
42
43impl FilterResult {
44    #[must_use]
45    #[allow(clippy::cast_precision_loss)]
46    pub fn savings_pct(&self) -> f64 {
47        if self.raw_chars == 0 {
48            return 0.0;
49        }
50        (1.0 - self.filtered_chars as f64 / self.raw_chars as f64) * 100.0
51    }
52}
53
54// ---------------------------------------------------------------------------
55// CommandMatcher (#439)
56// ---------------------------------------------------------------------------
57
58#[non_exhaustive]
59pub enum CommandMatcher {
60    Exact(Arc<str>),
61    Prefix(Arc<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.as_ref(),
77            Self::Prefix(s) => command.starts_with(s.as_ref()),
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) -> &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
259pub(crate) use zeph_config::tools::FilterConfig;
260
261// ---------------------------------------------------------------------------
262// OutputFilterRegistry
263// ---------------------------------------------------------------------------
264
265/// Registry of filters with pipeline support, security whitelist, and metrics.
266pub struct OutputFilterRegistry {
267    filters: Vec<Box<dyn OutputFilter>>,
268    enabled: bool,
269    security_enabled: bool,
270    extra_security_patterns: Vec<regex::Regex>,
271    metrics: Mutex<FilterMetrics>,
272}
273
274impl std::fmt::Debug for OutputFilterRegistry {
275    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
276        f.debug_struct("OutputFilterRegistry")
277            .field("enabled", &self.enabled)
278            .field("filter_count", &self.filters.len())
279            .finish_non_exhaustive()
280    }
281}
282
283impl OutputFilterRegistry {
284    #[must_use]
285    pub fn new(enabled: bool) -> Self {
286        Self {
287            filters: Vec::new(),
288            enabled,
289            security_enabled: true,
290            extra_security_patterns: Vec::new(),
291            metrics: Mutex::new(FilterMetrics::new()),
292        }
293    }
294
295    pub fn register(&mut self, filter: Box<dyn OutputFilter>) {
296        self.filters.push(filter);
297    }
298
299    #[must_use]
300    pub fn default_filters(config: &FilterConfig) -> Self {
301        let mut r = Self {
302            filters: Vec::new(),
303            enabled: config.enabled,
304            security_enabled: config.security.enabled,
305            extra_security_patterns: security::compile_extra_patterns(
306                &config.security.extra_patterns,
307            ),
308            metrics: Mutex::new(FilterMetrics::new()),
309        };
310        for f in declarative::load_declarative_filters(config.filters_path.as_deref()) {
311            r.register(f);
312        }
313        r
314    }
315
316    #[must_use]
317    pub fn apply(&self, command: &str, raw_output: &str, exit_code: i32) -> Option<FilterResult> {
318        if !self.enabled {
319            return None;
320        }
321
322        let matching: Vec<&dyn OutputFilter> = self
323            .filters
324            .iter()
325            .filter(|f| f.matcher().matches(command))
326            .map(AsRef::as_ref)
327            .collect();
328
329        if matching.is_empty() {
330            return None;
331        }
332
333        let mut result = if matching.len() == 1 {
334            matching[0].filter(command, raw_output, exit_code)
335        } else {
336            let mut pipeline = FilterPipeline::new();
337            for f in &matching {
338                pipeline.push(*f);
339            }
340            pipeline.run(command, raw_output, exit_code)
341        };
342
343        if self.security_enabled {
344            security::append_security_warnings(
345                &mut result.output,
346                raw_output,
347                &self.extra_security_patterns,
348            );
349        }
350
351        self.record_metrics(&result);
352        Some(result)
353    }
354
355    fn record_metrics(&self, result: &FilterResult) {
356        let mut m = self.metrics.lock();
357        m.record(result);
358        if m.total_commands.is_multiple_of(50) {
359            tracing::debug!(
360                total = m.total_commands,
361                filtered = m.filtered_commands,
362                savings_pct = format!("{:.1}", m.savings_pct()),
363                "filter metrics"
364            );
365        }
366    }
367
368    #[must_use]
369    pub fn metrics(&self) -> FilterMetrics {
370        self.metrics.lock().clone()
371    }
372}
373
374// ---------------------------------------------------------------------------
375// Helpers
376// ---------------------------------------------------------------------------
377
378static ANSI_RE: LazyLock<Regex> =
379    LazyLock::new(|| Regex::new(r"\x1b\[[0-9;]*[a-zA-Z]|\x1b[()][A-B0-2]").unwrap());
380
381/// Strip only ANSI escape sequences, preserving newlines and whitespace.
382#[must_use]
383pub fn strip_ansi(raw: &str) -> String {
384    ANSI_RE.replace_all(raw, "").into_owned()
385}
386
387/// Strip ANSI escape sequences, carriage-return progress bars, and collapse blank lines.
388#[must_use]
389pub fn sanitize_output(raw: &str) -> String {
390    let no_ansi = ANSI_RE.replace_all(raw, "");
391
392    let mut result = String::with_capacity(no_ansi.len());
393    let mut prev_blank = false;
394
395    for line in no_ansi.lines() {
396        let clean = if line.contains('\r') {
397            line.rsplit('\r').next().unwrap_or("")
398        } else {
399            line
400        };
401
402        let is_blank = clean.trim().is_empty();
403        if is_blank && prev_blank {
404            continue;
405        }
406        prev_blank = is_blank;
407
408        if !result.is_empty() {
409            result.push('\n');
410        }
411        result.push_str(clean);
412    }
413    result
414}
415
416fn count_lines(s: &str) -> usize {
417    if s.is_empty() { 0 } else { s.lines().count() }
418}
419
420fn make_result(
421    raw: &str,
422    output: String,
423    confidence: FilterConfidence,
424    kept_lines: Vec<usize>,
425) -> FilterResult {
426    let filtered_chars = output.len();
427    FilterResult {
428        raw_lines: count_lines(raw),
429        filtered_lines: count_lines(&output),
430        output,
431        raw_chars: raw.len(),
432        filtered_chars,
433        confidence,
434        kept_lines,
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441
442    #[test]
443    fn sanitize_strips_ansi() {
444        let input = "\x1b[32mOK\x1b[0m test passed";
445        assert_eq!(sanitize_output(input), "OK test passed");
446    }
447
448    #[test]
449    fn sanitize_strips_cr_progress() {
450        let input = "Downloading... 50%\rDownloading... 100%";
451        assert_eq!(sanitize_output(input), "Downloading... 100%");
452    }
453
454    #[test]
455    fn sanitize_collapses_blank_lines() {
456        let input = "line1\n\n\n\nline2";
457        assert_eq!(sanitize_output(input), "line1\n\nline2");
458    }
459
460    #[test]
461    fn sanitize_preserves_crlf_content() {
462        let input = "line1\r\nline2\r\n";
463        let result = sanitize_output(input);
464        assert!(result.contains("line1"));
465        assert!(result.contains("line2"));
466    }
467
468    #[test]
469    fn filter_result_savings_pct() {
470        let r = FilterResult {
471            output: String::new(),
472            raw_chars: 1000,
473            filtered_chars: 200,
474            raw_lines: 0,
475            filtered_lines: 0,
476            confidence: FilterConfidence::Full,
477            kept_lines: vec![],
478        };
479        assert!((r.savings_pct() - 80.0).abs() < 0.01);
480    }
481
482    #[test]
483    fn filter_result_savings_pct_zero_raw() {
484        let r = FilterResult {
485            output: String::new(),
486            raw_chars: 0,
487            filtered_chars: 0,
488            raw_lines: 0,
489            filtered_lines: 0,
490            confidence: FilterConfidence::Full,
491            kept_lines: vec![],
492        };
493        assert!((r.savings_pct()).abs() < 0.01);
494    }
495
496    #[test]
497    fn count_lines_helper() {
498        assert_eq!(count_lines(""), 0);
499        assert_eq!(count_lines("one"), 1);
500        assert_eq!(count_lines("one\ntwo\nthree"), 3);
501        assert_eq!(count_lines("trailing\n"), 1);
502    }
503
504    #[test]
505    fn make_result_counts_lines() {
506        let raw = "line1\nline2\nline3\nline4\nline5";
507        let filtered = "line1\nline3".to_owned();
508        let r = make_result(raw, filtered, FilterConfidence::Full, vec![]);
509        assert_eq!(r.raw_lines, 5);
510        assert_eq!(r.filtered_lines, 2);
511    }
512
513    #[test]
514    fn registry_disabled_returns_none() {
515        let r = OutputFilterRegistry::new(false);
516        assert!(r.apply("cargo test", "output", 0).is_none());
517    }
518
519    #[test]
520    fn registry_no_match_returns_none() {
521        let r = OutputFilterRegistry::new(true);
522        assert!(r.apply("some-unknown-cmd", "output", 0).is_none());
523    }
524
525    #[test]
526    fn registry_default_has_filters() {
527        let r = OutputFilterRegistry::default_filters(&FilterConfig::default());
528        assert!(
529            r.apply(
530                "cargo test",
531                "test result: ok. 5 passed; 0 failed; 0 ignored; 0 filtered out",
532                0
533            )
534            .is_some()
535        );
536    }
537
538    #[test]
539    fn filter_config_default_enabled() {
540        let c = FilterConfig::default();
541        assert!(c.enabled);
542    }
543
544    #[test]
545    fn filter_config_deserialize() {
546        let toml_str = "enabled = false";
547        let c: FilterConfig = toml::from_str(toml_str).unwrap();
548        assert!(!c.enabled);
549    }
550
551    #[test]
552    fn filter_config_deserialize_minimal() {
553        let toml_str = "enabled = true";
554        let c: FilterConfig = toml::from_str(toml_str).unwrap();
555        assert!(c.enabled);
556        assert!(c.security.enabled);
557    }
558
559    #[test]
560    fn filter_config_deserialize_security() {
561        let toml_str = r#"
562enabled = true
563
564[security]
565enabled = true
566extra_patterns = ["TODO: security review"]
567"#;
568        let c: FilterConfig = toml::from_str(toml_str).unwrap();
569        assert!(c.enabled);
570        assert_eq!(c.security.extra_patterns, vec!["TODO: security review"]);
571    }
572
573    // CommandMatcher tests
574    #[test]
575    fn command_matcher_exact() {
576        let m = CommandMatcher::Exact(Arc::from("ls"));
577        assert!(m.matches("ls"));
578        assert!(!m.matches("ls -la"));
579    }
580
581    #[test]
582    fn command_matcher_prefix() {
583        let m = CommandMatcher::Prefix(Arc::from("git "));
584        assert!(m.matches("git status"));
585        assert!(!m.matches("github"));
586    }
587
588    #[test]
589    fn command_matcher_regex() {
590        let m = CommandMatcher::Regex(Regex::new(r"^cargo\s+test").unwrap());
591        assert!(m.matches("cargo test"));
592        assert!(m.matches("cargo test --lib"));
593        assert!(!m.matches("cargo build"));
594    }
595
596    #[test]
597    fn command_matcher_custom() {
598        let m = CommandMatcher::Custom(Box::new(|cmd| cmd.contains("hello")));
599        assert!(m.matches("say hello world"));
600        assert!(!m.matches("goodbye"));
601    }
602
603    #[test]
604    fn command_matcher_compound_cd_and() {
605        let m = CommandMatcher::Prefix(Arc::from("cargo "));
606        assert!(m.matches("cd /some/path && cargo test --workspace --lib"));
607        assert!(m.matches("cd /path && cargo clippy --workspace -- -D warnings 2>&1"));
608    }
609
610    #[test]
611    fn command_matcher_compound_with_pipe() {
612        let m = CommandMatcher::Custom(Box::new(|cmd| cmd.split_whitespace().any(|t| t == "test")));
613        assert!(m.matches("cd /path && cargo test --workspace --lib 2>&1 | tail -80"));
614    }
615
616    #[test]
617    fn command_matcher_compound_no_false_positive() {
618        let m = CommandMatcher::Exact(Arc::from("ls"));
619        assert!(!m.matches("cd /path && cargo test"));
620    }
621
622    #[test]
623    fn extract_last_command_basic() {
624        assert_eq!(
625            extract_last_command("cd /path && cargo test --lib"),
626            Some("cargo test --lib")
627        );
628        assert_eq!(
629            extract_last_command("cd /p && cargo clippy 2>&1 | tail -20"),
630            Some("cargo clippy")
631        );
632        assert!(extract_last_command("cargo test").is_none());
633    }
634
635    // FilterConfidence derives
636    #[test]
637    fn filter_confidence_derives() {
638        let a = FilterConfidence::Full;
639        let b = a;
640        assert_eq!(a, b);
641        let _ = format!("{a:?}");
642        let mut set = std::collections::HashSet::new();
643        set.insert(a);
644    }
645
646    // FilterMetrics tests
647    #[test]
648    fn filter_metrics_new_zeros() {
649        let m = FilterMetrics::new();
650        assert_eq!(m.total_commands, 0);
651        assert_eq!(m.filtered_commands, 0);
652        assert_eq!(m.skipped_commands, 0);
653        assert_eq!(m.confidence_counts, [0; 3]);
654    }
655
656    #[test]
657    fn filter_metrics_record() {
658        let mut m = FilterMetrics::new();
659        let r = FilterResult {
660            output: "short".into(),
661            raw_chars: 100,
662            filtered_chars: 5,
663            raw_lines: 10,
664            filtered_lines: 1,
665            confidence: FilterConfidence::Full,
666            kept_lines: vec![],
667        };
668        m.record(&r);
669        assert_eq!(m.total_commands, 1);
670        assert_eq!(m.filtered_commands, 1);
671        assert_eq!(m.skipped_commands, 0);
672        assert_eq!(m.confidence_counts[0], 1);
673    }
674
675    #[test]
676    fn filter_metrics_savings_pct() {
677        let mut m = FilterMetrics::new();
678        m.raw_chars_total = 1000;
679        m.filtered_chars_total = 200;
680        assert!((m.savings_pct() - 80.0).abs() < 0.01);
681    }
682
683    #[test]
684    fn registry_metrics_updated() {
685        let r = OutputFilterRegistry::default_filters(&FilterConfig::default());
686        let _ = r.apply(
687            "cargo test",
688            "test result: ok. 5 passed; 0 failed; 0 ignored; 0 filtered out",
689            0,
690        );
691        let m = r.metrics();
692        assert_eq!(m.total_commands, 1);
693    }
694
695    // Pipeline tests
696    #[test]
697    fn confidence_aggregation() {
698        assert_eq!(
699            worse_confidence(FilterConfidence::Full, FilterConfidence::Partial),
700            FilterConfidence::Partial
701        );
702        assert_eq!(
703            worse_confidence(FilterConfidence::Full, FilterConfidence::Fallback),
704            FilterConfidence::Fallback
705        );
706        assert_eq!(
707            worse_confidence(FilterConfidence::Partial, FilterConfidence::Fallback),
708            FilterConfidence::Fallback
709        );
710        assert_eq!(
711            worse_confidence(FilterConfidence::Full, FilterConfidence::Full),
712            FilterConfidence::Full
713        );
714    }
715
716    // Helper filter for pipeline integration test: replaces a word.
717    struct ReplaceFilter {
718        from: &'static str,
719        to: &'static str,
720        confidence: FilterConfidence,
721    }
722
723    static MATCH_ALL: LazyLock<CommandMatcher> =
724        LazyLock::new(|| CommandMatcher::Custom(Box::new(|_| true)));
725
726    impl OutputFilter for ReplaceFilter {
727        fn name(&self) -> &'static str {
728            "replace"
729        }
730        fn matcher(&self) -> &CommandMatcher {
731            &MATCH_ALL
732        }
733        fn filter(&self, _cmd: &str, raw: &str, _exit: i32) -> FilterResult {
734            let output = raw.replace(self.from, self.to);
735            make_result(raw, output, self.confidence, vec![])
736        }
737    }
738
739    #[test]
740    fn pipeline_multi_stage_chains_and_aggregates() {
741        let f1 = ReplaceFilter {
742            from: "hello",
743            to: "world",
744            confidence: FilterConfidence::Full,
745        };
746        let f2 = ReplaceFilter {
747            from: "world",
748            to: "DONE",
749            confidence: FilterConfidence::Partial,
750        };
751
752        let mut pipeline = FilterPipeline::new();
753        pipeline.push(&f1);
754        pipeline.push(&f2);
755
756        let result = pipeline.run("test", "say hello there", 0);
757        // f1: "hello" -> "world", f2: "world" -> "DONE"
758        assert_eq!(result.output, "say DONE there");
759        assert_eq!(result.confidence, FilterConfidence::Partial);
760        assert_eq!(result.raw_chars, "say hello there".len());
761        assert_eq!(result.filtered_chars, "say DONE there".len());
762    }
763
764    use proptest::prelude::*;
765
766    proptest! {
767        #[test]
768        fn filter_pipeline_run_never_panics(cmd in ".*", output in ".*", exit_code in -1i32..=255) {
769            let pipeline = FilterPipeline::new();
770            let _ = pipeline.run(&cmd, &output, exit_code);
771        }
772
773        #[test]
774        fn output_filter_registry_apply_never_panics(cmd in ".*", output in ".*", exit_code in -1i32..=255) {
775            let reg = OutputFilterRegistry::new(true);
776            let _ = reg.apply(&cmd, &output, exit_code);
777        }
778    }
779
780    #[test]
781    fn registry_pipeline_with_two_matching_filters() {
782        let mut reg = OutputFilterRegistry::new(true);
783        reg.register(Box::new(ReplaceFilter {
784            from: "aaa",
785            to: "bbb",
786            confidence: FilterConfidence::Full,
787        }));
788        reg.register(Box::new(ReplaceFilter {
789            from: "bbb",
790            to: "ccc",
791            confidence: FilterConfidence::Fallback,
792        }));
793
794        let result = reg.apply("test", "aaa", 0).unwrap();
795        // Both match "test" via MATCH_ALL. Pipeline: "aaa" -> "bbb" -> "ccc"
796        assert_eq!(result.output, "ccc");
797        assert_eq!(result.confidence, FilterConfidence::Fallback);
798    }
799}