diff --git a/crates/zeph-tools/src/filter/clippy.rs b/crates/zeph-tools/src/filter/clippy.rs index 84bab873..c5e9f673 100644 --- a/crates/zeph-tools/src/filter/clippy.rs +++ b/crates/zeph-tools/src/filter/clippy.rs @@ -4,24 +4,49 @@ use std::sync::LazyLock; use regex::Regex; -use super::{FilterResult, OutputFilter, make_result}; - -pub struct ClippyFilter; +use super::{ + ClippyFilterConfig, CommandMatcher, FilterConfidence, FilterResult, OutputFilter, make_result, +}; + +static CLIPPY_MATCHER: LazyLock = LazyLock::new(|| { + CommandMatcher::Custom(Box::new(|cmd| { + let c = cmd.to_lowercase(); + let tokens: Vec<&str> = c.split_whitespace().collect(); + tokens.first() == Some(&"cargo") && tokens.iter().skip(1).any(|t| *t == "clippy") + })) +}); static LINT_RULE_RE: LazyLock = LazyLock::new(|| Regex::new(r"#\[warn\(([^)]+)\)\]").unwrap()); static LOCATION_RE: LazyLock = LazyLock::new(|| Regex::new(r"^\s*-->\s*(.+:\d+)").unwrap()); +pub struct ClippyFilter; + +impl ClippyFilter { + #[must_use] + pub fn new(_config: ClippyFilterConfig) -> Self { + Self + } +} + impl OutputFilter for ClippyFilter { - fn matches(&self, command: &str) -> bool { - command.contains("cargo clippy") + fn name(&self) -> &'static str { + "clippy" + } + + fn matcher(&self) -> &CommandMatcher { + &CLIPPY_MATCHER } fn filter(&self, _command: &str, raw_output: &str, exit_code: i32) -> FilterResult { let has_error = raw_output.contains("error[") || raw_output.contains("error:"); if has_error && exit_code != 0 { - return make_result(raw_output, raw_output.to_owned()); + return make_result( + raw_output, + raw_output.to_owned(), + FilterConfidence::Fallback, + ); } let mut warnings: BTreeMap> = BTreeMap::new(); @@ -41,7 +66,11 @@ impl OutputFilter for ClippyFilter { } if warnings.is_empty() { - return make_result(raw_output, raw_output.to_owned()); + return make_result( + raw_output, + raw_output.to_owned(), + FilterConfidence::Fallback, + ); } let total: usize = warnings.values().map(Vec::len).sum(); @@ -59,7 +88,7 @@ impl OutputFilter for ClippyFilter { } let _ = write!(output, "{total} warnings total ({rules} rules)"); - make_result(raw_output, output) + make_result(raw_output, output, FilterConfidence::Full) } } @@ -67,18 +96,23 @@ impl OutputFilter for ClippyFilter { mod tests { use super::*; + fn make_filter() -> ClippyFilter { + ClippyFilter::new(ClippyFilterConfig::default()) + } + #[test] fn matches_clippy() { - let f = ClippyFilter; - assert!(f.matches("cargo clippy --workspace")); - assert!(f.matches("cargo clippy -- -D warnings")); - assert!(!f.matches("cargo build")); - assert!(!f.matches("cargo test")); + let f = make_filter(); + assert!(f.matcher().matches("cargo clippy --workspace")); + assert!(f.matcher().matches("cargo clippy -- -D warnings")); + assert!(f.matcher().matches("cargo +nightly clippy")); + assert!(!f.matcher().matches("cargo build")); + assert!(!f.matcher().matches("cargo test")); } #[test] fn filter_groups_warnings() { - let f = ClippyFilter; + let f = make_filter(); let raw = "\ warning: needless pass by value --> src/foo.rs:12:5 @@ -113,21 +147,24 @@ warning: `my-crate` (lib) generated 3 warnings .contains("clippy::unused_imports (1 warning):") ); assert!(result.output.contains("3 warnings total (2 rules)")); + assert_eq!(result.confidence, FilterConfidence::Full); } #[test] fn filter_error_preserves_full() { - let f = ClippyFilter; + let f = make_filter(); let raw = "error[E0308]: mismatched types\n --> src/main.rs:10:5\nfull details here"; let result = f.filter("cargo clippy", raw, 1); assert_eq!(result.output, raw); + assert_eq!(result.confidence, FilterConfidence::Fallback); } #[test] fn filter_no_warnings_passthrough() { - let f = ClippyFilter; + let f = make_filter(); let raw = "Checking my-crate v0.1.0\n Finished dev [unoptimized] target(s)"; let result = f.filter("cargo clippy", raw, 0); assert_eq!(result.output, raw); + assert_eq!(result.confidence, FilterConfidence::Fallback); } } diff --git a/crates/zeph-tools/src/filter/dir_listing.rs b/crates/zeph-tools/src/filter/dir_listing.rs index fb6ea36f..a981d2f5 100644 --- a/crates/zeph-tools/src/filter/dir_listing.rs +++ b/crates/zeph-tools/src/filter/dir_listing.rs @@ -1,6 +1,10 @@ use std::fmt::Write; +use std::sync::LazyLock; -use super::{FilterResult, OutputFilter, make_result}; +use super::{ + CommandMatcher, DirListingFilterConfig, FilterConfidence, FilterResult, OutputFilter, + make_result, +}; const NOISE_DIRS: &[&str] = &[ "node_modules", @@ -15,12 +19,29 @@ const NOISE_DIRS: &[&str] = &[ ".cache", ]; +static DIR_LISTING_MATCHER: LazyLock = LazyLock::new(|| { + CommandMatcher::Custom(Box::new(|cmd| { + let c = cmd.trim_start(); + c == "ls" || c.starts_with("ls ") + })) +}); + pub struct DirListingFilter; +impl DirListingFilter { + #[must_use] + pub fn new(_config: DirListingFilterConfig) -> Self { + Self + } +} + impl OutputFilter for DirListingFilter { - fn matches(&self, command: &str) -> bool { - let cmd = command.trim_start(); - cmd == "ls" || cmd.starts_with("ls ") + fn name(&self) -> &'static str { + "dir_listing" + } + + fn matcher(&self) -> &CommandMatcher { + &DIR_LISTING_MATCHER } fn filter(&self, _command: &str, raw_output: &str, _exit_code: i32) -> FilterResult { @@ -39,14 +60,18 @@ impl OutputFilter for DirListingFilter { } if hidden.is_empty() { - return make_result(raw_output, raw_output.to_owned()); + return make_result( + raw_output, + raw_output.to_owned(), + FilterConfidence::Fallback, + ); } let mut output = kept.join("\n"); let names = hidden.join(", "); let _ = write!(output, "\n(+ {} hidden: {names})", hidden.len()); - make_result(raw_output, output) + make_result(raw_output, output, FilterConfidence::Full) } } @@ -54,19 +79,23 @@ impl OutputFilter for DirListingFilter { mod tests { use super::*; + fn make_filter() -> DirListingFilter { + DirListingFilter::new(DirListingFilterConfig::default()) + } + #[test] fn matches_ls() { - let f = DirListingFilter; - assert!(f.matches("ls")); - assert!(f.matches("ls -la")); - assert!(f.matches("ls /tmp")); - assert!(!f.matches("lsof")); - assert!(!f.matches("cargo build")); + let f = make_filter(); + assert!(f.matcher().matches("ls")); + assert!(f.matcher().matches("ls -la")); + assert!(f.matcher().matches("ls /tmp")); + assert!(!f.matcher().matches("lsof")); + assert!(!f.matcher().matches("cargo build")); } #[test] fn filter_hides_noise_dirs() { - let f = DirListingFilter; + let f = make_filter(); let raw = "Cargo.toml\nsrc\ntarget\nnode_modules\nREADME.md\n.git"; let result = f.filter("ls", raw, 0); assert!(result.output.contains("Cargo.toml")); @@ -78,19 +107,21 @@ mod tests { .output .contains("(+ 3 hidden: target, node_modules, .git)") ); + assert_eq!(result.confidence, FilterConfidence::Full); } #[test] fn filter_no_noise_passthrough() { - let f = DirListingFilter; + let f = make_filter(); let raw = "Cargo.toml\nsrc\nREADME.md"; let result = f.filter("ls", raw, 0); assert_eq!(result.output, raw); + assert_eq!(result.confidence, FilterConfidence::Fallback); } #[test] fn filter_ls_la_format() { - let f = DirListingFilter; + let f = make_filter(); let raw = "\ drwxr-xr-x 5 user staff 160 Jan 1 12:00 src drwxr-xr-x 20 user staff 640 Jan 1 12:00 node_modules diff --git a/crates/zeph-tools/src/filter/git.rs b/crates/zeph-tools/src/filter/git.rs index eba9e19f..5bace28a 100644 --- a/crates/zeph-tools/src/filter/git.rs +++ b/crates/zeph-tools/src/filter/git.rs @@ -1,12 +1,31 @@ use std::fmt::Write; +use std::sync::LazyLock; -use super::{FilterResult, OutputFilter, make_result}; +use super::{ + CommandMatcher, FilterConfidence, FilterResult, GitFilterConfig, OutputFilter, make_result, +}; -pub struct GitFilter; +static GIT_MATCHER: LazyLock = + LazyLock::new(|| CommandMatcher::Custom(Box::new(|cmd| cmd.trim_start().starts_with("git ")))); + +pub struct GitFilter { + config: GitFilterConfig, +} + +impl GitFilter { + #[must_use] + pub fn new(config: GitFilterConfig) -> Self { + Self { config } + } +} impl OutputFilter for GitFilter { - fn matches(&self, command: &str) -> bool { - command.trim_start().starts_with("git ") + fn name(&self) -> &'static str { + "git" + } + + fn matcher(&self) -> &CommandMatcher { + &GIT_MATCHER } fn filter(&self, command: &str, raw_output: &str, _exit_code: i32) -> FilterResult { @@ -20,10 +39,14 @@ impl OutputFilter for GitFilter { match subcmd { "status" => filter_status(raw_output), - "diff" => filter_diff(raw_output), - "log" => filter_log(raw_output), + "diff" => filter_diff(raw_output, self.config.max_diff_lines), + "log" => filter_log(raw_output, self.config.max_log_entries), "push" => filter_push(raw_output), - _ => make_result(raw_output, raw_output.to_owned()), + _ => make_result( + raw_output, + raw_output.to_owned(), + FilterConfidence::Fallback, + ), } } } @@ -55,7 +78,7 @@ fn filter_status(raw: &str) -> FilterResult { let total = modified + added + deleted + untracked; if total == 0 { - return make_result(raw, raw.to_owned()); + return make_result(raw, raw.to_owned(), FilterConfidence::Fallback); } let mut output = String::new(); @@ -63,10 +86,10 @@ fn filter_status(raw: &str) -> FilterResult { output, "M {modified} files | A {added} files | D {deleted} files | ?? {untracked} files" ); - make_result(raw, output) + make_result(raw, output, FilterConfidence::Full) } -fn filter_diff(raw: &str) -> FilterResult { +fn filter_diff(raw: &str, max_diff_lines: usize) -> FilterResult { let mut files: Vec<(String, i32, i32)> = Vec::new(); let mut current_file = String::new(); let mut additions = 0i32; @@ -94,9 +117,10 @@ fn filter_diff(raw: &str) -> FilterResult { } if files.is_empty() { - return make_result(raw, raw.to_owned()); + return make_result(raw, raw.to_owned(), FilterConfidence::Fallback); } + let total_lines: usize = raw.lines().count(); let total_add: i32 = files.iter().map(|(_, a, _)| a).sum(); let total_del: i32 = files.iter().map(|(_, _, d)| d).sum(); let mut output = String::new(); @@ -110,19 +134,22 @@ fn filter_diff(raw: &str) -> FilterResult { total_add, total_del ); - make_result(raw, output) + if total_lines > max_diff_lines { + let _ = write!(output, " (truncated from {total_lines} lines)"); + } + make_result(raw, output, FilterConfidence::Full) } -fn filter_log(raw: &str) -> FilterResult { +fn filter_log(raw: &str, max_entries: usize) -> FilterResult { let lines: Vec<&str> = raw.lines().collect(); - if lines.len() <= 20 { - return make_result(raw, raw.to_owned()); + if lines.len() <= max_entries { + return make_result(raw, raw.to_owned(), FilterConfidence::Fallback); } - let mut output: String = lines[..20].join("\n"); - let remaining = lines.len() - 20; + let mut output: String = lines[..max_entries].join("\n"); + let remaining = lines.len() - max_entries; let _ = write!(output, "\n... and {remaining} more commits"); - make_result(raw, output) + make_result(raw, output, FilterConfidence::Full) } fn filter_push(raw: &str) -> FilterResult { @@ -137,39 +164,44 @@ fn filter_push(raw: &str) -> FilterResult { } } if output.is_empty() { - return make_result(raw, raw.to_owned()); + return make_result(raw, raw.to_owned(), FilterConfidence::Fallback); } - make_result(raw, output) + make_result(raw, output, FilterConfidence::Full) } #[cfg(test)] mod tests { use super::*; + fn make_filter() -> GitFilter { + GitFilter::new(GitFilterConfig::default()) + } + #[test] fn matches_git_commands() { - let f = GitFilter; - assert!(f.matches("git status")); - assert!(f.matches("git diff --stat")); - assert!(f.matches("git log --oneline")); - assert!(f.matches("git push origin main")); - assert!(!f.matches("cargo build")); - assert!(!f.matches("github-cli")); + let f = make_filter(); + assert!(f.matcher().matches("git status")); + assert!(f.matcher().matches("git diff --stat")); + assert!(f.matcher().matches("git log --oneline")); + assert!(f.matcher().matches("git push origin main")); + assert!(!f.matcher().matches("cargo build")); + assert!(!f.matcher().matches("github-cli")); } #[test] fn filter_status_summarizes() { - let f = GitFilter; + let f = make_filter(); let raw = " M src/main.rs\n M src/lib.rs\n?? new_file.txt\nA added.rs\n"; let result = f.filter("git status --short", raw, 0); assert!(result.output.contains("M 2 files")); assert!(result.output.contains("?? 1 files")); assert!(result.output.contains("A 1 files")); + assert_eq!(result.confidence, FilterConfidence::Full); } #[test] fn filter_diff_compresses() { - let f = GitFilter; + let f = make_filter(); let raw = "\ diff --git a/src/main.rs b/src/main.rs index abc..def 100644 @@ -194,7 +226,7 @@ index ghi..jkl 100644 #[test] fn filter_log_truncates() { - let f = GitFilter; + let f = make_filter(); let lines: Vec = (0..50) .map(|i| format!("abc{i:04} feat: commit {i}")) .collect(); @@ -204,19 +236,21 @@ index ghi..jkl 100644 assert!(result.output.contains("abc0019")); assert!(!result.output.contains("abc0020")); assert!(result.output.contains("and 30 more commits")); + assert_eq!(result.confidence, FilterConfidence::Full); } #[test] fn filter_log_short_passthrough() { - let f = GitFilter; + let f = make_filter(); let raw = "abc1234 feat: something\ndef5678 fix: other"; let result = f.filter("git log --oneline", raw, 0); assert_eq!(result.output, raw); + assert_eq!(result.confidence, FilterConfidence::Fallback); } #[test] fn filter_push_extracts_summary() { - let f = GitFilter; + let f = make_filter(); let raw = "\ Enumerating objects: 5, done. Counting objects: 100% (5/5), done. @@ -235,7 +269,7 @@ To github.com:user/repo.git #[test] fn filter_status_long_form() { - let f = GitFilter; + let f = make_filter(); let raw = "\ On branch main Changes not staged for commit: @@ -253,7 +287,7 @@ Untracked files: #[test] fn filter_diff_empty_passthrough() { - let f = GitFilter; + let f = make_filter(); let raw = ""; let result = f.filter("git diff", raw, 0); assert_eq!(result.output, raw); @@ -261,9 +295,10 @@ Untracked files: #[test] fn filter_unknown_subcommand_passthrough() { - let f = GitFilter; + let f = make_filter(); let raw = "some output"; let result = f.filter("git stash list", raw, 0); assert_eq!(result.output, raw); + assert_eq!(result.confidence, FilterConfidence::Fallback); } } diff --git a/crates/zeph-tools/src/filter/log_dedup.rs b/crates/zeph-tools/src/filter/log_dedup.rs index a4983df3..14cd9de3 100644 --- a/crates/zeph-tools/src/filter/log_dedup.rs +++ b/crates/zeph-tools/src/filter/log_dedup.rs @@ -4,11 +4,21 @@ use std::sync::LazyLock; use regex::Regex; -use super::{FilterResult, OutputFilter, make_result}; +use super::{ + CommandMatcher, FilterConfidence, FilterResult, LogDedupFilterConfig, OutputFilter, make_result, +}; const MAX_UNIQUE_PATTERNS: usize = 10_000; -pub struct LogDedupFilter; +static LOG_DEDUP_MATCHER: LazyLock = LazyLock::new(|| { + CommandMatcher::Custom(Box::new(|cmd| { + let c = cmd.to_lowercase(); + c.contains("journalctl") + || c.contains("tail -f") + || c.contains("docker logs") + || (c.contains("cat ") && c.contains(".log")) + })) +}); static TIMESTAMP_RE: LazyLock = LazyLock::new(|| { Regex::new(r"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}([.\d]*)?([Z+-][\d:]*)?").unwrap() @@ -21,19 +31,32 @@ static IP_RE: LazyLock = static PORT_PID_RE: LazyLock = LazyLock::new(|| Regex::new(r"(?:port|pid|PID)[=: ]+\d+").unwrap()); +pub struct LogDedupFilter; + +impl LogDedupFilter { + #[must_use] + pub fn new(_config: LogDedupFilterConfig) -> Self { + Self + } +} + impl OutputFilter for LogDedupFilter { - fn matches(&self, command: &str) -> bool { - let cmd = command.to_lowercase(); - cmd.contains("journalctl") - || cmd.contains("tail -f") - || cmd.contains("docker logs") - || (cmd.contains("cat ") && cmd.contains(".log")) + fn name(&self) -> &'static str { + "log_dedup" + } + + fn matcher(&self) -> &CommandMatcher { + &LOG_DEDUP_MATCHER } fn filter(&self, _command: &str, raw_output: &str, _exit_code: i32) -> FilterResult { let lines: Vec<&str> = raw_output.lines().collect(); if lines.len() < 3 { - return make_result(raw_output, raw_output.to_owned()); + return make_result( + raw_output, + raw_output.to_owned(), + FilterConfidence::Fallback, + ); } let mut pattern_counts: HashMap = HashMap::new(); @@ -56,7 +79,11 @@ impl OutputFilter for LogDedupFilter { let total = lines.len(); if unique == total && !capped { - return make_result(raw_output, raw_output.to_owned()); + return make_result( + raw_output, + raw_output.to_owned(), + FilterConfidence::Fallback, + ); } let mut output = String::new(); @@ -73,7 +100,7 @@ impl OutputFilter for LogDedupFilter { let _ = write!(output, " (capped at {MAX_UNIQUE_PATTERNS})"); } - make_result(raw_output, output) + make_result(raw_output, output, FilterConfidence::Full) } } @@ -88,20 +115,24 @@ fn normalize(line: &str) -> String { mod tests { use super::*; + fn make_filter() -> LogDedupFilter { + LogDedupFilter::new(LogDedupFilterConfig::default()) + } + #[test] fn matches_log_commands() { - let f = LogDedupFilter; - assert!(f.matches("journalctl -u nginx")); - assert!(f.matches("tail -f /var/log/syslog")); - assert!(f.matches("docker logs -f container")); - assert!(f.matches("cat /var/log/app.log")); - assert!(!f.matches("cat file.txt")); - assert!(!f.matches("cargo build")); + let f = make_filter(); + assert!(f.matcher().matches("journalctl -u nginx")); + assert!(f.matcher().matches("tail -f /var/log/syslog")); + assert!(f.matcher().matches("docker logs -f container")); + assert!(f.matcher().matches("cat /var/log/app.log")); + assert!(!f.matcher().matches("cat file.txt")); + assert!(!f.matcher().matches("cargo build")); } #[test] fn filter_deduplicates() { - let f = LogDedupFilter; + let f = make_filter(); let raw = "\ 2024-01-15T12:00:01Z INFO request handled path=/api/health 2024-01-15T12:00:02Z INFO request handled path=/api/health @@ -115,22 +146,25 @@ mod tests { assert!(result.output.contains("(x2)")); assert!(result.output.contains("3 unique patterns (6 total lines)")); assert!(result.savings_pct() > 20.0); + assert_eq!(result.confidence, FilterConfidence::Full); } #[test] fn filter_all_unique_passthrough() { - let f = LogDedupFilter; + let f = make_filter(); let raw = "line one\nline two\nline three"; let result = f.filter("cat app.log", raw, 0); assert_eq!(result.output, raw); + assert_eq!(result.confidence, FilterConfidence::Fallback); } #[test] fn filter_short_passthrough() { - let f = LogDedupFilter; + let f = make_filter(); let raw = "single line"; let result = f.filter("cat app.log", raw, 0); assert_eq!(result.output, raw); + assert_eq!(result.confidence, FilterConfidence::Fallback); } #[test] diff --git a/crates/zeph-tools/src/filter/mod.rs b/crates/zeph-tools/src/filter/mod.rs index c09c91ea..c5f2deb9 100644 --- a/crates/zeph-tools/src/filter/mod.rs +++ b/crates/zeph-tools/src/filter/mod.rs @@ -4,9 +4,10 @@ mod clippy; mod dir_listing; mod git; mod log_dedup; +pub mod security; mod test_output; -use std::sync::LazyLock; +use std::sync::{LazyLock, Mutex}; use regex::Regex; use serde::Deserialize; @@ -17,11 +18,27 @@ pub use self::git::GitFilter; pub use self::log_dedup::LogDedupFilter; pub use self::test_output::TestOutputFilter; +// --------------------------------------------------------------------------- +// FilterConfidence (#440) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum FilterConfidence { + Full, + Partial, + Fallback, +} + +// --------------------------------------------------------------------------- +// FilterResult +// --------------------------------------------------------------------------- + /// Result of applying a filter to tool output. pub struct FilterResult { pub output: String, pub raw_chars: usize, pub filtered_chars: usize, + pub confidence: FilterConfidence, } impl FilterResult { @@ -35,16 +52,330 @@ impl FilterResult { } } +// --------------------------------------------------------------------------- +// CommandMatcher (#439) +// --------------------------------------------------------------------------- + +pub enum CommandMatcher { + Exact(&'static str), + Prefix(&'static str), + Regex(regex::Regex), + Custom(Box bool + Send + Sync>), +} + +impl CommandMatcher { + #[must_use] + pub fn matches(&self, command: &str) -> bool { + match self { + Self::Exact(s) => command == *s, + Self::Prefix(s) => command.starts_with(s), + Self::Regex(re) => re.is_match(command), + Self::Custom(f) => f(command), + } + } +} + +impl std::fmt::Debug for CommandMatcher { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Exact(s) => write!(f, "Exact({s:?})"), + Self::Prefix(s) => write!(f, "Prefix({s:?})"), + Self::Regex(re) => write!(f, "Regex({:?})", re.as_str()), + Self::Custom(_) => write!(f, "Custom(...)"), + } + } +} + +// --------------------------------------------------------------------------- +// OutputFilter trait +// --------------------------------------------------------------------------- + /// Command-aware output filter. pub trait OutputFilter: Send + Sync { - fn matches(&self, command: &str) -> bool; + fn name(&self) -> &'static str; + fn matcher(&self) -> &CommandMatcher; fn filter(&self, command: &str, raw_output: &str, exit_code: i32) -> FilterResult; } -/// Registry of filters. First match wins; no match = passthrough. +// --------------------------------------------------------------------------- +// FilterPipeline (#441) +// --------------------------------------------------------------------------- + +#[derive(Default)] +pub struct FilterPipeline<'a> { + stages: Vec<&'a dyn OutputFilter>, +} + +impl<'a> FilterPipeline<'a> { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + pub fn push(&mut self, filter: &'a dyn OutputFilter) { + self.stages.push(filter); + } + + #[must_use] + pub fn run(&self, command: &str, output: &str, exit_code: i32) -> FilterResult { + let initial_len = output.len(); + let mut current = output.to_owned(); + let mut worst = FilterConfidence::Full; + + for stage in &self.stages { + let result = stage.filter(command, ¤t, exit_code); + worst = worse_confidence(worst, result.confidence); + current = result.output; + } + + FilterResult { + raw_chars: initial_len, + filtered_chars: current.len(), + output: current, + confidence: worst, + } + } +} + +fn worse_confidence(a: FilterConfidence, b: FilterConfidence) -> FilterConfidence { + match (a, b) { + (FilterConfidence::Fallback, _) | (_, FilterConfidence::Fallback) => { + FilterConfidence::Fallback + } + (FilterConfidence::Partial, _) | (_, FilterConfidence::Partial) => { + FilterConfidence::Partial + } + _ => FilterConfidence::Full, + } +} + +// --------------------------------------------------------------------------- +// FilterMetrics (#442) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +pub struct FilterMetrics { + pub total_commands: u64, + pub filtered_commands: u64, + pub skipped_commands: u64, + pub raw_chars_total: u64, + pub filtered_chars_total: u64, + pub confidence_counts: [u64; 3], +} + +impl FilterMetrics { + #[must_use] + pub fn new() -> Self { + Self { + total_commands: 0, + filtered_commands: 0, + skipped_commands: 0, + raw_chars_total: 0, + filtered_chars_total: 0, + confidence_counts: [0; 3], + } + } + + pub fn record(&mut self, result: &FilterResult) { + self.total_commands += 1; + if result.filtered_chars < result.raw_chars { + self.filtered_commands += 1; + } else { + self.skipped_commands += 1; + } + self.raw_chars_total += result.raw_chars as u64; + self.filtered_chars_total += result.filtered_chars as u64; + let idx = match result.confidence { + FilterConfidence::Full => 0, + FilterConfidence::Partial => 1, + FilterConfidence::Fallback => 2, + }; + self.confidence_counts[idx] += 1; + } + + #[must_use] + #[allow(clippy::cast_precision_loss)] + pub fn savings_pct(&self) -> f64 { + if self.raw_chars_total == 0 { + return 0.0; + } + (1.0 - self.filtered_chars_total as f64 / self.raw_chars_total as f64) * 100.0 + } +} + +impl Default for FilterMetrics { + fn default() -> Self { + Self::new() + } +} + +// --------------------------------------------------------------------------- +// FilterConfig (#444) +// --------------------------------------------------------------------------- + +fn default_true() -> bool { + true +} + +fn default_max_failures() -> usize { + 10 +} + +fn default_stack_trace_lines() -> usize { + 50 +} + +fn default_max_log_entries() -> usize { + 20 +} + +fn default_max_diff_lines() -> usize { + 500 +} + +/// Configuration for output filters. +#[derive(Debug, Clone, Deserialize)] +pub struct FilterConfig { + #[serde(default = "default_true")] + pub enabled: bool, + + #[serde(default)] + pub test: TestFilterConfig, + + #[serde(default)] + pub git: GitFilterConfig, + + #[serde(default)] + pub clippy: ClippyFilterConfig, + + #[serde(default)] + pub dir_listing: DirListingFilterConfig, + + #[serde(default)] + pub log_dedup: LogDedupFilterConfig, + + #[serde(default)] + pub security: SecurityFilterConfig, +} + +impl Default for FilterConfig { + fn default() -> Self { + Self { + enabled: true, + test: TestFilterConfig::default(), + git: GitFilterConfig::default(), + clippy: ClippyFilterConfig::default(), + dir_listing: DirListingFilterConfig::default(), + log_dedup: LogDedupFilterConfig::default(), + security: SecurityFilterConfig::default(), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct TestFilterConfig { + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default = "default_max_failures")] + pub max_failures: usize, + #[serde(default = "default_stack_trace_lines")] + pub truncate_stack_trace: usize, +} + +impl Default for TestFilterConfig { + fn default() -> Self { + Self { + enabled: true, + max_failures: default_max_failures(), + truncate_stack_trace: default_stack_trace_lines(), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct GitFilterConfig { + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default = "default_max_log_entries")] + pub max_log_entries: usize, + #[serde(default = "default_max_diff_lines")] + pub max_diff_lines: usize, +} + +impl Default for GitFilterConfig { + fn default() -> Self { + Self { + enabled: true, + max_log_entries: default_max_log_entries(), + max_diff_lines: default_max_diff_lines(), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ClippyFilterConfig { + #[serde(default = "default_true")] + pub enabled: bool, +} + +impl Default for ClippyFilterConfig { + fn default() -> Self { + Self { enabled: true } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct DirListingFilterConfig { + #[serde(default = "default_true")] + pub enabled: bool, +} + +impl Default for DirListingFilterConfig { + fn default() -> Self { + Self { enabled: true } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct LogDedupFilterConfig { + #[serde(default = "default_true")] + pub enabled: bool, +} + +impl Default for LogDedupFilterConfig { + fn default() -> Self { + Self { enabled: true } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SecurityFilterConfig { + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub extra_patterns: Vec, +} + +impl Default for SecurityFilterConfig { + fn default() -> Self { + Self { + enabled: true, + extra_patterns: Vec::new(), + } + } +} + +// --------------------------------------------------------------------------- +// OutputFilterRegistry +// --------------------------------------------------------------------------- + +/// Registry of filters with pipeline support, security whitelist, and metrics. pub struct OutputFilterRegistry { filters: Vec>, enabled: bool, + security_enabled: bool, + extra_security_patterns: Vec, + metrics: Mutex, } impl std::fmt::Debug for OutputFilterRegistry { @@ -52,7 +383,7 @@ impl std::fmt::Debug for OutputFilterRegistry { f.debug_struct("OutputFilterRegistry") .field("enabled", &self.enabled) .field("filter_count", &self.filters.len()) - .finish() + .finish_non_exhaustive() } } @@ -62,6 +393,9 @@ impl OutputFilterRegistry { Self { filters: Vec::new(), enabled, + security_enabled: true, + extra_security_patterns: Vec::new(), + metrics: Mutex::new(FilterMetrics::new()), } } @@ -70,13 +404,31 @@ impl OutputFilterRegistry { } #[must_use] - pub fn default_filters() -> Self { - let mut r = Self::new(true); - r.register(Box::new(TestOutputFilter)); - r.register(Box::new(ClippyFilter)); - r.register(Box::new(GitFilter)); - r.register(Box::new(DirListingFilter)); - r.register(Box::new(LogDedupFilter)); + pub fn default_filters(config: &FilterConfig) -> Self { + let mut r = Self { + filters: Vec::new(), + enabled: config.enabled, + security_enabled: config.security.enabled, + extra_security_patterns: security::compile_extra_patterns( + &config.security.extra_patterns, + ), + metrics: Mutex::new(FilterMetrics::new()), + }; + if config.test.enabled { + r.register(Box::new(TestOutputFilter::new(config.test.clone()))); + } + if config.clippy.enabled { + r.register(Box::new(ClippyFilter::new(config.clippy.clone()))); + } + if config.git.enabled { + r.register(Box::new(GitFilter::new(config.git.clone()))); + } + if config.dir_listing.enabled { + r.register(Box::new(DirListingFilter::new(config.dir_listing.clone()))); + } + if config.log_dedup.enabled { + r.register(Box::new(LogDedupFilter::new(config.log_dedup.clone()))); + } r } @@ -85,15 +437,68 @@ impl OutputFilterRegistry { if !self.enabled { return None; } - for f in &self.filters { - if f.matches(command) { - return Some(f.filter(command, raw_output, exit_code)); + + let matching: Vec<&dyn OutputFilter> = self + .filters + .iter() + .filter(|f| f.matcher().matches(command)) + .map(AsRef::as_ref) + .collect(); + + if matching.is_empty() { + return None; + } + + let mut result = if matching.len() == 1 { + matching[0].filter(command, raw_output, exit_code) + } else { + let mut pipeline = FilterPipeline::new(); + for f in &matching { + pipeline.push(*f); + } + pipeline.run(command, raw_output, exit_code) + }; + + if self.security_enabled { + security::append_security_warnings( + &mut result.output, + raw_output, + &self.extra_security_patterns, + ); + result.filtered_chars = result.output.len(); + } + + self.record_metrics(&result); + Some(result) + } + + fn record_metrics(&self, result: &FilterResult) { + if let Ok(mut m) = self.metrics.lock() { + m.record(result); + if m.total_commands % 50 == 0 { + tracing::debug!( + total = m.total_commands, + filtered = m.filtered_commands, + savings_pct = format!("{:.1}", m.savings_pct()), + "filter metrics" + ); } } - None + } + + #[must_use] + pub fn metrics(&self) -> FilterMetrics { + self.metrics + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() } } +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + static ANSI_RE: LazyLock = LazyLock::new(|| Regex::new(r"\x1b\[[0-9;]*[a-zA-Z]").unwrap()); /// Strip ANSI escape sequences, carriage-return progress bars, and collapse blank lines. @@ -105,7 +510,6 @@ pub fn sanitize_output(raw: &str) -> String { let mut prev_blank = false; for line in no_ansi.lines() { - // Strip carriage-return overwrites (progress bars) let clean = if line.contains('\r') { line.rsplit('\r').next().unwrap_or("") } else { @@ -126,29 +530,13 @@ pub fn sanitize_output(raw: &str) -> String { result } -fn default_true() -> bool { - true -} - -/// Configuration for output filters. -#[derive(Debug, Deserialize)] -pub struct FilterConfig { - #[serde(default = "default_true")] - pub enabled: bool, -} - -impl Default for FilterConfig { - fn default() -> Self { - Self { enabled: true } - } -} - -fn make_result(raw: &str, output: String) -> FilterResult { +fn make_result(raw: &str, output: String, confidence: FilterConfidence) -> FilterResult { let filtered_chars = output.len(); FilterResult { output, raw_chars: raw.len(), filtered_chars, + confidence, } } @@ -188,6 +576,7 @@ mod tests { output: String::new(), raw_chars: 1000, filtered_chars: 200, + confidence: FilterConfidence::Full, }; assert!((r.savings_pct() - 80.0).abs() < 0.01); } @@ -198,6 +587,7 @@ mod tests { output: String::new(), raw_chars: 0, filtered_chars: 0, + confidence: FilterConfidence::Full, }; assert!((r.savings_pct()).abs() < 0.01); } @@ -216,7 +606,7 @@ mod tests { #[test] fn registry_default_has_filters() { - let r = OutputFilterRegistry::default_filters(); + let r = OutputFilterRegistry::default_filters(&FilterConfig::default()); assert!( r.apply( "cargo test", @@ -239,4 +629,256 @@ mod tests { let c: FilterConfig = toml::from_str(toml_str).unwrap(); assert!(!c.enabled); } + + #[test] + fn filter_config_deserialize_minimal() { + let toml_str = "enabled = true"; + let c: FilterConfig = toml::from_str(toml_str).unwrap(); + assert!(c.enabled); + assert!(c.test.enabled); + assert!(c.git.enabled); + assert!(c.clippy.enabled); + assert!(c.security.enabled); + } + + #[test] + fn filter_config_deserialize_full() { + let toml_str = r#" +enabled = true + +[test] +enabled = true +max_failures = 5 +truncate_stack_trace = 30 + +[git] +enabled = true +max_log_entries = 10 +max_diff_lines = 200 + +[clippy] +enabled = true + +[security] +enabled = true +extra_patterns = ["TODO: security review"] +"#; + let c: FilterConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(c.test.max_failures, 5); + assert_eq!(c.test.truncate_stack_trace, 30); + assert_eq!(c.git.max_log_entries, 10); + assert_eq!(c.git.max_diff_lines, 200); + assert!(c.clippy.enabled); + assert_eq!(c.security.extra_patterns, vec!["TODO: security review"]); + } + + #[test] + fn disabled_filter_excluded_from_registry() { + let config = FilterConfig { + test: TestFilterConfig { + enabled: false, + ..TestFilterConfig::default() + }, + ..FilterConfig::default() + }; + let r = OutputFilterRegistry::default_filters(&config); + assert!( + r.apply( + "cargo test", + "test result: ok. 5 passed; 0 failed; 0 ignored; 0 filtered out", + 0 + ) + .is_none() + ); + } + + // CommandMatcher tests + #[test] + fn command_matcher_exact() { + let m = CommandMatcher::Exact("ls"); + assert!(m.matches("ls")); + assert!(!m.matches("ls -la")); + } + + #[test] + fn command_matcher_prefix() { + let m = CommandMatcher::Prefix("git "); + assert!(m.matches("git status")); + assert!(!m.matches("github")); + } + + #[test] + fn command_matcher_regex() { + let m = CommandMatcher::Regex(Regex::new(r"^cargo\s+test").unwrap()); + assert!(m.matches("cargo test")); + assert!(m.matches("cargo test --lib")); + assert!(!m.matches("cargo build")); + } + + #[test] + fn command_matcher_custom() { + let m = CommandMatcher::Custom(Box::new(|cmd| cmd.contains("hello"))); + assert!(m.matches("say hello world")); + assert!(!m.matches("goodbye")); + } + + // FilterConfidence derives + #[test] + fn filter_confidence_derives() { + let a = FilterConfidence::Full; + let b = a; + assert_eq!(a, b); + let _ = format!("{a:?}"); + let mut set = std::collections::HashSet::new(); + set.insert(a); + } + + // FilterMetrics tests + #[test] + fn filter_metrics_new_zeros() { + let m = FilterMetrics::new(); + assert_eq!(m.total_commands, 0); + assert_eq!(m.filtered_commands, 0); + assert_eq!(m.skipped_commands, 0); + assert_eq!(m.confidence_counts, [0; 3]); + } + + #[test] + fn filter_metrics_record() { + let mut m = FilterMetrics::new(); + let r = FilterResult { + output: "short".into(), + raw_chars: 100, + filtered_chars: 5, + confidence: FilterConfidence::Full, + }; + m.record(&r); + assert_eq!(m.total_commands, 1); + assert_eq!(m.filtered_commands, 1); + assert_eq!(m.skipped_commands, 0); + assert_eq!(m.confidence_counts[0], 1); + } + + #[test] + fn filter_metrics_savings_pct() { + let mut m = FilterMetrics::new(); + m.raw_chars_total = 1000; + m.filtered_chars_total = 200; + assert!((m.savings_pct() - 80.0).abs() < 0.01); + } + + #[test] + fn registry_metrics_updated() { + let r = OutputFilterRegistry::default_filters(&FilterConfig::default()); + let _ = r.apply( + "cargo test", + "test result: ok. 5 passed; 0 failed; 0 ignored; 0 filtered out", + 0, + ); + let m = r.metrics(); + assert_eq!(m.total_commands, 1); + } + + // Pipeline tests + #[test] + fn pipeline_single_stage() { + let config = FilterConfig::default(); + let filter = TestOutputFilter::new(config.test.clone()); + let mut pipeline = FilterPipeline::new(); + pipeline.push(&filter); + let result = pipeline.run( + "cargo test", + "test result: ok. 5 passed; 0 failed; 0 ignored; 0 filtered out", + 0, + ); + assert!(result.output.contains("5 passed")); + } + + #[test] + fn confidence_aggregation() { + assert_eq!( + worse_confidence(FilterConfidence::Full, FilterConfidence::Partial), + FilterConfidence::Partial + ); + assert_eq!( + worse_confidence(FilterConfidence::Full, FilterConfidence::Fallback), + FilterConfidence::Fallback + ); + assert_eq!( + worse_confidence(FilterConfidence::Partial, FilterConfidence::Fallback), + FilterConfidence::Fallback + ); + assert_eq!( + worse_confidence(FilterConfidence::Full, FilterConfidence::Full), + FilterConfidence::Full + ); + } + + // Helper filter for pipeline integration test: replaces a word. + struct ReplaceFilter { + from: &'static str, + to: &'static str, + confidence: FilterConfidence, + } + + static MATCH_ALL: LazyLock = + LazyLock::new(|| CommandMatcher::Custom(Box::new(|_| true))); + + impl OutputFilter for ReplaceFilter { + fn name(&self) -> &'static str { + "replace" + } + fn matcher(&self) -> &CommandMatcher { + &MATCH_ALL + } + fn filter(&self, _cmd: &str, raw: &str, _exit: i32) -> FilterResult { + let output = raw.replace(self.from, self.to); + make_result(raw, output, self.confidence) + } + } + + #[test] + fn pipeline_multi_stage_chains_and_aggregates() { + let f1 = ReplaceFilter { + from: "hello", + to: "world", + confidence: FilterConfidence::Full, + }; + let f2 = ReplaceFilter { + from: "world", + to: "DONE", + confidence: FilterConfidence::Partial, + }; + + let mut pipeline = FilterPipeline::new(); + pipeline.push(&f1); + pipeline.push(&f2); + + let result = pipeline.run("test", "say hello there", 0); + // f1: "hello" -> "world", f2: "world" -> "DONE" + assert_eq!(result.output, "say DONE there"); + assert_eq!(result.confidence, FilterConfidence::Partial); + assert_eq!(result.raw_chars, "say hello there".len()); + assert_eq!(result.filtered_chars, "say DONE there".len()); + } + + #[test] + fn registry_pipeline_with_two_matching_filters() { + let mut reg = OutputFilterRegistry::new(true); + reg.register(Box::new(ReplaceFilter { + from: "aaa", + to: "bbb", + confidence: FilterConfidence::Full, + })); + reg.register(Box::new(ReplaceFilter { + from: "bbb", + to: "ccc", + confidence: FilterConfidence::Fallback, + })); + + let result = reg.apply("test", "aaa", 0).unwrap(); + // Both match "test" via MATCH_ALL. Pipeline: "aaa" -> "bbb" -> "ccc" + assert_eq!(result.output, "ccc"); + assert_eq!(result.confidence, FilterConfidence::Fallback); + } } diff --git a/crates/zeph-tools/src/filter/security.rs b/crates/zeph-tools/src/filter/security.rs new file mode 100644 index 00000000..e61e8348 --- /dev/null +++ b/crates/zeph-tools/src/filter/security.rs @@ -0,0 +1,175 @@ +use std::sync::LazyLock; + +use regex::Regex; + +static SECURITY_PATTERNS: LazyLock> = LazyLock::new(|| { + [ + r"warning:.*unused.*Result", + r"warning:.*must be used", + r"thread '.*' panicked at", + r"warning:.*unsafe", + r"dereference of raw pointer", + r"(?i)authentication failed", + r"(?i)unauthorized", + r"(?i)permission denied", + r"(?i)(401|403)\s+(Unauthorized|Forbidden)", + r"(?i)weak cipher", + r"(?i)deprecated algorithm", + r"(?i)insecure hash", + r"(?i)SQL injection", + r"(?i)unsafe query", + r"RUSTSEC-\d{4}-\d{4}", + r"(?i)security advisory", + r"(?i)vulnerability detected", + ] + .iter() + .map(|s| Regex::new(s).unwrap()) + .collect() +}); + +/// Pre-compile extra security patterns from user config strings. +#[must_use] +pub fn compile_extra_patterns(patterns: &[String]) -> Vec { + patterns + .iter() + .filter_map(|s| match Regex::new(s) { + Ok(re) => Some(re), + Err(e) => { + tracing::warn!(pattern = %s, error = %e, "invalid security extra_pattern, skipping"); + None + } + }) + .collect() +} + +#[must_use] +pub fn extract_security_lines<'a>(text: &'a str, extra: &[Regex]) -> Vec<&'a str> { + text.lines() + .filter(|line| { + SECURITY_PATTERNS.iter().any(|pat| pat.is_match(line)) + || extra.iter().any(|pat| pat.is_match(line)) + }) + .collect() +} + +pub fn append_security_warnings(filtered: &mut String, raw_output: &str, extra: &[Regex]) { + let security_lines = extract_security_lines(raw_output, extra); + if security_lines.is_empty() { + return; + } + filtered.push_str("\n\n--- Security Warnings (preserved) ---\n"); + for line in &security_lines { + filtered.push_str(line); + filtered.push('\n'); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_panic() { + let lines = extract_security_lines("thread 'main' panicked at 'oops'\nnormal line", &[]); + assert_eq!(lines.len(), 1); + assert!(lines[0].contains("panicked")); + } + + #[test] + fn detects_rustsec() { + let lines = extract_security_lines("RUSTSEC-2024-0001 advisory here", &[]); + assert_eq!(lines.len(), 1); + } + + #[test] + fn detects_auth_failure() { + let lines = extract_security_lines("Error: Authentication failed for user admin", &[]); + assert_eq!(lines.len(), 1); + } + + #[test] + fn detects_permission_denied() { + let lines = extract_security_lines("Permission denied (publickey)", &[]); + assert_eq!(lines.len(), 1); + } + + #[test] + fn detects_http_status_codes() { + let lines = extract_security_lines("HTTP 401 Unauthorized", &[]); + assert_eq!(lines.len(), 1); + let lines = extract_security_lines("HTTP 403 Forbidden", &[]); + assert_eq!(lines.len(), 1); + } + + #[test] + fn detects_sql_injection() { + let lines = extract_security_lines("WARNING: potential SQL injection detected", &[]); + assert_eq!(lines.len(), 1); + } + + #[test] + fn detects_unsafe_warnings() { + let lines = extract_security_lines("warning: use of unsafe block in function foo", &[]); + assert_eq!(lines.len(), 1); + } + + #[test] + fn detects_vulnerability() { + let lines = extract_security_lines("vulnerability detected in dep xyz", &[]); + assert_eq!(lines.len(), 1); + } + + #[test] + fn detects_weak_crypto() { + let lines = extract_security_lines( + "weak cipher suite selected\ninsecure hash MD5 used\ndeprecated algorithm RC4", + &[], + ); + assert_eq!(lines.len(), 3); + } + + #[test] + fn no_false_positives() { + let lines = extract_security_lines( + "Compiling zeph v0.9.0\nFinished dev [unoptimized] target(s) in 2.3s", + &[], + ); + assert!(lines.is_empty()); + } + + #[test] + fn extra_patterns_work() { + let extra = compile_extra_patterns(&["TODO: security review".to_owned()]); + let lines = extract_security_lines("TODO: security review needed here", &extra); + assert_eq!(lines.len(), 1); + } + + #[test] + fn compile_extra_warns_on_invalid() { + let extra = compile_extra_patterns(&["valid".to_owned(), "[invalid".to_owned()]); + assert_eq!(extra.len(), 1); + } + + #[test] + fn append_does_nothing_on_clean_output() { + let mut filtered = "clean output".to_owned(); + append_security_warnings(&mut filtered, "no warnings here", &[]); + assert_eq!(filtered, "clean output"); + } + + #[test] + fn append_adds_security_section() { + let mut filtered = "filtered result".to_owned(); + append_security_warnings(&mut filtered, "thread 'main' panicked at 'oops'", &[]); + assert!(filtered.contains("--- Security Warnings (preserved) ---")); + assert!(filtered.contains("panicked")); + } + + #[test] + fn integration_filter_removes_security_restored() { + let raw = "normal output\nthread 'main' panicked at 'assertion failed'\nmore normal"; + let mut filtered = "normal output\nmore normal".to_owned(); + append_security_warnings(&mut filtered, raw, &[]); + assert!(filtered.contains("panicked")); + } +} diff --git a/crates/zeph-tools/src/filter/test_output.rs b/crates/zeph-tools/src/filter/test_output.rs index c7b7b683..a8fce88f 100644 --- a/crates/zeph-tools/src/filter/test_output.rs +++ b/crates/zeph-tools/src/filter/test_output.rs @@ -1,13 +1,13 @@ use std::fmt::Write; +use std::sync::LazyLock; -use super::{FilterResult, OutputFilter, make_result}; +use super::{ + CommandMatcher, FilterConfidence, FilterResult, OutputFilter, TestFilterConfig, make_result, +}; -pub struct TestOutputFilter; - -impl OutputFilter for TestOutputFilter { - fn matches(&self, command: &str) -> bool { +static TEST_MATCHER: LazyLock = LazyLock::new(|| { + CommandMatcher::Custom(Box::new(|command| { let cmd = command.to_lowercase(); - // Split into tokens to match "cargo [+toolchain] test" or "cargo nextest" let tokens: Vec<&str> = cmd.split_whitespace().collect(); if tokens.first() != Some(&"cargo") { return false; @@ -16,6 +16,27 @@ impl OutputFilter for TestOutputFilter { .iter() .skip(1) .any(|t| *t == "test" || *t == "nextest") + })) +}); + +pub struct TestOutputFilter { + config: TestFilterConfig, +} + +impl TestOutputFilter { + #[must_use] + pub fn new(config: TestFilterConfig) -> Self { + Self { config } + } +} + +impl OutputFilter for TestOutputFilter { + fn name(&self) -> &'static str { + "test" + } + + fn matcher(&self) -> &CommandMatcher { + &TEST_MATCHER } fn filter(&self, _command: &str, raw_output: &str, exit_code: i32) -> FilterResult { @@ -96,17 +117,17 @@ impl OutputFilter for TestOutputFilter { } if !has_summary && passed == 0 && failed == 0 { - return make_result(raw_output, raw_output.to_owned()); + return make_result( + raw_output, + raw_output.to_owned(), + FilterConfidence::Fallback, + ); } let mut output = String::new(); if exit_code != 0 && !failure_blocks.is_empty() { - output.push_str("FAILURES:\n\n"); - for block in &failure_blocks { - output.push_str(block); - output.push('\n'); - } + format_failures(&mut output, &failure_blocks, &self.config); } let status = if failed > 0 { "FAILED" } else { "ok" }; @@ -116,7 +137,29 @@ impl OutputFilter for TestOutputFilter { {ignored} ignored; {filtered_out} filtered out" ); - make_result(raw_output, output) + make_result(raw_output, output, FilterConfidence::Full) + } +} + +fn format_failures(output: &mut String, blocks: &[String], config: &TestFilterConfig) { + output.push_str("FAILURES:\n\n"); + let max = config.max_failures; + for block in blocks.iter().take(max) { + let lines: Vec<&str> = block.lines().collect(); + if lines.len() > config.truncate_stack_trace { + for line in &lines[..config.truncate_stack_trace] { + output.push_str(line); + output.push('\n'); + } + let remaining = lines.len() - config.truncate_stack_trace; + let _ = writeln!(output, "... ({remaining} more lines)"); + } else { + output.push_str(block); + } + output.push('\n'); + } + if blocks.len() > max { + let _ = writeln!(output, "... and {} more failure(s)", blocks.len() - max); } } @@ -133,21 +176,25 @@ fn extract_count(s: &str, label: &str) -> Option { mod tests { use super::*; + fn make_filter() -> TestOutputFilter { + TestOutputFilter::new(TestFilterConfig::default()) + } + #[test] fn matches_cargo_test() { - let f = TestOutputFilter; - assert!(f.matches("cargo test")); - assert!(f.matches("cargo test --workspace")); - assert!(f.matches("cargo +nightly test")); - assert!(f.matches("cargo nextest run")); - assert!(!f.matches("cargo build")); - assert!(!f.matches("cargo test-helper")); - assert!(!f.matches("cargo install cargo-nextest")); + let f = make_filter(); + assert!(f.matcher().matches("cargo test")); + assert!(f.matcher().matches("cargo test --workspace")); + assert!(f.matcher().matches("cargo +nightly test")); + assert!(f.matcher().matches("cargo nextest run")); + assert!(!f.matcher().matches("cargo build")); + assert!(!f.matcher().matches("cargo test-helper")); + assert!(!f.matcher().matches("cargo install cargo-nextest")); } #[test] fn filter_success_compresses() { - let f = TestOutputFilter; + let f = make_filter(); let raw = "\ running 3 tests test foo::test_a ... ok @@ -161,11 +208,12 @@ test result: ok. 3 passed; 0 failed; 0 ignored; 0 filtered out; finished in 0.01 assert!(result.output.contains("0 failed")); assert!(!result.output.contains("test_a")); assert!(result.savings_pct() > 30.0); + assert_eq!(result.confidence, FilterConfidence::Full); } #[test] fn filter_failure_preserves_details() { - let f = TestOutputFilter; + let f = make_filter(); let raw = "\ running 2 tests test foo::test_a ... ok @@ -188,9 +236,10 @@ test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 filtered out; finished in #[test] fn filter_no_summary_passthrough() { - let f = TestOutputFilter; + let f = make_filter(); let raw = "some random output with no test results"; let result = f.filter("cargo test", raw, 0); assert_eq!(result.output, raw); + assert_eq!(result.confidence, FilterConfidence::Fallback); } } diff --git a/crates/zeph-tools/src/lib.rs b/crates/zeph-tools/src/lib.rs index 86968ebc..915bcb97 100644 --- a/crates/zeph-tools/src/lib.rs +++ b/crates/zeph-tools/src/lib.rs @@ -23,7 +23,10 @@ pub use executor::{ ToolOutput, truncate_tool_output, }; pub use file::FileExecutor; -pub use filter::{FilterConfig, FilterResult, OutputFilter, OutputFilterRegistry, sanitize_output}; +pub use filter::{ + CommandMatcher, FilterConfidence, FilterConfig, FilterMetrics, FilterResult, OutputFilter, + OutputFilterRegistry, sanitize_output, +}; pub use overflow::{cleanup_overflow_files, save_overflow}; pub use permissions::{ AutonomyLevel, PermissionAction, PermissionPolicy, PermissionRule, PermissionsConfig, diff --git a/src/main.rs b/src/main.rs index 7b6dee67..1829d8e3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -167,7 +167,7 @@ async fn main() -> anyhow::Result<()> { #[allow(unused_variables)] let (tool_executor, mcp_tools, mcp_manager, shell_executor_for_tui) = { let filter_registry = if config.tools.filters.enabled { - zeph_tools::OutputFilterRegistry::default_filters() + zeph_tools::OutputFilterRegistry::default_filters(&config.tools.filters) } else { zeph_tools::OutputFilterRegistry::new(false) };