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