use regex::Regex;
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
pub enum BacktraceCleanerError {
#[error("invalid regex: {0}")]
InvalidRegex(String),
}
#[derive(Debug, Clone, Default)]
pub struct BacktraceCleaner {
filters: Vec<Regex>,
silencers: Vec<Regex>,
}
impl BacktraceCleaner {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn add_filter(&mut self, pattern: &str) -> Result<(), BacktraceCleanerError> {
let regex = Regex::new(pattern)
.map_err(|error| BacktraceCleanerError::InvalidRegex(error.to_string()))?;
self.filters.push(regex);
Ok(())
}
pub fn add_silencer(&mut self, pattern: &str) -> Result<(), BacktraceCleanerError> {
let regex = Regex::new(pattern)
.map_err(|error| BacktraceCleanerError::InvalidRegex(error.to_string()))?;
self.silencers.push(regex);
Ok(())
}
#[must_use]
pub fn clean(&self, backtrace: &str) -> Vec<String> {
if backtrace.trim().is_empty() {
return Vec::new();
}
backtrace
.lines()
.filter(|line| !line.trim().is_empty())
.filter(|line| {
self.filters.is_empty() || self.filters.iter().any(|regex| regex.is_match(line))
})
.filter(|line| !self.silencers.iter().any(|regex| regex.is_match(line)))
.map(ToOwned::to_owned)
.collect()
}
}
#[cfg(test)]
mod tests {
use super::{BacktraceCleaner, BacktraceCleanerError};
const BACKTRACE: &str =
"app/models/user.rs:12\nlib/vendor/gem.rb:4\napp/controllers/home.rs:7\n";
#[test]
fn clean_returns_empty_for_empty_backtrace() {
let cleaner = BacktraceCleaner::new();
assert!(cleaner.clean("").is_empty());
}
#[test]
fn clean_returns_all_lines_when_no_rules_are_configured() {
let cleaner = BacktraceCleaner::new();
assert_eq!(
cleaner.clean(BACKTRACE),
vec![
String::from("app/models/user.rs:12"),
String::from("lib/vendor/gem.rb:4"),
String::from("app/controllers/home.rs:7"),
],
);
}
#[test]
fn add_filter_keeps_only_matching_lines() {
let mut cleaner = BacktraceCleaner::new();
cleaner.add_filter("^app/").expect("filter should compile");
assert_eq!(
cleaner.clean(BACKTRACE),
vec![
String::from("app/models/user.rs:12"),
String::from("app/controllers/home.rs:7"),
],
);
}
#[test]
fn add_silencer_removes_matching_lines() {
let mut cleaner = BacktraceCleaner::new();
cleaner
.add_silencer("vendor")
.expect("silencer should compile");
assert_eq!(
cleaner.clean(BACKTRACE),
vec![
String::from("app/models/user.rs:12"),
String::from("app/controllers/home.rs:7"),
],
);
}
#[test]
fn silencers_run_after_filters() {
let mut cleaner = BacktraceCleaner::new();
cleaner.add_filter("^app/").expect("filter should compile");
cleaner
.add_silencer("controllers")
.expect("silencer should compile");
assert_eq!(
cleaner.clean(BACKTRACE),
vec![String::from("app/models/user.rs:12")]
);
}
#[test]
fn invalid_filter_regex_returns_a_typed_error() {
let mut cleaner = BacktraceCleaner::new();
let error = cleaner
.add_filter("[")
.expect_err("invalid regex should fail");
assert!(matches!(error, BacktraceCleanerError::InvalidRegex(_)));
}
#[test]
fn invalid_silencer_regex_returns_a_typed_error() {
let mut cleaner = BacktraceCleaner::new();
let error = cleaner
.add_silencer("[")
.expect_err("invalid regex should fail");
assert!(matches!(error, BacktraceCleanerError::InvalidRegex(_)));
}
#[test]
fn clean_preserves_original_line_order() {
let mut cleaner = BacktraceCleaner::new();
cleaner.add_filter(".+").expect("filter should compile");
assert_eq!(cleaner.clean(BACKTRACE)[0], "app/models/user.rs:12");
assert_eq!(cleaner.clean(BACKTRACE)[1], "lib/vendor/gem.rb:4");
}
#[test]
fn blank_lines_are_discarded() {
let cleaner = BacktraceCleaner::new();
let cleaned = cleaner.clean("first\n\nsecond\n");
assert_eq!(cleaned, vec![String::from("first"), String::from("second")]);
}
}