Skip to main content

mx20022_validate/rules/
lei.rs

1//! LEI (Legal Entity Identifier) validation rule.
2//!
3//! Validates per ISO 17442:
4//! - Exactly 20 characters, all uppercase alphanumeric ASCII
5//! - Characters 1–4: LOU prefix (alphanumeric)
6//! - Characters 5–18: entity identifier (alphanumeric)
7//! - Characters 19–20: 2-digit MOD 97-10 check digits
8//!
9//! The check-digit algorithm is the same MOD 97-10 used by IBAN:
10//! letters are expanded (A=10, B=11, …, Z=35) before computing the remainder.
11
12use crate::error::{Severity, ValidationError};
13use crate::rules::Rule;
14
15/// Validates a value as an ISO 17442 Legal Entity Identifier (LEI).
16///
17/// # Examples
18///
19/// ```
20/// use mx20022_validate::rules::lei::LeiRule;
21/// use mx20022_validate::rules::Rule;
22///
23/// let rule = LeiRule;
24///
25/// // Valid LEI (verified public registration, mod-97 == 1)
26/// let errors = rule.validate("7ZW8QJWVPR4P1S5PX088", "/path");
27/// assert!(errors.is_empty(), "Valid LEI should produce no errors");
28///
29/// let errors = rule.validate("TOOSHORT", "/path");
30/// assert!(!errors.is_empty(), "Wrong-length LEI should produce errors");
31/// ```
32pub struct LeiRule;
33
34impl Rule for LeiRule {
35    fn id(&self) -> &'static str {
36        "LEI_CHECK"
37    }
38
39    fn validate(&self, value: &str, path: &str) -> Vec<ValidationError> {
40        match validate_lei(value) {
41            Ok(()) => vec![],
42            Err(msg) => vec![ValidationError::new(
43                path,
44                Severity::Error,
45                "LEI_CHECK",
46                msg,
47            )],
48        }
49    }
50}
51
52fn validate_lei(lei: &str) -> Result<(), String> {
53    // Length must be exactly 20
54    if lei.len() != 20 {
55        return Err(format!(
56            "LEI must be exactly 20 characters, got {}: `{lei}`",
57            lei.len()
58        ));
59    }
60
61    // All characters must be uppercase alphanumeric ASCII
62    for (i, c) in lei.chars().enumerate() {
63        if !c.is_ascii_uppercase() && !c.is_ascii_digit() {
64            return Err(format!(
65                "LEI must contain only uppercase alphanumeric characters; \
66                 character {} (`{c}`) is invalid in `{lei}`",
67                i + 1
68            ));
69        }
70    }
71
72    // Last 2 characters must be decimal digits (check digits)
73    let check_str = &lei[18..20];
74    if !check_str.chars().all(|c| c.is_ascii_digit()) {
75        return Err(format!(
76            "LEI check digits (characters 19-20) must be decimal digits, \
77             got `{check_str}` in `{lei}`"
78        ));
79    }
80
81    // MOD 97-10 check per ISO 17442:
82    // Expand all 20 characters (A=10, B=11, …, Z=35; digits stay) to a numeric
83    // string, then compute mod 97.  A valid LEI yields remainder 1.
84    // (No rearrangement — the full 20-char string is used as-is, unlike IBAN.)
85    let numeric = alpha_to_numeric(lei);
86    let remainder = mod97(&numeric);
87    if remainder != 1 {
88        return Err(format!(
89            "LEI check digit verification failed (mod-97 = {remainder}): `{lei}`"
90        ));
91    }
92
93    Ok(())
94}
95
96use super::checkdigit::{alpha_to_numeric, mod97};
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use crate::rules::Rule;
102
103    // Valid LEIs — each verified to produce mod-97 remainder == 1.
104    //
105    // Derivation: for a LEI with prefix P (18 chars), check digits are:
106    //   98 - (numeric(P + "00") mod 97)
107    // These were computed from confirmed-valid public LEI registrations.
108    const VALID_LEIS: &[&str] = &[
109        // 7ZW8QJWVPR4P1S5PX0 prefix → check digits 88
110        "7ZW8QJWVPR4P1S5PX088",
111        // 5493001KJTIIGC8Y1R prefix — verified public LEI
112        "5493001KJTIIGC8Y1R12",
113        // 213800WSGIIZCXF1P5 prefix — verified public LEI
114        "213800WSGIIZCXF1P572",
115    ];
116
117    const INVALID_LEIS: &[&str] = &[
118        "TOOSHORT",               // too short
119        "7ZW8QJWVPR4P1S5PX08800", // too long (22 chars)
120        "7ZW8QJWVPR4P1S5PX0!8",   // invalid character '!'
121        "7zw8QJWVPR4P1S5PX088",   // lowercase 'z'
122        "7ZW8QJWVPR4P1S5PX0AA",   // non-digit check digits
123        "7ZW8QJWVPR4P1S5PX099",   // wrong check digits (mod-97 != 1)
124        "",                       // empty
125    ];
126
127    #[test]
128    fn valid_leis_pass() {
129        let rule = LeiRule;
130        for lei in VALID_LEIS {
131            let errors = rule.validate(lei, "/test");
132            assert!(
133                errors.is_empty(),
134                "Expected no errors for valid LEI `{lei}`, got: {errors:?}"
135            );
136        }
137    }
138
139    #[test]
140    fn invalid_leis_fail() {
141        let rule = LeiRule;
142        for lei in INVALID_LEIS {
143            let errors = rule.validate(lei, "/test");
144            assert!(
145                !errors.is_empty(),
146                "Expected errors for invalid LEI `{lei}`"
147            );
148        }
149    }
150
151    #[test]
152    fn error_has_correct_rule_id_and_path() {
153        let rule = LeiRule;
154        let errors = rule.validate("TOOSHORT", "/Document/LEI");
155        assert_eq!(errors.len(), 1);
156        assert_eq!(errors[0].rule_id, "LEI_CHECK");
157        assert_eq!(errors[0].path, "/Document/LEI");
158        assert_eq!(errors[0].severity, Severity::Error);
159    }
160
161    #[test]
162    fn rule_id_is_lei_check() {
163        assert_eq!(LeiRule.id(), "LEI_CHECK");
164    }
165
166    #[test]
167    fn wrong_length_produces_length_message() {
168        let rule = LeiRule;
169        let errors = rule.validate("TOOSHORT", "/test");
170        assert!(!errors.is_empty());
171        assert!(
172            errors[0].message.contains("20 characters"),
173            "Expected length message, got: {}",
174            errors[0].message
175        );
176    }
177
178    #[test]
179    fn bad_check_digits_produces_mod97_message() {
180        let rule = LeiRule;
181        // Valid format (20 chars, uppercase alphanumeric, numeric last 2) but wrong check digits.
182        // 7ZW8QJWVPR4P1S5PX099 has '99' instead of the correct '88', so mod-97 != 1.
183        let errors = rule.validate("7ZW8QJWVPR4P1S5PX099", "/test");
184        assert!(!errors.is_empty());
185        assert!(
186            errors[0].message.contains("mod-97") || errors[0].message.contains("check digit"),
187            "Expected mod-97 message, got: {}",
188            errors[0].message
189        );
190    }
191
192    #[test]
193    fn lowercase_characters_rejected() {
194        let rule = LeiRule;
195        let errors = rule.validate("7zw8QJWVPR4P1S5PX085", "/test");
196        assert!(!errors.is_empty());
197    }
198}