1pub(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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17pub enum FilterConfidence {
18 Full,
19 Partial,
20 Fallback,
21}
22
23pub 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
48pub 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
78fn 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 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
113pub 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#[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, ¤t, 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(¤t),
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#[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
239pub(crate) fn default_true() -> bool {
244 true
245}
246
247#[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 #[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
289pub 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
406static ANSI_RE: LazyLock<Regex> =
411 LazyLock::new(|| Regex::new(r"\x1b\[[0-9;]*[a-zA-Z]|\x1b[()][A-B0-2]").unwrap());
412
413#[must_use]
415pub fn strip_ansi(raw: &str) -> String {
416 ANSI_RE.replace_all(raw, "").into_owned()
417}
418
419#[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 #[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 #[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 #[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 #[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 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 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 assert_eq!(result.output, "ccc");
820 assert_eq!(result.confidence, FilterConfidence::Fallback);
821 }
822}