Skip to main content

mx20022_validate/schema/
constraints.rs

1//! Schema-level constraint definitions.
2//!
3//! Each [`FieldConstraint`] describes what rules apply to a specific field path.
4//! The [`ConstraintSet`] holds a collection of constraints and drives validation
5//! through the [`RuleRegistry`].
6
7use crate::error::{ValidationError, ValidationResult};
8use crate::rules::RuleRegistry;
9
10/// A constraint specification for a single field path.
11///
12/// A field constraint pairs an XPath-like field path with a list of rule IDs
13/// from the [`RuleRegistry`] that must all pass for the field to be valid.
14#[derive(Debug, Clone)]
15pub struct FieldConstraint {
16    /// XPath-like path identifying the field (e.g. `/Document/GrpHdr/MsgId`).
17    pub path: String,
18    /// Rule IDs to apply. Must be registered in the [`RuleRegistry`] at validation time.
19    pub rule_ids: Vec<String>,
20}
21
22impl FieldConstraint {
23    /// Create a new field constraint.
24    pub fn new(
25        path: impl Into<String>,
26        rule_ids: impl IntoIterator<Item = impl Into<String>>,
27    ) -> Self {
28        Self {
29            path: path.into(),
30            rule_ids: rule_ids.into_iter().map(Into::into).collect(),
31        }
32    }
33}
34
35/// A set of field constraints for a message type.
36///
37/// Typically one `ConstraintSet` is constructed per message schema (e.g. pacs.008)
38/// and reused across many validations.
39///
40/// # Examples
41///
42/// ```
43/// use mx20022_validate::schema::constraints::{ConstraintSet, FieldConstraint};
44/// use mx20022_validate::rules::RuleRegistry;
45///
46/// let mut cs = ConstraintSet::new();
47/// cs.add(FieldConstraint::new(
48///     "/Document/GrpHdr/MsgId",
49///     ["MAX_LENGTH"],
50/// ));
51/// ```
52#[derive(Debug, Default)]
53pub struct ConstraintSet {
54    constraints: Vec<FieldConstraint>,
55}
56
57impl ConstraintSet {
58    /// Create an empty constraint set.
59    pub fn new() -> Self {
60        Self::default()
61    }
62
63    /// Add a constraint to the set.
64    pub fn add(&mut self, constraint: FieldConstraint) {
65        self.constraints.push(constraint);
66    }
67
68    /// Look up all constraints for a given path.
69    pub fn for_path(&self, path: &str) -> Vec<&FieldConstraint> {
70        self.constraints.iter().filter(|c| c.path == path).collect()
71    }
72
73    /// Validate a `(path, value)` pair against all matching constraints using `registry`.
74    ///
75    /// Returns a [`ValidationResult`] aggregating any findings.
76    pub fn validate_field(
77        &self,
78        path: &str,
79        value: &str,
80        registry: &RuleRegistry,
81    ) -> ValidationResult {
82        let rule_ids: Vec<&str> = self
83            .for_path(path)
84            .into_iter()
85            .flat_map(|c| c.rule_ids.iter().map(String::as_str))
86            .collect();
87
88        if rule_ids.is_empty() {
89            return ValidationResult::default();
90        }
91
92        let errors: Vec<ValidationError> = registry.validate_field(value, path, &rule_ids);
93        ValidationResult::new(errors)
94    }
95
96    /// Returns the number of registered constraints.
97    pub fn len(&self) -> usize {
98        self.constraints.len()
99    }
100
101    /// Returns `true` if the set contains no constraints.
102    pub fn is_empty(&self) -> bool {
103        self.constraints.is_empty()
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use crate::rules::RuleRegistry;
111
112    #[test]
113    fn empty_constraint_set_produces_no_errors() {
114        let cs = ConstraintSet::new();
115        let registry = RuleRegistry::with_defaults();
116        let result = cs.validate_field("/some/path", "value", &registry);
117        assert!(result.is_valid());
118    }
119
120    #[test]
121    fn constraint_set_with_iban_validates() {
122        let mut cs = ConstraintSet::new();
123        cs.add(FieldConstraint::new("/iban", ["IBAN_CHECK"]));
124        let registry = RuleRegistry::with_defaults();
125
126        // Valid IBAN
127        let ok = cs.validate_field("/iban", "GB82WEST12345698765432", &registry);
128        assert!(ok.is_valid());
129
130        // Invalid IBAN
131        let fail = cs.validate_field("/iban", "NOTANIBAN", &registry);
132        assert!(!fail.is_valid());
133    }
134
135    #[test]
136    fn constraint_set_with_bic_validates() {
137        let mut cs = ConstraintSet::new();
138        cs.add(FieldConstraint::new("/bic", ["BIC_CHECK"]));
139        let registry = RuleRegistry::with_defaults();
140
141        let ok = cs.validate_field("/bic", "AAAAGB2L", &registry);
142        assert!(ok.is_valid());
143
144        let fail = cs.validate_field("/bic", "bad", &registry);
145        assert!(!fail.is_valid());
146    }
147
148    #[test]
149    fn unknown_rule_id_does_not_panic() {
150        let mut cs = ConstraintSet::new();
151        cs.add(FieldConstraint::new("/f", ["NO_SUCH_RULE"]));
152        let registry = RuleRegistry::with_defaults();
153        // Should silently skip unknown rules and produce no errors
154        let result = cs.validate_field("/f", "anything", &registry);
155        assert!(result.is_valid());
156    }
157
158    #[test]
159    fn for_path_returns_correct_constraints() {
160        let mut cs = ConstraintSet::new();
161        cs.add(FieldConstraint::new("/a", ["IBAN_CHECK"]));
162        cs.add(FieldConstraint::new("/b", ["BIC_CHECK"]));
163        cs.add(FieldConstraint::new("/a", ["BIC_CHECK"]));
164
165        let a_constraints = cs.for_path("/a");
166        assert_eq!(a_constraints.len(), 2);
167
168        let b_constraints = cs.for_path("/b");
169        assert_eq!(b_constraints.len(), 1);
170
171        let c_constraints = cs.for_path("/c");
172        assert!(c_constraints.is_empty());
173    }
174
175    #[test]
176    fn len_and_is_empty() {
177        let mut cs = ConstraintSet::new();
178        assert!(cs.is_empty());
179        assert_eq!(cs.len(), 0);
180        cs.add(FieldConstraint::new("/a", ["R1"]));
181        assert!(!cs.is_empty());
182        assert_eq!(cs.len(), 1);
183    }
184}