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 {
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.find('@') {
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 {
return value.to_string();
}
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 {
if self.visible_prefix >= total {
return value.to_string();
}
let mut result = chars;
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_allows_full_visibility() {
let policy = TextRedactionPolicy::keep_with(KeepConfig::first(3));
assert_eq!(policy.apply_to("ab"), "ab");
}
#[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_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"), "x@a.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"), "ab@x.com");
assert_eq!(policy.apply_to("a@b.c"), "a@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 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_keeps_entire_value() {
let policy = TextRedactionPolicy::keep_with(KeepConfig::both(2, 2));
assert_eq!(policy.apply_to("abc"), "abc");
let policy = TextRedactionPolicy::keep_with(KeepConfig::both(3, 3));
assert_eq!(policy.apply_to("abcd"), "abcd");
let policy = TextRedactionPolicy::keep_with(KeepConfig::both(2, 2));
assert_eq!(policy.apply_to("abcd"), "abcd");
let policy = TextRedactionPolicy::keep_with(KeepConfig::both(usize::MAX, usize::MAX));
assert_eq!(policy.apply_to("abcd"), "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**"); }
}