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