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