fraiseql_core/validation/
rich_scalars.rs1use std::sync::LazyLock;
7
8use regex::Regex;
9
10use crate::validation::patterns;
11
12static EMAIL_REGEX: LazyLock<Regex> =
14 LazyLock::new(|| Regex::new(patterns::EMAIL).expect("email regex is valid"));
15
16static PHONE_REGEX: LazyLock<Regex> =
19 LazyLock::new(|| Regex::new(patterns::PHONE_LENIENT).expect("phone regex is valid"));
20
21static VIN_REGEX: LazyLock<Regex> =
23 LazyLock::new(|| Regex::new(r"^[A-HJ-NPR-Z0-9]{17}$").expect("VIN regex is valid"));
24
25static COUNTRY_CODE_REGEX: LazyLock<Regex> =
27 LazyLock::new(|| Regex::new(r"^[A-Z]{2}$").expect("country code regex is valid"));
28
29pub struct EmailValidator;
31
32impl EmailValidator {
33 pub fn validate(value: &str) -> bool {
38 !value.is_empty() && value.len() <= 254 && EMAIL_REGEX.is_match(value)
39 }
40
41 pub const fn error_message() -> &'static str {
43 "Invalid email format"
44 }
45}
46
47pub struct PhoneNumberValidator;
49
50impl PhoneNumberValidator {
51 pub fn validate(value: &str) -> bool {
60 !value.is_empty() && value.len() <= 20 && PHONE_REGEX.is_match(value)
61 }
62
63 pub const fn error_message() -> &'static str {
65 "Invalid phone number format"
66 }
67}
68
69pub struct VinValidator;
71
72impl VinValidator {
73 pub fn validate(value: &str) -> bool {
82 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 pub const fn error_message() -> &'static str {
93 "Invalid VIN format (must be 17 alphanumeric characters, excluding I, O, Q)"
94 }
95}
96
97pub struct CountryCodeValidator {
99 valid_codes: std::collections::HashSet<&'static str>,
100}
101
102impl CountryCodeValidator {
103 pub fn new() -> Self {
105 let mut codes = std::collections::HashSet::new();
106 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 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 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 #[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 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 #[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")); assert!(!PhoneNumberValidator::validate(""));
420 }
421
422 #[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")); assert!(!VinValidator::validate("3G1FB1E30D110918O")); assert!(!VinValidator::validate("3G1FB1E30D110918Q")); }
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 assert!(!VinValidator::validate("3G1FB1E30D110918"), "16-char VIN rejected");
456 }
457
458 #[test]
459 fn test_vin_18_chars_rejected_by_length_guard() {
460 assert!(!VinValidator::validate("3G1FB1E30D11091862"), "18-char VIN rejected");
462 }
463
464 #[test]
465 fn test_vin_very_long_string_rejected_by_length_guard() {
466 let long_input = "A".repeat(100);
468 assert!(!VinValidator::validate(&long_input), "100-char string rejected");
469 }
470
471 #[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}