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    /// Run **all** registered rules against `value` at `path`.
160    pub fn validate_all(&self, value: &str, path: &str) -> Vec<ValidationError> {
161        self.rules
162            .values()
163            .flat_map(|rule| rule.validate(value, path))
164            .collect()
165    }
166}
167
168impl Default for RuleRegistry {
169    fn default() -> Self {
170        Self::with_defaults()
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use crate::error::Severity;
178
179    struct AlwaysFailRule;
180    impl Rule for AlwaysFailRule {
181        fn id(&self) -> &'static str {
182            "ALWAYS_FAIL"
183        }
184        fn validate(&self, _value: &str, path: &str) -> Vec<ValidationError> {
185            vec![ValidationError::new(
186                path,
187                Severity::Error,
188                "ALWAYS_FAIL",
189                "always fails",
190            )]
191        }
192    }
193
194    #[test]
195    fn empty_registry_produces_no_errors() {
196        let registry = RuleRegistry::new();
197        let errors = registry.validate_field("any", "/p", &["IBAN_CHECK"]);
198        assert!(errors.is_empty());
199    }
200
201    #[test]
202    fn registered_rule_is_invoked() {
203        let mut registry = RuleRegistry::new();
204        registry.register(Box::new(AlwaysFailRule));
205        let errors = registry.validate_field("any", "/p", &["ALWAYS_FAIL"]);
206        assert_eq!(errors.len(), 1);
207    }
208
209    #[test]
210    fn unknown_rule_id_is_skipped() {
211        let registry = RuleRegistry::with_defaults();
212        let errors = registry.validate_field("any", "/p", &["NO_SUCH_RULE"]);
213        assert!(errors.is_empty());
214    }
215
216    #[test]
217    fn with_defaults_includes_iban_check() {
218        let registry = RuleRegistry::with_defaults();
219        assert!(registry.get("IBAN_CHECK").is_some());
220    }
221
222    #[test]
223    fn with_defaults_includes_bic_check() {
224        let registry = RuleRegistry::with_defaults();
225        assert!(registry.get("BIC_CHECK").is_some());
226    }
227
228    #[test]
229    fn with_defaults_includes_all_new_rules() {
230        let registry = RuleRegistry::with_defaults();
231        assert!(registry.get("CURRENCY_CHECK").is_some());
232        assert!(registry.get("COUNTRY_CHECK").is_some());
233        assert!(registry.get("LEI_CHECK").is_some());
234        assert!(registry.get("AMOUNT_FORMAT").is_some());
235        assert!(registry.get("DATETIME_CHECK").is_some());
236        assert!(registry.get("DATE_CHECK").is_some());
237    }
238
239    #[test]
240    fn registering_replaces_existing_rule() {
241        let mut registry = RuleRegistry::new();
242        registry.register(Box::new(AlwaysFailRule));
243        registry.register(Box::new(AlwaysFailRule)); // register again — should not panic
244        let errors = registry.validate_field("any", "/p", &["ALWAYS_FAIL"]);
245        // Should still be exactly 1 (last-write-wins, same rule)
246        assert_eq!(errors.len(), 1);
247    }
248
249    #[test]
250    fn validate_field_with_multiple_rules() {
251        let mut registry = RuleRegistry::new();
252        registry.register(Box::new(AlwaysFailRule));
253        registry.register(Box::new(iban::IbanRule));
254        // ALWAYS_FAIL will fire; IBAN_CHECK will also fire for "NOTANIBAN"
255        let errors = registry.validate_field("NOTANIBAN", "/p", &["ALWAYS_FAIL", "IBAN_CHECK"]);
256        assert_eq!(errors.len(), 2);
257    }
258
259    #[test]
260    fn default_registry_is_with_defaults() {
261        let registry = RuleRegistry::default();
262        assert!(registry.get("IBAN_CHECK").is_some());
263        assert!(registry.get("BIC_CHECK").is_some());
264    }
265
266    #[test]
267    fn valid_iban_through_registry() {
268        let registry = RuleRegistry::with_defaults();
269        let errors = registry.validate_field("GB82WEST12345698765432", "/path", &["IBAN_CHECK"]);
270        assert!(errors.is_empty());
271    }
272
273    #[test]
274    fn valid_bic_through_registry() {
275        let registry = RuleRegistry::with_defaults();
276        let errors = registry.validate_field("AAAAGB2L", "/path", &["BIC_CHECK"]);
277        assert!(errors.is_empty());
278    }
279}