1pub(crate) mod declarative;
7pub mod security;
8
9use std::sync::{Arc, LazyLock};
10
11use parking_lot::Mutex;
12
13use regex::Regex;
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(Arc<str>),
59 Prefix(Arc<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.as_ref(),
75 Self::Prefix(s) => command.starts_with(s.as_ref()),
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) -> &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
257pub(crate) use zeph_config::tools::FilterConfig;
258
259pub struct OutputFilterRegistry {
265 filters: Vec<Box<dyn OutputFilter>>,
266 enabled: bool,
267 security_enabled: bool,
268 extra_security_patterns: Vec<regex::Regex>,
269 metrics: Mutex<FilterMetrics>,
270}
271
272impl std::fmt::Debug for OutputFilterRegistry {
273 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
274 f.debug_struct("OutputFilterRegistry")
275 .field("enabled", &self.enabled)
276 .field("filter_count", &self.filters.len())
277 .finish_non_exhaustive()
278 }
279}
280
281impl OutputFilterRegistry {
282 #[must_use]
283 pub fn new(enabled: bool) -> Self {
284 Self {
285 filters: Vec::new(),
286 enabled,
287 security_enabled: true,
288 extra_security_patterns: Vec::new(),
289 metrics: Mutex::new(FilterMetrics::new()),
290 }
291 }
292
293 pub fn register(&mut self, filter: Box<dyn OutputFilter>) {
294 self.filters.push(filter);
295 }
296
297 #[must_use]
298 pub fn default_filters(config: &FilterConfig) -> Self {
299 let mut r = Self {
300 filters: Vec::new(),
301 enabled: config.enabled,
302 security_enabled: config.security.enabled,
303 extra_security_patterns: security::compile_extra_patterns(
304 &config.security.extra_patterns,
305 ),
306 metrics: Mutex::new(FilterMetrics::new()),
307 };
308 for f in declarative::load_declarative_filters(config.filters_path.as_deref()) {
309 r.register(f);
310 }
311 r
312 }
313
314 #[must_use]
315 pub fn apply(&self, command: &str, raw_output: &str, exit_code: i32) -> Option<FilterResult> {
316 if !self.enabled {
317 return None;
318 }
319
320 let matching: Vec<&dyn OutputFilter> = self
321 .filters
322 .iter()
323 .filter(|f| f.matcher().matches(command))
324 .map(AsRef::as_ref)
325 .collect();
326
327 if matching.is_empty() {
328 return None;
329 }
330
331 let mut result = if matching.len() == 1 {
332 matching[0].filter(command, raw_output, exit_code)
333 } else {
334 let mut pipeline = FilterPipeline::new();
335 for f in &matching {
336 pipeline.push(*f);
337 }
338 pipeline.run(command, raw_output, exit_code)
339 };
340
341 if self.security_enabled {
342 security::append_security_warnings(
343 &mut result.output,
344 raw_output,
345 &self.extra_security_patterns,
346 );
347 }
348
349 self.record_metrics(&result);
350 Some(result)
351 }
352
353 fn record_metrics(&self, result: &FilterResult) {
354 let mut m = self.metrics.lock();
355 m.record(result);
356 if m.total_commands.is_multiple_of(50) {
357 tracing::debug!(
358 total = m.total_commands,
359 filtered = m.filtered_commands,
360 savings_pct = format!("{:.1}", m.savings_pct()),
361 "filter metrics"
362 );
363 }
364 }
365
366 #[must_use]
367 pub fn metrics(&self) -> FilterMetrics {
368 self.metrics.lock().clone()
369 }
370}
371
372static ANSI_RE: LazyLock<Regex> =
377 LazyLock::new(|| Regex::new(r"\x1b\[[0-9;]*[a-zA-Z]|\x1b[()][A-B0-2]").unwrap());
378
379#[must_use]
381pub fn strip_ansi(raw: &str) -> String {
382 ANSI_RE.replace_all(raw, "").into_owned()
383}
384
385#[must_use]
387pub fn sanitize_output(raw: &str) -> String {
388 let no_ansi = ANSI_RE.replace_all(raw, "");
389
390 let mut result = String::with_capacity(no_ansi.len());
391 let mut prev_blank = false;
392
393 for line in no_ansi.lines() {
394 let clean = if line.contains('\r') {
395 line.rsplit('\r').next().unwrap_or("")
396 } else {
397 line
398 };
399
400 let is_blank = clean.trim().is_empty();
401 if is_blank && prev_blank {
402 continue;
403 }
404 prev_blank = is_blank;
405
406 if !result.is_empty() {
407 result.push('\n');
408 }
409 result.push_str(clean);
410 }
411 result
412}
413
414fn count_lines(s: &str) -> usize {
415 if s.is_empty() { 0 } else { s.lines().count() }
416}
417
418fn make_result(
419 raw: &str,
420 output: String,
421 confidence: FilterConfidence,
422 kept_lines: Vec<usize>,
423) -> FilterResult {
424 let filtered_chars = output.len();
425 FilterResult {
426 raw_lines: count_lines(raw),
427 filtered_lines: count_lines(&output),
428 output,
429 raw_chars: raw.len(),
430 filtered_chars,
431 confidence,
432 kept_lines,
433 }
434}
435
436#[cfg(test)]
437mod tests {
438 use super::*;
439
440 #[test]
441 fn sanitize_strips_ansi() {
442 let input = "\x1b[32mOK\x1b[0m test passed";
443 assert_eq!(sanitize_output(input), "OK test passed");
444 }
445
446 #[test]
447 fn sanitize_strips_cr_progress() {
448 let input = "Downloading... 50%\rDownloading... 100%";
449 assert_eq!(sanitize_output(input), "Downloading... 100%");
450 }
451
452 #[test]
453 fn sanitize_collapses_blank_lines() {
454 let input = "line1\n\n\n\nline2";
455 assert_eq!(sanitize_output(input), "line1\n\nline2");
456 }
457
458 #[test]
459 fn sanitize_preserves_crlf_content() {
460 let input = "line1\r\nline2\r\n";
461 let result = sanitize_output(input);
462 assert!(result.contains("line1"));
463 assert!(result.contains("line2"));
464 }
465
466 #[test]
467 fn filter_result_savings_pct() {
468 let r = FilterResult {
469 output: String::new(),
470 raw_chars: 1000,
471 filtered_chars: 200,
472 raw_lines: 0,
473 filtered_lines: 0,
474 confidence: FilterConfidence::Full,
475 kept_lines: vec![],
476 };
477 assert!((r.savings_pct() - 80.0).abs() < 0.01);
478 }
479
480 #[test]
481 fn filter_result_savings_pct_zero_raw() {
482 let r = FilterResult {
483 output: String::new(),
484 raw_chars: 0,
485 filtered_chars: 0,
486 raw_lines: 0,
487 filtered_lines: 0,
488 confidence: FilterConfidence::Full,
489 kept_lines: vec![],
490 };
491 assert!((r.savings_pct()).abs() < 0.01);
492 }
493
494 #[test]
495 fn count_lines_helper() {
496 assert_eq!(count_lines(""), 0);
497 assert_eq!(count_lines("one"), 1);
498 assert_eq!(count_lines("one\ntwo\nthree"), 3);
499 assert_eq!(count_lines("trailing\n"), 1);
500 }
501
502 #[test]
503 fn make_result_counts_lines() {
504 let raw = "line1\nline2\nline3\nline4\nline5";
505 let filtered = "line1\nline3".to_owned();
506 let r = make_result(raw, filtered, FilterConfidence::Full, vec![]);
507 assert_eq!(r.raw_lines, 5);
508 assert_eq!(r.filtered_lines, 2);
509 }
510
511 #[test]
512 fn registry_disabled_returns_none() {
513 let r = OutputFilterRegistry::new(false);
514 assert!(r.apply("cargo test", "output", 0).is_none());
515 }
516
517 #[test]
518 fn registry_no_match_returns_none() {
519 let r = OutputFilterRegistry::new(true);
520 assert!(r.apply("some-unknown-cmd", "output", 0).is_none());
521 }
522
523 #[test]
524 fn registry_default_has_filters() {
525 let r = OutputFilterRegistry::default_filters(&FilterConfig::default());
526 assert!(
527 r.apply(
528 "cargo test",
529 "test result: ok. 5 passed; 0 failed; 0 ignored; 0 filtered out",
530 0
531 )
532 .is_some()
533 );
534 }
535
536 #[test]
537 fn filter_config_default_enabled() {
538 let c = FilterConfig::default();
539 assert!(c.enabled);
540 }
541
542 #[test]
543 fn filter_config_deserialize() {
544 let toml_str = "enabled = false";
545 let c: FilterConfig = toml::from_str(toml_str).unwrap();
546 assert!(!c.enabled);
547 }
548
549 #[test]
550 fn filter_config_deserialize_minimal() {
551 let toml_str = "enabled = true";
552 let c: FilterConfig = toml::from_str(toml_str).unwrap();
553 assert!(c.enabled);
554 assert!(c.security.enabled);
555 }
556
557 #[test]
558 fn filter_config_deserialize_security() {
559 let toml_str = r#"
560enabled = true
561
562[security]
563enabled = true
564extra_patterns = ["TODO: security review"]
565"#;
566 let c: FilterConfig = toml::from_str(toml_str).unwrap();
567 assert!(c.enabled);
568 assert_eq!(c.security.extra_patterns, vec!["TODO: security review"]);
569 }
570
571 #[test]
573 fn command_matcher_exact() {
574 let m = CommandMatcher::Exact(Arc::from("ls"));
575 assert!(m.matches("ls"));
576 assert!(!m.matches("ls -la"));
577 }
578
579 #[test]
580 fn command_matcher_prefix() {
581 let m = CommandMatcher::Prefix(Arc::from("git "));
582 assert!(m.matches("git status"));
583 assert!(!m.matches("github"));
584 }
585
586 #[test]
587 fn command_matcher_regex() {
588 let m = CommandMatcher::Regex(Regex::new(r"^cargo\s+test").unwrap());
589 assert!(m.matches("cargo test"));
590 assert!(m.matches("cargo test --lib"));
591 assert!(!m.matches("cargo build"));
592 }
593
594 #[test]
595 fn command_matcher_custom() {
596 let m = CommandMatcher::Custom(Box::new(|cmd| cmd.contains("hello")));
597 assert!(m.matches("say hello world"));
598 assert!(!m.matches("goodbye"));
599 }
600
601 #[test]
602 fn command_matcher_compound_cd_and() {
603 let m = CommandMatcher::Prefix(Arc::from("cargo "));
604 assert!(m.matches("cd /some/path && cargo test --workspace --lib"));
605 assert!(m.matches("cd /path && cargo clippy --workspace -- -D warnings 2>&1"));
606 }
607
608 #[test]
609 fn command_matcher_compound_with_pipe() {
610 let m = CommandMatcher::Custom(Box::new(|cmd| cmd.split_whitespace().any(|t| t == "test")));
611 assert!(m.matches("cd /path && cargo test --workspace --lib 2>&1 | tail -80"));
612 }
613
614 #[test]
615 fn command_matcher_compound_no_false_positive() {
616 let m = CommandMatcher::Exact(Arc::from("ls"));
617 assert!(!m.matches("cd /path && cargo test"));
618 }
619
620 #[test]
621 fn extract_last_command_basic() {
622 assert_eq!(
623 extract_last_command("cd /path && cargo test --lib"),
624 Some("cargo test --lib")
625 );
626 assert_eq!(
627 extract_last_command("cd /p && cargo clippy 2>&1 | tail -20"),
628 Some("cargo clippy")
629 );
630 assert!(extract_last_command("cargo test").is_none());
631 }
632
633 #[test]
635 fn filter_confidence_derives() {
636 let a = FilterConfidence::Full;
637 let b = a;
638 assert_eq!(a, b);
639 let _ = format!("{a:?}");
640 let mut set = std::collections::HashSet::new();
641 set.insert(a);
642 }
643
644 #[test]
646 fn filter_metrics_new_zeros() {
647 let m = FilterMetrics::new();
648 assert_eq!(m.total_commands, 0);
649 assert_eq!(m.filtered_commands, 0);
650 assert_eq!(m.skipped_commands, 0);
651 assert_eq!(m.confidence_counts, [0; 3]);
652 }
653
654 #[test]
655 fn filter_metrics_record() {
656 let mut m = FilterMetrics::new();
657 let r = FilterResult {
658 output: "short".into(),
659 raw_chars: 100,
660 filtered_chars: 5,
661 raw_lines: 10,
662 filtered_lines: 1,
663 confidence: FilterConfidence::Full,
664 kept_lines: vec![],
665 };
666 m.record(&r);
667 assert_eq!(m.total_commands, 1);
668 assert_eq!(m.filtered_commands, 1);
669 assert_eq!(m.skipped_commands, 0);
670 assert_eq!(m.confidence_counts[0], 1);
671 }
672
673 #[test]
674 fn filter_metrics_savings_pct() {
675 let mut m = FilterMetrics::new();
676 m.raw_chars_total = 1000;
677 m.filtered_chars_total = 200;
678 assert!((m.savings_pct() - 80.0).abs() < 0.01);
679 }
680
681 #[test]
682 fn registry_metrics_updated() {
683 let r = OutputFilterRegistry::default_filters(&FilterConfig::default());
684 let _ = r.apply(
685 "cargo test",
686 "test result: ok. 5 passed; 0 failed; 0 ignored; 0 filtered out",
687 0,
688 );
689 let m = r.metrics();
690 assert_eq!(m.total_commands, 1);
691 }
692
693 #[test]
695 fn confidence_aggregation() {
696 assert_eq!(
697 worse_confidence(FilterConfidence::Full, FilterConfidence::Partial),
698 FilterConfidence::Partial
699 );
700 assert_eq!(
701 worse_confidence(FilterConfidence::Full, FilterConfidence::Fallback),
702 FilterConfidence::Fallback
703 );
704 assert_eq!(
705 worse_confidence(FilterConfidence::Partial, FilterConfidence::Fallback),
706 FilterConfidence::Fallback
707 );
708 assert_eq!(
709 worse_confidence(FilterConfidence::Full, FilterConfidence::Full),
710 FilterConfidence::Full
711 );
712 }
713
714 struct ReplaceFilter {
716 from: &'static str,
717 to: &'static str,
718 confidence: FilterConfidence,
719 }
720
721 static MATCH_ALL: LazyLock<CommandMatcher> =
722 LazyLock::new(|| CommandMatcher::Custom(Box::new(|_| true)));
723
724 impl OutputFilter for ReplaceFilter {
725 fn name(&self) -> &'static str {
726 "replace"
727 }
728 fn matcher(&self) -> &CommandMatcher {
729 &MATCH_ALL
730 }
731 fn filter(&self, _cmd: &str, raw: &str, _exit: i32) -> FilterResult {
732 let output = raw.replace(self.from, self.to);
733 make_result(raw, output, self.confidence, vec![])
734 }
735 }
736
737 #[test]
738 fn pipeline_multi_stage_chains_and_aggregates() {
739 let f1 = ReplaceFilter {
740 from: "hello",
741 to: "world",
742 confidence: FilterConfidence::Full,
743 };
744 let f2 = ReplaceFilter {
745 from: "world",
746 to: "DONE",
747 confidence: FilterConfidence::Partial,
748 };
749
750 let mut pipeline = FilterPipeline::new();
751 pipeline.push(&f1);
752 pipeline.push(&f2);
753
754 let result = pipeline.run("test", "say hello there", 0);
755 assert_eq!(result.output, "say DONE there");
757 assert_eq!(result.confidence, FilterConfidence::Partial);
758 assert_eq!(result.raw_chars, "say hello there".len());
759 assert_eq!(result.filtered_chars, "say DONE there".len());
760 }
761
762 use proptest::prelude::*;
763
764 proptest! {
765 #[test]
766 fn filter_pipeline_run_never_panics(cmd in ".*", output in ".*", exit_code in -1i32..=255) {
767 let pipeline = FilterPipeline::new();
768 let _ = pipeline.run(&cmd, &output, exit_code);
769 }
770
771 #[test]
772 fn output_filter_registry_apply_never_panics(cmd in ".*", output in ".*", exit_code in -1i32..=255) {
773 let reg = OutputFilterRegistry::new(true);
774 let _ = reg.apply(&cmd, &output, exit_code);
775 }
776 }
777
778 #[test]
779 fn registry_pipeline_with_two_matching_filters() {
780 let mut reg = OutputFilterRegistry::new(true);
781 reg.register(Box::new(ReplaceFilter {
782 from: "aaa",
783 to: "bbb",
784 confidence: FilterConfidence::Full,
785 }));
786 reg.register(Box::new(ReplaceFilter {
787 from: "bbb",
788 to: "ccc",
789 confidence: FilterConfidence::Fallback,
790 }));
791
792 let result = reg.apply("test", "aaa", 0).unwrap();
793 assert_eq!(result.output, "ccc");
795 assert_eq!(result.confidence, FilterConfidence::Fallback);
796 }
797}