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 = lei_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
96/// Expand alphanumeric string for MOD 97-10: digits stay, letters become A=10…Z=35.
97fn lei_to_numeric(s: &str) -> String {
98    let mut out = String::with_capacity(s.len() * 2);
99    for c in s.chars() {
100        if c.is_ascii_digit() {
101            out.push(c);
102        } else {
103            let n = (c as u32) - ('A' as u32) + 10;
104            out.push_str(&n.to_string());
105        }
106    }
107    out
108}
109
110use super::checkdigit::mod97;
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use crate::rules::Rule;
116
117    // Valid LEIs — each verified to produce mod-97 remainder == 1.
118    //
119    // Derivation: for a LEI with prefix P (18 chars), check digits are:
120    //   98 - (numeric(P + "00") mod 97)
121    // These were computed from confirmed-valid public LEI registrations.
122    const VALID_LEIS: &[&str] = &[
123        // 7ZW8QJWVPR4P1S5PX0 prefix → check digits 88
124        "7ZW8QJWVPR4P1S5PX088",
125        // 5493001KJTIIGC8Y1R prefix — verified public LEI
126        "5493001KJTIIGC8Y1R12",
127        // 213800WSGIIZCXF1P5 prefix — verified public LEI
128        "213800WSGIIZCXF1P572",
129    ];
130
131    const INVALID_LEIS: &[&str] = &[
132        "TOOSHORT",               // too short
133        "7ZW8QJWVPR4P1S5PX08800", // too long (22 chars)
134        "7ZW8QJWVPR4P1S5PX0!8",   // invalid character '!'
135        "7zw8QJWVPR4P1S5PX088",   // lowercase 'z'
136        "7ZW8QJWVPR4P1S5PX0AA",   // non-digit check digits
137        "7ZW8QJWVPR4P1S5PX099",   // wrong check digits (mod-97 != 1)
138        "",                       // empty
139    ];
140
141    #[test]
142    fn valid_leis_pass() {
143        let rule = LeiRule;
144        for lei in VALID_LEIS {
145            let errors = rule.validate(lei, "/test");
146            assert!(
147                errors.is_empty(),
148                "Expected no errors for valid LEI `{lei}`, got: {errors:?}"
149            );
150        }
151    }
152
153    #[test]
154    fn invalid_leis_fail() {
155        let rule = LeiRule;
156        for lei in INVALID_LEIS {
157            let errors = rule.validate(lei, "/test");
158            assert!(
159                !errors.is_empty(),
160                "Expected errors for invalid LEI `{lei}`"
161            );
162        }
163    }
164
165    #[test]
166    fn error_has_correct_rule_id_and_path() {
167        let rule = LeiRule;
168        let errors = rule.validate("TOOSHORT", "/Document/LEI");
169        assert_eq!(errors.len(), 1);
170        assert_eq!(errors[0].rule_id, "LEI_CHECK");
171        assert_eq!(errors[0].path, "/Document/LEI");
172        assert_eq!(errors[0].severity, Severity::Error);
173    }
174
175    #[test]
176    fn rule_id_is_lei_check() {
177        assert_eq!(LeiRule.id(), "LEI_CHECK");
178    }
179
180    #[test]
181    fn wrong_length_produces_length_message() {
182        let rule = LeiRule;
183        let errors = rule.validate("TOOSHORT", "/test");
184        assert!(!errors.is_empty());
185        assert!(
186            errors[0].message.contains("20 characters"),
187            "Expected length message, got: {}",
188            errors[0].message
189        );
190    }
191
192    #[test]
193    fn bad_check_digits_produces_mod97_message() {
194        let rule = LeiRule;
195        // Valid format (20 chars, uppercase alphanumeric, numeric last 2) but wrong check digits.
196        // 7ZW8QJWVPR4P1S5PX099 has '99' instead of the correct '88', so mod-97 != 1.
197        let errors = rule.validate("7ZW8QJWVPR4P1S5PX099", "/test");
198        assert!(!errors.is_empty());
199        assert!(
200            errors[0].message.contains("mod-97") || errors[0].message.contains("check digit"),
201            "Expected mod-97 message, got: {}",
202            errors[0].message
203        );
204    }
205
206    #[test]
207    fn lowercase_characters_rejected() {
208        let rule = LeiRule;
209        let errors = rule.validate("7zw8QJWVPR4P1S5PX085", "/test");
210        assert!(!errors.is_empty());
211    }
212}