Skip to main content

rustrails_support/
backtrace_cleaner.rs

1use regex::Regex;
2
3/// Errors returned while configuring a backtrace cleaner.
4#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
5pub enum BacktraceCleanerError {
6    /// A supplied regex pattern was invalid.
7    #[error("invalid regex: {0}")]
8    InvalidRegex(String),
9}
10
11/// Filters and silences lines from a textual backtrace.
12#[derive(Debug, Clone, Default)]
13pub struct BacktraceCleaner {
14    filters: Vec<Regex>,
15    silencers: Vec<Regex>,
16}
17
18impl BacktraceCleaner {
19    /// Creates an empty backtrace cleaner.
20    #[must_use]
21    pub fn new() -> Self {
22        Self::default()
23    }
24
25    /// Adds a whitelist regex. When at least one filter exists, only matching lines are kept.
26    pub fn add_filter(&mut self, pattern: &str) -> Result<(), BacktraceCleanerError> {
27        let regex = Regex::new(pattern)
28            .map_err(|error| BacktraceCleanerError::InvalidRegex(error.to_string()))?;
29        self.filters.push(regex);
30        Ok(())
31    }
32
33    /// Adds a silencer regex. Matching lines are removed from the final output.
34    pub fn add_silencer(&mut self, pattern: &str) -> Result<(), BacktraceCleanerError> {
35        let regex = Regex::new(pattern)
36            .map_err(|error| BacktraceCleanerError::InvalidRegex(error.to_string()))?;
37        self.silencers.push(regex);
38        Ok(())
39    }
40
41    /// Cleans a textual backtrace and returns the remaining lines.
42    #[must_use]
43    pub fn clean(&self, backtrace: &str) -> Vec<String> {
44        if backtrace.trim().is_empty() {
45            return Vec::new();
46        }
47
48        backtrace
49            .lines()
50            .filter(|line| !line.trim().is_empty())
51            .filter(|line| {
52                self.filters.is_empty() || self.filters.iter().any(|regex| regex.is_match(line))
53            })
54            .filter(|line| !self.silencers.iter().any(|regex| regex.is_match(line)))
55            .map(ToOwned::to_owned)
56            .collect()
57    }
58}
59
60#[cfg(test)]
61mod tests {
62    use super::{BacktraceCleaner, BacktraceCleanerError};
63
64    const BACKTRACE: &str =
65        "app/models/user.rs:12\nlib/vendor/gem.rb:4\napp/controllers/home.rs:7\n";
66
67    #[test]
68    fn clean_returns_empty_for_empty_backtrace() {
69        let cleaner = BacktraceCleaner::new();
70        assert!(cleaner.clean("").is_empty());
71    }
72
73    #[test]
74    fn clean_returns_all_lines_when_no_rules_are_configured() {
75        let cleaner = BacktraceCleaner::new();
76        assert_eq!(
77            cleaner.clean(BACKTRACE),
78            vec![
79                String::from("app/models/user.rs:12"),
80                String::from("lib/vendor/gem.rb:4"),
81                String::from("app/controllers/home.rs:7"),
82            ],
83        );
84    }
85
86    #[test]
87    fn add_filter_keeps_only_matching_lines() {
88        let mut cleaner = BacktraceCleaner::new();
89        cleaner.add_filter("^app/").expect("filter should compile");
90
91        assert_eq!(
92            cleaner.clean(BACKTRACE),
93            vec![
94                String::from("app/models/user.rs:12"),
95                String::from("app/controllers/home.rs:7"),
96            ],
97        );
98    }
99
100    #[test]
101    fn add_silencer_removes_matching_lines() {
102        let mut cleaner = BacktraceCleaner::new();
103        cleaner
104            .add_silencer("vendor")
105            .expect("silencer should compile");
106
107        assert_eq!(
108            cleaner.clean(BACKTRACE),
109            vec![
110                String::from("app/models/user.rs:12"),
111                String::from("app/controllers/home.rs:7"),
112            ],
113        );
114    }
115
116    #[test]
117    fn silencers_run_after_filters() {
118        let mut cleaner = BacktraceCleaner::new();
119        cleaner.add_filter("^app/").expect("filter should compile");
120        cleaner
121            .add_silencer("controllers")
122            .expect("silencer should compile");
123
124        assert_eq!(
125            cleaner.clean(BACKTRACE),
126            vec![String::from("app/models/user.rs:12")]
127        );
128    }
129
130    #[test]
131    fn invalid_filter_regex_returns_a_typed_error() {
132        let mut cleaner = BacktraceCleaner::new();
133        let error = cleaner
134            .add_filter("[")
135            .expect_err("invalid regex should fail");
136
137        assert!(matches!(error, BacktraceCleanerError::InvalidRegex(_)));
138    }
139
140    #[test]
141    fn invalid_silencer_regex_returns_a_typed_error() {
142        let mut cleaner = BacktraceCleaner::new();
143        let error = cleaner
144            .add_silencer("[")
145            .expect_err("invalid regex should fail");
146
147        assert!(matches!(error, BacktraceCleanerError::InvalidRegex(_)));
148    }
149
150    #[test]
151    fn clean_preserves_original_line_order() {
152        let mut cleaner = BacktraceCleaner::new();
153        cleaner.add_filter(".+").expect("filter should compile");
154
155        assert_eq!(cleaner.clean(BACKTRACE)[0], "app/models/user.rs:12");
156        assert_eq!(cleaner.clean(BACKTRACE)[1], "lib/vendor/gem.rb:4");
157    }
158
159    #[test]
160    fn blank_lines_are_discarded() {
161        let cleaner = BacktraceCleaner::new();
162        let cleaned = cleaner.clean("first\n\nsecond\n");
163
164        assert_eq!(cleaned, vec![String::from("first"), String::from("second")]);
165    }
166}