regit_identifiers/lei.rs
1// Copyright 2026 Regit.io — Nicolas Koenig
2// SPDX-License-Identifier: Apache-2.0
3
4//! LEI — Legal Entity Identifier (ISO 17442).
5//!
6//! An LEI is the globally unique reference code for a legal entity that
7//! participates in a financial transaction. It is exactly 20 characters in
8//! four segments:
9//!
10//! ```text
11//! 5 4 9 3 0 0 1 K J T I I G C 8 Y 1 R 1 2
12//! └──┬──┘ └┬┘ └──────┬──────┘ └┬┘
13//! │ │ │ └ check digits [18..20] two digits [0-9]
14//! │ │ └─────────── entity ID [6..18] twelve chars [A-Z0-9]
15//! │ └───────────────────── reserved [4..6] the literal "00"
16//! └─────────────────────────── LOU prefix [0..4] four chars [A-Z0-9]
17//! ```
18//!
19//! - The **LOU prefix** identifies the Local Operating Unit that issued the
20//! identifier; it carries no further structure here.
21//! - Positions 5–6 are a **reserved** field, fixed by the standard to the
22//! literal `00`.
23//! - The **entity ID** is the LOU-assigned unique reference for the entity.
24//! (ISO 17442 calls this segment the *entity-specific part*; this crate's
25//! accessor is [`Lei::entity_id`].)
26//! - The **check digits** are the ISO 7064 MOD 97-10 of the 18-character body
27//! — see [`crate::checkdigit::lei_check_digits`].
28//!
29//! [`Lei::parse`] enforces every rule: exact length, the per-segment
30//! character set, the reserved `00` field, and check digits that are
31//! recomputed and verified — never trusted.
32//!
33//! # References
34//!
35//! - ISO 17442, *Financial services — Legal entity identifier (LEI)*.
36//! - ISO/IEC 7064, *Information technology — Security techniques — Check
37//! character systems* (the MOD 97-10 system).
38
39use crate::checkdigit;
40use crate::errors::ValidationError;
41
42/// A validated Legal Entity Identifier (ISO 17442).
43///
44/// A `Lei` can only be created by [`Lei::parse`] (or the explicitly unchecked
45/// [`Lei::from_bytes_unchecked`]), so a value of this type is a proof that the
46/// 20 characters form a structurally valid LEI with correct check digits. It
47/// stores the identifier inline as `[u8; 20]`, is `Copy`, and allocates
48/// nothing.
49///
50/// # Examples
51///
52/// ```
53/// use regit_identifiers::Lei;
54///
55/// let lei = Lei::parse("5493001KJTIIGC8Y1R12").unwrap();
56/// assert_eq!(lei.lou_prefix(), "5493");
57/// assert_eq!(lei.entity_id(), "1KJTIIGC8Y1R");
58/// assert_eq!(lei.check_digits(), "12");
59/// assert_eq!(lei.as_str(), "5493001KJTIIGC8Y1R12");
60/// ```
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
62pub struct Lei {
63 /// The 20 validated ASCII bytes of the identifier.
64 bytes: [u8; Self::LENGTH],
65}
66
67impl Lei {
68 /// The number of characters in an LEI.
69 pub const LENGTH: usize = 20;
70
71 /// Parses and fully validates an LEI.
72 ///
73 /// Validation is strict and, in order: the input must be exactly 20
74 /// characters; characters 1–18 must each be an ASCII digit or upper-case
75 /// letter and characters 19–20 ASCII digits; characters 5–6 must be the
76 /// reserved literal `00`; and the two check digits must equal the values
77 /// recomputed from the 18-character body.
78 ///
79 /// # Errors
80 ///
81 /// - [`ValidationError::WrongLength`] if the input is not 20 characters.
82 /// - [`ValidationError::InvalidCharacter`] if a character falls outside
83 /// the set its position allows (this also rejects lower-case input and
84 /// any non-ASCII character).
85 /// - [`ValidationError::Structure`] if characters 5–6 are not `00`.
86 /// - [`ValidationError::BadCheckDigit`] if a supplied check digit does not
87 /// match the recomputed one; the first differing digit is reported.
88 ///
89 /// # Examples
90 ///
91 /// ```
92 /// use regit_identifiers::Lei;
93 /// use regit_identifiers::errors::ValidationError;
94 ///
95 /// assert!(Lei::parse("5493001KJTIIGC8Y1R12").is_ok());
96 ///
97 /// // A single wrong check digit is caught, not silently accepted.
98 /// assert_eq!(
99 /// Lei::parse("5493001KJTIIGC8Y1R13"),
100 /// Err(ValidationError::BadCheckDigit { expected: '2', found: '3' }),
101 /// );
102 /// ```
103 pub fn parse(s: &str) -> Result<Self, ValidationError> {
104 // An LEI is exactly 20 characters.
105 let found = s.chars().count();
106 if found != Self::LENGTH {
107 return Err(ValidationError::WrongLength {
108 expected: Self::LENGTH,
109 found,
110 });
111 }
112 // Per-position character set: [0..18] are [A-Z0-9], [18..20] are
113 // digits. A non-ASCII character fails both predicates and is rejected
114 // here.
115 for (i, ch) in s.chars().enumerate() {
116 let legal = if i >= Self::LENGTH - 2 {
117 ch.is_ascii_digit()
118 } else {
119 ch.is_ascii_digit() || ch.is_ascii_uppercase()
120 };
121 if !legal {
122 return Err(ValidationError::InvalidCharacter {
123 position: i + 1,
124 found: ch,
125 });
126 }
127 }
128 // Every character is ASCII, so the string is exactly 20 ASCII bytes.
129 let mut bytes = [0u8; Self::LENGTH];
130 bytes.copy_from_slice(s.as_bytes());
131
132 // Characters 5–6 are a reserved field fixed to the literal "00".
133 let reserved = core::str::from_utf8(&bytes[4..6]).unwrap_or("");
134 if reserved != "00" {
135 return Err(ValidationError::Structure {
136 rule: "LEI positions 5-6 must be 00",
137 });
138 }
139 // Recompute the check digits from the 18-character body and compare,
140 // reporting the first position where they differ.
141 let body = core::str::from_utf8(&bytes[0..18]).unwrap_or("");
142 let expected = checkdigit::lei_check_digits(body)?;
143 for offset in 0..2 {
144 let want = expected[offset];
145 let got = char::from(bytes[18 + offset]);
146 if want != got {
147 return Err(ValidationError::BadCheckDigit {
148 expected: want,
149 found: got,
150 });
151 }
152 }
153 Ok(Self { bytes })
154 }
155
156 /// Validates an LEI without constructing one.
157 ///
158 /// Equivalent to `Lei::parse(s).map(|_| ())`; use it when only the verdict
159 /// is needed.
160 ///
161 /// # Errors
162 ///
163 /// Returns the same [`ValidationError`] variants as [`Lei::parse`].
164 ///
165 /// # Examples
166 ///
167 /// ```
168 /// use regit_identifiers::Lei;
169 ///
170 /// assert!(Lei::validate("5493001KJTIIGC8Y1R12").is_ok());
171 /// assert!(Lei::validate("5493001KJTIIGC8Y1R13").is_err());
172 /// ```
173 pub fn validate(s: &str) -> Result<(), ValidationError> {
174 Self::parse(s).map(|_| ())
175 }
176
177 /// Wraps 20 raw bytes as a `Lei` without any validation.
178 ///
179 /// The caller asserts that `bytes` holds the 20 ASCII characters of a
180 /// valid LEI. This exists for reconstructing a `Lei` from bytes that were
181 /// validated earlier; prefer [`Lei::parse`] for any untrusted input.
182 ///
183 /// # Examples
184 ///
185 /// ```
186 /// use regit_identifiers::Lei;
187 ///
188 /// let lei = Lei::from_bytes_unchecked(*b"5493001KJTIIGC8Y1R12");
189 /// assert_eq!(lei.as_str(), "5493001KJTIIGC8Y1R12");
190 /// ```
191 #[must_use]
192 pub const fn from_bytes_unchecked(bytes: [u8; Self::LENGTH]) -> Self {
193 Self { bytes }
194 }
195
196 /// Returns the LEI as a string slice.
197 ///
198 /// # Examples
199 ///
200 /// ```
201 /// use regit_identifiers::Lei;
202 ///
203 /// assert_eq!(
204 /// Lei::parse("5493001KJTIIGC8Y1R12").unwrap().as_str(),
205 /// "5493001KJTIIGC8Y1R12",
206 /// );
207 /// ```
208 #[must_use]
209 #[inline]
210 pub fn as_str(&self) -> &str {
211 core::str::from_utf8(&self.bytes).unwrap_or("")
212 }
213
214 /// Returns the LEI as its 20 raw ASCII bytes.
215 ///
216 /// # Examples
217 ///
218 /// ```
219 /// use regit_identifiers::Lei;
220 ///
221 /// assert_eq!(
222 /// Lei::parse("5493001KJTIIGC8Y1R12").unwrap().as_bytes(),
223 /// b"5493001KJTIIGC8Y1R12",
224 /// );
225 /// ```
226 #[must_use]
227 #[inline]
228 pub fn as_bytes(&self) -> &[u8] {
229 &self.bytes
230 }
231
232 /// Returns the four-character LOU prefix, characters 1–4.
233 ///
234 /// # Examples
235 ///
236 /// ```
237 /// use regit_identifiers::Lei;
238 ///
239 /// assert_eq!(
240 /// Lei::parse("5493001KJTIIGC8Y1R12").unwrap().lou_prefix(),
241 /// "5493",
242 /// );
243 /// ```
244 #[must_use]
245 #[inline]
246 pub fn lou_prefix(&self) -> &str {
247 core::str::from_utf8(&self.bytes[0..4]).unwrap_or("")
248 }
249
250 /// Returns the twelve-character entity ID, characters 7–18 (the segment
251 /// ISO 17442 calls the *entity-specific part*).
252 ///
253 /// # Examples
254 ///
255 /// ```
256 /// use regit_identifiers::Lei;
257 ///
258 /// assert_eq!(
259 /// Lei::parse("5493001KJTIIGC8Y1R12").unwrap().entity_id(),
260 /// "1KJTIIGC8Y1R",
261 /// );
262 /// ```
263 #[must_use]
264 #[inline]
265 pub fn entity_id(&self) -> &str {
266 core::str::from_utf8(&self.bytes[6..18]).unwrap_or("")
267 }
268
269 /// Returns the two check digits, characters 19–20.
270 ///
271 /// # Examples
272 ///
273 /// ```
274 /// use regit_identifiers::Lei;
275 ///
276 /// assert_eq!(
277 /// Lei::parse("5493001KJTIIGC8Y1R12").unwrap().check_digits(),
278 /// "12",
279 /// );
280 /// ```
281 #[must_use]
282 #[inline]
283 pub fn check_digits(&self) -> &str {
284 core::str::from_utf8(&self.bytes[18..20]).unwrap_or("")
285 }
286}
287
288impl core::fmt::Display for Lei {
289 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
290 f.write_str(self.as_str())
291 }
292}
293
294impl core::str::FromStr for Lei {
295 type Err = ValidationError;
296
297 fn from_str(s: &str) -> Result<Self, Self::Err> {
298 Self::parse(s)
299 }
300}
301
302impl AsRef<str> for Lei {
303 fn as_ref(&self) -> &str {
304 self.as_str()
305 }
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311 use crate::test_support::display;
312 use core::str::FromStr;
313
314 /// Real, well-known LEIs used as regression anchors.
315 const GOLDEN: &[&str] = &[
316 "5493001KJTIIGC8Y1R12", // Bloomberg Finance L.P.
317 "549300DTUYXVMJXZNY75", // a second real LEI
318 ];
319
320 #[test]
321 fn parses_golden_leis() {
322 for &s in GOLDEN {
323 let lei = Lei::parse(s).unwrap_or_else(|e| panic!("{s} should parse: {e}"));
324 assert_eq!(lei.as_str(), s);
325 }
326 }
327
328 #[test]
329 fn segment_accessors() {
330 let lei = Lei::parse("5493001KJTIIGC8Y1R12").unwrap();
331 assert_eq!(lei.lou_prefix(), "5493");
332 assert_eq!(lei.entity_id(), "1KJTIIGC8Y1R");
333 assert_eq!(lei.check_digits(), "12");
334 assert_eq!(lei.as_bytes(), b"5493001KJTIIGC8Y1R12");
335 assert_eq!(Lei::LENGTH, 20);
336 }
337
338 #[test]
339 fn rejects_bad_check_digit() {
340 // The first differing digit is reported.
341 assert_eq!(
342 Lei::parse("5493001KJTIIGC8Y1R13"),
343 Err(ValidationError::BadCheckDigit {
344 expected: '2',
345 found: '3',
346 })
347 );
348 }
349
350 #[test]
351 fn rejects_bad_first_check_digit() {
352 // When both digits are wrong, the first one is reported.
353 assert_eq!(
354 Lei::parse("5493001KJTIIGC8Y1R99"),
355 Err(ValidationError::BadCheckDigit {
356 expected: '1',
357 found: '9',
358 })
359 );
360 }
361
362 #[test]
363 fn rejects_wrong_length() {
364 assert_eq!(
365 Lei::parse("5493001KJTIIGC8Y1R1"),
366 Err(ValidationError::WrongLength {
367 expected: 20,
368 found: 19,
369 })
370 );
371 assert_eq!(
372 Lei::parse("5493001KJTIIGC8Y1R123"),
373 Err(ValidationError::WrongLength {
374 expected: 20,
375 found: 21,
376 })
377 );
378 assert_eq!(
379 Lei::parse(""),
380 Err(ValidationError::WrongLength {
381 expected: 20,
382 found: 0,
383 })
384 );
385 }
386
387 #[test]
388 fn rejects_lower_case() {
389 assert!(matches!(
390 Lei::parse("5493001kJTIIGC8Y1R12"),
391 Err(ValidationError::InvalidCharacter { position: 8, .. })
392 ));
393 }
394
395 #[test]
396 fn rejects_non_digit_check_position() {
397 // Characters 19–20 must be digits.
398 assert!(matches!(
399 Lei::parse("5493001KJTIIGC8Y1RX2"),
400 Err(ValidationError::InvalidCharacter { position: 19, .. })
401 ));
402 assert!(matches!(
403 Lei::parse("5493001KJTIIGC8Y1R1X"),
404 Err(ValidationError::InvalidCharacter { position: 20, .. })
405 ));
406 }
407
408 #[test]
409 fn rejects_bad_body_character() {
410 assert_eq!(
411 Lei::parse("5493001KJTIIGC8Y1-12"),
412 Err(ValidationError::InvalidCharacter {
413 position: 18,
414 found: '-',
415 })
416 );
417 }
418
419 #[test]
420 fn rejects_reserved_field_not_zero_zero() {
421 // Positions 5–6 must be the literal "00".
422 assert_eq!(
423 Lei::parse("5493011KJTIIGC8Y1R12"),
424 Err(ValidationError::Structure {
425 rule: "LEI positions 5-6 must be 00",
426 })
427 );
428 assert_eq!(
429 Lei::parse("5493A01KJTIIGC8Y1R12"),
430 Err(ValidationError::Structure {
431 rule: "LEI positions 5-6 must be 00",
432 })
433 );
434 }
435
436 #[test]
437 fn rejects_non_ascii_without_panic() {
438 // A multi-byte character must be rejected cleanly.
439 assert!(Lei::parse("5493001KJTIIGC8Y1Ré2").is_err());
440 assert!(Lei::parse("É493001KJTIIGC8Y1R12").is_err());
441 }
442
443 #[test]
444 fn round_trips_through_str() {
445 for &s in GOLDEN {
446 assert_eq!(Lei::parse(s).unwrap().as_str(), s);
447 }
448 }
449
450 #[test]
451 fn from_str_matches_parse() {
452 assert_eq!(
453 Lei::from_str("5493001KJTIIGC8Y1R12"),
454 Lei::parse("5493001KJTIIGC8Y1R12")
455 );
456 assert!(Lei::from_str("nonsense").is_err());
457 }
458
459 #[test]
460 fn display_renders_identifier() {
461 let lei = Lei::parse("5493001KJTIIGC8Y1R12").unwrap();
462 assert_eq!(display(lei).as_str(), "5493001KJTIIGC8Y1R12");
463 }
464
465 #[test]
466 fn as_ref_str() {
467 let lei = Lei::parse("5493001KJTIIGC8Y1R12").unwrap();
468 let s: &str = lei.as_ref();
469 assert_eq!(s, "5493001KJTIIGC8Y1R12");
470 }
471
472 #[test]
473 fn from_bytes_unchecked_round_trip() {
474 let lei = Lei::from_bytes_unchecked(*b"5493001KJTIIGC8Y1R12");
475 assert_eq!(lei, Lei::parse("5493001KJTIIGC8Y1R12").unwrap());
476 }
477
478 #[test]
479 fn is_copy_and_eq_and_hashable() {
480 let a = Lei::parse("5493001KJTIIGC8Y1R12").unwrap();
481 let b = a; // Copy
482 assert_eq!(a, b);
483 assert_ne!(a, Lei::parse("549300DTUYXVMJXZNY75").unwrap());
484 // Usable as a map key (Eq + Hash) — checked by constructing a slice.
485 let keys = [a, b];
486 assert_eq!(keys[0], keys[1]);
487 }
488}