Skip to main content

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}