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::GithubToken,
47 regex: r"\b(?:gh[pousr]_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9]{22}_[A-Za-z0-9]{59})\b",
48 score: 0.95,
49 },
50 SecretPattern {
51 entity_type: EntityType::GitlabToken,
52 regex: r"\bglpat-[A-Za-z0-9_-]{20}\b",
53 score: 0.95,
54 },
55 SecretPattern {
56 entity_type: EntityType::SlackToken,
57 regex: r"\bxox[baprs]-[A-Za-z0-9-]{10,72}\b",
58 score: 0.95,
59 },
60 SecretPattern {
61 entity_type: EntityType::SlackWebhook,
62 regex: r"https://hooks\.slack\.com/services/T[A-Za-z0-9_]+/B[A-Za-z0-9_]+/[A-Za-z0-9_]{20,}",
63 score: 0.95,
64 },
65 SecretPattern {
66 entity_type: EntityType::StripeApiKey,
67 regex: r"\b(?:sk|rk)_(?:live|test)_[0-9a-zA-Z]{24,99}\b",
71 score: 0.95,
72 },
73 SecretPattern {
74 entity_type: EntityType::GoogleApiKey,
75 regex: r"\bAIza[0-9A-Za-z_-]{35}",
76 score: 0.95,
77 },
78 SecretPattern {
79 entity_type: EntityType::OpenAiApiKey,
80 regex: r"\bsk-(?:proj-[A-Za-z0-9_-]{40,}|[A-Za-z0-9]{32,})",
86 score: 0.90,
87 },
88 SecretPattern {
89 entity_type: EntityType::AnthropicApiKey,
90 regex: r"\bsk-ant-(?:api03-)?[A-Za-z0-9_-]{24,}",
91 score: 0.95,
92 },
93 SecretPattern {
94 entity_type: EntityType::NpmToken,
95 regex: r"\bnpm_[A-Za-z0-9]{36}\b",
96 score: 0.95,
97 },
98 SecretPattern {
99 entity_type: EntityType::PyPiToken,
100 regex: r"\bpypi-AgEIcHlwaS5vcmc[A-Za-z0-9_-]{50,}",
101 score: 0.95,
102 },
103 SecretPattern {
104 entity_type: EntityType::SendGridApiKey,
105 regex: r"\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}",
106 score: 0.95,
107 },
108 SecretPattern {
109 entity_type: EntityType::TwilioApiKey,
110 regex: r"\bSK[0-9a-fA-F]{32}\b",
111 score: 0.85,
112 },
113 SecretPattern {
114 entity_type: EntityType::TelegramBotToken,
115 regex: r"\b\d{8,10}:AA[A-Za-z0-9_-]{33}",
116 score: 0.95,
117 },
118 SecretPattern {
119 entity_type: EntityType::HashicorpVaultToken,
120 regex: r"\bhv[sbr]\.[A-Za-z0-9_-]{24,}",
121 score: 0.95,
122 },
123 SecretPattern {
124 entity_type: EntityType::DatabaseConnectionString,
125 regex: r"\b(?:mongodb(?:\+srv)?|postgres(?:ql)?|mysql|mariadb|redis|amqp|mssql)://[^:@/\s]+:[^@/\s]+@[^\s/]+(?:/[^\s?#]*)?(?:\?[^\s#]*)?",
129 score: 0.90,
130 },
131];
132
133#[derive(Debug, Clone)]
135pub struct PatternRecognizer {
136 name: String,
137 patterns: HashMap<EntityType, Vec<CompiledPattern>>,
138 min_score: f32,
139}
140
141#[derive(Debug, Clone)]
142struct CompiledPattern {
143 regex: Regex,
144 score: f32,
145 context_words: Vec<String>,
146}
147
148impl PatternRecognizer {
149 pub fn new() -> Self {
151 let mut recognizer = Self {
152 name: "PatternRecognizer".to_string(),
153 patterns: HashMap::new(),
154 min_score: 0.5,
155 };
156 recognizer.load_default_patterns();
157 recognizer
158 }
159
160 pub fn with_name(name: impl Into<String>) -> Self {
162 let mut recognizer = Self::new();
163 recognizer.name = name.into();
164 recognizer
165 }
166
167 pub fn with_min_score(mut self, min_score: f32) -> Self {
169 self.min_score = min_score;
170 self
171 }
172
173 pub fn add_pattern(
175 &mut self,
176 entity_type: EntityType,
177 pattern: &str,
178 score: f32,
179 ) -> Result<()> {
180 let regex = Regex::new(pattern)?;
181 let compiled = CompiledPattern {
182 regex,
183 score,
184 context_words: vec![],
185 };
186 self.patterns.entry(entity_type).or_default().push(compiled);
187 Ok(())
188 }
189
190 pub fn add_pattern_with_context(
192 &mut self,
193 entity_type: EntityType,
194 pattern: &str,
195 score: f32,
196 context_words: Vec<String>,
197 ) -> Result<()> {
198 let regex = Regex::new(pattern)?;
199 let compiled = CompiledPattern {
200 regex,
201 score,
202 context_words,
203 };
204 self.patterns.entry(entity_type).or_default().push(compiled);
205 Ok(())
206 }
207
208 fn load_default_patterns(&mut self) {
210 let _ = self.add_pattern(
212 EntityType::EmailAddress,
213 r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b",
214 0.8,
215 );
216
217 let _ = self.add_pattern(
223 EntityType::PhoneNumber,
224 r"\(\d{3}\)[-.\s]?\d{3}[-.\s]?\d{4}\b|\b\d{3}[-.\s]\d{3}[-.\s]?\d{4}\b",
225 0.7,
226 );
227
228 let _ = self.add_pattern(
230 EntityType::CreditCard,
231 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",
232 0.9,
233 );
234
235 let _ = self.add_pattern(EntityType::UsSsn, r"\b\d{3}-\d{2}-\d{4}\b", 0.9);
238
239 let _ = self.add_pattern(
241 EntityType::IpAddress,
242 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",
243 0.8,
244 );
245
246 let _ = self.add_pattern(
248 EntityType::Url,
249 r"\b(?:https?://|www\.)[a-zA-Z0-9][-a-zA-Z0-9]*(?:\.[a-zA-Z0-9][-a-zA-Z0-9]*)+(?:/[^\s]*)?\b",
250 0.7,
251 );
252
253 let _ = self.add_pattern(
255 EntityType::DomainName,
256 r"\b(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,}\b",
257 0.7,
258 );
259
260 let _ = self.add_pattern(
262 EntityType::Guid,
263 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",
264 0.9,
265 );
266
267 let _ = self.add_pattern(
269 EntityType::MacAddress,
270 r"\b(?:[0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}\b",
271 0.9,
272 );
273
274 let _ = self.add_pattern_with_context(
276 EntityType::UkNhs,
277 r"\b(?:\d{3}\s?\d{3}\s?\d{4}|\d{10})\b",
278 0.6,
279 vec![
280 "NHS".to_string(),
281 "patient".to_string(),
282 "health".to_string(),
283 ],
284 );
285
286 let _ = self.add_pattern(
288 EntityType::UkNino,
289 r"\b[A-CEGHJ-PR-TW-Z]{1}[A-CEGHJ-NPR-TW-Z]{1}\d{6}[A-D]{1}\b",
290 0.85,
291 );
292
293 let _ = self.add_pattern(
295 EntityType::UkPostcode,
296 r"\b[A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2}\b",
297 0.75,
298 );
299
300 let _ = self.add_pattern(EntityType::UkSortCode, r"\b\d{2}-\d{2}-\d{2}\b", 0.7);
302
303 let _ = self.add_pattern(
305 EntityType::IbanCode,
306 r"\b[A-Z]{2}\d{2}[A-Z0-9]{1,30}\b",
307 0.75,
308 );
309
310 let _ = self.add_pattern(
312 EntityType::BtcAddress,
313 r"\b(?:bc1|[13])[a-zA-HJ-NP-Z0-9]{25,62}\b",
314 0.85,
315 );
316
317 let _ = self.add_pattern(EntityType::EthAddress, r"\b0x[a-fA-F0-9]{40}\b", 0.9);
319
320 let _ = self.add_pattern(EntityType::Md5Hash, r"\b[a-fA-F0-9]{32}\b", 0.6);
322
323 let _ = self.add_pattern(EntityType::Sha1Hash, r"\b[a-fA-F0-9]{40}\b", 0.6);
325
326 let _ = self.add_pattern(EntityType::Sha256Hash, r"\b[a-fA-F0-9]{64}\b", 0.6);
328
329 let _ = self.add_pattern(
331 EntityType::UsZipCode,
332 r"\b\d{5}(?:-\d{4})?\b",
333 0.6, );
335
336 let _ = self.add_pattern_with_context(
338 EntityType::PoBox,
339 r"\b(?:P\.?\s?O\.?|POST\s+OFFICE)\s*BOX\s+\d+\b",
340 0.85,
341 vec![
342 "address".to_string(),
343 "mail".to_string(),
344 "ship".to_string(),
345 ],
346 );
347
348 let _ = self.add_pattern(
350 EntityType::Isbn,
351 r"\b(?:ISBN(?:-1[03])?:?\s*)?(?:\d{9}[\dX]|\d{13})\b",
352 0.8,
353 );
354
355 let _ = self.add_pattern_with_context(
357 EntityType::PassportNumber,
358 r"\b[A-Z]{1,2}\d{6,9}\b",
359 0.7,
360 vec!["passport".to_string(), "travel".to_string()],
361 );
362
363 let _ = self.add_pattern_with_context(
365 EntityType::MedicalRecordNumber,
366 r"\b(?:MRN|Medical\s*Record|Patient\s*ID):?\s*[A-Z0-9]{6,12}\b",
367 0.85,
368 vec![
369 "patient".to_string(),
370 "medical".to_string(),
371 "hospital".to_string(),
372 ],
373 );
374
375 let _ = self.add_pattern_with_context(
377 EntityType::Age,
378 r"\b(?:age|aged|years old):?\s*(\d{1,3})\b",
379 0.8,
380 vec!["years".to_string(), "old".to_string(), "age".to_string()],
381 );
382
383 let _ = self.add_pattern(
385 EntityType::DateTime,
386 r"\b\d{4}-\d{2}-\d{2}(?:[T\s]\d{2}:\d{2}(?::\d{2})?)?\b",
387 0.5,
388 );
389
390 let _ = self.add_pattern_with_context(
396 EntityType::UsDriverLicense,
397 r"\b[A-Z]\d{6,8}\b|\b[A-Z]\d{3}-\d{4}-\d{4}\b",
398 0.4,
399 vec![
400 "driver".to_string(),
401 "license".to_string(),
402 "DL".to_string(),
403 "DMV".to_string(),
404 ],
405 );
406
407 let _ = self.add_pattern_with_context(
410 EntityType::UsPassport,
411 r"\b[A-Z]?\d{9}\b",
412 0.4,
413 vec![
414 "passport".to_string(),
415 "travel".to_string(),
416 "state department".to_string(),
417 ],
418 );
419
420 let _ = self.add_pattern_with_context(
423 EntityType::UsBankNumber,
424 r"\b\d{8,17}\b",
425 0.3,
426 vec![
427 "account".to_string(),
428 "bank".to_string(),
429 "routing".to_string(),
430 "checking".to_string(),
431 "savings".to_string(),
432 ],
433 );
434
435 let _ = self.add_pattern(
438 EntityType::UkDriverLicense,
439 r"\b[A-Z]{5}\d{6}[A-Z0-9]{2}\d[A-Z]{2}\s?\d{2}\b",
440 0.85,
441 );
442
443 let _ = self.add_pattern_with_context(
446 EntityType::UkPassportNumber,
447 r"\b\d{9}\b",
448 0.3,
449 vec![
450 "passport".to_string(),
451 "travel".to_string(),
452 "HMPO".to_string(),
453 ],
454 );
455
456 let _ = self.add_pattern(
458 EntityType::UkPhoneNumber,
459 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",
460 0.75,
461 );
462
463 let _ = self.add_pattern(
465 EntityType::UkMobileNumber,
466 r"\b07\d{3}\s?\d{3}\s?\d{3}\b",
467 0.8,
468 );
469
470 let _ = self.add_pattern_with_context(
473 EntityType::UkCompanyNumber,
474 r"\b(?:\d{8}|[A-Z]{2}\d{6})\b",
475 0.3,
476 vec![
477 "company".to_string(),
478 "companies house".to_string(),
479 "registration".to_string(),
480 "CRN".to_string(),
481 ],
482 );
483
484 let _ = self.add_pattern_with_context(
486 EntityType::MedicalLicense,
487 r"\b(?:MD|DO|NP|PA|RN|LPN)[-\s]?\d{5,10}\b",
488 0.8,
489 vec![
490 "license".to_string(),
491 "medical".to_string(),
492 "physician".to_string(),
493 "doctor".to_string(),
494 "nurse".to_string(),
495 ],
496 );
497
498 let _ = self.add_pattern_with_context(
501 EntityType::CryptoWallet,
502 r"\b[LMr3][a-km-zA-HJ-NP-Z1-9]{25,34}\b",
503 0.75,
504 vec![
505 "wallet".to_string(),
506 "crypto".to_string(),
507 "address".to_string(),
508 "coin".to_string(),
509 ],
510 );
511
512 for p in SECRET_PATTERNS {
514 let _ = self.add_pattern(p.entity_type.clone(), p.regex, p.score);
515 }
516 }
517
518 fn check_context(&self, text: &str, start: usize, end: usize, context_words: &[String]) -> f32 {
520 if context_words.is_empty() {
521 return 0.0;
522 }
523
524 let context_start = start.saturating_sub(50);
526 let context_end = (end + 50).min(text.len());
527 let context = &text[context_start..context_end].to_lowercase();
528
529 let matches = context_words
531 .iter()
532 .filter(|word| context.contains(&word.to_lowercase()))
533 .count();
534
535 (matches as f32 / context_words.len() as f32) * 0.3
537 }
538}
539
540impl Default for PatternRecognizer {
541 fn default() -> Self {
542 Self::new()
543 }
544}
545
546impl Recognizer for PatternRecognizer {
547 fn name(&self) -> &str {
548 &self.name
549 }
550
551 fn supported_entities(&self) -> &[EntityType] {
552 lazy_static! {
553 static ref SUPPORTED: Vec<EntityType> = vec![
554 EntityType::EmailAddress,
556 EntityType::PhoneNumber,
557 EntityType::IpAddress,
558 EntityType::Url,
559 EntityType::DomainName,
560 EntityType::CreditCard,
562 EntityType::IbanCode,
563 EntityType::UsBankNumber,
564 EntityType::UsSsn,
566 EntityType::UsDriverLicense,
567 EntityType::UsPassport,
568 EntityType::UsZipCode,
569 EntityType::UkNhs,
571 EntityType::UkNino,
572 EntityType::UkPostcode,
573 EntityType::UkSortCode,
574 EntityType::UkDriverLicense,
575 EntityType::UkPassportNumber,
576 EntityType::UkPhoneNumber,
577 EntityType::UkMobileNumber,
578 EntityType::UkCompanyNumber,
579 EntityType::MedicalLicense,
581 EntityType::MedicalRecordNumber,
582 EntityType::PassportNumber,
584 EntityType::Age,
585 EntityType::Isbn,
586 EntityType::PoBox,
587 EntityType::DateTime,
588 EntityType::CryptoWallet,
590 EntityType::BtcAddress,
591 EntityType::EthAddress,
592 EntityType::Guid,
594 EntityType::MacAddress,
595 EntityType::Md5Hash,
596 EntityType::Sha1Hash,
597 EntityType::Sha256Hash,
598 EntityType::PrivateKey,
600 EntityType::JwtToken,
601 EntityType::AwsAccessKey,
602 EntityType::GithubToken,
603 EntityType::GitlabToken,
604 EntityType::SlackToken,
605 EntityType::SlackWebhook,
606 EntityType::StripeApiKey,
607 EntityType::GoogleApiKey,
608 EntityType::OpenAiApiKey,
609 EntityType::AnthropicApiKey,
610 EntityType::NpmToken,
611 EntityType::PyPiToken,
612 EntityType::SendGridApiKey,
613 EntityType::TwilioApiKey,
614 EntityType::TelegramBotToken,
615 EntityType::HashicorpVaultToken,
616 EntityType::DatabaseConnectionString,
617 ];
618 }
619 &SUPPORTED
620 }
621
622 fn analyze(&self, text: &str, _language: &str) -> Result<Vec<RecognizerResult>> {
623 let mut results = Vec::new();
624
625 for (entity_type, patterns) in &self.patterns {
626 for pattern in patterns {
627 for capture in pattern.regex.captures_iter(text) {
628 if let Some(matched) = capture.get(0) {
629 let start = matched.start();
630 let end = matched.end();
631 let matched_text = matched.as_str();
632
633 let mut score = pattern.score;
635
636 if !pattern.context_words.is_empty() {
638 score += self.check_context(text, start, end, &pattern.context_words);
639 score = score.min(1.0); }
641
642 let validation_factor = validate_entity(entity_type, matched_text);
645 score *= validation_factor;
646
647 if score >= self.min_score {
648 results.push(
649 RecognizerResult::new(
650 entity_type.clone(),
651 start,
652 end,
653 score,
654 self.name(),
655 )
656 .with_text(text),
657 );
658 }
659 }
660 }
661 }
662 }
663
664 Ok(results)
665 }
666
667 fn min_score(&self) -> f32 {
668 self.min_score
669 }
670}
671
672#[cfg(test)]
673mod tests {
674 use super::*;
675
676 #[test]
677 fn test_email_detection() {
678 let recognizer = PatternRecognizer::new();
679 let text = "Contact me at john.doe@example.com for details";
680 let results = recognizer.analyze(text, "en").unwrap();
681
682 let email_results: Vec<_> = results
683 .iter()
684 .filter(|r| r.entity_type == EntityType::EmailAddress)
685 .collect();
686 assert_eq!(email_results.len(), 1);
687 assert_eq!(
688 email_results[0].text,
689 Some("john.doe@example.com".to_string())
690 );
691 assert!(email_results[0].score >= 0.8);
692 }
693
694 #[test]
695 fn test_phone_detection() {
696 let recognizer = PatternRecognizer::new();
697 let text = "Call me at (555) 123-4567";
698 let results = recognizer.analyze(text, "en").unwrap();
699
700 assert!(!results.is_empty());
701 let phone_result = results
702 .iter()
703 .find(|r| r.entity_type == EntityType::PhoneNumber);
704 assert!(phone_result.is_some());
705 }
706
707 #[test]
708 fn test_credit_card_detection() {
709 let recognizer = PatternRecognizer::new();
710 let text = "Card number: 4532015112830366";
711 let results = recognizer.analyze(text, "en").unwrap();
712
713 assert!(!results.is_empty());
714 let cc_result = results
715 .iter()
716 .find(|r| r.entity_type == EntityType::CreditCard);
717 assert!(cc_result.is_some());
718 }
719
720 #[test]
721 fn test_ssn_detection() {
722 let recognizer = PatternRecognizer::new();
723 let text = "SSN: 123-45-6789";
724 let results = recognizer.analyze(text, "en").unwrap();
725
726 assert!(!results.is_empty());
727 let ssn_result = results.iter().find(|r| r.entity_type == EntityType::UsSsn);
728 assert!(ssn_result.is_some());
729 }
730
731 #[test]
732 fn test_uk_nhs_with_context() {
733 let recognizer = PatternRecognizer::new();
734 let text = "NHS patient number is 401 023 2137";
739 let results = recognizer.analyze(text, "en").unwrap();
740
741 assert!(!results.is_empty());
742 let nhs_result = results.iter().find(|r| r.entity_type == EntityType::UkNhs);
743 assert!(
744 nhs_result.is_some(),
745 "Should detect NHS number with context"
746 );
747 if let Some(result) = nhs_result {
749 assert!(result.score > 0.6);
750 }
751 }
752
753 #[test]
754 fn test_uk_nino_detection() {
755 let recognizer = PatternRecognizer::new();
756 let text = "NINO: AB123456C";
757 let results = recognizer.analyze(text, "en").unwrap();
758
759 assert!(!results.is_empty());
760 let nino_result = results.iter().find(|r| r.entity_type == EntityType::UkNino);
761 assert!(nino_result.is_some());
762 }
763
764 #[test]
765 fn test_multiple_entities() {
766 let recognizer = PatternRecognizer::new();
767 let text = "Email john@example.com, phone (555) 123-4567, SSN 123-45-6789";
768 let results = recognizer.analyze(text, "en").unwrap();
769
770 assert!(results.len() >= 3);
771 assert!(results
772 .iter()
773 .any(|r| r.entity_type == EntityType::EmailAddress));
774 assert!(results
775 .iter()
776 .any(|r| r.entity_type == EntityType::PhoneNumber));
777 assert!(results.iter().any(|r| r.entity_type == EntityType::UsSsn));
778 }
779
780 #[test]
781 fn test_custom_pattern() {
782 let mut recognizer = PatternRecognizer::new();
783 recognizer
784 .add_pattern(
785 EntityType::Custom("CUSTOM_ID".to_string()),
786 r"\bCID-\d{6}\b",
787 0.9,
788 )
789 .unwrap();
790
791 let text = "Your customer ID is CID-123456";
792 let results = recognizer.analyze(text, "en").unwrap();
793
794 let custom_result = results
795 .iter()
796 .find(|r| matches!(r.entity_type, EntityType::Custom(_)));
797 assert!(custom_result.is_some());
798 }
799
800 #[test]
801 fn test_min_score_filtering() {
802 let recognizer = PatternRecognizer::new().with_min_score(0.9);
803 let text = "Date: 2024-01-15"; let results = recognizer.analyze(text, "en").unwrap();
805
806 let date_results = results
808 .iter()
809 .filter(|r| r.entity_type == EntityType::DateTime)
810 .count();
811 assert_eq!(date_results, 0);
812 }
813
814 #[test]
815 fn test_uk_driver_license_detection() {
816 let recognizer = PatternRecognizer::new();
817 let text = "UK DL: MORGA753116SM9IJ 35";
818 let results = recognizer.analyze(text, "en").unwrap();
819
820 let dl_result = results
821 .iter()
822 .find(|r| r.entity_type == EntityType::UkDriverLicense);
823 assert!(dl_result.is_some(), "Should detect UK driver's license");
824 }
825
826 #[test]
827 fn test_uk_mobile_detection() {
828 let recognizer = PatternRecognizer::new();
829 let text = "Call me on 07700 900123";
830 let results = recognizer.analyze(text, "en").unwrap();
831
832 let mobile_result = results
833 .iter()
834 .find(|r| r.entity_type == EntityType::UkMobileNumber);
835 assert!(mobile_result.is_some(), "Should detect UK mobile number");
836 }
837
838 #[test]
839 fn test_uk_phone_detection() {
840 let recognizer = PatternRecognizer::new();
841 let text = "Office: 0207 123 4567";
842 let results = recognizer.analyze(text, "en").unwrap();
843
844 let phone_result = results
845 .iter()
846 .find(|r| r.entity_type == EntityType::UkPhoneNumber);
847 assert!(phone_result.is_some(), "Should detect UK phone number");
848 }
849
850 #[test]
851 fn test_medical_license_detection() {
852 let recognizer = PatternRecognizer::new();
853 let text = "Medical license: MD-123456789";
854 let results = recognizer.analyze(text, "en").unwrap();
855
856 let license_result = results
857 .iter()
858 .find(|r| r.entity_type == EntityType::MedicalLicense);
859 assert!(license_result.is_some(), "Should detect medical license");
860 }
861
862 #[test]
863 fn test_supported_entities_count() {
864 let recognizer = PatternRecognizer::new();
865 let supported = recognizer.supported_entities();
866 assert_eq!(
868 supported.len(),
869 54,
870 "Should support 54 pattern-based entity types, got {}",
871 supported.len()
872 );
873 }
874}