1pub(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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28pub enum FilterConfidence {
29 Full,
30 Partial,
31 Fallback,
32}
33
34pub 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
59pub 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
87fn 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 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
121pub 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#[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, ¤t, 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(¤t),
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#[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
247fn 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#[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
419pub 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
551static ANSI_RE: LazyLock<Regex> =
556 LazyLock::new(|| Regex::new(r"\x1b\[[0-9;]*[a-zA-Z]|\x1b[()][A-B0-2]").unwrap());
557
558#[must_use]
560pub fn strip_ansi(raw: &str) -> String {
561 ANSI_RE.replace_all(raw, "").into_owned()
562}
563
564#[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 #[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 #[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 #[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 #[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 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 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 assert_eq!(result.output, "ccc");
1019 assert_eq!(result.confidence, FilterConfidence::Fallback);
1020 }
1021}