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}