Skip to main content

mx20022_validate/rules/
pattern.rs

1//! Regex pattern validation rule derived from XSD `pattern` facets.
2//!
3//! Uses the [`regex`] crate to compile and match patterns. Patterns are anchored
4//! (full-string match) to match XSD semantics where the pattern must match the
5//! entire value.
6
7use crate::error::{Severity, ValidationError};
8use crate::rules::Rule;
9use regex::Regex;
10
11/// Validates a string against a compiled regular expression (full-string match).
12///
13/// The pattern is anchored with `^...$` so that it must match the entire value,
14/// mirroring XSD `<xs:pattern>` behaviour.
15///
16/// # Examples
17///
18/// ```
19/// use mx20022_validate::rules::pattern::PatternRule;
20/// use mx20022_validate::rules::Rule;
21///
22/// let rule = PatternRule::new("COUNTRY_CODE", "[A-Z]{2}").unwrap();
23/// assert!(rule.validate("GB", "/path").is_empty());
24/// assert!(!rule.validate("gb", "/path").is_empty());
25/// assert!(!rule.validate("GBR", "/path").is_empty());
26/// ```
27pub struct PatternRule {
28    rule_id: String,
29    pattern: String,
30    regex: Regex,
31}
32
33impl PatternRule {
34    /// Build a new `PatternRule` with the given rule id and regex pattern.
35    ///
36    /// The pattern is automatically anchored — do **not** include `^` / `$` yourself.
37    ///
38    /// # Errors
39    ///
40    /// Returns an error if `pattern` is not a valid regular expression.
41    pub fn new(rule_id: impl Into<String>, pattern: &str) -> Result<Self, regex::Error> {
42        let rule_id = rule_id.into();
43        // Anchor the pattern to enforce full-string matching (XSD semantics).
44        let anchored = format!("^(?:{pattern})$");
45        let regex = Regex::new(&anchored)?;
46        Ok(Self {
47            rule_id,
48            pattern: pattern.to_owned(),
49            regex,
50        })
51    }
52
53    /// The raw (unanchored) pattern string used to construct this rule.
54    pub fn pattern(&self) -> &str {
55        &self.pattern
56    }
57}
58
59impl Rule for PatternRule {
60    fn id(&self) -> &str {
61        &self.rule_id
62    }
63
64    fn validate(&self, value: &str, path: &str) -> Vec<ValidationError> {
65        if self.regex.is_match(value) {
66            vec![]
67        } else {
68            vec![ValidationError::new(
69                path,
70                Severity::Error,
71                &self.rule_id,
72                format!("Value `{value}` does not match pattern `{}`", self.pattern),
73            )]
74        }
75    }
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81    use crate::rules::Rule;
82
83    #[test]
84    fn two_letter_country_code_passes() {
85        let rule = PatternRule::new("COUNTRY_CODE", "[A-Z]{2}").unwrap();
86        assert!(rule.validate("GB", "/p").is_empty());
87        assert!(rule.validate("US", "/p").is_empty());
88    }
89
90    #[test]
91    fn two_letter_country_code_rejects_lowercase() {
92        let rule = PatternRule::new("COUNTRY_CODE", "[A-Z]{2}").unwrap();
93        let errors = rule.validate("gb", "/p");
94        assert!(!errors.is_empty());
95    }
96
97    #[test]
98    fn two_letter_country_code_rejects_extra_chars() {
99        let rule = PatternRule::new("COUNTRY_CODE", "[A-Z]{2}").unwrap();
100        let errors = rule.validate("GBR", "/p");
101        assert!(!errors.is_empty());
102    }
103
104    #[test]
105    fn bic_pattern_passes() {
106        let rule = PatternRule::new(
107            "BIC_PATTERN",
108            "[A-Z0-9]{4,4}[A-Z]{2,2}[A-Z0-9]{2,2}([A-Z0-9]{3,3}){0,1}",
109        )
110        .unwrap();
111        assert!(rule.validate("AAAAGB2L", "/p").is_empty());
112        assert!(rule.validate("AAAAGB2LXXX", "/p").is_empty());
113    }
114
115    #[test]
116    fn bic_pattern_rejects_short() {
117        let rule = PatternRule::new(
118            "BIC_PATTERN",
119            "[A-Z0-9]{4,4}[A-Z]{2,2}[A-Z0-9]{2,2}([A-Z0-9]{3,3}){0,1}",
120        )
121        .unwrap();
122        let errors = rule.validate("AAAA", "/p");
123        assert!(!errors.is_empty());
124    }
125
126    #[test]
127    fn error_contains_pattern_and_rule_id() {
128        let rule = PatternRule::new("MY_RULE", "[A-Z]{3}").unwrap();
129        let errors = rule.validate("abc", "/some/path");
130        assert_eq!(errors.len(), 1);
131        assert_eq!(errors[0].rule_id, "MY_RULE");
132        assert_eq!(errors[0].path, "/some/path");
133        assert!(errors[0].message.contains("[A-Z]{3}"));
134    }
135
136    #[test]
137    fn invalid_regex_returns_error() {
138        let result = PatternRule::new("BAD", "[unclosed");
139        assert!(result.is_err());
140    }
141
142    #[test]
143    fn empty_string_matches_empty_pattern() {
144        let rule = PatternRule::new("EMPTY", "").unwrap();
145        assert!(rule.validate("", "/p").is_empty());
146        // A non-empty value should fail a pattern that only matches empty
147        let errors = rule.validate("x", "/p");
148        assert!(!errors.is_empty());
149    }
150
151    #[test]
152    fn pattern_is_full_string_match_not_partial() {
153        // Pattern [A-Z]{2} should NOT match "ABCD" even though "AB" is a substring
154        let rule = PatternRule::new("R", "[A-Z]{2}").unwrap();
155        let errors = rule.validate("ABCD", "/p");
156        assert!(!errors.is_empty(), "Partial match should be rejected");
157    }
158}