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