1pub struct Masker {
3 mask_email: bool,
4 mask_phone: bool,
5 mask_char: char,
6}
7
8impl Default for Masker {
9 fn default() -> Self {
10 Self::new()
11 }
12}
13
14impl Masker {
15 pub fn new() -> Self {
17 Self {
18 mask_email: false,
19 mask_phone: false,
20 mask_char: '*',
21 }
22 }
23
24 pub fn mask_emails(mut self) -> Self {
26 self.mask_email = true;
27 self
28 }
29
30 pub fn mask_phones(mut self) -> Self {
32 self.mask_phone = true;
33 self
34 }
35
36 pub fn with_mask_char(mut self, c: char) -> Self {
38 self.mask_char = c;
39 self
40 }
41
42 pub fn process(&self, input: &str) -> String {
44 let mut result = input.to_string();
45
46 if self.mask_email {
47 result = mask_emails_in_text(&result, self.mask_char);
48 }
49
50 if self.mask_phone {
51 result = mask_phones_in_text(&result, self.mask_char);
52 }
53
54 result
55 }
56}
57
58fn mask_emails_in_text(input: &str, mask_char: char) -> String {
59 let bytes = input.as_bytes();
60 let len = bytes.len();
61 let mut output = String::with_capacity(input.len());
62 let mut last = 0;
63 let mut i = 0;
64
65 while i < len {
66 if bytes[i] == b'@' {
67 let mut local_start = i;
68 while local_start > 0 && is_local_byte(bytes[local_start - 1]) {
69 local_start -= 1;
70 }
71 let local_end = i;
72
73 let domain_start = i + 1;
74 let mut domain_end = domain_start;
75 while domain_end < len && is_domain_byte(bytes[domain_end]) {
76 domain_end += 1;
77 }
78
79 if local_start < local_end && domain_start < domain_end {
80 let mut candidate_end = domain_end;
81 let mut matched_domain_end = None;
82 while candidate_end > domain_start {
83 let domain = &input[domain_start..candidate_end];
84 if is_valid_domain(domain) {
85 matched_domain_end = Some(candidate_end);
86 break;
87 }
88 candidate_end -= 1;
89 }
90
91 if let Some(valid_end) = matched_domain_end {
92 let local = &input[local_start..local_end];
93 let domain = &input[domain_start..valid_end];
94 output.push_str(&input[last..local_start]);
95 output.push_str(&mask_local(local, mask_char));
96 output.push('@');
97 output.push_str(domain);
98 last = valid_end;
99 i = valid_end;
100 continue;
101 }
102 }
103 }
104
105 i += 1;
106 }
107
108 output.push_str(&input[last..]);
109 output
110}
111
112fn mask_phones_in_text(input: &str, mask_char: char) -> String {
113 let bytes = input.as_bytes();
114 let len = bytes.len();
115 let mut output = String::with_capacity(input.len());
116 let mut last = 0;
117 let mut i = 0;
118
119 while i < len {
120 if is_phone_start(bytes[i]) {
121 let mut end = i;
122 while end < len && is_phone_char(bytes[end]) {
123 end += 1;
124 }
125
126 let mut digit_count = 0;
127 let mut last_digit_index = None;
128 for idx in i..end {
129 if bytes[idx].is_ascii_digit() {
130 digit_count += 1;
131 last_digit_index = Some(idx);
132 }
133 }
134
135 if let Some(last_digit) = last_digit_index {
136 let candidate_end = last_digit + 1;
137 if digit_count >= 5 {
138 let candidate = &input[i..candidate_end];
139 output.push_str(&input[last..i]);
140 output.push_str(&mask_phone_candidate(candidate, mask_char));
141 last = candidate_end;
142 i = candidate_end;
143 continue;
144 }
145 }
146
147 i = end;
148 continue;
149 }
150
151 i += 1;
152 }
153
154 output.push_str(&input[last..]);
155 output
156}
157
158fn mask_local(local: &str, mask_char: char) -> String {
159 let len = local.len();
160 if len > 1 {
161 let mut result = String::with_capacity(len);
162 let first = local.as_bytes()[0] as char;
163 result.push(first);
164 for _ in 1..len {
165 result.push(mask_char);
166 }
167 result
168 } else {
169 mask_char.to_string()
170 }
171}
172
173fn mask_phone_candidate(candidate: &str, mask_char: char) -> String {
174 let bytes = candidate.as_bytes();
175 let digit_count = bytes.iter().filter(|b| b.is_ascii_digit()).count();
176 let mut current_index = 0;
177 let mut result = String::with_capacity(candidate.len());
178
179 for &b in bytes {
180 if b.is_ascii_digit() {
181 current_index += 1;
182 if digit_count > 4 && current_index <= digit_count - 4 {
183 result.push(mask_char);
184 } else {
185 result.push(b as char);
186 }
187 } else {
188 result.push(b as char);
189 }
190 }
191
192 result
193}
194
195fn is_local_byte(byte: u8) -> bool {
196 matches!(
197 byte,
198 b'a'..=b'z'
199 | b'A'..=b'Z'
200 | b'0'..=b'9'
201 | b'.'
202 | b'_'
203 | b'%'
204 | b'+'
205 | b'-'
206 )
207}
208
209fn is_domain_byte(byte: u8) -> bool {
210 matches!(
211 byte,
212 b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'.'
213 )
214}
215
216fn is_valid_domain(domain: &str) -> bool {
217 if domain.starts_with('.') || domain.ends_with('.') {
218 return false;
219 }
220
221 let parts: Vec<&str> = domain.split('.').collect();
222 if parts.len() < 2 {
223 return false;
224 }
225
226 for part in &parts {
227 if part.is_empty() {
228 return false;
229 }
230 if part.starts_with('-') || part.ends_with('-') {
231 return false;
232 }
233 if !part
234 .as_bytes()
235 .iter()
236 .all(|b| b.is_ascii_alphanumeric() || *b == b'-')
237 {
238 return false;
239 }
240 }
241
242 let tld = parts.last().unwrap();
243 if tld.len() < 2 || !tld.as_bytes().iter().all(|b| b.is_ascii_alphabetic()) {
244 return false;
245 }
246
247 true
248}
249
250fn is_phone_start(byte: u8) -> bool {
251 byte.is_ascii_digit() || byte == b'+' || byte == b'('
252}
253
254fn is_phone_char(byte: u8) -> bool {
255 byte.is_ascii_digit() || matches!(byte, b' ' | b'-' | b'(' | b')' | b'+')
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261
262 fn assert_cases(masker: &Masker, cases: &[(&str, &str)]) {
263 for (input, expected) in cases {
264 assert_eq!(masker.process(input), *expected);
265 }
266 }
267
268 #[test]
269 fn test_email_basic_cases() {
270 let masker = Masker::new().mask_emails();
271 assert_cases(
272 &masker,
273 &[
274 ("alice@example.com", "a****@example.com"),
275 ("a@b.com", "*@b.com"),
276 ("ab@example.com", "a*@example.com"),
277 ("a.b+c_d@example.co.jp", "a******@example.co.jp"),
278 ],
279 );
280 }
281
282 #[test]
283 fn test_email_mixed_text() {
284 let masker = Masker::new().mask_emails();
285 assert_cases(
286 &masker,
287 &[
288 ("Contact: alice@example.com.", "Contact: a****@example.com."),
289 (
290 "alice@example.com and bob@example.org",
291 "a****@example.com and b**@example.org",
292 ),
293 ],
294 );
295 }
296
297 #[test]
298 fn test_email_edge_cases() {
299 let masker = Masker::new().mask_emails();
300 assert_cases(
301 &masker,
302 &[
303 ("alice@example", "alice@example"),
304 ("alice@localhost", "alice@localhost"),
305 ("alice@@example.com", "alice@@example.com"),
306 (
307 "first.last+tag@sub.domain.com",
308 "f*************@sub.domain.com",
309 ),
310 ],
311 );
312 }
313
314 #[test]
315 fn test_phone_basic_formats() {
316 let masker = Masker::new().mask_phones();
317 assert_cases(
318 &masker,
319 &[
320 ("090-1234-5678", "***-****-5678"),
321 ("Call (555) 123-4567", "Call (***) ***-4567"),
322 ("Intl: +81 3 1234 5678", "Intl: +** * **** 5678"),
323 ("+1 (800) 123-4567", "+* (***) ***-4567"),
324 ],
325 );
326 }
327
328 #[test]
329 fn test_phone_short_and_boundary_lengths() {
330 let masker = Masker::new().mask_phones();
331 assert_cases(
332 &masker,
333 &[("1234", "1234"), ("12345", "*2345"), ("12-3456", "**-3456")],
334 );
335 }
336
337 #[test]
338 fn test_phone_mixed_text() {
339 let masker = Masker::new().mask_phones();
340 assert_cases(
341 &masker,
342 &[
343 (
344 "Tel: 090-1234-5678 ext. 99",
345 "Tel: ***-****-5678 ext. 99",
346 ),
347 (
348 "Numbers: 111-2222 and 333-4444",
349 "Numbers: ***-2222 and ***-4444",
350 ),
351 ],
352 );
353 }
354
355 #[test]
356 fn test_phone_edge_cases() {
357 let masker = Masker::new().mask_phones();
358 assert_cases(
359 &masker,
360 &[("abcdef", "abcdef"), ("+", "+"), ("(12) 345 678", "(**) **5 678")],
361 );
362 }
363
364 #[test]
365 fn test_combined_masking() {
366 let masker = Masker::new().mask_emails().mask_phones();
367 assert_cases(
368 &masker,
369 &[
370 (
371 "Contact: alice@example.com or 090-1234-5678.",
372 "Contact: a****@example.com or ***-****-5678.",
373 ),
374 (
375 "Email bob@example.org, phone +1 (800) 123-4567",
376 "Email b**@example.org, phone +* (***) ***-4567",
377 ),
378 ],
379 );
380 }
381
382 #[test]
383 fn test_custom_mask_character() {
384 let email_masker = Masker::new().mask_emails().with_mask_char('#');
385 let phone_masker = Masker::new().mask_phones().with_mask_char('#');
386 let combined = Masker::new().mask_emails().mask_phones().with_mask_char('#');
387
388 assert_cases(&email_masker, &[("alice@example.com", "a####@example.com")]);
389 assert_cases(&phone_masker, &[("090-1234-5678", "###-####-5678")]);
390 assert_eq!(
391 combined.process("Contact: alice@example.com or 090-1234-5678."),
392 "Contact: a####@example.com or ###-####-5678."
393 );
394 }
395
396 #[test]
397 fn test_masker_configuration() {
398 let input = "alice@example.com 090-1234-5678";
399
400 let passthrough = Masker::new();
401 assert_eq!(passthrough.process(input), input);
402
403 let email_only = Masker::new().mask_emails();
404 assert_eq!(
405 email_only.process(input),
406 "a****@example.com 090-1234-5678"
407 );
408
409 let phone_only = Masker::new().mask_phones();
410 assert_eq!(
411 phone_only.process(input),
412 "alice@example.com ***-****-5678"
413 );
414
415 let both = Masker::new().mask_emails().mask_phones();
416 assert_eq!(both.process(input), "a****@example.com ***-****-5678");
417 }
418
419 #[test]
420 fn test_non_ascii_text_is_preserved() {
421 let masker = Masker::new().mask_emails().mask_phones();
422 let input = "連絡先: alice@example.com と 090-1234-5678";
423 let expected = "連絡先: a****@example.com と ***-****-5678";
424 assert_eq!(masker.process(input), expected);
425 }
426}