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