Skip to main content

redact_core/recognizers/
pattern.rs

1// Copyright 2026 Censgate LLC.
2// Licensed under the Apache License, Version 2.0. See the LICENSE file
3// in the project root for license information.
4
5use 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
12/// A built-in detection pattern expressed as data.
13///
14/// Secrets are kept in a flat table rather than as imperative registration
15/// calls so the full set can be reviewed at a glance and mirrored by an
16/// external pattern pack without touching detection logic.
17struct SecretPattern {
18    entity_type: EntityType,
19    regex: &'static str,
20    score: f32,
21}
22
23/// Built-in secret/credential detection patterns.
24///
25/// These are anchored/prefixed patterns only (e.g. `AKIA...`, `ghp_...`,
26/// `sk-ant-...`) chosen for high precision. Generic catch-alls like
27/// `api_key=...` or `password=...` are deliberately excluded here because
28/// they need entropy scoring to avoid false positives; that is future work.
29const 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        // Secret (`sk_`) and restricted (`rk_`) keys only. Publishable `pk_`
68        // keys are designed to be embedded in client-side code and are not
69        // secret, so redacting them is noise rather than protection.
70        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        // Two shapes, kept separate so neither has to be loose. Classic keys
81        // are pure alphanumeric, so requiring that rules out ordinary
82        // hyphenated identifiers (`sk-feature-branch-name`) that a combined
83        // `[A-Za-z0-9_-]{20,}` would otherwise match. Project keys do contain
84        // `-`/`_`, so they carry a longer minimum length instead.
85        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        // The trailing path/query are part of the connection string, so they
126        // are captured too. Without them a redaction leaves `/dbname` and any
127        // query parameters dangling after the placeholder.
128        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/// Pattern-based recognizer using regex
134#[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    /// Create a new pattern recognizer with default patterns
150    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    /// Create a new pattern recognizer with custom name
161    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    /// Set minimum confidence score
168    pub fn with_min_score(mut self, min_score: f32) -> Self {
169        self.min_score = min_score;
170        self
171    }
172
173    /// Add a custom pattern for an entity type
174    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    /// Add a pattern with context words for score boosting
191    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    /// Load default patterns for common PII types
209    fn load_default_patterns(&mut self) {
210        // Email addresses
211        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        // Phone numbers (US/international format with separators)
218        // Requires at least one separator or parentheses to avoid matching
219        // consecutive digits in credit cards, ISBNs, etc.
220        // Matches: (555) 123-4567, 555-123-4567, 555.123.4567, 555 123 4567
221        // Does NOT match: 5551234567 (no separators - too prone to false positives)
222        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        // Credit cards (4 groups of 4 digits)
229        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        // US SSN (simplified pattern - Rust regex doesn't support lookahead)
236        // Pattern matches XXX-XX-XXXX format
237        let _ = self.add_pattern(EntityType::UsSsn, r"\b\d{3}-\d{2}-\d{4}\b", 0.9);
238
239        // IP Address (IPv4)
240        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        // URL
247        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        // Domain name (standalone, without protocol - avoid overlapping URL)
254        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        // GUID/UUID
261        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        // MAC Address
268        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        // UK NHS Number
275        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        // UK National Insurance Number
287        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        // UK Postcode
294        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        // UK Sort Code
301        let _ = self.add_pattern(EntityType::UkSortCode, r"\b\d{2}-\d{2}-\d{2}\b", 0.7);
302
303        // IBAN
304        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        // Bitcoin Address
311        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        // Ethereum Address
318        let _ = self.add_pattern(EntityType::EthAddress, r"\b0x[a-fA-F0-9]{40}\b", 0.9);
319
320        // MD5 Hash
321        let _ = self.add_pattern(EntityType::Md5Hash, r"\b[a-fA-F0-9]{32}\b", 0.6);
322
323        // SHA1 Hash
324        let _ = self.add_pattern(EntityType::Sha1Hash, r"\b[a-fA-F0-9]{40}\b", 0.6);
325
326        // SHA256 Hash
327        let _ = self.add_pattern(EntityType::Sha256Hash, r"\b[a-fA-F0-9]{64}\b", 0.6);
328
329        // US ZIP Code (5 digits or ZIP+4 format)
330        let _ = self.add_pattern(
331            EntityType::UsZipCode,
332            r"\b\d{5}(?:-\d{4})?\b",
333            0.6, // Lower confidence as could be other 5-digit numbers
334        );
335
336        // PO Box
337        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        // ISBN (10 or 13 digit formats)
349        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        // Generic Passport Number (alphanumeric, 6-9 characters)
356        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        // Medical Record Number (various formats with MRN context)
364        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        // Age (with context)
376        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        // Date/Time (ISO format and common variants)
384        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        // US Driver's License (varies by state, common formats)
391        // More specific patterns to avoid false positives:
392        // - Letter prefix followed by 6-8 digits (most states)
393        // - State-specific format with dashes
394        // Base score is low (0.4) - requires context to reach min_score
395        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        // US Passport Number (9 digits, sometimes with letter prefix)
408        // Base score is low - requires context
409        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        // US Bank Account Number (typically 8-17 digits)
421        // Very low base score - highly dependent on context
422        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        // UK Driver's License (DVLA format: 5 letters + 6 digits + 2 letters + 3 digits + 2 letters)
436        // Example: MORGA753116SM9IJ 35
437        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        // UK Passport Number (9 digits)
444        // Low base score - requires context to avoid matching random 9-digit numbers
445        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        // UK Phone Number (landline: 01/02/03 prefix)
457        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        // UK Mobile Number (07 prefix)
464        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        // UK Company Number (Companies House: 8 digits or 2 letters + 6 digits)
471        // Low base score - requires context to avoid matching random 8-digit numbers
472        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        // Medical License Number (various formats with context)
485        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        // Generic Crypto Wallet (covers various formats beyond BTC/ETH)
499        // Matches Litecoin (L/M/3), Ripple (r), etc.
500        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        // Secrets and credentials - loaded from the flat data table above.
513        for p in SECRET_PATTERNS {
514            let _ = self.add_pattern(p.entity_type.clone(), p.regex, p.score);
515        }
516    }
517
518    /// Check context words around a match to boost confidence
519    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        // Get 50 characters before and after the match
525        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        // Count matching context words
530        let matches = context_words
531            .iter()
532            .filter(|word| context.contains(&word.to_lowercase()))
533            .count();
534
535        // Boost score based on context matches (up to +0.3)
536        (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                // Contact information
555                EntityType::EmailAddress,
556                EntityType::PhoneNumber,
557                EntityType::IpAddress,
558                EntityType::Url,
559                EntityType::DomainName,
560                // Financial
561                EntityType::CreditCard,
562                EntityType::IbanCode,
563                EntityType::UsBankNumber,
564                // US-specific
565                EntityType::UsSsn,
566                EntityType::UsDriverLicense,
567                EntityType::UsPassport,
568                EntityType::UsZipCode,
569                // UK-specific
570                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                // Healthcare
580                EntityType::MedicalLicense,
581                EntityType::MedicalRecordNumber,
582                // Generic identifiers
583                EntityType::PassportNumber,
584                EntityType::Age,
585                EntityType::Isbn,
586                EntityType::PoBox,
587                EntityType::DateTime,
588                // Crypto
589                EntityType::CryptoWallet,
590                EntityType::BtcAddress,
591                EntityType::EthAddress,
592                // Technical
593                EntityType::Guid,
594                EntityType::MacAddress,
595                EntityType::Md5Hash,
596                EntityType::Sha1Hash,
597                EntityType::Sha256Hash,
598                // Secrets and credentials
599                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                        // Base score from pattern
634                        let mut score = pattern.score;
635
636                        // Boost score based on context if context words are provided
637                        if !pattern.context_words.is_empty() {
638                            score += self.check_context(text, start, end, &pattern.context_words);
639                            score = score.min(1.0); // Cap at 1.0
640                        }
641
642                        // Apply validation (checksum, format validation)
643                        // This can reduce or zero out the score for invalid matches
644                        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        // Use a valid NHS number that passes mod-11 checksum: 943 476 5919
735        // Checksum: 9*10 + 4*9 + 3*8 + 4*7 + 7*6 + 6*5 + 5*4 + 9*3 + 1*2 = 220
736        // 11 - (220 % 11) = 11 - 0 = 11 -> 0, but last digit is 9, so let's use a known valid one
737        // Valid NHS: 401 023 2137 (checksum verified)
738        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        // Score should be boosted due to "NHS" context
748        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"; // Date has score 0.5
804        let results = recognizer.analyze(text, "en").unwrap();
805
806        // Date should be filtered out due to min_score
807        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        // Should have 54 pattern-based entity types (36 original + 18 secrets)
867        assert_eq!(
868            supported.len(),
869            54,
870            "Should support 54 pattern-based entity types, got {}",
871            supported.len()
872        );
873    }
874}