use std::borrow::Cow;
pub const REDACTED_PLACEHOLDER: &str = "[REDACTED]";
pub const MASK_CHAR: char = '*';
#[derive(Clone, Copy, Debug)]
pub struct KeepConfig {
visible_prefix: usize,
visible_suffix: usize,
mask_char: char,
}
impl KeepConfig {
#[must_use]
pub fn first(visible_prefix: usize) -> Self {
Self {
visible_prefix,
visible_suffix: 0,
mask_char: MASK_CHAR,
}
}
#[must_use]
pub fn last(visible_suffix: usize) -> Self {
Self {
visible_prefix: 0,
visible_suffix,
mask_char: MASK_CHAR,
}
}
#[must_use]
pub fn both(visible_prefix: usize, visible_suffix: usize) -> Self {
Self {
visible_prefix,
visible_suffix,
mask_char: MASK_CHAR,
}
}
#[must_use]
pub fn with_mask_char(mut self, mask_char: char) -> Self {
self.mask_char = mask_char;
self
}
pub(crate) fn set_mask_char(&mut self, mask_char: char) {
self.mask_char = mask_char;
}
pub(crate) fn apply_to(&self, value: &str) -> String {
let mut chars: Vec<char> = value.chars().collect();
let total = chars.len();
if total == 0 {
return REDACTED_PLACEHOLDER.to_string();
}
if self.visible_prefix.saturating_add(self.visible_suffix) >= total {
chars.fill(self.mask_char);
return chars.into_iter().collect();
}
for ch in &mut chars[self.visible_prefix..(total - self.visible_suffix)] {
*ch = self.mask_char;
}
chars.into_iter().collect()
}
}
#[derive(Clone, Copy, Debug)]
#[allow(clippy::struct_field_names)] pub struct MaskConfig {
mask_prefix: usize,
mask_suffix: usize,
mask_char: char,
}
impl MaskConfig {
#[must_use]
pub fn first(mask_prefix: usize) -> Self {
Self {
mask_prefix,
mask_suffix: 0,
mask_char: MASK_CHAR,
}
}
#[must_use]
pub fn last(mask_suffix: usize) -> Self {
Self {
mask_prefix: 0,
mask_suffix,
mask_char: MASK_CHAR,
}
}
#[must_use]
pub fn both(mask_prefix: usize, mask_suffix: usize) -> Self {
Self {
mask_prefix,
mask_suffix,
mask_char: MASK_CHAR,
}
}
#[must_use]
pub fn with_mask_char(mut self, mask_char: char) -> Self {
self.mask_char = mask_char;
self
}
pub(crate) fn set_mask_char(&mut self, mask_char: char) {
self.mask_char = mask_char;
}
pub(crate) fn apply_to(&self, value: &str) -> String {
let mut chars: Vec<char> = value.chars().collect();
let total = chars.len();
if total == 0 {
return REDACTED_PLACEHOLDER.to_string();
}
if self.mask_prefix.saturating_add(self.mask_suffix) >= total {
chars.fill(self.mask_char);
return chars.into_iter().collect();
}
for ch in &mut chars[..self.mask_prefix] {
*ch = self.mask_char;
}
if self.mask_suffix > 0 {
let start = total - self.mask_suffix;
for ch in &mut chars[start..] {
*ch = self.mask_char;
}
}
chars.into_iter().collect()
}
}
#[derive(Clone, Copy, Debug)]
pub struct EmailConfig {
visible_prefix: usize,
mask_char: char,
}
impl EmailConfig {
#[must_use]
pub fn new(visible_prefix: usize) -> Self {
Self {
visible_prefix,
mask_char: MASK_CHAR,
}
}
#[must_use]
pub fn with_mask_char(mut self, mask_char: char) -> Self {
self.mask_char = mask_char;
self
}
pub(crate) fn set_mask_char(&mut self, mask_char: char) {
self.mask_char = mask_char;
}
pub(crate) fn apply_to(&self, value: &str) -> String {
let chars: Vec<char> = value.chars().collect();
let total = chars.len();
if total == 0 {
return REDACTED_PLACEHOLDER.to_string();
}
if let Some(at_pos) = value.rfind('@') {
let local = &value[..at_pos];
let domain = &value[at_pos..];
let local_chars: Vec<char> = local.chars().collect();
let local_len = local_chars.len();
if self.visible_prefix >= local_len {
let masked: String = std::iter::repeat_n(self.mask_char, local_len).collect();
return format!("{masked}{domain}");
}
let visible: String = local_chars[..self.visible_prefix].iter().collect();
let masked_count = local_len - self.visible_prefix;
let masked: String = std::iter::repeat_n(self.mask_char, masked_count).collect();
format!("{visible}{masked}{domain}")
} else {
let mut result = chars;
if self.visible_prefix >= total {
result.fill(self.mask_char);
return result.into_iter().collect();
}
for ch in &mut result[self.visible_prefix..] {
*ch = self.mask_char;
}
result.into_iter().collect()
}
}
}
#[derive(Clone, Debug)]
pub enum TextRedactionPolicy {
Full {
placeholder: Cow<'static, str>,
},
Keep(KeepConfig),
Mask(MaskConfig),
Email(EmailConfig),
}
impl TextRedactionPolicy {
#[must_use]
pub fn default_full() -> Self {
Self::Full {
placeholder: Cow::Borrowed(REDACTED_PLACEHOLDER),
}
}
#[must_use]
pub fn full_with<P>(placeholder: P) -> Self
where
P: Into<Cow<'static, str>>,
{
Self::Full {
placeholder: placeholder.into(),
}
}
#[must_use]
pub fn keep_with(config: KeepConfig) -> Self {
Self::Keep(config)
}
#[must_use]
pub fn keep_first(visible_prefix: usize) -> Self {
Self::keep_with(KeepConfig::first(visible_prefix))
}
#[must_use]
pub fn keep_last(visible_suffix: usize) -> Self {
Self::keep_with(KeepConfig::last(visible_suffix))
}
#[must_use]
pub fn mask_with(config: MaskConfig) -> Self {
Self::Mask(config)
}
#[must_use]
pub fn mask_first(mask_prefix: usize) -> Self {
Self::mask_with(MaskConfig::first(mask_prefix))
}
#[must_use]
pub fn mask_last(mask_suffix: usize) -> Self {
Self::mask_with(MaskConfig::last(mask_suffix))
}
#[must_use]
pub fn email_local(visible_prefix: usize) -> Self {
Self::Email(EmailConfig::new(visible_prefix))
}
#[must_use]
pub fn with_mask_char(mut self, mask_char: char) -> Self {
match &mut self {
TextRedactionPolicy::Full { .. } => {}
TextRedactionPolicy::Keep(config) => {
config.set_mask_char(mask_char);
}
TextRedactionPolicy::Mask(config) => {
config.set_mask_char(mask_char);
}
TextRedactionPolicy::Email(config) => {
config.set_mask_char(mask_char);
}
}
self
}
#[must_use]
pub fn apply_to(&self, value: &str) -> String {
match self {
TextRedactionPolicy::Full { placeholder } => placeholder.clone().into_owned(),
TextRedactionPolicy::Keep(config) => config.apply_to(value),
TextRedactionPolicy::Mask(config) => config.apply_to(value),
TextRedactionPolicy::Email(config) => config.apply_to(value),
}
}
}
impl std::default::Default for TextRedactionPolicy {
fn default() -> Self {
Self::default_full()
}
}
#[cfg(test)]
mod tests {
use super::{KeepConfig, MaskConfig, REDACTED_PLACEHOLDER, TextRedactionPolicy};
#[test]
fn keep_policy_masks_short_values() {
let policy = TextRedactionPolicy::keep_with(KeepConfig::first(3));
assert_eq!(policy.apply_to("ab"), "**");
assert_eq!(policy.apply_to("abc"), "***");
let policy = TextRedactionPolicy::keep_last(4);
assert_eq!(policy.apply_to("1234"), "****");
assert_eq!(policy.apply_to("12345"), "*2345");
}
#[test]
fn keep_policy_counts_unicode_scalars_for_multibyte_inputs() {
let cjk = "\u{6771}\u{4eac}\u{5927}\u{962a}";
let policy = TextRedactionPolicy::keep_last(2);
assert_eq!(policy.apply_to(cjk), "**\u{5927}\u{962a}");
assert_eq!(policy.apply_to("\u{6771}\u{4eac}"), "**");
let emoji_zwj = "\u{1f469}\u{200d}\u{1f4bb}";
let policy = TextRedactionPolicy::keep_last(1);
assert_eq!(policy.apply_to(emoji_zwj), "**\u{1f4bb}");
let policy = TextRedactionPolicy::keep_last(3);
assert_eq!(policy.apply_to(emoji_zwj), "***");
let combining = "e\u{301}clair";
let policy = TextRedactionPolicy::keep_first(1);
assert_eq!(policy.apply_to(combining), "e******");
let policy = TextRedactionPolicy::keep_first(2);
assert_eq!(policy.apply_to("e\u{301}"), "**");
}
#[test]
fn keep_policy_respects_mask_char() {
let policy = TextRedactionPolicy::keep_first(2).with_mask_char('#');
assert_eq!(policy.apply_to("abcdef"), "ab####");
}
#[test]
fn full_policy_uses_default_placeholder() {
let policy = TextRedactionPolicy::default_full();
assert_eq!(policy.apply_to("secret"), REDACTED_PLACEHOLDER);
}
#[test]
fn full_policy_uses_custom_placeholder() {
let policy = TextRedactionPolicy::full_with("<redacted>");
assert_eq!(policy.apply_to("secret"), "<redacted>");
}
#[test]
fn mask_policy_masks_first_and_last_segments() {
let policy = TextRedactionPolicy::mask_first(2);
assert_eq!(policy.apply_to("abcdef"), "**cdef");
let policy = TextRedactionPolicy::mask_last(3);
assert_eq!(policy.apply_to("abcdef"), "abc***");
}
#[test]
fn mask_policy_counts_unicode_scalars_for_multibyte_inputs() {
let cjk = "\u{6771}\u{4eac}\u{5927}\u{962a}";
let policy = TextRedactionPolicy::mask_first(2);
assert_eq!(policy.apply_to(cjk), "**\u{5927}\u{962a}");
assert_eq!(policy.apply_to("\u{6771}\u{4eac}"), "**");
let policy = TextRedactionPolicy::mask_last(2);
assert_eq!(policy.apply_to(cjk), "\u{6771}\u{4eac}**");
let emoji_zwj = "\u{1f469}\u{200d}\u{1f4bb}";
let policy = TextRedactionPolicy::mask_last(1);
assert_eq!(policy.apply_to(emoji_zwj), "\u{1f469}\u{200d}*");
let policy = TextRedactionPolicy::mask_with(MaskConfig::both(1, 2));
assert_eq!(policy.apply_to(emoji_zwj), "***");
let combining = "e\u{301}clair";
let policy = TextRedactionPolicy::mask_first(1);
assert_eq!(policy.apply_to(combining), "*\u{301}clair");
let policy = TextRedactionPolicy::mask_with(MaskConfig::both(1, 1));
assert_eq!(policy.apply_to("e\u{301}"), "**");
}
#[test]
fn mask_policy_respects_custom_mask_char() {
let policy = TextRedactionPolicy::mask_with(MaskConfig::last(2)).with_mask_char('#');
assert_eq!(policy.apply_to("abcd"), "ab##");
}
#[test]
fn email_policy_preserves_domain() {
let policy = TextRedactionPolicy::email_local(2);
assert_eq!(policy.apply_to("alice@example.com"), "al***@example.com");
assert_eq!(policy.apply_to("bob@company.io"), "bo*@company.io");
assert_eq!(policy.apply_to("x@a.com"), "*@a.com");
}
#[test]
fn email_policy_counts_unicode_scalars_for_local_parts() {
let policy = TextRedactionPolicy::email_local(2);
assert_eq!(
policy.apply_to("\u{6771}\u{4eac}user@example.com"),
"\u{6771}\u{4eac}****@example.com"
);
assert_eq!(
policy.apply_to("\u{6771}\u{4eac}@example.com"),
"**@example.com"
);
assert_eq!(
policy.apply_to("\u{1f469}\u{200d}\u{1f4bb}dev@example.com"),
"\u{1f469}\u{200d}****@example.com"
);
let policy = TextRedactionPolicy::email_local(3);
assert_eq!(
policy.apply_to("\u{1f469}\u{200d}\u{1f4bb}@example.com"),
"***@example.com"
);
let policy = TextRedactionPolicy::email_local(1);
assert_eq!(
policy.apply_to("e\u{301}clair@example.com"),
"e******@example.com"
);
let policy = TextRedactionPolicy::email_local(2);
assert_eq!(policy.apply_to("e\u{301}@example.com"), "**@example.com");
}
#[test]
fn email_policy_masks_non_email_inputs() {
let policy = TextRedactionPolicy::email_local(2);
assert_eq!(policy.apply_to("noatsymbol"), "no********");
assert_eq!(policy.apply_to(""), REDACTED_PLACEHOLDER);
assert_eq!(policy.apply_to("ab@x.com"), "**@x.com");
assert_eq!(policy.apply_to("a@b.c"), "*@b.c");
}
#[test]
fn email_policy_respects_mask_char() {
let policy = TextRedactionPolicy::email_local(2).with_mask_char('#');
assert_eq!(policy.apply_to("alice@example.com"), "al###@example.com");
}
#[test]
fn email_policy_splits_on_last_at_sign() {
let policy = TextRedactionPolicy::email_local(2);
assert_eq!(policy.apply_to("\"a@b\"@example.com"), "\"a***@example.com");
assert_eq!(policy.apply_to("user@@example.com"), "us***@example.com");
}
#[test]
fn empty_string_returns_placeholder_for_policies() {
let keep_policy = TextRedactionPolicy::keep_first(4);
assert_eq!(keep_policy.apply_to(""), REDACTED_PLACEHOLDER);
let mask_policy = TextRedactionPolicy::mask_first(4);
assert_eq!(mask_policy.apply_to(""), REDACTED_PLACEHOLDER);
let email_policy = TextRedactionPolicy::email_local(2);
assert_eq!(email_policy.apply_to(""), REDACTED_PLACEHOLDER);
let full_policy = TextRedactionPolicy::default_full();
assert_eq!(full_policy.apply_to(""), REDACTED_PLACEHOLDER);
}
#[test]
fn keep_both_overlap_masks_entire_value() {
let policy = TextRedactionPolicy::keep_with(KeepConfig::both(2, 2));
assert_eq!(policy.apply_to("abc"), "***");
let policy = TextRedactionPolicy::keep_with(KeepConfig::both(3, 3));
assert_eq!(policy.apply_to("abcd"), "****");
let policy = TextRedactionPolicy::keep_with(KeepConfig::both(2, 2));
assert_eq!(policy.apply_to("abcd"), "****");
let policy = TextRedactionPolicy::keep_with(KeepConfig::both(usize::MAX, usize::MAX));
assert_eq!(policy.apply_to("abcd"), "****");
}
#[test]
fn mask_both_overlap_masks_entire_value() {
let policy = TextRedactionPolicy::mask_with(MaskConfig::both(2, 2));
assert_eq!(policy.apply_to("abc"), "***");
let policy = TextRedactionPolicy::mask_with(MaskConfig::both(3, 3));
assert_eq!(policy.apply_to("abcd"), "****");
let policy = TextRedactionPolicy::mask_with(MaskConfig::both(2, 2));
assert_eq!(policy.apply_to("abcd"), "****");
let policy = TextRedactionPolicy::mask_with(MaskConfig::both(usize::MAX, usize::MAX));
assert_eq!(policy.apply_to("abcd"), "****");
}
#[test]
fn keep_both_no_overlap() {
let policy = TextRedactionPolicy::keep_with(KeepConfig::both(2, 2));
assert_eq!(policy.apply_to("abcdef"), "ab**ef"); }
#[test]
fn mask_both_no_overlap() {
let policy = TextRedactionPolicy::mask_with(MaskConfig::both(2, 2));
assert_eq!(policy.apply_to("abcdef"), "**cd**"); }
}