1use super::{validation::validate_entity, Recognizer, RecognizerResult};
6use crate::types::EntityType;
7use anyhow::Result;
8use lazy_static::lazy_static;
9use regex::Regex;
10use std::collections::HashMap;
11
12struct SecretPattern {
18 entity_type: EntityType,
19 regex: &'static str,
20 score: f32,
21}
22
23const SECRET_PATTERNS: &[SecretPattern] = &[
30 SecretPattern {
31 entity_type: EntityType::PrivateKey,
32 regex: r"-----BEGIN (?:[A-Z]+ )*PRIVATE KEY(?: BLOCK)?-----[\s\S]*?-----END (?:[A-Z]+ )*PRIVATE KEY(?: BLOCK)?-----",
33 score: 0.98,
34 },
35 SecretPattern {
36 entity_type: EntityType::JwtToken,
37 regex: r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]*",
38 score: 0.90,
39 },
40 SecretPattern {
41 entity_type: EntityType::AwsAccessKey,
42 regex: r"\b(?:AKIA|ASIA|ABIA|ACCA|A3T[A-Z0-9])[0-9A-Z]{16}\b",
43 score: 0.95,
44 },
45 SecretPattern {
46 entity_type: EntityType::AwsAccessKey,
47 regex: r"\b(ABSK[A-Za-z0-9+/]{109,269}={0,2})(?:\s|$|[^A-Za-z0-9+/=])",
51 score: 0.95,
52 },
53 SecretPattern {
54 entity_type: EntityType::AwsAccessKey,
55 regex: r"\bbedrock-api-key-YmVkcm9jay5hbWF6b25hd3MuY29t\b",
56 score: 0.95,
57 },
58 SecretPattern {
59 entity_type: EntityType::GithubToken,
60 regex: r"\b(?:gh[pousr]_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9]{22}_[A-Za-z0-9]{59})\b",
61 score: 0.95,
62 },
63 SecretPattern {
64 entity_type: EntityType::GitlabToken,
65 regex: r"\bglpat-[A-Za-z0-9_-]{20}\b",
66 score: 0.95,
67 },
68 SecretPattern {
69 entity_type: EntityType::GitlabToken,
70 regex: r"\bglpat-[0-9a-zA-Z_-]{27,300}\.[0-9a-z]{2}[0-9a-z]{7}\b",
71 score: 0.95,
72 },
73 SecretPattern {
74 entity_type: EntityType::GitlabToken,
75 regex: r"\bglcbt-[0-9a-zA-Z]{1,5}_[0-9a-zA-Z_-]{20}\b",
76 score: 0.95,
77 },
78 SecretPattern {
79 entity_type: EntityType::GitlabToken,
80 regex: r"\bglagent-[A-Za-z0-9_-]{50}\b",
81 score: 0.95,
82 },
83 SecretPattern {
84 entity_type: EntityType::GitlabToken,
85 regex: r"\bgloas-[A-Za-z0-9_-]{64}\b",
86 score: 0.95,
87 },
88 SecretPattern {
89 entity_type: EntityType::GitlabToken,
90 regex: r"\bgldt-[A-Za-z0-9_-]{20}\b",
91 score: 0.95,
92 },
93 SecretPattern {
94 entity_type: EntityType::GitlabToken,
95 regex: r"\bglft-[A-Za-z0-9_-]{20}\b",
96 score: 0.95,
97 },
98 SecretPattern {
99 entity_type: EntityType::GitlabToken,
100 regex: r"\bglptt-[0-9a-fA-F]{40}\b",
101 score: 0.95,
102 },
103 SecretPattern {
104 entity_type: EntityType::SlackToken,
105 regex: r"\bxox[baprs]-[A-Za-z0-9-]{10,72}\b",
106 score: 0.95,
107 },
108 SecretPattern {
109 entity_type: EntityType::SlackWebhook,
110 regex: r"https://hooks\.slack\.com/services/T[A-Za-z0-9_]+/B[A-Za-z0-9_]+/[A-Za-z0-9_]{20,}",
111 score: 0.95,
112 },
113 SecretPattern {
114 entity_type: EntityType::StripeApiKey,
115 regex: r"\b(?:sk|rk)_(?:live|test)_[0-9a-zA-Z]{24,99}\b",
119 score: 0.95,
120 },
121 SecretPattern {
122 entity_type: EntityType::GoogleApiKey,
123 regex: r"\bAIza[0-9A-Za-z_-]{35}",
124 score: 0.95,
125 },
126 SecretPattern {
127 entity_type: EntityType::OpenAiApiKey,
128 regex: r"\bsk-(?:proj-[A-Za-z0-9_-]{40,}|[A-Za-z0-9]{32,})",
134 score: 0.90,
135 },
136 SecretPattern {
137 entity_type: EntityType::AnthropicApiKey,
138 regex: r"\bsk-ant-(?:api03-)?[A-Za-z0-9_-]{24,}",
139 score: 0.95,
140 },
141 SecretPattern {
142 entity_type: EntityType::NpmToken,
143 regex: r"\bnpm_[A-Za-z0-9]{36}\b",
144 score: 0.95,
145 },
146 SecretPattern {
147 entity_type: EntityType::PyPiToken,
148 regex: r"\bpypi-AgEIcHlwaS5vcmc[A-Za-z0-9_-]{50,}",
149 score: 0.95,
150 },
151 SecretPattern {
152 entity_type: EntityType::SendGridApiKey,
153 regex: r"\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}",
154 score: 0.95,
155 },
156 SecretPattern {
157 entity_type: EntityType::TwilioApiKey,
158 regex: r"\bSK[0-9a-fA-F]{32}\b",
159 score: 0.85,
160 },
161 SecretPattern {
162 entity_type: EntityType::TelegramBotToken,
163 regex: r"\b\d{8,10}:AA[A-Za-z0-9_-]{33}",
164 score: 0.95,
165 },
166 SecretPattern {
167 entity_type: EntityType::HashicorpVaultToken,
168 regex: r"\bhv[sbr]\.[A-Za-z0-9_-]{24,}",
169 score: 0.95,
170 },
171 SecretPattern {
172 entity_type: EntityType::DatabaseConnectionString,
173 regex: r"\b(?:mongodb(?:\+srv)?|postgres(?:ql)?|mysql|mariadb|redis|amqp|mssql)://[^:@/\s]+:[^@/\s]+@[^\s/]+(?:/[^\s?#]*)?(?:\?[^\s#]*)?",
177 score: 0.90,
178 },
179 SecretPattern {
180 entity_type: EntityType::HuggingFaceToken,
181 regex: r"\b(?:hf_|api_org_)[A-Za-z]{34}\b",
182 score: 0.95,
183 },
184 SecretPattern {
185 entity_type: EntityType::DatabricksToken,
186 regex: r"\bdapi[0-9a-fA-F]{32}(?:-\d)?\b",
187 score: 0.95,
188 },
189 SecretPattern {
190 entity_type: EntityType::DigitalOceanToken,
191 regex: r"\bdo[por]_v1_[0-9a-fA-F]{64}\b",
192 score: 0.95,
193 },
194 SecretPattern {
195 entity_type: EntityType::NotionApiKey,
196 regex: r"\bntn_[0-9]{11}[A-Za-z0-9]{35}\b",
197 score: 0.95,
198 },
199 SecretPattern {
200 entity_type: EntityType::PerplexityApiKey,
201 regex: r"\bpplx-[A-Za-z0-9]{48}\b",
202 score: 0.95,
203 },
204 SecretPattern {
205 entity_type: EntityType::HttpBasicAuth,
206 regex: r"(?i)\bBasic\s+([A-Za-z0-9+/]+={0,2})(?:\s|$|[^A-Za-z0-9+/=])",
209 score: 0.95,
210 },
211];
212
213#[derive(Debug, Clone)]
215pub struct PatternRecognizer {
216 name: String,
217 patterns: HashMap<EntityType, Vec<CompiledPattern>>,
218 min_score: f32,
219}
220
221#[derive(Debug, Clone)]
222struct CompiledPattern {
223 regex: Regex,
224 score: f32,
225 context_words: Vec<String>,
226}
227
228impl PatternRecognizer {
229 pub fn new() -> Self {
231 let mut recognizer = Self {
232 name: "PatternRecognizer".to_string(),
233 patterns: HashMap::new(),
234 min_score: 0.5,
235 };
236 recognizer.load_default_patterns();
237 recognizer
238 }
239
240 pub fn with_name(name: impl Into<String>) -> Self {
242 let mut recognizer = Self::new();
243 recognizer.name = name.into();
244 recognizer
245 }
246
247 pub fn with_min_score(mut self, min_score: f32) -> Self {
249 self.min_score = min_score;
250 self
251 }
252
253 pub fn add_pattern(
255 &mut self,
256 entity_type: EntityType,
257 pattern: &str,
258 score: f32,
259 ) -> Result<()> {
260 let regex = Regex::new(pattern)?;
261 let compiled = CompiledPattern {
262 regex,
263 score,
264 context_words: vec![],
265 };
266 self.patterns.entry(entity_type).or_default().push(compiled);
267 Ok(())
268 }
269
270 pub fn add_pattern_with_context(
272 &mut self,
273 entity_type: EntityType,
274 pattern: &str,
275 score: f32,
276 context_words: Vec<String>,
277 ) -> Result<()> {
278 let regex = Regex::new(pattern)?;
279 let compiled = CompiledPattern {
280 regex,
281 score,
282 context_words,
283 };
284 self.patterns.entry(entity_type).or_default().push(compiled);
285 Ok(())
286 }
287
288 fn load_default_patterns(&mut self) {
290 let _ = self.add_pattern(
292 EntityType::EmailAddress,
293 r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b",
294 0.8,
295 );
296
297 let _ = self.add_pattern(
303 EntityType::PhoneNumber,
304 r"\(\d{3}\)[-.\s]?\d{3}[-.\s]?\d{4}\b|\b\d{3}[-.\s]\d{3}[-.\s]?\d{4}\b",
305 0.7,
306 );
307
308 let _ = self.add_pattern(
310 EntityType::CreditCard,
311 r"\b(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|3[47][0-9]{13}|6(?:011|5[0-9]{2})[0-9]{12})\b",
312 0.9,
313 );
314
315 let _ = self.add_pattern(EntityType::UsSsn, r"\b\d{3}-\d{2}-\d{4}\b", 0.9);
318
319 let _ = self.add_pattern(
321 EntityType::IpAddress,
322 r"\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b",
323 0.8,
324 );
325
326 let _ = self.add_pattern(
328 EntityType::Url,
329 r"\b(?:https?://|www\.)[a-zA-Z0-9][-a-zA-Z0-9]*(?:\.[a-zA-Z0-9][-a-zA-Z0-9]*)+(?:/[^\s]*)?\b",
330 0.7,
331 );
332
333 let _ = self.add_pattern(
335 EntityType::DomainName,
336 r"\b(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,}\b",
337 0.7,
338 );
339
340 let _ = self.add_pattern(
342 EntityType::Guid,
343 r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b",
344 0.9,
345 );
346
347 let _ = self.add_pattern(
349 EntityType::MacAddress,
350 r"\b(?:[0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}\b",
351 0.9,
352 );
353
354 let _ = self.add_pattern_with_context(
356 EntityType::UkNhs,
357 r"\b(?:\d{3}\s?\d{3}\s?\d{4}|\d{10})\b",
358 0.6,
359 vec![
360 "NHS".to_string(),
361 "patient".to_string(),
362 "health".to_string(),
363 ],
364 );
365
366 let _ = self.add_pattern(
368 EntityType::UkNino,
369 r"\b[A-CEGHJ-PR-TW-Z]{1}[A-CEGHJ-NPR-TW-Z]{1}\d{6}[A-D]{1}\b",
370 0.85,
371 );
372
373 let _ = self.add_pattern(
375 EntityType::UkPostcode,
376 r"\b[A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2}\b",
377 0.75,
378 );
379
380 let _ = self.add_pattern(EntityType::UkSortCode, r"\b\d{2}-\d{2}-\d{2}\b", 0.7);
382
383 let _ = self.add_pattern(
385 EntityType::IbanCode,
386 r"\b[A-Z]{2}\d{2}[A-Z0-9]{1,30}\b",
387 0.75,
388 );
389
390 let _ = self.add_pattern(
392 EntityType::BtcAddress,
393 r"\b(?:bc1|[13])[a-zA-HJ-NP-Z0-9]{25,62}\b",
394 0.85,
395 );
396
397 let _ = self.add_pattern(EntityType::EthAddress, r"\b0x[a-fA-F0-9]{40}\b", 0.9);
399
400 let _ = self.add_pattern(EntityType::Md5Hash, r"\b[a-fA-F0-9]{32}\b", 0.6);
402
403 let _ = self.add_pattern(EntityType::Sha1Hash, r"\b[a-fA-F0-9]{40}\b", 0.6);
405
406 let _ = self.add_pattern(EntityType::Sha256Hash, r"\b[a-fA-F0-9]{64}\b", 0.6);
408
409 let _ = self.add_pattern(
411 EntityType::UsZipCode,
412 r"\b\d{5}(?:-\d{4})?\b",
413 0.6, );
415
416 let _ = self.add_pattern_with_context(
418 EntityType::PoBox,
419 r"\b(?:P\.?\s?O\.?|POST\s+OFFICE)\s*BOX\s+\d+\b",
420 0.85,
421 vec![
422 "address".to_string(),
423 "mail".to_string(),
424 "ship".to_string(),
425 ],
426 );
427
428 let _ = self.add_pattern(
430 EntityType::Isbn,
431 r"\b(?:ISBN(?:-1[03])?:?\s*)?(?:\d{9}[\dX]|\d{13})\b",
432 0.8,
433 );
434
435 let _ = self.add_pattern_with_context(
437 EntityType::PassportNumber,
438 r"\b[A-Z]{1,2}\d{6,9}\b",
439 0.7,
440 vec!["passport".to_string(), "travel".to_string()],
441 );
442
443 let _ = self.add_pattern_with_context(
445 EntityType::MedicalRecordNumber,
446 r"\b(?:MRN|Medical\s*Record|Patient\s*ID):?\s*[A-Z0-9]{6,12}\b",
447 0.85,
448 vec![
449 "patient".to_string(),
450 "medical".to_string(),
451 "hospital".to_string(),
452 ],
453 );
454
455 let _ = self.add_pattern_with_context(
457 EntityType::Age,
458 r"\b(?:age|aged|years old):?\s*(\d{1,3})\b",
459 0.8,
460 vec!["years".to_string(), "old".to_string(), "age".to_string()],
461 );
462
463 let _ = self.add_pattern(
465 EntityType::DateTime,
466 r"\b\d{4}-\d{2}-\d{2}(?:[T\s]\d{2}:\d{2}(?::\d{2})?)?\b",
467 0.5,
468 );
469
470 let _ = self.add_pattern_with_context(
476 EntityType::UsDriverLicense,
477 r"\b[A-Z]\d{6,8}\b|\b[A-Z]\d{3}-\d{4}-\d{4}\b",
478 0.4,
479 vec![
480 "driver".to_string(),
481 "license".to_string(),
482 "DL".to_string(),
483 "DMV".to_string(),
484 ],
485 );
486
487 let _ = self.add_pattern_with_context(
490 EntityType::UsPassport,
491 r"\b[A-Z]?\d{9}\b",
492 0.4,
493 vec![
494 "passport".to_string(),
495 "travel".to_string(),
496 "state department".to_string(),
497 ],
498 );
499
500 let _ = self.add_pattern_with_context(
503 EntityType::UsBankNumber,
504 r"\b\d{8,17}\b",
505 0.3,
506 vec![
507 "account".to_string(),
508 "bank".to_string(),
509 "routing".to_string(),
510 "checking".to_string(),
511 "savings".to_string(),
512 ],
513 );
514
515 let _ = self.add_pattern(
518 EntityType::UkDriverLicense,
519 r"\b[A-Z]{5}\d{6}[A-Z0-9]{2}\d[A-Z]{2}\s?\d{2}\b",
520 0.85,
521 );
522
523 let _ = self.add_pattern_with_context(
526 EntityType::UkPassportNumber,
527 r"\b\d{9}\b",
528 0.3,
529 vec![
530 "passport".to_string(),
531 "travel".to_string(),
532 "HMPO".to_string(),
533 ],
534 );
535
536 let _ = self.add_pattern(
538 EntityType::UkPhoneNumber,
539 r"\b(?:0[1-3]\d{2,3}\s?\d{3}\s?\d{4}|0[1-3]\d{2,3}\s?\d{6,7})\b",
540 0.75,
541 );
542
543 let _ = self.add_pattern(
545 EntityType::UkMobileNumber,
546 r"\b07\d{3}\s?\d{3}\s?\d{3}\b",
547 0.8,
548 );
549
550 let _ = self.add_pattern_with_context(
553 EntityType::UkCompanyNumber,
554 r"\b(?:\d{8}|[A-Z]{2}\d{6})\b",
555 0.3,
556 vec![
557 "company".to_string(),
558 "companies house".to_string(),
559 "registration".to_string(),
560 "CRN".to_string(),
561 ],
562 );
563
564 let _ = self.add_pattern_with_context(
566 EntityType::MedicalLicense,
567 r"\b(?:MD|DO|NP|PA|RN|LPN)[-\s]?\d{5,10}\b",
568 0.8,
569 vec![
570 "license".to_string(),
571 "medical".to_string(),
572 "physician".to_string(),
573 "doctor".to_string(),
574 "nurse".to_string(),
575 ],
576 );
577
578 let _ = self.add_pattern_with_context(
581 EntityType::CryptoWallet,
582 r"\b[LMr3][a-km-zA-HJ-NP-Z1-9]{25,34}\b",
583 0.75,
584 vec![
585 "wallet".to_string(),
586 "crypto".to_string(),
587 "address".to_string(),
588 "coin".to_string(),
589 ],
590 );
591
592 for p in SECRET_PATTERNS {
596 self.add_pattern(p.entity_type.clone(), p.regex, p.score)
597 .unwrap_or_else(|e| {
598 panic!(
599 "SECRET_PATTERNS regex failed to compile for {:?}: {e}",
600 p.entity_type
601 )
602 });
603 }
604 }
605
606 fn check_context(&self, text: &str, start: usize, end: usize, context_words: &[String]) -> f32 {
608 if context_words.is_empty() {
609 return 0.0;
610 }
611
612 let context_start = start.saturating_sub(50);
614 let context_end = (end + 50).min(text.len());
615 let context = &text[context_start..context_end].to_lowercase();
616
617 let matches = context_words
619 .iter()
620 .filter(|word| context.contains(&word.to_lowercase()))
621 .count();
622
623 (matches as f32 / context_words.len() as f32) * 0.3
625 }
626}
627
628impl Default for PatternRecognizer {
629 fn default() -> Self {
630 Self::new()
631 }
632}
633
634impl Recognizer for PatternRecognizer {
635 fn name(&self) -> &str {
636 &self.name
637 }
638
639 fn supported_entities(&self) -> &[EntityType] {
640 lazy_static! {
641 static ref SUPPORTED: Vec<EntityType> = vec![
642 EntityType::EmailAddress,
644 EntityType::PhoneNumber,
645 EntityType::IpAddress,
646 EntityType::Url,
647 EntityType::DomainName,
648 EntityType::CreditCard,
650 EntityType::IbanCode,
651 EntityType::UsBankNumber,
652 EntityType::UsSsn,
654 EntityType::UsDriverLicense,
655 EntityType::UsPassport,
656 EntityType::UsZipCode,
657 EntityType::UkNhs,
659 EntityType::UkNino,
660 EntityType::UkPostcode,
661 EntityType::UkSortCode,
662 EntityType::UkDriverLicense,
663 EntityType::UkPassportNumber,
664 EntityType::UkPhoneNumber,
665 EntityType::UkMobileNumber,
666 EntityType::UkCompanyNumber,
667 EntityType::MedicalLicense,
669 EntityType::MedicalRecordNumber,
670 EntityType::PassportNumber,
672 EntityType::Age,
673 EntityType::Isbn,
674 EntityType::PoBox,
675 EntityType::DateTime,
676 EntityType::CryptoWallet,
678 EntityType::BtcAddress,
679 EntityType::EthAddress,
680 EntityType::Guid,
682 EntityType::MacAddress,
683 EntityType::Md5Hash,
684 EntityType::Sha1Hash,
685 EntityType::Sha256Hash,
686 EntityType::PrivateKey,
688 EntityType::JwtToken,
689 EntityType::AwsAccessKey,
690 EntityType::GithubToken,
691 EntityType::GitlabToken,
692 EntityType::SlackToken,
693 EntityType::SlackWebhook,
694 EntityType::StripeApiKey,
695 EntityType::GoogleApiKey,
696 EntityType::OpenAiApiKey,
697 EntityType::AnthropicApiKey,
698 EntityType::NpmToken,
699 EntityType::PyPiToken,
700 EntityType::SendGridApiKey,
701 EntityType::TwilioApiKey,
702 EntityType::TelegramBotToken,
703 EntityType::HashicorpVaultToken,
704 EntityType::DatabaseConnectionString,
705 EntityType::HuggingFaceToken,
706 EntityType::DatabricksToken,
707 EntityType::DigitalOceanToken,
708 EntityType::NotionApiKey,
709 EntityType::PerplexityApiKey,
710 EntityType::HttpBasicAuth,
711 ];
712 }
713 &SUPPORTED
714 }
715
716 fn analyze(&self, text: &str, _language: &str) -> Result<Vec<RecognizerResult>> {
717 let _span = crate::operations_enabled()
718 .then(|| tracing::info_span!("redact.gateway.detect.patterns").entered());
719 let mut results = Vec::new();
720
721 for (entity_type, patterns) in &self.patterns {
722 for pattern in patterns {
723 for capture in pattern.regex.captures_iter(text) {
724 if let Some(matched) = match entity_type {
729 EntityType::HttpBasicAuth | EntityType::AwsAccessKey => {
730 capture.get(1).or_else(|| capture.get(0))
731 }
732 _ => capture.get(0),
733 } {
734 let start = matched.start();
735 let end = matched.end();
736 let matched_text = matched.as_str();
737
738 let mut score = pattern.score;
740
741 if !pattern.context_words.is_empty() {
743 score += self.check_context(text, start, end, &pattern.context_words);
744 score = score.min(1.0); }
746
747 let validation_factor = validate_entity(entity_type, matched_text);
750 score *= validation_factor;
751
752 if score >= self.min_score {
753 results.push(
754 RecognizerResult::new(
755 entity_type.clone(),
756 start,
757 end,
758 score,
759 self.name(),
760 )
761 .with_text(text),
762 );
763 }
764 }
765 }
766 }
767 }
768
769 Ok(results)
770 }
771
772 fn min_score(&self) -> f32 {
773 self.min_score
774 }
775}
776
777#[cfg(test)]
778mod tests {
779 use super::*;
780
781 #[test]
782 fn test_age_span_includes_label() {
783 let recognizer = PatternRecognizer::new();
784 let text = "age: 42";
785 let results = recognizer.analyze(text, "en").unwrap();
786 let age = results
787 .iter()
788 .find(|r| r.entity_type == EntityType::Age)
789 .expect("AGE");
790 assert_eq!(&text[age.start..age.end], "age: 42");
791 }
792
793 #[test]
794 fn test_email_detection() {
795 let recognizer = PatternRecognizer::new();
796 let text = "Contact me at john.doe@example.com for details";
797 let results = recognizer.analyze(text, "en").unwrap();
798
799 let email_results: Vec<_> = results
800 .iter()
801 .filter(|r| r.entity_type == EntityType::EmailAddress)
802 .collect();
803 assert_eq!(email_results.len(), 1);
804 assert_eq!(
805 email_results[0].text,
806 Some("john.doe@example.com".to_string())
807 );
808 assert!(email_results[0].score >= 0.8);
809 }
810
811 #[test]
812 fn test_phone_detection() {
813 let recognizer = PatternRecognizer::new();
814 let text = "Call me at (555) 123-4567";
815 let results = recognizer.analyze(text, "en").unwrap();
816
817 assert!(!results.is_empty());
818 let phone_result = results
819 .iter()
820 .find(|r| r.entity_type == EntityType::PhoneNumber);
821 assert!(phone_result.is_some());
822 }
823
824 #[test]
825 fn test_credit_card_detection() {
826 let recognizer = PatternRecognizer::new();
827 let text = "Card number: 4532015112830366";
828 let results = recognizer.analyze(text, "en").unwrap();
829
830 assert!(!results.is_empty());
831 let cc_result = results
832 .iter()
833 .find(|r| r.entity_type == EntityType::CreditCard);
834 assert!(cc_result.is_some());
835 }
836
837 #[test]
838 fn test_ssn_detection() {
839 let recognizer = PatternRecognizer::new();
840 let text = "SSN: 123-45-6789";
841 let results = recognizer.analyze(text, "en").unwrap();
842
843 assert!(!results.is_empty());
844 let ssn_result = results.iter().find(|r| r.entity_type == EntityType::UsSsn);
845 assert!(ssn_result.is_some());
846 }
847
848 #[test]
849 fn test_uk_nhs_with_context() {
850 let recognizer = PatternRecognizer::new();
851 let text = "NHS patient number is 401 023 2137";
856 let results = recognizer.analyze(text, "en").unwrap();
857
858 assert!(!results.is_empty());
859 let nhs_result = results.iter().find(|r| r.entity_type == EntityType::UkNhs);
860 assert!(
861 nhs_result.is_some(),
862 "Should detect NHS number with context"
863 );
864 if let Some(result) = nhs_result {
866 assert!(result.score > 0.6);
867 }
868 }
869
870 #[test]
871 fn test_uk_nino_detection() {
872 let recognizer = PatternRecognizer::new();
873 let text = "NINO: AB123456C";
874 let results = recognizer.analyze(text, "en").unwrap();
875
876 assert!(!results.is_empty());
877 let nino_result = results.iter().find(|r| r.entity_type == EntityType::UkNino);
878 assert!(nino_result.is_some());
879 }
880
881 #[test]
882 fn test_multiple_entities() {
883 let recognizer = PatternRecognizer::new();
884 let text = "Email john@example.com, phone (555) 123-4567, SSN 123-45-6789";
885 let results = recognizer.analyze(text, "en").unwrap();
886
887 assert!(results.len() >= 3);
888 assert!(results
889 .iter()
890 .any(|r| r.entity_type == EntityType::EmailAddress));
891 assert!(results
892 .iter()
893 .any(|r| r.entity_type == EntityType::PhoneNumber));
894 assert!(results.iter().any(|r| r.entity_type == EntityType::UsSsn));
895 }
896
897 #[test]
898 fn test_custom_pattern() {
899 let mut recognizer = PatternRecognizer::new();
900 recognizer
901 .add_pattern(
902 EntityType::Custom("CUSTOM_ID".to_string()),
903 r"\bCID-\d{6}\b",
904 0.9,
905 )
906 .unwrap();
907
908 let text = "Your customer ID is CID-123456";
909 let results = recognizer.analyze(text, "en").unwrap();
910
911 let custom_result = results
912 .iter()
913 .find(|r| matches!(r.entity_type, EntityType::Custom(_)));
914 assert!(custom_result.is_some());
915 }
916
917 #[test]
918 fn test_min_score_filtering() {
919 let recognizer = PatternRecognizer::new().with_min_score(0.9);
920 let text = "Date: 2024-01-15"; let results = recognizer.analyze(text, "en").unwrap();
922
923 let date_results = results
925 .iter()
926 .filter(|r| r.entity_type == EntityType::DateTime)
927 .count();
928 assert_eq!(date_results, 0);
929 }
930
931 #[test]
932 fn test_uk_driver_license_detection() {
933 let recognizer = PatternRecognizer::new();
934 let text = "UK DL: MORGA753116SM9IJ 35";
935 let results = recognizer.analyze(text, "en").unwrap();
936
937 let dl_result = results
938 .iter()
939 .find(|r| r.entity_type == EntityType::UkDriverLicense);
940 assert!(dl_result.is_some(), "Should detect UK driver's license");
941 }
942
943 #[test]
944 fn test_uk_mobile_detection() {
945 let recognizer = PatternRecognizer::new();
946 let text = "Call me on 07700 900123";
947 let results = recognizer.analyze(text, "en").unwrap();
948
949 let mobile_result = results
950 .iter()
951 .find(|r| r.entity_type == EntityType::UkMobileNumber);
952 assert!(mobile_result.is_some(), "Should detect UK mobile number");
953 }
954
955 #[test]
956 fn test_uk_phone_detection() {
957 let recognizer = PatternRecognizer::new();
958 let text = "Office: 0207 123 4567";
959 let results = recognizer.analyze(text, "en").unwrap();
960
961 let phone_result = results
962 .iter()
963 .find(|r| r.entity_type == EntityType::UkPhoneNumber);
964 assert!(phone_result.is_some(), "Should detect UK phone number");
965 }
966
967 #[test]
968 fn test_medical_license_detection() {
969 let recognizer = PatternRecognizer::new();
970 let text = "Medical license: MD-123456789";
971 let results = recognizer.analyze(text, "en").unwrap();
972
973 let license_result = results
974 .iter()
975 .find(|r| r.entity_type == EntityType::MedicalLicense);
976 assert!(license_result.is_some(), "Should detect medical license");
977 }
978
979 #[test]
980 fn test_supported_entities_count() {
981 let recognizer = PatternRecognizer::new();
982 let supported = recognizer.supported_entities();
983 assert_eq!(
985 supported.len(),
986 60,
987 "Should support 60 pattern-based entity types, got {}",
988 supported.len()
989 );
990 }
991}