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    #[must_use]
43    pub const fn error_message() -> &'static str {
44        "Invalid email format"
45    }
46}
47
48/// International phone number validator.
49pub struct PhoneNumberValidator;
50
51impl PhoneNumberValidator {
52    /// Validate an international phone number format.
53    ///
54    /// Accepts formats like:
55    /// - +1234567890
56    /// - +1-234-567-8900
57    /// - 1234567890
58    ///
59    /// The pattern allows +1 to +999 country codes followed by 1-14 additional digits.
60    pub fn validate(value: &str) -> bool {
61        !value.is_empty() && value.len() <= 20 && PHONE_REGEX.is_match(value)
62    }
63
64    /// Return the standard validation error message for an invalid phone number.
65    #[must_use]
66    pub const fn error_message() -> &'static str {
67        "Invalid phone number format"
68    }
69}
70
71/// VIN (Vehicle Identification Number) validator.
72pub struct VinValidator;
73
74impl VinValidator {
75    /// Validate a VIN format.
76    ///
77    /// A valid VIN is:
78    /// - Exactly 17 characters
79    /// - Only alphanumeric (no I, O, Q to avoid confusion with numbers)
80    /// - Case insensitive
81    ///
82    /// Note: This validates format only, not checksum (different per manufacturer).
83    pub fn validate(value: &str) -> bool {
84        // VINs are always exactly 17 characters — reject anything else before
85        // allocating the uppercase copy and running the regex.
86        if value.len() != 17 {
87            return false;
88        }
89        let value_upper = value.to_uppercase();
90        VIN_REGEX.is_match(&value_upper)
91    }
92
93    /// Return the standard validation error message for an invalid VIN.
94    #[must_use]
95    pub const fn error_message() -> &'static str {
96        "Invalid VIN format (must be 17 alphanumeric characters, excluding I, O, Q)"
97    }
98}
99
100/// Country code validator (ISO 3166-1 alpha-2).
101#[doc(hidden)] // Internal-pub: validation helper used by fraiseql-core's rich-scalars module; shape may change as the ISO table is reorganised.
102pub struct CountryCodeValidator {
103    valid_codes: std::collections::HashSet<&'static str>,
104}
105
106impl CountryCodeValidator {
107    /// Create a new country code validator with all valid ISO codes.
108    #[must_use]
109    pub fn new() -> Self {
110        let mut codes = std::collections::HashSet::new();
111        // All ISO 3166-1 alpha-2 codes
112        codes.insert("AD");
113        codes.insert("AE");
114        codes.insert("AF");
115        codes.insert("AG");
116        codes.insert("AI");
117        codes.insert("AL");
118        codes.insert("AM");
119        codes.insert("AO");
120        codes.insert("AQ");
121        codes.insert("AR");
122        codes.insert("AS");
123        codes.insert("AT");
124        codes.insert("AU");
125        codes.insert("AW");
126        codes.insert("AX");
127        codes.insert("AZ");
128        codes.insert("BA");
129        codes.insert("BB");
130        codes.insert("BD");
131        codes.insert("BE");
132        codes.insert("BF");
133        codes.insert("BG");
134        codes.insert("BH");
135        codes.insert("BI");
136        codes.insert("BJ");
137        codes.insert("BL");
138        codes.insert("BM");
139        codes.insert("BN");
140        codes.insert("BO");
141        codes.insert("BQ");
142        codes.insert("BR");
143        codes.insert("BS");
144        codes.insert("BT");
145        codes.insert("BV");
146        codes.insert("BW");
147        codes.insert("BY");
148        codes.insert("BZ");
149        codes.insert("CA");
150        codes.insert("CC");
151        codes.insert("CD");
152        codes.insert("CF");
153        codes.insert("CG");
154        codes.insert("CH");
155        codes.insert("CI");
156        codes.insert("CK");
157        codes.insert("CL");
158        codes.insert("CM");
159        codes.insert("CN");
160        codes.insert("CO");
161        codes.insert("CR");
162        codes.insert("CU");
163        codes.insert("CV");
164        codes.insert("CW");
165        codes.insert("CX");
166        codes.insert("CY");
167        codes.insert("CZ");
168        codes.insert("DE");
169        codes.insert("DJ");
170        codes.insert("DK");
171        codes.insert("DM");
172        codes.insert("DO");
173        codes.insert("DZ");
174        codes.insert("EC");
175        codes.insert("EE");
176        codes.insert("EG");
177        codes.insert("EH");
178        codes.insert("ER");
179        codes.insert("ES");
180        codes.insert("ET");
181        codes.insert("FI");
182        codes.insert("FJ");
183        codes.insert("FK");
184        codes.insert("FM");
185        codes.insert("FO");
186        codes.insert("FR");
187        codes.insert("GA");
188        codes.insert("GB");
189        codes.insert("GD");
190        codes.insert("GE");
191        codes.insert("GF");
192        codes.insert("GG");
193        codes.insert("GH");
194        codes.insert("GI");
195        codes.insert("GL");
196        codes.insert("GM");
197        codes.insert("GN");
198        codes.insert("GP");
199        codes.insert("GQ");
200        codes.insert("GR");
201        codes.insert("GS");
202        codes.insert("GT");
203        codes.insert("GU");
204        codes.insert("GW");
205        codes.insert("GY");
206        codes.insert("HK");
207        codes.insert("HM");
208        codes.insert("HN");
209        codes.insert("HR");
210        codes.insert("HT");
211        codes.insert("HU");
212        codes.insert("ID");
213        codes.insert("IE");
214        codes.insert("IL");
215        codes.insert("IM");
216        codes.insert("IN");
217        codes.insert("IO");
218        codes.insert("IQ");
219        codes.insert("IR");
220        codes.insert("IS");
221        codes.insert("IT");
222        codes.insert("JE");
223        codes.insert("JM");
224        codes.insert("JO");
225        codes.insert("JP");
226        codes.insert("KE");
227        codes.insert("KG");
228        codes.insert("KH");
229        codes.insert("KI");
230        codes.insert("KM");
231        codes.insert("KN");
232        codes.insert("KP");
233        codes.insert("KR");
234        codes.insert("KW");
235        codes.insert("KY");
236        codes.insert("KZ");
237        codes.insert("LA");
238        codes.insert("LB");
239        codes.insert("LC");
240        codes.insert("LI");
241        codes.insert("LK");
242        codes.insert("LR");
243        codes.insert("LS");
244        codes.insert("LT");
245        codes.insert("LU");
246        codes.insert("LV");
247        codes.insert("LY");
248        codes.insert("MA");
249        codes.insert("MC");
250        codes.insert("MD");
251        codes.insert("ME");
252        codes.insert("MF");
253        codes.insert("MG");
254        codes.insert("MH");
255        codes.insert("MK");
256        codes.insert("ML");
257        codes.insert("MM");
258        codes.insert("MN");
259        codes.insert("MO");
260        codes.insert("MP");
261        codes.insert("MQ");
262        codes.insert("MR");
263        codes.insert("MS");
264        codes.insert("MT");
265        codes.insert("MU");
266        codes.insert("MV");
267        codes.insert("MW");
268        codes.insert("MX");
269        codes.insert("MY");
270        codes.insert("MZ");
271        codes.insert("NA");
272        codes.insert("NC");
273        codes.insert("NE");
274        codes.insert("NF");
275        codes.insert("NG");
276        codes.insert("NI");
277        codes.insert("NL");
278        codes.insert("NO");
279        codes.insert("NP");
280        codes.insert("NR");
281        codes.insert("NU");
282        codes.insert("NZ");
283        codes.insert("OM");
284        codes.insert("PA");
285        codes.insert("PE");
286        codes.insert("PF");
287        codes.insert("PG");
288        codes.insert("PH");
289        codes.insert("PK");
290        codes.insert("PL");
291        codes.insert("PM");
292        codes.insert("PN");
293        codes.insert("PR");
294        codes.insert("PS");
295        codes.insert("PT");
296        codes.insert("PW");
297        codes.insert("PY");
298        codes.insert("QA");
299        codes.insert("RE");
300        codes.insert("RO");
301        codes.insert("RS");
302        codes.insert("RU");
303        codes.insert("RW");
304        codes.insert("SA");
305        codes.insert("SB");
306        codes.insert("SC");
307        codes.insert("SD");
308        codes.insert("SE");
309        codes.insert("SG");
310        codes.insert("SH");
311        codes.insert("SI");
312        codes.insert("SJ");
313        codes.insert("SK");
314        codes.insert("SL");
315        codes.insert("SM");
316        codes.insert("SN");
317        codes.insert("SO");
318        codes.insert("SR");
319        codes.insert("SS");
320        codes.insert("ST");
321        codes.insert("SV");
322        codes.insert("SX");
323        codes.insert("SY");
324        codes.insert("SZ");
325        codes.insert("TC");
326        codes.insert("TD");
327        codes.insert("TF");
328        codes.insert("TG");
329        codes.insert("TH");
330        codes.insert("TJ");
331        codes.insert("TK");
332        codes.insert("TL");
333        codes.insert("TM");
334        codes.insert("TN");
335        codes.insert("TO");
336        codes.insert("TR");
337        codes.insert("TT");
338        codes.insert("TV");
339        codes.insert("TW");
340        codes.insert("TZ");
341        codes.insert("UA");
342        codes.insert("UG");
343        codes.insert("UM");
344        codes.insert("US");
345        codes.insert("UY");
346        codes.insert("UZ");
347        codes.insert("VA");
348        codes.insert("VC");
349        codes.insert("VE");
350        codes.insert("VG");
351        codes.insert("VI");
352        codes.insert("VN");
353        codes.insert("VU");
354        codes.insert("WF");
355        codes.insert("WS");
356        codes.insert("YE");
357        codes.insert("YT");
358        codes.insert("ZA");
359        codes.insert("ZM");
360        codes.insert("ZW");
361        Self { valid_codes: codes }
362    }
363
364    /// Validate a country code against ISO 3166-1 alpha-2 standard.
365    pub fn validate(&self, value: &str) -> bool {
366        let value_upper = value.to_uppercase();
367        COUNTRY_CODE_REGEX.is_match(&value_upper) && self.valid_codes.contains(value_upper.as_str())
368    }
369
370    /// Return the standard validation error message for an invalid country code.
371    #[must_use]
372    pub const fn error_message() -> &'static str {
373        "Invalid country code (must be ISO 3166-1 alpha-2)"
374    }
375}
376
377impl Default for CountryCodeValidator {
378    fn default() -> Self {
379        Self::new()
380    }
381}