mx20022-model 0.2.0

Strongly-typed ISO 20022 message models generated from official XSD schemas (pacs, pain, camt, head)
Documentation
//! Validation trait and types for ISO 20022 generated types.
//!
//! The [`Validatable`] trait enables generated types to self-validate against
//! their XSD constraints (pattern, length, range, etc.). Implementations are
//! generated by the `mx20022-codegen` tool based on XSD restriction annotations.
//!
//! Struct types recurse into their fields; newtypes check their inner value
//! against known constraints; code enums and opaque types are no-ops.
//!
//! # Example
//!
//! ```no_run
//! use mx20022_model::common::validate::{Validatable, ConstraintViolation};
//!
//! // Generated types implement Validatable:
//! # struct Max35Text(pub String);
//! # impl Validatable for Max35Text {
//! #     fn validate_constraints(&self, path: &str, violations: &mut Vec<ConstraintViolation>) {
//! #         if self.0.chars().count() > 35 {
//! #             violations.push(ConstraintViolation {
//! #                 path: path.to_string(),
//! #                 message: "exceeds maximum length 35".to_string(),
//! #                 kind: mx20022_model::common::validate::ConstraintKind::MaxLength,
//! #             });
//! #         }
//! #     }
//! # }
//!
//! let val = Max35Text("short".to_string());
//! let mut violations = Vec::new();
//! val.validate_constraints("/Path/Field", &mut violations);
//! assert!(violations.is_empty());
//! ```

/// A violation of an XSD-level constraint.
///
/// Produced by [`Validatable::validate_constraints`] when a field value does
/// not satisfy its schema-defined restrictions.
#[derive(Debug, Clone, PartialEq)]
pub struct ConstraintViolation {
    /// `XPath`-like path to the violating field (e.g.
    /// `"/Document/FIToFICstmrCdtTrf/GrpHdr/MsgId"`).
    pub path: String,
    /// Human-readable description of the violation.
    pub message: String,
    /// Which constraint kind was violated.
    pub kind: ConstraintKind,
}

/// Error returned when constructing a constrained newtype with an invalid value.
///
/// Produced by `TryFrom<String>` and `new()` on generated newtypes that have
/// XSD constraints (pattern, length, digits).
#[derive(Debug, Clone, PartialEq)]
pub struct ConstraintError {
    /// Which constraint kind was violated.
    pub kind: ConstraintKind,
    /// Human-readable description of the violation.
    pub message: String,
}

impl core::fmt::Display for ConstraintError {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        f.write_str(&self.message)
    }
}

impl std::error::Error for ConstraintError {}

/// The kind of XSD constraint that was violated.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConstraintKind {
    /// `xs:minLength` — value is shorter than required.
    MinLength,
    /// `xs:maxLength` — value exceeds maximum length.
    MaxLength,
    /// `xs:pattern` — value does not match the required regex.
    Pattern,
    /// `xs:minInclusive` — value is below the minimum.
    MinInclusive,
    /// `xs:maxInclusive` — value exceeds the maximum.
    MaxInclusive,
    /// `xs:totalDigits` — value has too many digits.
    TotalDigits,
    /// `xs:fractionDigits` — value has too many fractional digits.
    FractionDigits,
}

/// Types that can self-validate against their XSD-level constraints.
///
/// Implementations are auto-generated by `mx20022-codegen`. Each type checks
/// its own constraints and recurses into nested types, accumulating all
/// violations into the provided `Vec`.
///
/// The `path` parameter is the `XPath` prefix for error reporting. Recursive
/// calls append the current field's XML name to build a full path like
/// `"/Document/FIToFICstmrCdtTrf/GrpHdr/MsgId"`.
pub trait Validatable {
    /// Validate XSD constraints on this value and all nested children.
    ///
    /// `path` is the `XPath`-like location of this value in the message tree.
    /// Violations are appended to `violations`.
    fn validate_constraints(&self, path: &str, violations: &mut Vec<ConstraintViolation>);
}

/// Marker trait for document-level types that represent a complete ISO 20022
/// message.
///
/// Only top-level `Document` structs implement this trait. It provides
/// the message type identifier and root `XPath` used as the base path for
/// constraint validation.
pub trait IsoMessage: Validatable {
    /// The ISO 20022 message type identifier (e.g. `"pacs.008.001.13"`).
    fn message_type(&self) -> &'static str;

    /// The root XML element path (e.g. `"/Document"`).
    fn root_path(&self) -> &'static str;

    /// Validate all XSD constraints starting from the root path.
    ///
    /// Convenience method that calls [`Validatable::validate_constraints`]
    /// with the correct root path.
    fn validate_message(&self) -> Vec<ConstraintViolation> {
        let mut violations = Vec::new();
        self.validate_constraints(self.root_path(), &mut violations);
        violations
    }
}

// ── Blanket impls for standard wrappers ──────────────────────────────────────

