test_better_snapshot/
redact.rs1use std::fmt;
24
25type RedactionRule = Box<dyn Fn(&str) -> String + Send + Sync>;
29
30#[derive(Default)]
52pub struct Redactions {
53 rules: Vec<RedactionRule>,
55}
56
57impl fmt::Debug for Redactions {
58 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59 f.debug_struct("Redactions")
62 .field("rules", &self.rules.len())
63 .finish()
64 }
65}
66
67impl Redactions {
68 #[must_use]
71 pub fn new() -> Self {
72 Self { rules: Vec::new() }
73 }
74
75 #[must_use]
81 pub fn replace(mut self, needle: impl Into<String>, placeholder: impl Into<String>) -> Self {
82 let needle = needle.into();
83 let placeholder = placeholder.into();
84 self.rules.push(Box::new(move |input| {
85 if needle.is_empty() {
86 input.to_string()
87 } else {
88 input.replace(needle.as_str(), placeholder.as_str())
89 }
90 }));
91 self
92 }
93
94 #[must_use]
97 pub fn redact_uuids(mut self) -> Self {
98 self.rules
99 .push(Box::new(|input| scan_replace(input, "[uuid]", uuid_at)));
100 self
101 }
102
103 #[must_use]
107 pub fn redact_rfc3339_timestamps(mut self) -> Self {
108 self.rules.push(Box::new(|input| {
109 scan_replace(input, "[timestamp]", rfc3339_at)
110 }));
111 self
112 }
113
114 #[must_use]
119 pub fn redact_with(mut self, rule: impl Fn(&str) -> String + Send + Sync + 'static) -> Self {
120 self.rules.push(Box::new(rule));
121 self
122 }
123
124 #[must_use]
127 pub fn apply(&self, input: &str) -> String {
128 let mut text = input.to_string();
129 for rule in &self.rules {
130 text = rule(&text);
131 }
132 text
133 }
134
135 #[must_use]
138 pub fn is_empty(&self) -> bool {
139 self.rules.is_empty()
140 }
141}
142
143fn scan_replace(input: &str, placeholder: &str, matcher: impl Fn(&str) -> Option<usize>) -> String {
150 let mut out = String::with_capacity(input.len());
151 let mut rest = input;
152 while !rest.is_empty() {
153 if let Some(len) = matcher(rest) {
154 out.push_str(placeholder);
155 rest = &rest[len..];
156 } else {
157 match rest.chars().next() {
158 Some(ch) => {
159 out.push(ch);
160 rest = &rest[ch.len_utf8()..];
161 }
162 None => break,
165 }
166 }
167 }
168 out
169}
170
171fn uuid_at(s: &str) -> Option<usize> {
176 const GROUPS: [usize; 5] = [8, 4, 4, 4, 12];
177 let bytes = s.as_bytes();
178 let mut pos = 0usize;
179 for (index, &len) in GROUPS.iter().enumerate() {
180 for _ in 0..len {
181 if !bytes.get(pos)?.is_ascii_hexdigit() {
182 return None;
183 }
184 pos += 1;
185 }
186 if index < GROUPS.len() - 1 {
187 if bytes.get(pos) != Some(&b'-') {
188 return None;
189 }
190 pos += 1;
191 }
192 }
193 if bytes.get(pos).is_some_and(u8::is_ascii_hexdigit) {
195 return None;
196 }
197 Some(pos)
198}
199
200fn rfc3339_at(s: &str) -> Option<usize> {
202 let bytes = s.as_bytes();
203 let mut pos = 0usize;
204
205 take_digits(bytes, &mut pos, 4)?;
207 take_byte(bytes, &mut pos, b'-')?;
208 take_digits(bytes, &mut pos, 2)?;
209 take_byte(bytes, &mut pos, b'-')?;
210 take_digits(bytes, &mut pos, 2)?;
211
212 match bytes.get(pos) {
214 Some(b'T' | b't' | b' ') => pos += 1,
215 _ => return None,
216 }
217
218 take_digits(bytes, &mut pos, 2)?;
220 take_byte(bytes, &mut pos, b':')?;
221 take_digits(bytes, &mut pos, 2)?;
222 take_byte(bytes, &mut pos, b':')?;
223 take_digits(bytes, &mut pos, 2)?;
224
225 if bytes.get(pos) == Some(&b'.') {
227 pos += 1;
228 let frac_start = pos;
229 while bytes.get(pos).is_some_and(u8::is_ascii_digit) {
230 pos += 1;
231 }
232 if pos == frac_start {
233 return None;
234 }
235 }
236
237 match bytes.get(pos) {
239 Some(b'Z' | b'z') => pos += 1,
240 Some(b'+' | b'-') => {
241 pos += 1;
242 take_digits(bytes, &mut pos, 2)?;
243 take_byte(bytes, &mut pos, b':')?;
244 take_digits(bytes, &mut pos, 2)?;
245 }
246 _ => return None,
247 }
248
249 Some(pos)
250}
251
252fn take_digits(bytes: &[u8], pos: &mut usize, count: usize) -> Option<()> {
255 for offset in 0..count {
256 if !bytes.get(*pos + offset)?.is_ascii_digit() {
257 return None;
258 }
259 }
260 *pos += count;
261 Some(())
262}
263
264fn take_byte(bytes: &[u8], pos: &mut usize, expected: u8) -> Option<()> {
267 if bytes.get(*pos) == Some(&expected) {
268 *pos += 1;
269 Some(())
270 } else {
271 None
272 }
273}
274
275#[cfg(test)]
276mod tests {
277 use test_better_core::TestResult;
278 use test_better_matchers::{check, eq, is_true};
279
280 use super::*;
281
282 #[test]
283 fn an_empty_set_returns_its_input_unchanged() -> TestResult {
284 let redactions = Redactions::new();
285 check!(redactions.is_empty()).satisfies(is_true())?;
286 check!(redactions.apply("untouched")).satisfies(eq("untouched".to_string()))
287 }
288
289 #[test]
290 fn redact_uuids_replaces_every_canonical_uuid() -> TestResult {
291 let redactions = Redactions::new().redact_uuids();
292 let input = "from 550E8400-E29B-41D4-A716-446655440000 to \
293 00000000-0000-0000-0000-000000000000";
294 check!(redactions.apply(input)).satisfies(eq("from [uuid] to [uuid]".to_string()))
295 }
296
297 #[test]
298 fn redact_uuids_leaves_a_near_miss_alone() -> TestResult {
299 let redactions = Redactions::new().redact_uuids();
300 let input = "550e8400-e29b-41d4-a716-44665544000 and zzze8400-e29b";
303 check!(redactions.apply(input)).satisfies(eq(input.to_string()))
304 }
305
306 #[test]
307 fn redact_rfc3339_timestamps_handles_z_and_offset_and_fractions() -> TestResult {
308 let redactions = Redactions::new().redact_rfc3339_timestamps();
309 let input = "at 2026-05-14T12:34:56Z and 2026-01-02T03:04:05.678-05:00 done";
310 check!(redactions.apply(input))
311 .satisfies(eq("at [timestamp] and [timestamp] done".to_string()))
312 }
313
314 #[test]
315 fn rules_run_in_order_and_compose() -> TestResult {
316 let redactions = Redactions::new()
317 .redact_uuids()
318 .replace("[uuid]", "<id>")
319 .redact_with(|text| text.to_uppercase());
320 check!(redactions.apply("id 550e8400-e29b-41d4-a716-446655440000"))
321 .satisfies(eq("ID <ID>".to_string()))
322 }
323
324 #[test]
325 fn replace_ignores_an_empty_needle() -> TestResult {
326 let redactions = Redactions::new().replace("", "X");
327 check!(redactions.apply("abc")).satisfies(eq("abc".to_string()))
328 }
329
330 #[test]
331 fn a_uuid_glued_to_more_hex_is_not_redacted() -> TestResult {
332 let redactions = Redactions::new().redact_uuids();
335 let input = "550e8400-e29b-41d4-a716-446655440000f";
336 check!(redactions.apply(input)).satisfies(eq(input.to_string()))?;
337 check!(format!("{redactions:?}").contains("rules: 1")).satisfies(is_true())
339 }
340}