1use regex::Regex;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::sync::LazyLock;
12
13static EMAIL_RE: LazyLock<Regex> = LazyLock::new(|| {
15 Regex::new(
16 r"^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*\.[a-zA-Z]{2,}$",
17 )
18 .unwrap()
19});
20static PHONE_RE: LazyLock<Regex> =
21 LazyLock::new(|| Regex::new(r"^\+?[\d\s\-\(\)]{7,20}$").unwrap());
22static DATE_RE: LazyLock<Regex> =
23 LazyLock::new(|| Regex::new(r"^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$").unwrap());
24static TIME_RE: LazyLock<Regex> =
25 LazyLock::new(|| Regex::new(r"^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$").unwrap());
26
27#[derive(Debug, Clone, Serialize, Deserialize, Default)]
29pub struct FieldRules {
30 #[serde(default)]
32 pub required: bool,
33
34 #[serde(skip_serializing_if = "Option::is_none")]
36 pub min: Option<usize>,
37
38 #[serde(skip_serializing_if = "Option::is_none")]
40 pub max: Option<usize>,
41
42 #[serde(skip_serializing_if = "Option::is_none")]
44 pub field_type: Option<String>,
45
46 #[serde(skip_serializing_if = "Option::is_none")]
48 pub pattern: Option<String>,
49
50 #[serde(skip_serializing_if = "Option::is_none")]
52 pub match_field: Option<String>,
53
54 #[serde(skip_serializing_if = "Option::is_none")]
56 pub unique: Option<String>,
57
58 #[serde(skip_serializing_if = "Option::is_none")]
60 pub error_message: Option<String>,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize, Default)]
65pub struct FormRules {
66 pub fields: HashMap<String, FieldRules>,
67}
68
69#[derive(Debug, Clone)]
71pub struct ValidationResult {
72 pub errors: HashMap<String, String>,
73 pub is_valid: bool,
74}
75
76pub fn validate_form(data: &HashMap<String, String>, rules: &FormRules) -> ValidationResult {
78 let mut errors = HashMap::new();
79
80 for (field_name, field_rules) in &rules.fields {
81 let value = data.get(field_name).map(|s| s.as_str()).unwrap_or("");
82 let custom_msg = field_rules.error_message.as_deref();
83
84 if field_rules.required && value.is_empty() {
86 errors.insert(
87 field_name.clone(),
88 custom_msg
89 .unwrap_or(&format!("{} is required", field_name))
90 .to_string(),
91 );
92 continue; }
94
95 if value.is_empty() {
97 continue;
98 }
99
100 if let Some(min) = field_rules.min {
102 if value.len() < min {
103 errors.insert(
104 field_name.clone(),
105 custom_msg
106 .unwrap_or(&format!(
107 "{} must be at least {} characters",
108 field_name, min
109 ))
110 .to_string(),
111 );
112 continue;
113 }
114 }
115
116 if let Some(max) = field_rules.max {
118 if value.len() > max {
119 errors.insert(
120 field_name.clone(),
121 custom_msg
122 .unwrap_or(&format!(
123 "{} must be at most {} characters",
124 field_name, max
125 ))
126 .to_string(),
127 );
128 continue;
129 }
130 }
131
132 if let Some(ref field_type) = field_rules.field_type {
134 let valid = match field_type.as_str() {
135 "email" => EMAIL_RE.is_match(value),
136 "url" => url::Url::parse(value).is_ok(),
137 "number" => value.parse::<f64>().is_ok(),
138 "phone" => PHONE_RE.is_match(value),
139 "date" => DATE_RE.is_match(value),
140 "time" => TIME_RE.is_match(value),
141 _ => true,
142 };
143 if !valid {
144 errors.insert(
145 field_name.clone(),
146 custom_msg
147 .unwrap_or(&format!("{} must be a valid {}", field_name, field_type))
148 .to_string(),
149 );
150 continue;
151 }
152 }
153
154 if let Some(ref pattern) = field_rules.pattern {
156 if pattern.len() > 512 {
157 errors.insert(
158 field_name.clone(),
159 custom_msg
160 .unwrap_or(&format!("{}: validation pattern too long", field_name))
161 .to_string(),
162 );
163 continue;
164 }
165 if let Ok(re) = regex::RegexBuilder::new(pattern)
166 .size_limit(1 << 20) .build()
168 {
169 if !re.is_match(value) {
170 errors.insert(
171 field_name.clone(),
172 custom_msg
173 .unwrap_or(&format!(
174 "{} does not match the required format",
175 field_name
176 ))
177 .to_string(),
178 );
179 continue;
180 }
181 }
182 }
183
184 if let Some(ref match_name) = field_rules.match_field {
186 let other_value = data.get(match_name).map(|s| s.as_str()).unwrap_or("");
187 if value != other_value {
188 errors.insert(
189 field_name.clone(),
190 custom_msg
191 .unwrap_or(&format!("{} must match {}", field_name, match_name))
192 .to_string(),
193 );
194 }
195 }
196
197 }
199
200 let is_valid = errors.is_empty();
201 ValidationResult { errors, is_valid }
202}
203
204pub fn parse_form_rules(form_html: &str) -> FormRules {
206 let mut fields = HashMap::new();
207
208 let input_re =
211 regex::Regex::new(r#"<(?:input|textarea|select)\s[^>]*?name\s*=\s*"([^"]+)"[^>]*>"#)
212 .unwrap();
213
214 for cap in input_re.captures_iter(form_html) {
215 let field_name = cap[1].to_string();
216 let tag_html = cap[0].to_string();
217
218 let mut rules = FieldRules::default();
219 let mut has_rules = false;
220
221 if tag_html.contains("w-required") {
222 rules.required = true;
223 has_rules = true;
224 }
225
226 if let Some(min_cap) = regex::Regex::new(r#"w-min\s*=\s*"(\d+)""#)
227 .ok()
228 .and_then(|re| re.captures(&tag_html))
229 {
230 rules.min = min_cap[1].parse().ok();
231 has_rules = true;
232 }
233
234 if let Some(max_cap) = regex::Regex::new(r#"w-max\s*=\s*"(\d+)""#)
235 .ok()
236 .and_then(|re| re.captures(&tag_html))
237 {
238 rules.max = max_cap[1].parse().ok();
239 has_rules = true;
240 }
241
242 if let Some(type_cap) = regex::Regex::new(r#"w-type\s*=\s*"([^"]+)""#)
243 .ok()
244 .and_then(|re| re.captures(&tag_html))
245 {
246 rules.field_type = Some(type_cap[1].to_string());
247 has_rules = true;
248 }
249
250 if let Some(pattern_cap) = regex::Regex::new(r#"w-pattern\s*=\s*"([^"]+)""#)
251 .ok()
252 .and_then(|re| re.captures(&tag_html))
253 {
254 rules.pattern = Some(pattern_cap[1].to_string());
255 has_rules = true;
256 }
257
258 if let Some(match_cap) = regex::Regex::new(r#"w-match\s*=\s*"([^"]+)""#)
259 .ok()
260 .and_then(|re| re.captures(&tag_html))
261 {
262 rules.match_field = Some(match_cap[1].to_string());
263 has_rules = true;
264 }
265
266 if let Some(unique_cap) = regex::Regex::new(r#"w-unique\s*=\s*"([^"]+)""#)
267 .ok()
268 .and_then(|re| re.captures(&tag_html))
269 {
270 rules.unique = Some(unique_cap[1].to_string());
271 has_rules = true;
272 }
273
274 if let Some(error_cap) = regex::Regex::new(r#"w-error\s*=\s*"([^"]+)""#)
275 .ok()
276 .and_then(|re| re.captures(&tag_html))
277 {
278 rules.error_message = Some(error_cap[1].to_string());
279 }
280
281 if has_rules {
282 fields.insert(field_name, rules);
283 }
284 }
285
286 FormRules { fields }
287}
288
289pub fn encode_rules(rules: &FormRules, secret: &str) -> Option<String> {
291 use jsonwebtoken::{EncodingKey, Header, encode};
292 encode(
293 &Header::default(),
294 rules,
295 &EncodingKey::from_secret(secret.as_bytes()),
296 )
297 .ok()
298}
299
300pub fn decode_rules(token: &str, secret: &str) -> Option<FormRules> {
302 use jsonwebtoken::{DecodingKey, Validation, decode};
303 let mut validation = Validation::default();
304 validation.required_spec_claims.clear();
305 validation.validate_exp = false;
306
307 decode::<FormRules>(
308 token,
309 &DecodingKey::from_secret(secret.as_bytes()),
310 &validation,
311 )
312 .ok()
313 .map(|data| data.claims)
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319
320 #[test]
321 fn test_validate_required_present() {
322 let rules = FormRules {
323 fields: HashMap::from([(
324 "name".to_string(),
325 FieldRules {
326 required: true,
327 ..Default::default()
328 },
329 )]),
330 };
331 let data = HashMap::from([("name".to_string(), "Alice".to_string())]);
332 let result = validate_form(&data, &rules);
333 assert!(result.is_valid);
334 }
335
336 #[test]
337 fn test_validate_required_missing() {
338 let rules = FormRules {
339 fields: HashMap::from([(
340 "name".to_string(),
341 FieldRules {
342 required: true,
343 ..Default::default()
344 },
345 )]),
346 };
347 let data = HashMap::new();
348 let result = validate_form(&data, &rules);
349 assert!(!result.is_valid);
350 assert!(result.errors.contains_key("name"));
351 }
352
353 #[test]
354 fn test_validate_min_length() {
355 let rules = FormRules {
356 fields: HashMap::from([(
357 "password".to_string(),
358 FieldRules {
359 min: Some(8),
360 ..Default::default()
361 },
362 )]),
363 };
364 let data = HashMap::from([("password".to_string(), "short".to_string())]);
365 let result = validate_form(&data, &rules);
366 assert!(!result.is_valid);
367 assert!(
368 result
369 .errors
370 .get("password")
371 .unwrap()
372 .contains("at least 8")
373 );
374 }
375
376 #[test]
377 fn test_validate_max_length() {
378 let rules = FormRules {
379 fields: HashMap::from([(
380 "bio".to_string(),
381 FieldRules {
382 max: Some(5),
383 ..Default::default()
384 },
385 )]),
386 };
387 let data = HashMap::from([("bio".to_string(), "too long text".to_string())]);
388 let result = validate_form(&data, &rules);
389 assert!(!result.is_valid);
390 }
391
392 #[test]
393 fn test_validate_email_type() {
394 let rules = FormRules {
395 fields: HashMap::from([(
396 "email".to_string(),
397 FieldRules {
398 field_type: Some("email".to_string()),
399 ..Default::default()
400 },
401 )]),
402 };
403 let valid = HashMap::from([("email".to_string(), "user@example.com".to_string())]);
404 assert!(validate_form(&valid, &rules).is_valid);
405
406 let invalid = HashMap::from([("email".to_string(), "notanemail".to_string())]);
407 assert!(!validate_form(&invalid, &rules).is_valid);
408 }
409
410 #[test]
411 fn test_validate_url_type() {
412 let rules = FormRules {
413 fields: HashMap::from([(
414 "website".to_string(),
415 FieldRules {
416 field_type: Some("url".to_string()),
417 ..Default::default()
418 },
419 )]),
420 };
421 let valid = HashMap::from([("website".to_string(), "https://example.com".to_string())]);
422 assert!(validate_form(&valid, &rules).is_valid);
423
424 let invalid = HashMap::from([("website".to_string(), "not-a-url".to_string())]);
425 assert!(!validate_form(&invalid, &rules).is_valid);
426 }
427
428 #[test]
429 fn test_validate_number_type() {
430 let rules = FormRules {
431 fields: HashMap::from([(
432 "age".to_string(),
433 FieldRules {
434 field_type: Some("number".to_string()),
435 ..Default::default()
436 },
437 )]),
438 };
439 let valid = HashMap::from([("age".to_string(), "25".to_string())]);
440 assert!(validate_form(&valid, &rules).is_valid);
441
442 let invalid = HashMap::from([("age".to_string(), "abc".to_string())]);
443 assert!(!validate_form(&invalid, &rules).is_valid);
444 }
445
446 #[test]
447 fn test_validate_pattern() {
448 let rules = FormRules {
449 fields: HashMap::from([(
450 "zip".to_string(),
451 FieldRules {
452 pattern: Some(r"^\d{5}$".to_string()),
453 ..Default::default()
454 },
455 )]),
456 };
457 let valid = HashMap::from([("zip".to_string(), "12345".to_string())]);
458 assert!(validate_form(&valid, &rules).is_valid);
459
460 let invalid = HashMap::from([("zip".to_string(), "1234".to_string())]);
461 assert!(!validate_form(&invalid, &rules).is_valid);
462 }
463
464 #[test]
465 fn test_validate_match_field() {
466 let rules = FormRules {
467 fields: HashMap::from([(
468 "confirm_password".to_string(),
469 FieldRules {
470 match_field: Some("password".to_string()),
471 ..Default::default()
472 },
473 )]),
474 };
475 let valid = HashMap::from([
476 ("password".to_string(), "secret".to_string()),
477 ("confirm_password".to_string(), "secret".to_string()),
478 ]);
479 assert!(validate_form(&valid, &rules).is_valid);
480
481 let invalid = HashMap::from([
482 ("password".to_string(), "secret".to_string()),
483 ("confirm_password".to_string(), "different".to_string()),
484 ]);
485 assert!(!validate_form(&invalid, &rules).is_valid);
486 }
487
488 #[test]
489 fn test_validate_custom_error_message() {
490 let rules = FormRules {
491 fields: HashMap::from([(
492 "name".to_string(),
493 FieldRules {
494 required: true,
495 error_message: Some("Please enter your name".to_string()),
496 ..Default::default()
497 },
498 )]),
499 };
500 let data = HashMap::new();
501 let result = validate_form(&data, &rules);
502 assert_eq!(result.errors.get("name").unwrap(), "Please enter your name");
503 }
504
505 #[test]
506 fn test_validate_empty_non_required_skips() {
507 let rules = FormRules {
508 fields: HashMap::from([(
509 "bio".to_string(),
510 FieldRules {
511 min: Some(10),
512 ..Default::default()
513 },
514 )]),
515 };
516 let data = HashMap::from([("bio".to_string(), "".to_string())]);
517 let result = validate_form(&data, &rules);
518 assert!(result.is_valid); }
520
521 #[test]
522 fn test_parse_form_rules_basic() {
523 let html = r#"<form w-validate>
524 <input type="text" name="username" w-required w-min="3" w-max="20">
525 <input type="email" name="email" w-required w-type="email">
526 <input type="submit" value="Submit">
527 </form>"#;
528 let rules = parse_form_rules(html);
529 assert_eq!(rules.fields.len(), 2);
530
531 let username = rules.fields.get("username").unwrap();
532 assert!(username.required);
533 assert_eq!(username.min, Some(3));
534 assert_eq!(username.max, Some(20));
535
536 let email = rules.fields.get("email").unwrap();
537 assert!(email.required);
538 assert_eq!(email.field_type.as_deref(), Some("email"));
539 }
540
541 #[test]
542 fn test_parse_form_rules_with_pattern() {
543 let html = r#"<input name="zip" w-pattern="^\d{5}$" w-error="Invalid zip code">"#;
544 let rules = parse_form_rules(html);
545 let zip = rules.fields.get("zip").unwrap();
546 assert_eq!(zip.pattern.as_deref(), Some(r"^\d{5}$"));
547 assert_eq!(zip.error_message.as_deref(), Some("Invalid zip code"));
548 }
549
550 #[test]
551 fn test_parse_form_rules_with_match() {
552 let html = r#"<input name="confirm" w-match="password" w-required>"#;
553 let rules = parse_form_rules(html);
554 let confirm = rules.fields.get("confirm").unwrap();
555 assert!(confirm.required);
556 assert_eq!(confirm.match_field.as_deref(), Some("password"));
557 }
558
559 #[test]
560 fn test_encode_decode_rules_roundtrip() {
561 let rules = FormRules {
562 fields: HashMap::from([
563 (
564 "name".to_string(),
565 FieldRules {
566 required: true,
567 min: Some(2),
568 ..Default::default()
569 },
570 ),
571 (
572 "email".to_string(),
573 FieldRules {
574 required: true,
575 field_type: Some("email".to_string()),
576 ..Default::default()
577 },
578 ),
579 ]),
580 };
581
582 let secret = "test-secret-key";
583 let token = encode_rules(&rules, secret).expect("encoding should work");
584 let decoded = decode_rules(&token, secret).expect("decoding should work");
585
586 assert_eq!(decoded.fields.len(), 2);
587 assert!(decoded.fields.get("name").unwrap().required);
588 assert_eq!(decoded.fields.get("name").unwrap().min, Some(2));
589 assert_eq!(
590 decoded.fields.get("email").unwrap().field_type.as_deref(),
591 Some("email")
592 );
593 }
594
595 #[test]
596 fn test_decode_rules_wrong_secret() {
597 let rules = FormRules {
598 fields: HashMap::from([(
599 "name".to_string(),
600 FieldRules {
601 required: true,
602 ..Default::default()
603 },
604 )]),
605 };
606 let token = encode_rules(&rules, "secret1").unwrap();
607 let result = decode_rules(&token, "secret2");
608 assert!(result.is_none());
609 }
610
611 #[test]
612 fn test_decode_rules_invalid_token() {
613 let result = decode_rules("not.a.valid.token", "secret");
614 assert!(result.is_none());
615 }
616
617 #[test]
620 fn test_validate_phone_type() {
621 let rules = FormRules {
622 fields: HashMap::from([(
623 "phone".to_string(),
624 FieldRules {
625 field_type: Some("phone".to_string()),
626 ..Default::default()
627 },
628 )]),
629 };
630 for phone in &[
632 "+1 555-123-4567",
633 "(555) 123-4567",
634 "5551234567",
635 "+44 20 7946 0958",
636 ] {
637 let data = HashMap::from([("phone".to_string(), phone.to_string())]);
638 assert!(
639 validate_form(&data, &rules).is_valid,
640 "Expected valid: {}",
641 phone
642 );
643 }
644 for phone in &["abc", "12", "+1"] {
646 let data = HashMap::from([("phone".to_string(), phone.to_string())]);
647 assert!(
648 !validate_form(&data, &rules).is_valid,
649 "Expected invalid: {}",
650 phone
651 );
652 }
653 }
654
655 #[test]
656 fn test_validate_date_type() {
657 let rules = FormRules {
658 fields: HashMap::from([(
659 "date".to_string(),
660 FieldRules {
661 field_type: Some("date".to_string()),
662 ..Default::default()
663 },
664 )]),
665 };
666 for date in &["2024-01-15", "2024-12-31", "2000-06-01"] {
668 let data = HashMap::from([("date".to_string(), date.to_string())]);
669 assert!(
670 validate_form(&data, &rules).is_valid,
671 "Expected valid: {}",
672 date
673 );
674 }
675 for date in &["2024-13-01", "2024-00-15", "24-01-15", "not-a-date"] {
677 let data = HashMap::from([("date".to_string(), date.to_string())]);
678 assert!(
679 !validate_form(&data, &rules).is_valid,
680 "Expected invalid: {}",
681 date
682 );
683 }
684 }
685
686 #[test]
687 fn test_validate_time_type() {
688 let rules = FormRules {
689 fields: HashMap::from([(
690 "time".to_string(),
691 FieldRules {
692 field_type: Some("time".to_string()),
693 ..Default::default()
694 },
695 )]),
696 };
697 for time in &["00:00", "23:59", "12:30", "09:15:30"] {
699 let data = HashMap::from([("time".to_string(), time.to_string())]);
700 assert!(
701 validate_form(&data, &rules).is_valid,
702 "Expected valid: {}",
703 time
704 );
705 }
706 for time in &["24:00", "12:60", "abc", "9:5"] {
708 let data = HashMap::from([("time".to_string(), time.to_string())]);
709 assert!(
710 !validate_form(&data, &rules).is_valid,
711 "Expected invalid: {}",
712 time
713 );
714 }
715 }
716
717 #[test]
718 fn test_validate_email_rfc5322() {
719 let rules = FormRules {
720 fields: HashMap::from([(
721 "email".to_string(),
722 FieldRules {
723 field_type: Some("email".to_string()),
724 ..Default::default()
725 },
726 )]),
727 };
728 for email in &["user@example.com", "user+tag@sub.domain.co", "a@b.io"] {
730 let data = HashMap::from([("email".to_string(), email.to_string())]);
731 assert!(
732 validate_form(&data, &rules).is_valid,
733 "Expected valid: {}",
734 email
735 );
736 }
737 for email in &["notanemail", "@no-local.com", "user@", "user@.com"] {
739 let data = HashMap::from([("email".to_string(), email.to_string())]);
740 assert!(
741 !validate_form(&data, &rules).is_valid,
742 "Expected invalid: {}",
743 email
744 );
745 }
746 }
747
748 #[test]
749 fn test_validate_url_proper() {
750 let rules = FormRules {
751 fields: HashMap::from([(
752 "website".to_string(),
753 FieldRules {
754 field_type: Some("url".to_string()),
755 ..Default::default()
756 },
757 )]),
758 };
759 for url in &[
761 "https://example.com",
762 "http://localhost:3000",
763 "ftp://files.example.com/doc.txt",
764 ] {
765 let data = HashMap::from([("website".to_string(), url.to_string())]);
766 assert!(
767 validate_form(&data, &rules).is_valid,
768 "Expected valid: {}",
769 url
770 );
771 }
772 for url in &["not-a-url", "example.com", "://missing-scheme"] {
774 let data = HashMap::from([("website".to_string(), url.to_string())]);
775 assert!(
776 !validate_form(&data, &rules).is_valid,
777 "Expected invalid: {}",
778 url
779 );
780 }
781 }
782
783 #[test]
784 fn test_oversized_pattern_rejected() {
785 let long_pattern = "a".repeat(600);
787 let rules = FormRules {
788 fields: HashMap::from([(
789 "code".to_string(),
790 FieldRules {
791 pattern: Some(long_pattern),
792 ..Default::default()
793 },
794 )]),
795 };
796 let data = HashMap::from([("code".to_string(), "abc".to_string())]);
797 let result = validate_form(&data, &rules);
798 assert!(!result.is_valid, "Oversized pattern should be rejected");
799 assert!(
800 result
801 .errors
802 .values()
803 .any(|e| e.contains("pattern too long")),
804 "Error message should mention pattern too long"
805 );
806 }
807
808 #[test]
809 fn test_normal_pattern_still_works() {
810 let rules = FormRules {
811 fields: HashMap::from([(
812 "code".to_string(),
813 FieldRules {
814 pattern: Some(r"^[A-Z]{3}\d{3}$".to_string()),
815 ..Default::default()
816 },
817 )]),
818 };
819 let data = HashMap::from([("code".to_string(), "ABC123".to_string())]);
821 assert!(validate_form(&data, &rules).is_valid);
822 let data = HashMap::from([("code".to_string(), "abc".to_string())]);
824 assert!(!validate_form(&data, &rules).is_valid);
825 }
826}