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}