Skip to main content

mx20022_validate/rules/
mod.rs

1//! Validation rule registry and built-in rule implementations.
2//!
3//! # Overview
4//!
5//! Rules are stateless validators that inspect a string value at a named path and
6//! produce zero or more [`ValidationError`]s. They are registered in a
7//! [`RuleRegistry`] and looked up by ID when validating fields.
8//!
9//! # Built-in rules
10//!
11//! | Rule ID           | Module          | Description                              |
12//! |-------------------|-----------------|------------------------------------------|
13//! | `IBAN_CHECK`      | [`iban`]        | ISO 13616 IBAN format + mod-97 check     |
14//! | `BIC_CHECK`       | [`bic`]         | ISO 9362 BIC/SWIFT code format           |
15//! | `CURRENCY_CHECK`  | [`currency`]    | ISO 4217 currency code                   |
16//! | `COUNTRY_CHECK`   | [`country`]     | ISO 3166-1 alpha-2 country code          |
17//! | `LEI_CHECK`       | [`lei`]         | ISO 17442 LEI format + mod-97 check      |
18//! | `AMOUNT_FORMAT`   | [`amount`]      | ISO 20022 decimal amount format          |
19//! | `DATETIME_CHECK`  | [`datetime`]    | ISO 8601 datetime (ISO 20022 subset)     |
20//! | `DATE_CHECK`      | [`datetime`]    | ISO 8601 date (ISO 20022 subset)         |
21//! | `MIN_LENGTH`      | [`length`]      | Minimum string length (XSD `minLength`)  |
22//! | `MAX_LENGTH`      | [`length`]      | Maximum string length (XSD `maxLength`)  |
23//! | `LENGTH_RANGE`    | [`length`]      | Combined min/max range                   |
24//! | `*` (custom)      | [`pattern`]     | Regex pattern (XSD `pattern` facet)      |
25
26pub mod amount;
27pub mod bic;
28pub(crate) mod checkdigit;
29pub mod country;
30pub mod currency;
31pub mod datetime;
32pub mod iban;
33pub mod lei;
34pub mod length;
35pub mod pattern;
36
37use crate::error::ValidationError;
38use std::collections::BTreeMap;
39
40/// A validation rule that can be applied to a string value at a given path.
41///
42/// Implement this trait to create custom validation rules.
43///
44/// # Examples
45///
46/// ```
47/// use mx20022_validate::rules::Rule;
48/// use mx20022_validate::error::{ValidationError, Severity};
49///
50/// struct NonEmptyRule;
51///
52/// impl Rule for NonEmptyRule {
53///     fn id(&self) -> &'static str { "NON_EMPTY" }
54///
55///     fn validate(&self, value: &str, path: &str) -> Vec<ValidationError> {
56///         if value.is_empty() {
57///             vec![ValidationError::new(path, Severity::Error, "NON_EMPTY", "Value must not be empty")]
58///         } else {
59///             vec![]
60///         }
61///     }
62/// }
63/// ```
64pub trait Rule: Send + Sync {
65    /// Unique identifier for this rule (e.g. `"IBAN_CHECK"`).
66    fn id(&self) -> &str;
67
68    /// Run the rule against `value` at the given `path` and return any findings.
69    fn validate(&self, value: &str, path: &str) -> Vec<ValidationError>;
70}
71
72/// A registry of named validation rules.
73///
74/// Rules are stored by their [`Rule::id`]. When multiple rules share an ID the
75/// last one registered wins (use unique IDs unless intentional override is needed).
76///
77/// # Examples
78///
79/// ```
80/// use mx20022_validate::rules::RuleRegistry;
81///
82/// let registry = RuleRegistry::with_defaults();
83/// let errors = registry.validate_field("GB82WEST12345698765432", "/path/iban", &["IBAN_CHECK"]);
84/// assert!(errors.is_empty());
85/// ```
86pub struct RuleRegistry {
87    rules: BTreeMap<String, Box<dyn Rule>>,
88}
89
90impl RuleRegistry {
91    /// Create an empty registry.
92    pub fn new() -> Self {
93        Self {
94            rules: BTreeMap::new(),
95        }
96    }
97
98    /// Create a registry pre-populated with all built-in rules.
99    ///
100    /// Built-in rules included:
101    /// - `IBAN_CHECK`      — IBAN format + mod-97
102    /// - `BIC_CHECK`       — BIC/SWIFT format
103    /// - `CURRENCY_CHECK`  — ISO 4217 currency code
104    /// - `COUNTRY_CHECK`   — ISO 3166-1 alpha-2 country code
105    /// - `LEI_CHECK`       — ISO 17442 LEI format + mod-97
106    /// - `AMOUNT_FORMAT`   — ISO 20022 decimal amount format
107    /// - `DATETIME_CHECK`  — ISO 8601 datetime
108    /// - `DATE_CHECK`      — ISO 8601 date
109    pub fn with_defaults() -> Self {
110        let mut registry = Self::new();
111        registry.register(Box::new(iban::IbanRule));
112        registry.register(Box::new(bic::BicRule));
113        registry.register(Box::new(currency::CurrencyRule));
114        registry.register(Box::new(country::CountryCodeRule));
115        registry.register(Box::new(lei::LeiRule));
116        registry.register(Box::new(amount::AmountFormatRule));
117        registry.register(Box::new(datetime::IsoDateTimeRule));
118        registry.register(Box::new(datetime::IsoDateRule));
119        registry
120    }
121
122    /// Register a rule. If a rule with the same ID already exists it is replaced.
123    pub fn register(&mut self, rule: Box<dyn Rule>) {
124        self.rules.insert(rule.id().to_owned(), rule);
125    }
126
127    /// Look up a registered rule by ID.
128    pub fn get(&self, rule_id: &str) -> Option<&dyn Rule> {
129        self.rules.get(rule_id).map(std::convert::AsRef::as_ref)
130    }
131
132    /// Run a specific subset of rules (identified by `rule_ids`) against `value`
133    /// at `path` and return all findings.
134    ///
135    /// Rules whose IDs are not present in the registry are silently skipped.
136    ///
137    /// # Examples
138    ///
139    /// ```
140    /// use mx20022_validate::rules::RuleRegistry;
141    ///
142    /// let registry = RuleRegistry::with_defaults();
143    /// let errors = registry.validate_field("NOT_AN_IBAN", "/doc/iban", &["IBAN_CHECK"]);
144    /// assert!(!errors.is_empty());
145    /// ```
146    pub fn validate_field(
147        &self,
148        value: &str,
149        path: &str,
150        rule_ids: &[&str],
151    ) -> Vec<ValidationError> {
152        rule_ids
153            .iter()
154            .filter_map(|id| self.rules.get(*id))
155            .flat_map(|rule| rule.validate(value, path))
156            .collect()
157    }
158}
159
160impl Default for RuleRegistry {
161    fn default() -> Self {
162        Self::with_defaults()
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use crate::error::Severity;
170
171    struct AlwaysFailRule;
172    impl Rule for AlwaysFailRule {
173        fn id(&self) -> &'static str {
174            "ALWAYS_FAIL"
175        }
176        fn validate(&self, _value: &str, path: &str) -> Vec<ValidationError> {
177            vec![ValidationError::new(
178                path,
179                Severity::Error,
180                "ALWAYS_FAIL",
181                "always fails",
182            )]
183        }
184    }
185
186    #[test]
187    fn empty_registry_produces_no_errors() {
188        let registry = RuleRegistry::new();
189        let errors = registry.validate_field("any", "/p", &["IBAN_CHECK"]);
190        assert!(errors.is_empty());
191    }
192
193    #[test]
194    fn registered_rule_is_invoked() {
195        let mut registry = RuleRegistry::new();
196        registry.register(Box::new(AlwaysFailRule));
197        let errors = registry.validate_field("any", "/p", &["ALWAYS_FAIL"]);
198        assert_eq!(errors.len(), 1);
199    }
200
201    #[test]
202    fn unknown_rule_id_is_skipped() {
203        let registry = RuleRegistry::with_defaults();
204        let errors = registry.validate_field("any", "/p", &["NO_SUCH_RULE"]);
205        assert!(errors.is_empty());
206    }
207
208    #[test]
209    fn with_defaults_includes_iban_check() {
210        let registry = RuleRegistry::with_defaults();
211        assert!(registry.get("IBAN_CHECK").is_some());
212    }
213
214    #[test]
215    fn with_defaults_includes_bic_check() {
216        let registry = RuleRegistry::with_defaults();
217        assert!(registry.get("BIC_CHECK").is_some());
218    }
219
220    #[test]
221    fn with_defaults_includes_all_new_rules() {
222        let registry = RuleRegistry::with_defaults();
223        assert!(registry.get("CURRENCY_CHECK").is_some());
224        assert!(registry.get("COUNTRY_CHECK").is_some());
225        assert!(registry.get("LEI_CHECK").is_some());
226        assert!(registry.get("AMOUNT_FORMAT").is_some());
227        assert!(registry.get("DATETIME_CHECK").is_some());
228        assert!(registry.get("DATE_CHECK").is_some());
229    }
230
231    #[test]
232    fn registering_replaces_existing_rule() {
233        let mut registry = RuleRegistry::new();
234        registry.register(Box::new(AlwaysFailRule));
235        registry.register(Box::new(AlwaysFailRule)); // register again — should not panic
236        let errors = registry.validate_field("any", "/p", &["ALWAYS_FAIL"]);
237        // Should still be exactly 1 (last-write-wins, same rule)
238        assert_eq!(errors.len(), 1);
239    }
240
241    #[test]
242    fn validate_field_with_multiple_rules() {
243        let mut registry = RuleRegistry::new();
244        registry.register(Box::new(AlwaysFailRule));
245        registry.register(Box::new(iban::IbanRule));
246        // ALWAYS_FAIL will fire; IBAN_CHECK will also fire for "NOTANIBAN"
247        let errors = registry.validate_field("NOTANIBAN", "/p", &["ALWAYS_FAIL", "IBAN_CHECK"]);
248        assert_eq!(errors.len(), 2);
249    }
250
251    #[test]
252    fn default_registry_is_with_defaults() {
253        let registry = RuleRegistry::default();
254        assert!(registry.get("IBAN_CHECK").is_some());
255        assert!(registry.get("BIC_CHECK").is_some());
256    }
257
258    #[test]
259    fn valid_iban_through_registry() {
260        let registry = RuleRegistry::with_defaults();
261        let errors = registry.validate_field("GB82WEST12345698765432", "/path", &["IBAN_CHECK"]);
262        assert!(errors.is_empty());
263    }
264
265    #[test]
266    fn valid_bic_through_registry() {
267        let registry = RuleRegistry::with_defaults();
268        let errors = registry.validate_field("AAAAGB2L", "/path", &["BIC_CHECK"]);
269        assert!(errors.is_empty());
270    }
271}