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 = 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 const VALID_LEIS: &[&str] = &[
109 "7ZW8QJWVPR4P1S5PX088",
111 "5493001KJTIIGC8Y1R12",
113 "213800WSGIIZCXF1P572",
115 ];
116
117 const INVALID_LEIS: &[&str] = &[
118 "TOOSHORT", "7ZW8QJWVPR4P1S5PX08800", "7ZW8QJWVPR4P1S5PX0!8", "7zw8QJWVPR4P1S5PX088", "7ZW8QJWVPR4P1S5PX0AA", "7ZW8QJWVPR4P1S5PX099", "", ];
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 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}