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