mx20022_validate/rules/
lei.rs1use crate::error::{Severity, ValidationError};
13use crate::rules::Rule;
14
15pub 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 if lei.len() != 20 {
55 return Err(format!(
56 "LEI must be exactly 20 characters, got {}: `{lei}`",
57 lei.len()
58 ));
59 }
60
61 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 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 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
96fn 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 const VALID_LEIS: &[&str] = &[
123 "7ZW8QJWVPR4P1S5PX088",
125 "5493001KJTIIGC8Y1R12",
127 "213800WSGIIZCXF1P572",
129 ];
130
131 const INVALID_LEIS: &[&str] = &[
132 "TOOSHORT", "7ZW8QJWVPR4P1S5PX08800", "7ZW8QJWVPR4P1S5PX0!8", "7zw8QJWVPR4P1S5PX088", "7ZW8QJWVPR4P1S5PX0AA", "7ZW8QJWVPR4P1S5PX099", "", ];
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 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}