use std::fmt;
type RedactionRule = Box<dyn Fn(&str) -> String + Send + Sync>;
#[derive(Default)]
pub struct Redactions {
rules: Vec<RedactionRule>,
}
impl fmt::Debug for Redactions {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Redactions")
.field("rules", &self.rules.len())
.finish()
}
}
impl Redactions {
#[must_use]
pub fn new() -> Self {
Self { rules: Vec::new() }
}
#[must_use]
pub fn replace(mut self, needle: impl Into<String>, placeholder: impl Into<String>) -> Self {
let needle = needle.into();
let placeholder = placeholder.into();
self.rules.push(Box::new(move |input| {
if needle.is_empty() {
input.to_string()
} else {
input.replace(needle.as_str(), placeholder.as_str())
}
}));
self
}
#[must_use]
pub fn redact_uuids(mut self) -> Self {
self.rules
.push(Box::new(|input| scan_replace(input, "[uuid]", uuid_at)));
self
}
#[must_use]
pub fn redact_rfc3339_timestamps(mut self) -> Self {
self.rules.push(Box::new(|input| {
scan_replace(input, "[timestamp]", rfc3339_at)
}));
self
}
#[must_use]
pub fn redact_with(mut self, rule: impl Fn(&str) -> String + Send + Sync + 'static) -> Self {
self.rules.push(Box::new(rule));
self
}
#[must_use]
pub fn apply(&self, input: &str) -> String {
let mut text = input.to_string();
for rule in &self.rules {
text = rule(&text);
}
text
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.rules.is_empty()
}
}
fn scan_replace(input: &str, placeholder: &str, matcher: impl Fn(&str) -> Option<usize>) -> String {
let mut out = String::with_capacity(input.len());
let mut rest = input;
while !rest.is_empty() {
if let Some(len) = matcher(rest) {
out.push_str(placeholder);
rest = &rest[len..];
} else {
match rest.chars().next() {
Some(ch) => {
out.push(ch);
rest = &rest[ch.len_utf8()..];
}
None => break,
}
}
}
out
}
fn uuid_at(s: &str) -> Option<usize> {
const GROUPS: [usize; 5] = [8, 4, 4, 4, 12];
let bytes = s.as_bytes();
let mut pos = 0usize;
for (index, &len) in GROUPS.iter().enumerate() {
for _ in 0..len {
if !bytes.get(pos)?.is_ascii_hexdigit() {
return None;
}
pos += 1;
}
if index < GROUPS.len() - 1 {
if bytes.get(pos) != Some(&b'-') {
return None;
}
pos += 1;
}
}
if bytes.get(pos).is_some_and(u8::is_ascii_hexdigit) {
return None;
}
Some(pos)
}
fn rfc3339_at(s: &str) -> Option<usize> {
let bytes = s.as_bytes();
let mut pos = 0usize;
take_digits(bytes, &mut pos, 4)?;
take_byte(bytes, &mut pos, b'-')?;
take_digits(bytes, &mut pos, 2)?;
take_byte(bytes, &mut pos, b'-')?;
take_digits(bytes, &mut pos, 2)?;
match bytes.get(pos) {
Some(b'T' | b't' | b' ') => pos += 1,
_ => return None,
}
take_digits(bytes, &mut pos, 2)?;
take_byte(bytes, &mut pos, b':')?;
take_digits(bytes, &mut pos, 2)?;
take_byte(bytes, &mut pos, b':')?;
take_digits(bytes, &mut pos, 2)?;
if bytes.get(pos) == Some(&b'.') {
pos += 1;
let frac_start = pos;
while bytes.get(pos).is_some_and(u8::is_ascii_digit) {
pos += 1;
}
if pos == frac_start {
return None;
}
}
match bytes.get(pos) {
Some(b'Z' | b'z') => pos += 1,
Some(b'+' | b'-') => {
pos += 1;
take_digits(bytes, &mut pos, 2)?;
take_byte(bytes, &mut pos, b':')?;
take_digits(bytes, &mut pos, 2)?;
}
_ => return None,
}
Some(pos)
}
fn take_digits(bytes: &[u8], pos: &mut usize, count: usize) -> Option<()> {
for offset in 0..count {
if !bytes.get(*pos + offset)?.is_ascii_digit() {
return None;
}
}
*pos += count;
Some(())
}
fn take_byte(bytes: &[u8], pos: &mut usize, expected: u8) -> Option<()> {
if bytes.get(*pos) == Some(&expected) {
*pos += 1;
Some(())
} else {
None
}
}
#[cfg(test)]
mod tests {
use test_better_core::TestResult;
use test_better_matchers::{eq, check, is_true};
use super::*;
#[test]
fn an_empty_set_returns_its_input_unchanged() -> TestResult {
let redactions = Redactions::new();
check!(redactions.is_empty()).satisfies(is_true())?;
check!(redactions.apply("untouched")).satisfies(eq("untouched".to_string()))
}
#[test]
fn redact_uuids_replaces_every_canonical_uuid() -> TestResult {
let redactions = Redactions::new().redact_uuids();
let input = "from 550E8400-E29B-41D4-A716-446655440000 to \
00000000-0000-0000-0000-000000000000";
check!(redactions.apply(input)).satisfies(eq("from [uuid] to [uuid]".to_string()))
}
#[test]
fn redact_uuids_leaves_a_near_miss_alone() -> TestResult {
let redactions = Redactions::new().redact_uuids();
let input = "550e8400-e29b-41d4-a716-44665544000 and zzze8400-e29b";
check!(redactions.apply(input)).satisfies(eq(input.to_string()))
}
#[test]
fn redact_rfc3339_timestamps_handles_z_and_offset_and_fractions() -> TestResult {
let redactions = Redactions::new().redact_rfc3339_timestamps();
let input = "at 2026-05-14T12:34:56Z and 2026-01-02T03:04:05.678-05:00 done";
check!(redactions.apply(input)).satisfies(eq("at [timestamp] and [timestamp] done".to_string()))
}
#[test]
fn rules_run_in_order_and_compose() -> TestResult {
let redactions = Redactions::new()
.redact_uuids()
.replace("[uuid]", "<id>")
.redact_with(|text| text.to_uppercase());
check!(redactions.apply("id 550e8400-e29b-41d4-a716-446655440000"))
.satisfies(eq("ID <ID>".to_string()))
}
#[test]
fn replace_ignores_an_empty_needle() -> TestResult {
let redactions = Redactions::new().replace("", "X");
check!(redactions.apply("abc")).satisfies(eq("abc".to_string()))
}
#[test]
fn a_uuid_glued_to_more_hex_is_not_redacted() -> TestResult {
let redactions = Redactions::new().redact_uuids();
let input = "550e8400-e29b-41d4-a716-446655440000f";
check!(redactions.apply(input)).satisfies(eq(input.to_string()))?;
check!(format!("{redactions:?}").contains("rules: 1")).satisfies(is_true())
}
}