rustrails-support 0.1.1

Core utilities (ActiveSupport equivalent)
Documentation
use regex::Regex;

/// Errors returned while configuring a backtrace cleaner.
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
pub enum BacktraceCleanerError {
    /// A supplied regex pattern was invalid.
    #[error("invalid regex: {0}")]
    InvalidRegex(String),
}

/// Filters and silences lines from a textual backtrace.
#[derive(Debug, Clone, Default)]
pub struct BacktraceCleaner {
    filters: Vec<Regex>,
    silencers: Vec<Regex>,
}

impl BacktraceCleaner {
    /// Creates an empty backtrace cleaner.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Adds a whitelist regex. When at least one filter exists, only matching lines are kept.
    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(())
    }

    /// Adds a silencer regex. Matching lines are removed from the final output.
    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(())
    }

    /// Cleans a textual backtrace and returns the remaining lines.
    #[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")]);
    }
}