Skip to main content

assert_snap/
redaction.rs

1//! Redaction module for masking dynamic or sensitive text using regular expressions.
2//!
3//! This module defines [`RedactionRule`], which encapsulates regex pattern matching, replacement rules,
4//! and match limits, as well as internal helpers for applying transformations sequentially.
5
6use std::borrow::Cow;
7
8use regex::Regex;
9
10/// A rule specifying how to redact data matching a specific regular expression pattern.
11///
12/// Rules consist of a regex pattern, a replacement string (supporting regex capture groups such as `$1`),
13/// and a maximum replacement limit.
14///
15/// # Examples
16///
17/// ```rust
18/// use assert_snap::redaction::RedactionRule;
19/// let rule = RedactionRule::new(r"token_\w+", "[REDACTED]", 1);
20/// ```
21pub struct RedactionRule<'a> {
22    /// The regular expression pattern to match.
23    pub pattern: &'a str,
24    /// The maximum number of replacements to make. If `0`, all occurrences are replaced.
25    pub limit: usize,
26    /// The replacement template (supports regex capture groups like `$1`).
27    pub replacement: &'a str,
28}
29
30impl<'a> RedactionRule<'a> {
31    /// Creates a new redaction rule.
32    ///
33    /// # Arguments
34    ///
35    /// * `pattern` - The regular expression pattern to match against the input data.
36    /// * `replacement` - The replacement template. Supports capture group references like `$1`, `$2`, etc.
37    /// * `limit` - The maximum number of replacements to make. If `0`, all occurrences are replaced.
38    ///
39    /// # Returns
40    ///
41    /// A new [`RedactionRule`] instance.
42    pub fn new(pattern: &'a str, replacement: &'a str, limit: usize) -> Self {
43        Self {
44            pattern,
45            limit,
46            replacement,
47        }
48    }
49}
50
51/// Applies a series of redaction rules to the input string sequentially.
52///
53/// Each rule defines a regular expression pattern, a replacement template (which supports
54/// capture groups like `$1`), and a limit on the number of matches to replace. If the limit
55/// is `0`, all occurrences matching the pattern are replaced.
56///
57/// # Panics
58///
59/// Panics if any of the regular expression patterns in the rules are invalid regex.
60#[track_caller]
61pub(crate) fn apply_redactions<'a>(
62    data: &'a str,
63    redaction_rules: &[RedactionRule],
64) -> Cow<'a, str> {
65    let mut result = Cow::from(data);
66    for RedactionRule {
67        pattern,
68        limit,
69        replacement,
70    } in redaction_rules
71    {
72        let regex = Regex::new(pattern).expect("regex parse error in redaction rule");
73        let redacted = regex.replacen(&result, *limit, *replacement);
74        if let Cow::Owned(modified) = redacted {
75            result = Cow::from(modified);
76        }
77    }
78    result
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    #[test]
86    fn test_apply_redactions_empty_rules() {
87        let data = "hello world";
88        let rules = [];
89        let result = apply_redactions(data, &rules);
90        assert_eq!(result, "hello world");
91        assert!(matches!(result, Cow::Borrowed(_)));
92    }
93
94    #[test]
95    fn test_apply_redactions_no_match() {
96        let data = "hello world";
97        let rules = [RedactionRule {
98            pattern: "foo",
99            limit: 0,
100            replacement: "bar",
101        }];
102        let result = apply_redactions(data, &rules);
103        assert_eq!(result, "hello world");
104        assert!(matches!(result, Cow::Borrowed(_)));
105    }
106
107    #[test]
108    fn test_apply_redactions_single_replacement() {
109        let data = "hello world hello";
110        let rules = [RedactionRule {
111            pattern: "hello",
112            limit: 1,
113            replacement: "hi",
114        }];
115        let result = apply_redactions(data, &rules);
116        assert_eq!(result, "hi world hello");
117        assert!(matches!(result, Cow::Owned(_)));
118    }
119
120    #[test]
121    fn test_apply_redactions_limit_zero_replaces_all() {
122        let data = "hello world hello";
123        let rules = [RedactionRule {
124            pattern: "hello",
125            limit: 0,
126            replacement: "hi",
127        }];
128        let result = apply_redactions(data, &rules);
129        assert_eq!(result, "hi world hi");
130    }
131
132    #[test]
133    fn test_apply_redactions_multiple_rules() {
134        let data = "hello world";
135        let rules = [
136            RedactionRule {
137                pattern: "hello",
138                limit: 1,
139                replacement: "hi",
140            },
141            RedactionRule {
142                pattern: "world",
143                limit: 1,
144                replacement: "earth",
145            },
146        ];
147        let result = apply_redactions(data, &rules);
148        assert_eq!(result, "hi earth");
149    }
150
151    #[test]
152    fn test_apply_redactions_capture_groups() {
153        let data = "hello world";
154        let rules = [RedactionRule {
155            pattern: r"hello (\w+)",
156            limit: 0,
157            replacement: "hi $1",
158        }];
159        let result = apply_redactions(data, &rules);
160        assert_eq!(result, "hi world");
161    }
162
163    #[test]
164    #[should_panic(expected = "regex parse error")]
165    fn test_apply_redactions_invalid_regex() {
166        let data = "hello world";
167        let rules = [RedactionRule {
168            pattern: "(invalid",
169            limit: 0,
170            replacement: "hi",
171        }];
172        let _result = apply_redactions(data, &rules);
173    }
174}