Skip to main content

fraiseql_core/validation/
rich_scalars.rs

1//! Rich scalar type validators for specialized data formats.
2//!
3//! This module provides validators for common structured data types like emails,
4//! phone numbers, IBANs, VINs, and country codes.
5
6use std::sync::LazyLock;
7
8use regex::Regex;
9
10// Email regex: Simple but practical pattern
11static EMAIL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
12    Regex::new(
13        r"^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$"
14    ).expect("email regex is valid")
15});
16
17// International phone: +1-999-999-9999 or +999999999999, etc.
18static PHONE_REGEX: LazyLock<Regex> =
19    LazyLock::new(|| Regex::new(r"^\+?[1-9]\d{1,14}$").expect("phone regex is valid"));
20
21// VIN: 17 alphanumeric characters (no I, O, Q)
22static VIN_REGEX: LazyLock<Regex> =
23    LazyLock::new(|| Regex::new(r"^[A-HJ-NPR-Z0-9]{17}$").expect("VIN regex is valid"));
24
25// Country code: ISO 3166-1 alpha-2 (2 letters)
26static COUNTRY_CODE_REGEX: LazyLock<Regex> =
27    LazyLock::new(|| Regex::new(r"^[A-Z]{2}$").expect("country code regex is valid"));
28
29/// Email address validator.
30pub struct EmailValidator;
31
32impl EmailValidator {
33    /// Validate an email address format.
34    ///
35    /// Uses a practical regex pattern that handles most common email formats.
36    /// Note: This validates format only, not domain existence (use async validator for that).
37    pub fn validate(value: &str) -> bool {
38        !value.is_empty() && value.len() <= 254 && EMAIL_REGEX.is_match(value)
39    }
40
41    pub fn error_message() -> &'static str {
42        "Invalid email format"
43    }
44}
45
46/// International phone number validator.
47pub struct PhoneNumberValidator;
48
49impl PhoneNumberValidator {
50    /// Validate an international phone number format.
51    ///
52    /// Accepts formats like:
53    /// - +1234567890
54    /// - +1-234-567-8900
55    /// - 1234567890
56    ///
57    /// The pattern allows +1 to +999 country codes followed by 1-14 additional digits.
58    pub fn validate(value: &str) -> bool {
59        !value.is_empty() && value.len() <= 20 && PHONE_REGEX.is_match(value)
60    }
61
62    pub fn error_message() -> &'static str {
63        "Invalid phone number format"
64    }
65}
66
67/// VIN (Vehicle Identification Number) validator.
68pub struct VinValidator;
69
70impl VinValidator {
71    /// Validate a VIN format.
72    ///
73    /// A valid VIN is:
74    /// - Exactly 17 characters
75    /// - Only alphanumeric (no I, O, Q to avoid confusion with numbers)
76    /// - Case insensitive
77    ///
78    /// Note: This validates format only, not checksum (different per manufacturer).
79    pub fn validate(value: &str) -> bool {
80        let value_upper = value.to_uppercase();
81        VIN_REGEX.is_match(&value_upper)
82    }
83
84    pub fn error_message() -> &'static str {
85        "Invalid VIN format (must be 17 alphanumeric characters, excluding I, O, Q)"
86    }
87}
88
89/// Country code validator (ISO 3166-1 alpha-2).
90pub struct CountryCodeValidator {
91    valid_codes: std::collections::HashSet<&'static str>,
92}
93
94impl CountryCodeValidator {
95    /// Create a new country code validator with all valid ISO codes.
96    pub fn new() -> Self {
97        let mut codes = std::collections::HashSet::new();
98        // All ISO 3166-1 alpha-2 codes
99        codes.insert("AD");
100        codes.insert("AE");
101        codes.insert("AF");
102        codes.insert("AG");
103        codes.insert("AI");
104        codes.insert("AL");
105        codes.insert("AM");
106        codes.insert("AO");
107        codes.insert("AQ");
108        codes.insert("AR");
109        codes.insert("AS");
110        codes.insert("AT");
111        codes.insert("AU");
112        codes.insert("AW");
113        codes.insert("AX");
114        codes.insert("AZ");
115        codes.insert("BA");
116        codes.insert("BB");
117        codes.insert("BD");
118        codes.insert("BE");
119        codes.insert("BF");
120        codes.insert("BG");
121        codes.insert("BH");
122        codes.insert("BI");
123        codes.insert("BJ");
124        codes.insert("BL");
125        codes.insert("BM");
126        codes.insert("BN");
127        codes.insert("BO");
128        codes.insert("BQ");
129        codes.insert("BR");
130        codes.insert("BS");
131        codes.insert("BT");
132        codes.insert("BV");
133        codes.insert("BW");
134        codes.insert("BY");
135        codes.insert("BZ");
136        codes.insert("CA");
137        codes.insert("CC");
138        codes.insert("CD");
139        codes.insert("CF");
140        codes.insert("CG");
141        codes.insert("CH");
142        codes.insert("CI");
143        codes.insert("CK");
144        codes.insert("CL");
145        codes.insert("CM");
146        codes.insert("CN");
147        codes.insert("CO");
148        codes.insert("CR");
149        codes.insert("CU");
150        codes.insert("CV");
151        codes.insert("CW");
152        codes.insert("CX");
153        codes.insert("CY");
154        codes.insert("CZ");
155        codes.insert("DE");
156        codes.insert("DJ");
157        codes.insert("DK");
158        codes.insert("DM");
159        codes.insert("DO");
160        codes.insert("DZ");
161        codes.insert("EC");
162        codes.insert("EE");
163        codes.insert("EG");
164        codes.insert("EH");
165        codes.insert("ER");
166        codes.insert("ES");
167        codes.insert("ET");
168        codes.insert("FI");
169        codes.insert("FJ");
170        codes.insert("FK");
171        codes.insert("FM");
172        codes.insert("FO");
173        codes.insert("FR");
174        codes.insert("GA");
175        codes.insert("GB");
176        codes.insert("GD");
177        codes.insert("GE");
178        codes.insert("GF");
179        codes.insert("GG");
180        codes.insert("GH");
181        codes.insert("GI");
182        codes.insert("GL");
183        codes.insert("GM");
184        codes.insert("GN");
185        codes.insert("GP");
186        codes.insert("GQ");
187        codes.insert("GR");
188        codes.insert("GS");
189        codes.insert("GT");
190        codes.insert("GU");
191        codes.insert("GW");
192        codes.insert("GY");
193        codes.insert("HK");
194        codes.insert("HM");
195        codes.insert("HN");
196        codes.insert("HR");
197        codes.insert("HT");
198        codes.insert("HU");
199        codes.insert("ID");
200        codes.insert("IE");
201        codes.insert("IL");
202        codes.insert("IM");
203        codes.insert("IN");
204        codes.insert("IO");
205        codes.insert("IQ");
206        codes.insert("IR");
207        codes.insert("IS");
208        codes.insert("IT");
209        codes.insert("JE");
210        codes.insert("JM");
211        codes.insert("JO");
212        codes.insert("JP");
213        codes.insert("KE");
214        codes.insert("KG");
215        codes.insert("KH");
216        codes.insert("KI");
217        codes.insert("KM");
218        codes.insert("KN");
219        codes.insert("KP");
220        codes.insert("KR");
221        codes.insert("KW");
222        codes.insert("KY");
223        codes.insert("KZ");
224        codes.insert("LA");
225        codes.insert("LB");
226        codes.insert("LC");
227        codes.insert("LI");
228        codes.insert("LK");
229        codes.insert("LR");
230        codes.insert("LS");
231        codes.insert("LT");
232        codes.insert("LU");
233        codes.insert("LV");
234        codes.insert("LY");
235        codes.insert("MA");
236        codes.insert("MC");
237        codes.insert("MD");
238        codes.insert("ME");
239        codes.insert("MF");
240        codes.insert("MG");
241        codes.insert("MH");
242        codes.insert("MK");
243        codes.insert("ML");
244        codes.insert("MM");
245        codes.insert("MN");
246        codes.insert("MO");
247        codes.insert("MP");
248        codes.insert("MQ");
249        codes.insert("MR");
250        codes.insert("MS");
251        codes.insert("MT");
252        codes.insert("MU");
253        codes.insert("MV");
254        codes.insert("MW");
255        codes.insert("MX");
256        codes.insert("MY");
257        codes.insert("MZ");
258        codes.insert("NA");
259        codes.insert("NC");
260        codes.insert("NE");
261        codes.insert("NF");
262        codes.insert("NG");
263        codes.insert("NI");
264        codes.insert("NL");
265        codes.insert("NO");
266        codes.insert("NP");
267        codes.insert("NR");
268        codes.insert("NU");
269        codes.insert("NZ");
270        codes.insert("OM");
271        codes.insert("PA");
272        codes.insert("PE");
273        codes.insert("PF");
274        codes.insert("PG");
275        codes.insert("PH");
276        codes.insert("PK");
277        codes.insert("PL");
278        codes.insert("PM");
279        codes.insert("PN");
280        codes.insert("PR");
281        codes.insert("PS");
282        codes.insert("PT");
283        codes.insert("PW");
284        codes.insert("PY");
285        codes.insert("QA");
286        codes.insert("RE");
287        codes.insert("RO");
288        codes.insert("RS");
289        codes.insert("RU");
290        codes.insert("RW");
291        codes.insert("SA");
292        codes.insert("SB");
293        codes.insert("SC");
294        codes.insert("SD");
295        codes.insert("SE");
296        codes.insert("SG");
297        codes.insert("SH");
298        codes.insert("SI");
299        codes.insert("SJ");
300        codes.insert("SK");
301        codes.insert("SL");
302        codes.insert("SM");
303        codes.insert("SN");
304        codes.insert("SO");
305        codes.insert("SR");
306        codes.insert("SS");
307        codes.insert("ST");
308        codes.insert("SV");
309        codes.insert("SX");
310        codes.insert("SY");
311        codes.insert("SZ");
312        codes.insert("TC");
313        codes.insert("TD");
314        codes.insert("TF");
315        codes.insert("TG");
316        codes.insert("TH");
317        codes.insert("TJ");
318        codes.insert("TK");
319        codes.insert("TL");
320        codes.insert("TM");
321        codes.insert("TN");
322        codes.insert("TO");
323        codes.insert("TR");
324        codes.insert("TT");
325        codes.insert("TV");
326        codes.insert("TW");
327        codes.insert("TZ");
328        codes.insert("UA");
329        codes.insert("UG");
330        codes.insert("UM");
331        codes.insert("US");
332        codes.insert("UY");
333        codes.insert("UZ");
334        codes.insert("VA");
335        codes.insert("VC");
336        codes.insert("VE");
337        codes.insert("VG");
338        codes.insert("VI");
339        codes.insert("VN");
340        codes.insert("VU");
341        codes.insert("WF");
342        codes.insert("WS");
343        codes.insert("YE");
344        codes.insert("YT");
345        codes.insert("ZA");
346        codes.insert("ZM");
347        codes.insert("ZW");
348        Self { valid_codes: codes }
349    }
350
351    /// Validate a country code against ISO 3166-1 alpha-2 standard.
352    pub fn validate(&self, value: &str) -> bool {
353        let value_upper = value.to_uppercase();
354        COUNTRY_CODE_REGEX.is_match(&value_upper) && self.valid_codes.contains(value_upper.as_str())
355    }
356
357    pub fn error_message() -> &'static str {
358        "Invalid country code (must be ISO 3166-1 alpha-2)"
359    }
360}
361
362impl Default for CountryCodeValidator {
363    fn default() -> Self {
364        Self::new()
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    // Email tests
373    #[test]
374    fn test_email_valid() {
375        assert!(EmailValidator::validate("user@example.com"));
376        assert!(EmailValidator::validate("john.doe@company.co.uk"));
377    }
378
379    #[test]
380    fn test_email_invalid() {
381        assert!(!EmailValidator::validate("invalid.email"));
382        assert!(!EmailValidator::validate("user@"));
383        assert!(!EmailValidator::validate("@example.com"));
384    }
385
386    #[test]
387    fn test_email_empty() {
388        assert!(!EmailValidator::validate(""));
389    }
390
391    // Phone tests
392    #[test]
393    fn test_phone_valid_plus_format() {
394        assert!(PhoneNumberValidator::validate("+1234567890"));
395        assert!(PhoneNumberValidator::validate("+33612345678"));
396    }
397
398    #[test]
399    fn test_phone_valid_no_plus() {
400        assert!(PhoneNumberValidator::validate("1234567890"));
401    }
402
403    #[test]
404    fn test_phone_invalid() {
405        assert!(!PhoneNumberValidator::validate("+0123456789")); // Can't start with 0
406        assert!(!PhoneNumberValidator::validate(""));
407    }
408
409    // VIN tests
410    #[test]
411    fn test_vin_valid() {
412        assert!(VinValidator::validate("3G1FB1E30D1109186"));
413        assert!(VinValidator::validate("JH2RC5004LM200591"));
414    }
415
416    #[test]
417    fn test_vin_valid_lowercase() {
418        assert!(VinValidator::validate("3g1fb1e30d1109186"));
419    }
420
421    #[test]
422    fn test_vin_invalid_length() {
423        assert!(!VinValidator::validate("3G1FB1E30D110918"));
424        assert!(!VinValidator::validate("3G1FB1E30D11091861"));
425    }
426
427    #[test]
428    fn test_vin_invalid_chars() {
429        assert!(!VinValidator::validate("3G1FB1E30D110918I")); // Contains I
430        assert!(!VinValidator::validate("3G1FB1E30D110918O")); // Contains O
431        assert!(!VinValidator::validate("3G1FB1E30D110918Q")); // Contains Q
432    }
433
434    // Country code tests
435    #[test]
436    fn test_country_code_valid() {
437        let validator = CountryCodeValidator::new();
438        assert!(validator.validate("US"));
439        assert!(validator.validate("GB"));
440        assert!(validator.validate("DE"));
441        assert!(validator.validate("FR"));
442    }
443
444    #[test]
445    fn test_country_code_lowercase() {
446        let validator = CountryCodeValidator::new();
447        assert!(validator.validate("us"));
448        assert!(validator.validate("gb"));
449    }
450
451    #[test]
452    fn test_country_code_invalid() {
453        let validator = CountryCodeValidator::new();
454        assert!(!validator.validate("XX"));
455        assert!(!validator.validate("USA"));
456        assert!(!validator.validate("U"));
457    }
458}