use super::{validation::validate_entity, Recognizer, RecognizerResult};
use crate::types::EntityType;
use anyhow::Result;
use lazy_static::lazy_static;
use regex::Regex;
use std::collections::HashMap;
struct SecretPattern {
entity_type: EntityType,
regex: &'static str,
score: f32,
}
const SECRET_PATTERNS: &[SecretPattern] = &[
SecretPattern {
entity_type: EntityType::PrivateKey,
regex: r"-----BEGIN (?:[A-Z]+ )*PRIVATE KEY(?: BLOCK)?-----[\s\S]*?-----END (?:[A-Z]+ )*PRIVATE KEY(?: BLOCK)?-----",
score: 0.98,
},
SecretPattern {
entity_type: EntityType::JwtToken,
regex: r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]*",
score: 0.90,
},
SecretPattern {
entity_type: EntityType::AwsAccessKey,
regex: r"\b(?:AKIA|ASIA|ABIA|ACCA|A3T[A-Z0-9])[0-9A-Z]{16}\b",
score: 0.95,
},
SecretPattern {
entity_type: EntityType::AwsAccessKey,
regex: r"\b(ABSK[A-Za-z0-9+/]{109,269}={0,2})(?:\s|$|[^A-Za-z0-9+/=])",
score: 0.95,
},
SecretPattern {
entity_type: EntityType::AwsAccessKey,
regex: r"\bbedrock-api-key-YmVkcm9jay5hbWF6b25hd3MuY29t\b",
score: 0.95,
},
SecretPattern {
entity_type: EntityType::GithubToken,
regex: r"\b(?:gh[pousr]_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9]{22}_[A-Za-z0-9]{59})\b",
score: 0.95,
},
SecretPattern {
entity_type: EntityType::GitlabToken,
regex: r"\bglpat-[A-Za-z0-9_-]{20}\b",
score: 0.95,
},
SecretPattern {
entity_type: EntityType::GitlabToken,
regex: r"\bglpat-[0-9a-zA-Z_-]{27,300}\.[0-9a-z]{2}[0-9a-z]{7}\b",
score: 0.95,
},
SecretPattern {
entity_type: EntityType::GitlabToken,
regex: r"\bglcbt-[0-9a-zA-Z]{1,5}_[0-9a-zA-Z_-]{20}\b",
score: 0.95,
},
SecretPattern {
entity_type: EntityType::GitlabToken,
regex: r"\bglagent-[A-Za-z0-9_-]{50}\b",
score: 0.95,
},
SecretPattern {
entity_type: EntityType::GitlabToken,
regex: r"\bgloas-[A-Za-z0-9_-]{64}\b",
score: 0.95,
},
SecretPattern {
entity_type: EntityType::GitlabToken,
regex: r"\bgldt-[A-Za-z0-9_-]{20}\b",
score: 0.95,
},
SecretPattern {
entity_type: EntityType::GitlabToken,
regex: r"\bglft-[A-Za-z0-9_-]{20}\b",
score: 0.95,
},
SecretPattern {
entity_type: EntityType::GitlabToken,
regex: r"\bglptt-[0-9a-fA-F]{40}\b",
score: 0.95,
},
SecretPattern {
entity_type: EntityType::SlackToken,
regex: r"\bxox[baprs]-[A-Za-z0-9-]{10,72}\b",
score: 0.95,
},
SecretPattern {
entity_type: EntityType::SlackWebhook,
regex: r"https://hooks\.slack\.com/services/T[A-Za-z0-9_]+/B[A-Za-z0-9_]+/[A-Za-z0-9_]{20,}",
score: 0.95,
},
SecretPattern {
entity_type: EntityType::StripeApiKey,
regex: r"\b(?:sk|rk)_(?:live|test)_[0-9a-zA-Z]{24,99}\b",
score: 0.95,
},
SecretPattern {
entity_type: EntityType::GoogleApiKey,
regex: r"\bAIza[0-9A-Za-z_-]{35}",
score: 0.95,
},
SecretPattern {
entity_type: EntityType::OpenAiApiKey,
regex: r"\bsk-(?:proj-[A-Za-z0-9_-]{40,}|[A-Za-z0-9]{32,})",
score: 0.90,
},
SecretPattern {
entity_type: EntityType::AnthropicApiKey,
regex: r"\bsk-ant-(?:api03-)?[A-Za-z0-9_-]{24,}",
score: 0.95,
},
SecretPattern {
entity_type: EntityType::NpmToken,
regex: r"\bnpm_[A-Za-z0-9]{36}\b",
score: 0.95,
},
SecretPattern {
entity_type: EntityType::PyPiToken,
regex: r"\bpypi-AgEIcHlwaS5vcmc[A-Za-z0-9_-]{50,}",
score: 0.95,
},
SecretPattern {
entity_type: EntityType::SendGridApiKey,
regex: r"\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}",
score: 0.95,
},
SecretPattern {
entity_type: EntityType::TwilioApiKey,
regex: r"\bSK[0-9a-fA-F]{32}\b",
score: 0.85,
},
SecretPattern {
entity_type: EntityType::TelegramBotToken,
regex: r"\b\d{8,10}:AA[A-Za-z0-9_-]{33}",
score: 0.95,
},
SecretPattern {
entity_type: EntityType::HashicorpVaultToken,
regex: r"\bhv[sbr]\.[A-Za-z0-9_-]{24,}",
score: 0.95,
},
SecretPattern {
entity_type: EntityType::DatabaseConnectionString,
regex: r"\b(?:mongodb(?:\+srv)?|postgres(?:ql)?|mysql|mariadb|redis|amqp|mssql)://[^:@/\s]+:[^@/\s]+@[^\s/]+(?:/[^\s?#]*)?(?:\?[^\s#]*)?",
score: 0.90,
},
SecretPattern {
entity_type: EntityType::HuggingFaceToken,
regex: r"\b(?:hf_|api_org_)[A-Za-z]{34}\b",
score: 0.95,
},
SecretPattern {
entity_type: EntityType::DatabricksToken,
regex: r"\bdapi[0-9a-fA-F]{32}(?:-\d)?\b",
score: 0.95,
},
SecretPattern {
entity_type: EntityType::DigitalOceanToken,
regex: r"\bdo[por]_v1_[0-9a-fA-F]{64}\b",
score: 0.95,
},
SecretPattern {
entity_type: EntityType::NotionApiKey,
regex: r"\bntn_[0-9]{11}[A-Za-z0-9]{35}\b",
score: 0.95,
},
SecretPattern {
entity_type: EntityType::PerplexityApiKey,
regex: r"\bpplx-[A-Za-z0-9]{48}\b",
score: 0.95,
},
SecretPattern {
entity_type: EntityType::HttpBasicAuth,
regex: r"(?i)\bBasic\s+([A-Za-z0-9+/]+={0,2})(?:\s|$|[^A-Za-z0-9+/=])",
score: 0.95,
},
];
#[derive(Debug, Clone)]
pub struct PatternRecognizer {
name: String,
patterns: HashMap<EntityType, Vec<CompiledPattern>>,
min_score: f32,
}
#[derive(Debug, Clone)]
struct CompiledPattern {
regex: Regex,
score: f32,
context_words: Vec<String>,
}
impl PatternRecognizer {
pub fn new() -> Self {
let mut recognizer = Self {
name: "PatternRecognizer".to_string(),
patterns: HashMap::new(),
min_score: 0.5,
};
recognizer.load_default_patterns();
recognizer
}
pub fn with_name(name: impl Into<String>) -> Self {
let mut recognizer = Self::new();
recognizer.name = name.into();
recognizer
}
pub fn with_min_score(mut self, min_score: f32) -> Self {
self.min_score = min_score;
self
}
pub fn add_pattern(
&mut self,
entity_type: EntityType,
pattern: &str,
score: f32,
) -> Result<()> {
let regex = Regex::new(pattern)?;
let compiled = CompiledPattern {
regex,
score,
context_words: vec![],
};
self.patterns.entry(entity_type).or_default().push(compiled);
Ok(())
}
pub fn add_pattern_with_context(
&mut self,
entity_type: EntityType,
pattern: &str,
score: f32,
context_words: Vec<String>,
) -> Result<()> {
let regex = Regex::new(pattern)?;
let compiled = CompiledPattern {
regex,
score,
context_words,
};
self.patterns.entry(entity_type).or_default().push(compiled);
Ok(())
}
fn load_default_patterns(&mut self) {
let _ = self.add_pattern(
EntityType::EmailAddress,
r"(?:^|[^A-Za-z0-9._%+-])([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})(?:[^A-Za-z0-9]|$)",
0.8,
);
let _ = self.add_pattern(
EntityType::PhoneNumber,
r"\(\d{3}\)[-.\s]?\d{3}[-.\s]?\d{4}\b|\b\d{3}[-.\s]\d{3}[-.\s]?\d{4}\b",
0.7,
);
let _ = self.add_pattern(
EntityType::CreditCard,
r"(?:^|[^0-9A-Za-z])((?:\d[ \-]?){12,18}\d)(?:[^0-9]|$)",
0.9,
);
let _ = self.add_pattern(EntityType::UsSsn, r"\b\d{3}-\d{2}-\d{4}\b", 0.9);
let _ = self.add_pattern(
EntityType::IpAddress,
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",
0.8,
);
let _ = self.add_pattern(
EntityType::Url,
r"\b(?:https?://|www\.)[a-zA-Z0-9][-a-zA-Z0-9]*(?:\.[a-zA-Z0-9][-a-zA-Z0-9]*)+(?:/[^\s]*)?\b",
0.7,
);
let _ = self.add_pattern(
EntityType::DomainName,
r"\b(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,}\b",
0.7,
);
let _ = self.add_pattern(
EntityType::Guid,
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",
0.9,
);
let _ = self.add_pattern(
EntityType::MacAddress,
r"\b(?:[0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}\b",
0.9,
);
let _ = self.add_pattern_with_context(
EntityType::UkNhs,
r"\b(?:\d{3}\s?\d{3}\s?\d{4}|\d{10})\b",
0.6,
vec![
"NHS".to_string(),
"patient".to_string(),
"health".to_string(),
],
);
let _ = self.add_pattern(
EntityType::UkNino,
r"\b[A-CEGHJ-PR-TW-Z]{1}[A-CEGHJ-NPR-TW-Z]{1}\d{6}[A-D]{1}\b",
0.85,
);
let _ = self.add_pattern(
EntityType::UkPostcode,
r"\b[A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2}\b",
0.75,
);
let _ = self.add_pattern(EntityType::UkSortCode, r"\b\d{2}-\d{2}-\d{2}\b", 0.7);
let _ = self.add_pattern(
EntityType::IbanCode,
r"(?:^|[^A-Z0-9])([A-Z]{2}\d{2}(?:[ ]?[A-Z0-9]){11,30})(?:[^A-Z0-9]|$)",
0.75,
);
let _ = self.add_pattern(
EntityType::BtcAddress,
r"\b(?:bc1|[13])[a-zA-HJ-NP-Z0-9]{25,62}\b",
0.85,
);
let _ = self.add_pattern(EntityType::EthAddress, r"\b0x[a-fA-F0-9]{40}\b", 0.9);
let _ = self.add_pattern(EntityType::Md5Hash, r"\b[a-fA-F0-9]{32}\b", 0.6);
let _ = self.add_pattern(EntityType::Sha1Hash, r"\b[a-fA-F0-9]{40}\b", 0.6);
let _ = self.add_pattern(EntityType::Sha256Hash, r"\b[a-fA-F0-9]{64}\b", 0.6);
let _ = self.add_pattern(
EntityType::UsZipCode,
r"\b\d{5}(?:-\d{4})?\b",
0.6, );
let _ = self.add_pattern_with_context(
EntityType::PoBox,
r"\b(?:P\.?\s?O\.?|POST\s+OFFICE)\s*BOX\s+\d+\b",
0.85,
vec![
"address".to_string(),
"mail".to_string(),
"ship".to_string(),
],
);
let _ = self.add_pattern(
EntityType::Isbn,
r"\b(?:ISBN(?:-1[03])?:?\s*)?(?:\d{9}[\dX]|\d{13})\b",
0.8,
);
let _ = self.add_pattern_with_context(
EntityType::PassportNumber,
r"\b[A-Z]{1,2}\d{6,9}\b",
0.7,
vec!["passport".to_string(), "travel".to_string()],
);
let _ = self.add_pattern_with_context(
EntityType::MedicalRecordNumber,
r"\b(?:MRN|Medical\s*Record|Patient\s*ID):?\s*[A-Z0-9]{6,12}\b",
0.85,
vec![
"patient".to_string(),
"medical".to_string(),
"hospital".to_string(),
],
);
let _ = self.add_pattern_with_context(
EntityType::Age,
r"\b(?:age|aged|years old):?\s*(\d{1,3})\b",
0.8,
vec!["years".to_string(), "old".to_string(), "age".to_string()],
);
let _ = self.add_pattern(
EntityType::DateTime,
r"\b\d{4}-\d{2}-\d{2}(?:[T\s]\d{2}:\d{2}(?::\d{2})?)?\b",
0.5,
);
let _ = self.add_pattern_with_context(
EntityType::UsDriverLicense,
r"\b[A-Z]\d{6,8}\b|\b[A-Z]\d{3}-\d{4}-\d{4}\b",
0.4,
vec![
"driver".to_string(),
"license".to_string(),
"DL".to_string(),
"DMV".to_string(),
],
);
let _ = self.add_pattern_with_context(
EntityType::UsPassport,
r"\b[A-Z]?\d{9}\b",
0.4,
vec![
"passport".to_string(),
"travel".to_string(),
"state department".to_string(),
],
);
let _ = self.add_pattern_with_context(
EntityType::UsBankNumber,
r"(?:^|[^0-9A-Za-z])(\d{8,19})(?:[^0-9]|$)",
0.3,
vec![
"account number".to_string(),
"acct".to_string(),
"account no".to_string(),
"kontonummer".to_string(),
"número de cuenta".to_string(),
"số tài khoản".to_string(),
"账号".to_string(),
"口座番号".to_string(),
],
);
let _ = self.add_pattern(
EntityType::UkDriverLicense,
r"\b[A-Z]{5}\d{6}[A-Z0-9]{2}\d[A-Z]{2}\s?\d{2}\b",
0.85,
);
let _ = self.add_pattern_with_context(
EntityType::UkPassportNumber,
r"\b\d{9}\b",
0.3,
vec![
"passport".to_string(),
"travel".to_string(),
"HMPO".to_string(),
],
);
let _ = self.add_pattern(
EntityType::UkPhoneNumber,
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",
0.75,
);
let _ = self.add_pattern(
EntityType::UkMobileNumber,
r"\b07\d{3}\s?\d{3}\s?\d{3}\b",
0.8,
);
let _ = self.add_pattern_with_context(
EntityType::UkCompanyNumber,
r"\b(?:\d{8}|[A-Z]{2}\d{6})\b",
0.3,
vec![
"company".to_string(),
"companies house".to_string(),
"registration".to_string(),
"CRN".to_string(),
],
);
let _ = self.add_pattern_with_context(
EntityType::MedicalLicense,
r"\b(?:MD|DO|NP|PA|RN|LPN)[-\s]?\d{5,10}\b",
0.8,
vec![
"license".to_string(),
"medical".to_string(),
"physician".to_string(),
"doctor".to_string(),
"nurse".to_string(),
],
);
let _ = self.add_pattern_with_context(
EntityType::CryptoWallet,
r"\b[LMr3][a-km-zA-HJ-NP-Z1-9]{25,34}\b",
0.75,
vec![
"wallet".to_string(),
"crypto".to_string(),
"address".to_string(),
"coin".to_string(),
],
);
for p in SECRET_PATTERNS {
self.add_pattern(p.entity_type.clone(), p.regex, p.score)
.unwrap_or_else(|e| {
panic!(
"SECRET_PATTERNS regex failed to compile for {:?}: {e}",
p.entity_type
)
});
}
}
fn check_context(&self, text: &str, start: usize, end: usize, context_words: &[String]) -> f32 {
if context_words.is_empty() {
return 0.0;
}
let (context_start, context_end) = context_window(text, start, end, CONTEXT_CHARS);
let context = text
.get(context_start..context_end)
.unwrap_or("")
.to_lowercase();
let matches = context_words
.iter()
.filter(|word| context.contains(&word.to_lowercase()))
.count();
(matches as f32 / context_words.len() as f32) * 0.3
}
}
const CONTEXT_CHARS: usize = 50;
fn context_window(text: &str, start: usize, end: usize, radius: usize) -> (usize, usize) {
let start = crate::floor_char_boundary(text, start);
let end = crate::ceil_char_boundary(text, end.max(start));
let window_start = text[..start]
.char_indices()
.rev()
.take(radius)
.last()
.map_or(start, |(index, _)| index);
let window_end = text[end..]
.char_indices()
.nth(radius)
.map_or(text.len(), |(index, _)| end + index);
(window_start, window_end)
}
impl Default for PatternRecognizer {
fn default() -> Self {
Self::new()
}
}
impl Recognizer for PatternRecognizer {
fn name(&self) -> &str {
&self.name
}
fn supported_entities(&self) -> &[EntityType] {
lazy_static! {
static ref SUPPORTED: Vec<EntityType> = vec![
EntityType::EmailAddress,
EntityType::PhoneNumber,
EntityType::IpAddress,
EntityType::Url,
EntityType::DomainName,
EntityType::CreditCard,
EntityType::IbanCode,
EntityType::UsBankNumber,
EntityType::UsSsn,
EntityType::UsDriverLicense,
EntityType::UsPassport,
EntityType::UsZipCode,
EntityType::UkNhs,
EntityType::UkNino,
EntityType::UkPostcode,
EntityType::UkSortCode,
EntityType::UkDriverLicense,
EntityType::UkPassportNumber,
EntityType::UkPhoneNumber,
EntityType::UkMobileNumber,
EntityType::UkCompanyNumber,
EntityType::MedicalLicense,
EntityType::MedicalRecordNumber,
EntityType::PassportNumber,
EntityType::Age,
EntityType::Isbn,
EntityType::PoBox,
EntityType::DateTime,
EntityType::CryptoWallet,
EntityType::BtcAddress,
EntityType::EthAddress,
EntityType::Guid,
EntityType::MacAddress,
EntityType::Md5Hash,
EntityType::Sha1Hash,
EntityType::Sha256Hash,
EntityType::PrivateKey,
EntityType::JwtToken,
EntityType::AwsAccessKey,
EntityType::GithubToken,
EntityType::GitlabToken,
EntityType::SlackToken,
EntityType::SlackWebhook,
EntityType::StripeApiKey,
EntityType::GoogleApiKey,
EntityType::OpenAiApiKey,
EntityType::AnthropicApiKey,
EntityType::NpmToken,
EntityType::PyPiToken,
EntityType::SendGridApiKey,
EntityType::TwilioApiKey,
EntityType::TelegramBotToken,
EntityType::HashicorpVaultToken,
EntityType::DatabaseConnectionString,
EntityType::HuggingFaceToken,
EntityType::DatabricksToken,
EntityType::DigitalOceanToken,
EntityType::NotionApiKey,
EntityType::PerplexityApiKey,
EntityType::HttpBasicAuth,
];
}
&SUPPORTED
}
fn analyze(&self, text: &str, _language: &str) -> Result<Vec<RecognizerResult>> {
let _span = crate::operations_enabled()
.then(|| tracing::info_span!("redact.gateway.detect.patterns").entered());
let mut results = Vec::new();
for (entity_type, patterns) in &self.patterns {
for pattern in patterns {
for capture in pattern.regex.captures_iter(text) {
if let Some(matched) = match entity_type {
EntityType::HttpBasicAuth
| EntityType::AwsAccessKey
| EntityType::EmailAddress
| EntityType::CreditCard
| EntityType::IbanCode
| EntityType::UsBankNumber => capture.get(1).or_else(|| capture.get(0)),
_ => capture.get(0),
} {
let start = matched.start();
let end = matched.end();
let matched_text = matched.as_str();
let mut score = pattern.score;
if *entity_type == EntityType::UsBankNumber {
if self.check_context(text, start, end, &pattern.context_words) == 0.0 {
continue;
}
score = 0.6;
} else if !pattern.context_words.is_empty() {
score += self.check_context(text, start, end, &pattern.context_words);
score = score.min(1.0);
}
let validation_factor = validate_entity(entity_type, matched_text);
score *= validation_factor;
if score >= self.min_score {
results.push(
RecognizerResult::new(
entity_type.clone(),
start,
end,
score,
self.name(),
)
.with_text(text),
);
}
}
}
}
}
Ok(results)
}
fn min_score(&self) -> f32 {
self.min_score
}
fn supports_language(&self, _language: &str) -> bool {
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn context_window_matches_legacy_byte_window_on_ascii() {
let text: String = (0..300).map(|i| (b'a' + (i % 26) as u8) as char).collect();
for start in 0..text.len() {
for end in start..(start + 20).min(text.len()) {
assert_eq!(
context_window(&text, start, end, 50),
(start.saturating_sub(50), (end + 50).min(text.len())),
"{start}..{end}"
);
}
}
}
#[test]
fn context_window_counts_vietnamese_chars_not_bytes() {
let text = format!("bank {}841234567890", "ạ ".repeat(20));
let start = text.find('8').unwrap();
let (window_start, window_end) = context_window(&text, start, start + 12, 50);
assert_eq!(text[window_start..start].chars().count(), 45);
assert_eq!(window_end, text.len());
assert!(text[window_start..window_end].contains("bank"));
}
#[test]
fn check_context_does_not_panic_on_multibyte_boundaries() {
let recognizer = PatternRecognizer::new();
for text in [
format!("841234567890{}ó", " ".repeat(49)),
format!("ử{}841234567890", " ".repeat(49)),
format!("841234567890{}📄", " ".repeat(48)),
"Mã đơn hàng 20260916 đã được xử lý thành công. Tổng cộng: 1.250.000 đồng.".to_string(),
] {
recognizer.analyze(&text, "en").expect("analyze");
}
}
#[test]
fn test_age_span_includes_label() {
let recognizer = PatternRecognizer::new();
let text = "age: 42";
let results = recognizer.analyze(text, "en").unwrap();
let age = results
.iter()
.find(|r| r.entity_type == EntityType::Age)
.expect("AGE");
assert_eq!(&text[age.start..age.end], "age: 42");
}
#[test]
fn pattern_recognizer_supports_any_language() {
let recognizer = PatternRecognizer::new();
for language in ["en", "es", "vi", "zh", "de", "fr"] {
assert!(
recognizer.supports_language(language),
"{language} should run pattern detection"
);
}
}
#[test]
fn test_email_detection() {
let recognizer = PatternRecognizer::new();
let text = "Contact me at john.doe@example.com for details";
let results = recognizer.analyze(text, "en").unwrap();
let email_results: Vec<_> = results
.iter()
.filter(|r| r.entity_type == EntityType::EmailAddress)
.collect();
assert_eq!(email_results.len(), 1);
assert_eq!(
email_results[0].text,
Some("john.doe@example.com".to_string())
);
assert!(email_results[0].score >= 0.8);
}
#[test]
fn test_phone_detection() {
let recognizer = PatternRecognizer::new();
let text = "Call me at (555) 123-4567";
let results = recognizer.analyze(text, "en").unwrap();
assert!(!results.is_empty());
let phone_result = results
.iter()
.find(|r| r.entity_type == EntityType::PhoneNumber);
assert!(phone_result.is_some());
}
#[test]
fn test_credit_card_detection() {
let recognizer = PatternRecognizer::new();
let text = "Card number: 4532015112830366";
let results = recognizer.analyze(text, "en").unwrap();
assert!(!results.is_empty());
let cc_result = results
.iter()
.find(|r| r.entity_type == EntityType::CreditCard);
assert!(cc_result.is_some());
}
#[test]
fn test_spaced_amex_4_6_5_uses_full_span() {
let recognizer = PatternRecognizer::new();
let text = "Amex 3782 822463 10005";
let results = recognizer.analyze(text, "es").unwrap();
let card = results
.iter()
.find(|r| r.entity_type == EntityType::CreditCard)
.expect("spaced Amex");
assert_eq!(card.text.as_deref(), Some("3782 822463 10005"));
}
#[test]
fn test_email_and_card_after_cjk() {
let recognizer = PatternRecognizer::new();
let text = "邮箱maria@example.com卡4532015112830366";
let results = recognizer.analyze(text, "zh").unwrap();
let email = results
.iter()
.find(|r| r.entity_type == EntityType::EmailAddress)
.expect("CJK-adjacent email");
assert_eq!(email.text.as_deref(), Some("maria@example.com"));
let card = results
.iter()
.find(|r| r.entity_type == EntityType::CreditCard)
.expect("CJK-adjacent card");
assert_eq!(card.text.as_deref(), Some("4532015112830366"));
}
#[test]
fn test_spaced_iban_full_span() {
let recognizer = PatternRecognizer::new();
let text = "IBAN DE89 3704 0044 0532 0130 00";
let results = recognizer.analyze(text, "de").unwrap();
let iban = results
.iter()
.find(|r| r.entity_type == EntityType::IbanCode)
.expect("spaced IBAN");
assert_eq!(iban.text.as_deref(), Some("DE89 3704 0044 0532 0130 00"));
}
#[test]
fn test_ssn_detection() {
let recognizer = PatternRecognizer::new();
let text = "SSN: 123-45-6789";
let results = recognizer.analyze(text, "en").unwrap();
assert!(!results.is_empty());
let ssn_result = results.iter().find(|r| r.entity_type == EntityType::UsSsn);
assert!(ssn_result.is_some());
}
#[test]
fn test_uk_nhs_with_context() {
let recognizer = PatternRecognizer::new();
let text = "NHS patient number is 401 023 2137";
let results = recognizer.analyze(text, "en").unwrap();
assert!(!results.is_empty());
let nhs_result = results.iter().find(|r| r.entity_type == EntityType::UkNhs);
assert!(
nhs_result.is_some(),
"Should detect NHS number with context"
);
if let Some(result) = nhs_result {
assert!(result.score > 0.6);
}
}
#[test]
fn test_uk_nino_detection() {
let recognizer = PatternRecognizer::new();
let text = "NINO: AB123456C";
let results = recognizer.analyze(text, "en").unwrap();
assert!(!results.is_empty());
let nino_result = results.iter().find(|r| r.entity_type == EntityType::UkNino);
assert!(nino_result.is_some());
}
#[test]
fn test_multiple_entities() {
let recognizer = PatternRecognizer::new();
let text = "Email john@example.com, phone (555) 123-4567, SSN 123-45-6789";
let results = recognizer.analyze(text, "en").unwrap();
assert!(results.len() >= 3);
assert!(results
.iter()
.any(|r| r.entity_type == EntityType::EmailAddress));
assert!(results
.iter()
.any(|r| r.entity_type == EntityType::PhoneNumber));
assert!(results.iter().any(|r| r.entity_type == EntityType::UsSsn));
}
#[test]
fn test_custom_pattern() {
let mut recognizer = PatternRecognizer::new();
recognizer
.add_pattern(
EntityType::Custom("CUSTOM_ID".to_string()),
r"\bCID-\d{6}\b",
0.9,
)
.unwrap();
let text = "Your customer ID is CID-123456";
let results = recognizer.analyze(text, "en").unwrap();
let custom_result = results
.iter()
.find(|r| matches!(r.entity_type, EntityType::Custom(_)));
assert!(custom_result.is_some());
}
#[test]
fn test_min_score_filtering() {
let recognizer = PatternRecognizer::new().with_min_score(0.9);
let text = "Date: 2024-01-15"; let results = recognizer.analyze(text, "en").unwrap();
let date_results = results
.iter()
.filter(|r| r.entity_type == EntityType::DateTime)
.count();
assert_eq!(date_results, 0);
}
#[test]
fn test_uk_driver_license_detection() {
let recognizer = PatternRecognizer::new();
let text = "UK DL: MORGA753116SM9IJ 35";
let results = recognizer.analyze(text, "en").unwrap();
let dl_result = results
.iter()
.find(|r| r.entity_type == EntityType::UkDriverLicense);
assert!(dl_result.is_some(), "Should detect UK driver's license");
}
#[test]
fn test_uk_mobile_detection() {
let recognizer = PatternRecognizer::new();
let text = "Call me on 07700 900123";
let results = recognizer.analyze(text, "en").unwrap();
let mobile_result = results
.iter()
.find(|r| r.entity_type == EntityType::UkMobileNumber);
assert!(mobile_result.is_some(), "Should detect UK mobile number");
}
#[test]
fn test_uk_phone_detection() {
let recognizer = PatternRecognizer::new();
let text = "Office: 0207 123 4567";
let results = recognizer.analyze(text, "en").unwrap();
let phone_result = results
.iter()
.find(|r| r.entity_type == EntityType::UkPhoneNumber);
assert!(phone_result.is_some(), "Should detect UK phone number");
}
#[test]
fn test_medical_license_detection() {
let recognizer = PatternRecognizer::new();
let text = "Medical license: MD-123456789";
let results = recognizer.analyze(text, "en").unwrap();
let license_result = results
.iter()
.find(|r| r.entity_type == EntityType::MedicalLicense);
assert!(license_result.is_some(), "Should detect medical license");
}
#[test]
fn test_supported_entities_count() {
let recognizer = PatternRecognizer::new();
let supported = recognizer.supported_entities();
assert_eq!(
supported.len(),
60,
"Should support 60 pattern-based entity types, got {}",
supported.len()
);
}
}