impl<T: Validatable> Validatable for Option<T> {
    fn validate_constraints(&self, path: &str, violations: &mut Vec<ConstraintViolation>) {
        if let Some(ref inner) = self {
            inner.validate_constraints(path, violations);
        }
    }
}

impl<T: Validatable> Validatable for Vec<T> {
    fn validate_constraints(&self, path: &str, violations: &mut Vec<ConstraintViolation>) {
        for (i, item) in self.iter().enumerate() {
            let snap = violations.len();
            item.validate_constraints("", violations);
            if violations.len() > snap {
                let pfx = format!("{path}[{i}]");
                for v in &mut violations[snap..] {
                    v.path.insert_str(0, &pfx);
                }
            }
        }
    }
}

/// No-op `Validatable` for `String` (no XSD constraints on bare strings).
impl Validatable for String {
    fn validate_constraints(&self, _path: &str, _violations: &mut Vec<ConstraintViolation>) {}
}

/// No-op `Validatable` for `bool`.
impl Validatable for bool {
    fn validate_constraints(&self, _path: &str, _violations: &mut Vec<ConstraintViolation>) {}
}

impl<T: Validatable> Validatable for super::ChoiceWrapper<T> {
    fn validate_constraints(&self, path: &str, violations: &mut Vec<ConstraintViolation>) {
        self.inner.validate_constraints(path, violations);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn constraint_error_display() {
        let err = ConstraintError {
            kind: ConstraintKind::Pattern,
            message: "value does not match pattern [A-Z]{3,3}".to_string(),
        };
        assert_eq!(err.to_string(), "value does not match pattern [A-Z]{3,3}");
    }

    #[test]
    fn constraint_error_debug() {
        let err = ConstraintError {
            kind: ConstraintKind::MaxLength,
            message: "too long".to_string(),
        };
        let debug = format!("{err:?}");
        assert!(debug.contains("ConstraintError"));
        assert!(debug.contains("MaxLength"));
    }

    #[test]
    fn constraint_error_eq() {
        let a = ConstraintError {
            kind: ConstraintKind::MinLength,
            message: "too short".to_string(),
        };
        let b = ConstraintError {
            kind: ConstraintKind::MinLength,
            message: "too short".to_string(),
        };
        let c = ConstraintError {
            kind: ConstraintKind::MaxLength,
            message: "too short".to_string(),
        };
        assert_eq!(a, b);
        assert_ne!(a, c);
    }

    #[test]
    fn constraint_error_is_std_error() {
        let err = ConstraintError {
            kind: ConstraintKind::Pattern,
            message: "bad pattern".to_string(),
        };
        let _: &dyn std::error::Error = &err;
    }

    // ── Blanket impl tests ───────────────────────────────────────────────

    /// Test helper: a type that always produces one violation.
    struct AlwaysViolates;
    impl Validatable for AlwaysViolates {
        fn validate_constraints(&self, path: &str, violations: &mut Vec<ConstraintViolation>) {
            violations.push(ConstraintViolation {
                path: path.to_string(),
                message: "always fails".to_string(),
                kind: ConstraintKind::Pattern,
            });
        }
    }

    #[test]
    fn option_none_produces_no_violations() {
        let val: Option<AlwaysViolates> = None;
        let mut v = vec![];
        val.validate_constraints("/root", &mut v);
        assert!(v.is_empty());
    }

    #[test]
    fn option_some_delegates_with_path() {
        let val: Option<AlwaysViolates> = Some(AlwaysViolates);
        let mut v = vec![];
        val.validate_constraints("/root/field", &mut v);
        assert_eq!(v.len(), 1);
        assert_eq!(v[0].path, "/root/field");
    }

    #[test]
    fn vec_empty_produces_no_violations() {
        let val: Vec<AlwaysViolates> = vec![];
        let mut v = vec![];
        val.validate_constraints("/root", &mut v);
        assert!(v.is_empty());
    }

    #[test]
    fn vec_indexes_path_correctly() {
        let val = vec![AlwaysViolates, AlwaysViolates, AlwaysViolates];
        let mut v = vec![];
        val.validate_constraints("/root/items", &mut v);
        assert_eq!(v.len(), 3);
        assert_eq!(v[0].path, "/root/items[0]");
        assert_eq!(v[1].path, "/root/items[1]");
        assert_eq!(v[2].path, "/root/items[2]");
    }

    #[test]
    fn choice_wrapper_delegates_with_same_path() {
        let val = super::super::ChoiceWrapper {
            inner: AlwaysViolates,
        };
        let mut v = vec![];
        val.validate_constraints("/root/choice", &mut v);
        assert_eq!(v.len(), 1);
        assert_eq!(v[0].path, "/root/choice");
    }

    #[test]
    fn string_produces_no_violations() {
        let val = "hello".to_string();
        let mut v = vec![];
        val.validate_constraints("/root", &mut v);
        assert!(v.is_empty());
    }
}