Skip to main content

wellformed_validate/
patterns.rs

1//! Vectorscan-based pattern matching for complex validation.
2//!
3//! This module provides high-performance regex matching using Vectorscan
4//! (the open-source fork of Intel Hyperscan). Key features:
5//!
6//! - Match thousands of patterns simultaneously
7//! - SIMD-accelerated on x86_64 (AVX2/AVX-512) and ARM (NEON)
8//! - Stream-oriented for network-speed processing
9//! - Zero-copy pattern matching
10//!
11//! ## Setup
12//!
13//! Vectorscan requires the system library to be installed:
14//!
15//! ```bash
16//! # macOS
17//! brew install vectorscan
18//!
19//! # Ubuntu/Debian
20//! apt-get install libvectorscan-dev
21//!
22//! # From source
23//! git clone https://github.com/VectorCamp/vectorscan
24//! cd vectorscan && cmake -B build && cmake --build build
25//! ```
26//!
27//! ## Usage
28//!
29//! ```rust
30//! use wellformed_validate::patterns::{PatternDb, PatternId};
31//!
32//! // Create a pattern database with built-in patterns
33//! let db = PatternDb::with_builtins().unwrap();
34//!
35//! // Match against input
36//! let matches = db.scan("test@example.com");
37//! assert!(matches.contains(PatternId::Email));
38//! ```
39
40use std::collections::HashSet;
41
42/// Pattern identifier for matching results.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
44#[repr(u32)]
45pub enum PatternId {
46    // TIN patterns
47    SsnFormatted = 0,
48    SsnUnformatted = 1,
49    EinFormatted = 2,
50    EinUnformatted = 3,
51
52    // Contact patterns
53    Email = 10,
54    PhoneUs = 11,
55    PhoneInternational = 12,
56    Url = 13,
57
58    // Address patterns
59    ZipCode = 20,
60    ZipPlus4 = 21,
61    StateCode = 22,
62
63    // Financial patterns
64    Cusip = 30,
65    AbaRouting = 31,
66    AccountNumber = 32,
67
68    // Date patterns
69    DateMmDdYyyy = 40,
70    DateYyyyMmDd = 41,
71    DateIso8601 = 42,
72
73    // Money patterns
74    MoneyUsd = 50,
75    MoneyDecimal = 51,
76}
77
78impl PatternId {
79    /// Convert to u32 for Vectorscan.
80    pub fn as_u32(self) -> u32 {
81        self as u32
82    }
83
84    /// Try to convert from u32.
85    pub fn from_u32(id: u32) -> Option<Self> {
86        match id {
87            0 => Some(Self::SsnFormatted),
88            1 => Some(Self::SsnUnformatted),
89            2 => Some(Self::EinFormatted),
90            3 => Some(Self::EinUnformatted),
91            10 => Some(Self::Email),
92            11 => Some(Self::PhoneUs),
93            12 => Some(Self::PhoneInternational),
94            13 => Some(Self::Url),
95            20 => Some(Self::ZipCode),
96            21 => Some(Self::ZipPlus4),
97            22 => Some(Self::StateCode),
98            30 => Some(Self::Cusip),
99            31 => Some(Self::AbaRouting),
100            32 => Some(Self::AccountNumber),
101            40 => Some(Self::DateMmDdYyyy),
102            41 => Some(Self::DateYyyyMmDd),
103            42 => Some(Self::DateIso8601),
104            50 => Some(Self::MoneyUsd),
105            51 => Some(Self::MoneyDecimal),
106            _ => None,
107        }
108    }
109}
110
111/// Result of pattern matching.
112#[derive(Debug, Default)]
113pub struct MatchResult {
114    /// Set of matched pattern IDs.
115    pub matches: HashSet<PatternId>,
116}
117
118impl MatchResult {
119    /// Create an empty result.
120    pub fn new() -> Self {
121        Self::default()
122    }
123
124    /// Check if a pattern matched.
125    pub fn contains(&self, id: PatternId) -> bool {
126        self.matches.contains(&id)
127    }
128
129    /// Check if any pattern matched.
130    pub fn has_matches(&self) -> bool {
131        !self.matches.is_empty()
132    }
133
134    /// Get the number of matches.
135    pub fn count(&self) -> usize {
136        self.matches.len()
137    }
138}
139
140/// Pre-compiled pattern definitions.
141pub struct PatternDef {
142    pub id: PatternId,
143    pub pattern: &'static str,
144    pub flags: u32,
145}
146
147/// All built-in patterns.
148pub static BUILTIN_PATTERNS: &[PatternDef] = &[
149    // SSN patterns
150    PatternDef {
151        id: PatternId::SsnFormatted,
152        pattern: r"^\d{3}-\d{2}-\d{4}$",
153        flags: 0,
154    },
155    PatternDef {
156        id: PatternId::SsnUnformatted,
157        pattern: r"^\d{9}$",
158        flags: 0,
159    },
160    // EIN patterns
161    PatternDef {
162        id: PatternId::EinFormatted,
163        pattern: r"^\d{2}-\d{7}$",
164        flags: 0,
165    },
166    PatternDef {
167        id: PatternId::EinUnformatted,
168        pattern: r"^\d{9}$",
169        flags: 0,
170    },
171    // Email
172    PatternDef {
173        id: PatternId::Email,
174        pattern: r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$",
175        flags: 0, // Case insensitive handled by pattern
176    },
177    // Phone
178    PatternDef {
179        id: PatternId::PhoneUs,
180        pattern: r"^(\+1)?[-.\s]?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}$",
181        flags: 0,
182    },
183    PatternDef {
184        id: PatternId::PhoneInternational,
185        pattern: r"^\+\d{1,3}[-.\s]?\d{4,14}$",
186        flags: 0,
187    },
188    // ZIP codes
189    PatternDef {
190        id: PatternId::ZipCode,
191        pattern: r"^\d{5}$",
192        flags: 0,
193    },
194    PatternDef {
195        id: PatternId::ZipPlus4,
196        pattern: r"^\d{5}-\d{4}$",
197        flags: 0,
198    },
199    // Dates
200    PatternDef {
201        id: PatternId::DateMmDdYyyy,
202        pattern: r"^(0[1-9]|1[0-2])/(0[1-9]|[12]\d|3[01])/\d{4}$",
203        flags: 0,
204    },
205    PatternDef {
206        id: PatternId::DateYyyyMmDd,
207        pattern: r"^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$",
208        flags: 0,
209    },
210    // Money
211    PatternDef {
212        id: PatternId::MoneyUsd,
213        pattern: r"^\$?\d{1,3}(,\d{3})*(\.\d{2})?$",
214        flags: 0,
215    },
216    PatternDef {
217        id: PatternId::MoneyDecimal,
218        pattern: r"^-?\d+(\.\d{1,2})?$",
219        flags: 0,
220    },
221    // Financial identifiers
222    PatternDef {
223        id: PatternId::Cusip,
224        pattern: r"^[A-Z0-9]{9}$",
225        flags: 0,
226    },
227    PatternDef {
228        id: PatternId::AbaRouting,
229        pattern: r"^\d{9}$",
230        flags: 0,
231    },
232];
233
234// ============================================================================
235// Vectorscan Implementation (feature-gated)
236// ============================================================================
237
238#[cfg(feature = "vectorscan")]
239mod vectorscan_impl {
240    use super::*;
241    use vectorscan_rs::{BlockDatabase, BlockScanner, Error, Flag, Pattern, Scan};
242
243    /// Pattern database backed by Vectorscan.
244    ///
245    /// Uses Intel/Vectorscan's SIMD-accelerated regex engine for
246    /// matching thousands of patterns simultaneously.
247    pub struct PatternDb {
248        database: BlockDatabase,
249    }
250
251    impl PatternDb {
252        /// Create a new pattern database with built-in patterns.
253        pub fn with_builtins() -> Result<Self, Error> {
254            let patterns: Vec<Pattern> = BUILTIN_PATTERNS
255                .iter()
256                .map(|def| {
257                    Pattern::new(
258                        def.pattern.as_bytes().to_vec(),
259                        Flag::default(),
260                        Some(def.id.as_u32()),
261                    )
262                })
263                .collect();
264
265            let database = BlockDatabase::new(patterns)?;
266
267            Ok(Self { database })
268        }
269
270        /// Scan input for pattern matches.
271        pub fn scan(&self, input: &str) -> MatchResult {
272            let mut result = MatchResult::new();
273
274            // Create a scanner for this scan operation
275            let mut scanner = match BlockScanner::new(&self.database) {
276                Ok(s) => s,
277                Err(_) => return result,
278            };
279
280            let scan_result = scanner.scan(input.as_bytes(), |id, _from, _to, _flags| {
281                if let Some(pattern_id) = PatternId::from_u32(id) {
282                    result.matches.insert(pattern_id);
283                }
284                Scan::Continue
285            });
286
287            // Ignore errors - just return what we found
288            let _ = scan_result;
289
290            result
291        }
292
293        /// Scan multiple inputs in batch.
294        pub fn scan_batch(&self, inputs: &[&str]) -> Vec<MatchResult> {
295            inputs.iter().map(|s| self.scan(s)).collect()
296        }
297    }
298}
299
300#[cfg(feature = "vectorscan")]
301pub use vectorscan_impl::PatternDb;
302
303// ============================================================================
304// Fallback Implementation (regex crate)
305// ============================================================================
306
307#[cfg(not(feature = "vectorscan"))]
308mod fallback_impl {
309    use super::*;
310    use std::sync::LazyLock;
311
312    /// Compiled regex patterns for fallback.
313    struct CompiledPatterns {
314        patterns: Vec<(PatternId, regex::Regex)>,
315    }
316
317    impl CompiledPatterns {
318        fn new() -> Self {
319            let patterns: Vec<_> = BUILTIN_PATTERNS
320                .iter()
321                .filter_map(|def| regex::Regex::new(def.pattern).ok().map(|r| (def.id, r)))
322                .collect();
323
324            Self { patterns }
325        }
326    }
327
328    static COMPILED: LazyLock<CompiledPatterns> = LazyLock::new(CompiledPatterns::new);
329
330    /// Pattern database using regex crate (fallback).
331    pub struct PatternDb;
332
333    impl PatternDb {
334        /// Create a new pattern database.
335        pub fn with_builtins() -> Result<Self, std::convert::Infallible> {
336            // Force initialization
337            let _ = &*COMPILED;
338            Ok(Self)
339        }
340
341        /// Scan input for pattern matches.
342        pub fn scan(&self, input: &str) -> MatchResult {
343            let mut result = MatchResult::new();
344
345            for (id, regex) in &COMPILED.patterns {
346                if regex.is_match(input) {
347                    result.matches.insert(*id);
348                }
349            }
350
351            result
352        }
353
354        /// Scan multiple inputs in batch.
355        pub fn scan_batch(&self, inputs: &[&str]) -> Vec<MatchResult> {
356            inputs.iter().map(|s| self.scan(s)).collect()
357        }
358    }
359}
360
361#[cfg(not(feature = "vectorscan"))]
362pub use fallback_impl::PatternDb;
363
364// ============================================================================
365// Convenience Functions
366// ============================================================================
367
368/// Check if a string matches the SSN format.
369pub fn is_ssn_format(s: &str) -> bool {
370    let bytes = s.as_bytes();
371
372    // Formatted: XXX-XX-XXXX (11 chars)
373    if bytes.len() == 11 {
374        return bytes[3] == b'-'
375            && bytes[6] == b'-'
376            && bytes[..3].iter().all(|b| b.is_ascii_digit())
377            && bytes[4..6].iter().all(|b| b.is_ascii_digit())
378            && bytes[7..].iter().all(|b| b.is_ascii_digit());
379    }
380
381    // Unformatted: XXXXXXXXX (9 chars)
382    if bytes.len() == 9 {
383        return bytes.iter().all(|b| b.is_ascii_digit());
384    }
385
386    false
387}
388
389/// Check if a string matches the EIN format.
390pub fn is_ein_format(s: &str) -> bool {
391    let bytes = s.as_bytes();
392
393    // Formatted: XX-XXXXXXX (10 chars)
394    if bytes.len() == 10 {
395        return bytes[2] == b'-'
396            && bytes[..2].iter().all(|b| b.is_ascii_digit())
397            && bytes[3..].iter().all(|b| b.is_ascii_digit());
398    }
399
400    // Unformatted: XXXXXXXXX (9 chars)
401    if bytes.len() == 9 {
402        return bytes.iter().all(|b| b.is_ascii_digit());
403    }
404
405    false
406}
407
408/// Check if a string matches the US ZIP code format.
409pub fn is_zip_format(s: &str) -> bool {
410    let bytes = s.as_bytes();
411
412    // 5-digit: XXXXX
413    if bytes.len() == 5 {
414        return bytes.iter().all(|b| b.is_ascii_digit());
415    }
416
417    // ZIP+4: XXXXX-XXXX
418    if bytes.len() == 10 {
419        return bytes[5] == b'-'
420            && bytes[..5].iter().all(|b| b.is_ascii_digit())
421            && bytes[6..].iter().all(|b| b.is_ascii_digit());
422    }
423
424    false
425}
426
427/// Check if a string looks like an email address.
428pub fn is_email_format(s: &str) -> bool {
429    // Simple check: has @ with content before and after, domain has dot
430    let at_pos = match s.find('@') {
431        Some(pos) if pos > 0 && pos < s.len() - 1 => pos,
432        _ => return false,
433    };
434
435    let domain = &s[at_pos + 1..];
436    domain.contains('.') && !domain.starts_with('.') && !domain.ends_with('.')
437}
438
439// ============================================================================
440// Tests
441// ============================================================================
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446
447    #[test]
448    fn test_ssn_format() {
449        assert!(is_ssn_format("123-45-6789"));
450        assert!(is_ssn_format("123456789"));
451        assert!(!is_ssn_format("12345678"));
452        assert!(!is_ssn_format("1234567890"));
453        assert!(!is_ssn_format("12-345-6789"));
454    }
455
456    #[test]
457    fn test_ein_format() {
458        assert!(is_ein_format("12-3456789"));
459        assert!(is_ein_format("123456789"));
460        assert!(!is_ein_format("123-456789"));
461        assert!(!is_ein_format("12345678"));
462    }
463
464    #[test]
465    fn test_zip_format() {
466        assert!(is_zip_format("12345"));
467        assert!(is_zip_format("12345-6789"));
468        assert!(!is_zip_format("1234"));
469        assert!(!is_zip_format("123456"));
470        assert!(!is_zip_format("12345-678"));
471    }
472
473    #[test]
474    fn test_email_format() {
475        assert!(is_email_format("test@example.com"));
476        assert!(is_email_format("a@b.c"));
477        assert!(is_email_format("user.name+tag@example.org"));
478        assert!(!is_email_format("invalid"));
479        assert!(!is_email_format("@example.com"));
480        assert!(!is_email_format("test@"));
481        assert!(!is_email_format("test@.com"));
482        assert!(!is_email_format("test@com."));
483    }
484
485    #[test]
486    fn test_pattern_db() {
487        let db = PatternDb::with_builtins().unwrap();
488
489        let result = db.scan("123-45-6789");
490        assert!(result.contains(PatternId::SsnFormatted));
491
492        let result = db.scan("12345");
493        assert!(result.contains(PatternId::ZipCode));
494
495        let result = db.scan("test@example.com");
496        assert!(result.contains(PatternId::Email));
497    }
498
499    #[test]
500    fn test_batch_scan() {
501        let db = PatternDb::with_builtins().unwrap();
502
503        let inputs = vec!["123-45-6789", "test@example.com", "12345"];
504        let results = db.scan_batch(&inputs);
505
506        assert!(results[0].contains(PatternId::SsnFormatted));
507        assert!(results[1].contains(PatternId::Email));
508        assert!(results[2].contains(PatternId::ZipCode));
509    }
510}