Skip to main content

what_core/validation/
mod.rs

1//! Form validation for What
2//!
3//! Provides server-side form validation using signed hidden fields (JWT).
4//! When the engine renders a `<form w-validate>`, it scans inputs for `w-*`
5//! validation attributes, serializes rules as a JWT, and injects a hidden field.
6//! On submission, the action handler decodes and validates against those rules.
7
8use regex::Regex;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::sync::LazyLock;
12
13// Pre-compiled validation regexes
14static 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/// Validation rules for a single form field
28#[derive(Debug, Clone, Serialize, Deserialize, Default)]
29pub struct FieldRules {
30    /// Field is required (non-empty)
31    #[serde(default)]
32    pub required: bool,
33
34    /// Minimum length
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub min: Option<usize>,
37
38    /// Maximum length
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub max: Option<usize>,
41
42    /// Field type validation (email, url, number)
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub field_type: Option<String>,
45
46    /// Regex pattern to match
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub pattern: Option<String>,
49
50    /// Another field that must match this one
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub match_field: Option<String>,
53
54    /// DataStore uniqueness check: "collection.field"
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub unique: Option<String>,
57
58    /// Custom error message
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub error_message: Option<String>,
61}
62
63/// All validation rules for a form
64#[derive(Debug, Clone, Serialize, Deserialize, Default)]
65pub struct FormRules {
66    pub fields: HashMap<String, FieldRules>,
67}
68
69/// Result of validating form data against rules
70#[derive(Debug, Clone)]
71pub struct ValidationResult {
72    pub errors: HashMap<String, String>,
73    pub is_valid: bool,
74}
75
76/// Validate form data against rules
77pub 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        // Required check
85        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; // Skip other checks if empty and required
93        }
94
95        // Skip other validations if value is empty and not required
96        if value.is_empty() {
97            continue;
98        }
99
100        // Min length
101        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        // Max length
117        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        // Type validation
133        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        // Regex pattern (with size/complexity limits to prevent ReDoS)
155        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) // 1MB compiled size limit
167                .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        // Match field
185        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        // Note: unique check requires DataStore access - handled in server/mod.rs
198    }
199
200    let is_valid = errors.is_empty();
201    ValidationResult { errors, is_valid }
202}
203
204/// Parse validation rules from form HTML by scanning for w-* attributes on inputs
205pub fn parse_form_rules(form_html: &str) -> FormRules {
206    let mut fields = HashMap::new();
207
208    // Use regex to find input/textarea/select elements with w-* attributes
209    // The scraper crate lowercases attributes, so we work with the raw HTML
210    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
289/// Encode validation rules as a JWT for embedding in a hidden form field
290pub 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
300/// Decode and verify validation rules from a JWT hidden field
301pub 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); // Empty is OK when not required
519    }
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    // --- New type validation tests ---
618
619    #[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        // Valid phone numbers
631        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        // Invalid phone numbers
645        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        // Valid dates
667        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        // Invalid dates
676        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        // Valid times
698        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        // Invalid times
707        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        // Valid emails
729        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        // Invalid emails
738        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        // Valid URLs
760        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        // Invalid URLs
773        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        // Pattern longer than 512 chars should be rejected
786        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        // Valid
820        let data = HashMap::from([("code".to_string(), "ABC123".to_string())]);
821        assert!(validate_form(&data, &rules).is_valid);
822        // Invalid
823        let data = HashMap::from([("code".to_string(), "abc".to_string())]);
824        assert!(!validate_form(&data, &rules).is_valid);
825    }
826}