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