Skip to main content

zeph_tools/filter/
mod.rs

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