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