Skip to main content

regit_identifiers/
checkdigit.rs

1// Copyright 2026 Regit.io — Nicolas Koenig
2// SPDX-License-Identifier: Apache-2.0
3
4//! Check-digit algorithms for securities identifiers.
5//!
6//! A check digit is a redundant character appended to an identifier so that
7//! a single mistyped or transposed character is detected rather than
8//! silently accepted. Five of the identifiers in this crate carry one, and
9//! each computes it differently — the scan direction, the letter expansion,
10//! and the treatment of two-digit products are all load-bearing and easy to
11//! get subtly wrong.
12//!
13//! Each function takes the identifier **body** — the identifier without its
14//! check digit(s) — validates that body's own length and character set
15//! defensively, and returns the check digit(s) the governing standard
16//! prescribes. A parser verifies a supplied check digit by recomputing it
17//! with the matching function and comparing; it never trusts the digit it
18//! was given.
19//!
20//! # The algorithms
21//!
22//! ```text
23//! luhn_checksum   Luhn mod-10 over a pure-digit string. Right-to-left,
24//!                 rightmost digit weight 2, alternating 2,1,2,...; a
25//!                 weighted product of 10 or more is folded to its digit
26//!                 sum (equivalently, p - 9).
27//!
28//! isin (ISO 6166) Each body character is first expanded — a digit stays a
29//!                 digit; a letter becomes the two-digit number 10 + (c -
30//!                 'A') — and a Luhn mod-10 is taken over the resulting
31//!                 digit string. Parity is assigned AFTER expansion.
32//!
33//! cusip (X9.6)    Modulus-10 "double add double". Left-to-right, 1-indexed:
34//!                 odd positions weight 1, even positions weight 2. Each
35//!                 weighted product is folded to floor(p/10) + (p mod 10).
36//!
37//! sedol           Fixed weight vector [1,3,1,7,3,9] applied left-to-right.
38//!                 Unlike the others, weighted products are NOT folded.
39//!
40//! lei (ISO 7064)  MOD 97-10: expand the body followed by the literal "00",
41//!                 read as one integer M; the check digits are 98 - (M mod
42//!                 97). The modulus is taken by a streaming recurrence — no
43//!                 wide integer is ever formed.
44//!
45//! figi (X9.145)   Modulus-10 double add double, but right-to-left with the
46//!                 RIGHTMOST character at weight 1 (not 2); every decimal
47//!                 digit of each weighted product is summed.
48//! ```
49//!
50//! Every example below is a real, well-known instrument whose check digit
51//! was recomputed by hand.
52//!
53//! # References
54//!
55//! - ISO 6166 — International Securities Identification Number (ISIN).
56//! - ISO/IEC 7064 — Check character systems (the MOD 97-10 system).
57//! - ANSI X9.6 — CUSIP, CUSIP Global Services.
58//! - ANSI X9.145 / Object Management Group — Financial Instrument Global
59//!   Identifier (FIGI), the `OpenFIGI` specification.
60//! - London Stock Exchange — SEDOL Masterfile service description.
61
62use crate::charset;
63use crate::errors::ValidationError;
64
65// ─── Shared primitives ───────────────────────────────────────────────────────
66
67/// Maps a computed check value to its ASCII digit character. The value is
68/// reduced modulo 10, so an input of `10` (the un-normalised result of
69/// `10 - 0`) maps to `'0'`.
70#[inline]
71fn digit_char(value: u32) -> char {
72    char::from(b'0' + u8::try_from(value % 10).unwrap_or(0))
73}
74
75/// Luhn contribution of a single digit `d` (`0..=9`). When `doubled` is
76/// `true` the digit is weighted by 2 and a product of 10 or more is folded
77/// to the sum of its two digits (equivalently `p - 9`, since `p` cannot
78/// exceed 18); otherwise the digit contributes its own value.
79#[inline]
80fn luhn_contribution(d: u32, doubled: bool) -> u32 {
81    let weighted = if doubled { d * 2 } else { d };
82    if weighted > 9 { weighted - 9 } else { weighted }
83}
84
85/// `true` if `ch` is an upper-case ASCII vowel, which SEDOL and FIGI bodies
86/// forbid.
87#[inline]
88fn is_vowel(ch: char) -> bool {
89    matches!(ch, 'A' | 'E' | 'I' | 'O' | 'U')
90}
91
92/// Numeric value of a CUSIP body character: a digit is its own value, a
93/// letter is `10 + (c - 'A')`, and the three special characters extend the
94/// alphabet (`* = 36`, `@ = 37`, `# = 38`). The caller must already have
95/// established that `b` is a legal CUSIP body byte.
96#[inline]
97fn cusip_value(b: u8) -> u32 {
98    match b {
99        b'*' => 36,
100        b'@' => 37,
101        b'#' => 38,
102        _ => charset::alnum_value(b),
103    }
104}
105
106// ─── Luhn mod-10 ─────────────────────────────────────────────────────────────
107
108/// Computes the Luhn mod-10 checksum digit of a pure-digit string.
109///
110/// The string is scanned right-to-left; the rightmost digit carries weight
111/// 2, and the weight then alternates 1, 2, 1, ... A weighted product of 10
112/// or more is folded to the sum of its digits. The checksum digit is
113/// `(10 - (sum mod 10)) mod 10` — the digit that, appended on the right,
114/// makes the whole string pass a Luhn check.
115///
116/// # Errors
117///
118/// - [`ValidationError::Empty`] if `digits` is empty.
119/// - [`ValidationError::InvalidCharacter`] if any character is not an ASCII
120///   decimal digit.
121///
122/// # Examples
123///
124/// ```
125/// use regit_identifiers::checkdigit::luhn_checksum;
126///
127/// // The canonical Luhn example: "7992739871" has checksum digit 3.
128/// assert_eq!(luhn_checksum("7992739871").unwrap(), 3);
129/// ```
130pub fn luhn_checksum(digits: &str) -> Result<u8, ValidationError> {
131    if digits.is_empty() {
132        return Err(ValidationError::Empty);
133    }
134    for (i, ch) in digits.chars().enumerate() {
135        if !ch.is_ascii_digit() {
136            return Err(ValidationError::InvalidCharacter {
137                position: i + 1,
138                found: ch,
139            });
140        }
141    }
142    // Right-to-left: the rightmost digit is doubled, then doubling alternates.
143    let mut sum = 0u32;
144    let mut doubled = true;
145    for &b in digits.as_bytes().iter().rev() {
146        sum += luhn_contribution(charset::digit_value(b), doubled);
147        doubled = !doubled;
148    }
149    Ok(u8::try_from((10 - (sum % 10)) % 10).unwrap_or(0))
150}
151
152// ─── ISIN — ISO 6166 ─────────────────────────────────────────────────────────
153
154/// Computes the ISIN check digit (ISO 6166) of an 11-character body.
155///
156/// The body is the country prefix plus the NSIN — the ISIN without its final
157/// digit. Each character is expanded (a digit stays itself; a letter becomes
158/// the two-digit number `10 + (c - 'A')`), and the Luhn mod-10 is taken over
159/// the expanded digit string. Crucially, the alternating Luhn weights are
160/// assigned over the *expanded* string, not the original characters.
161///
162/// # Errors
163///
164/// - [`ValidationError::WrongLength`] if the body is not exactly 11
165///   characters.
166/// - [`ValidationError::InvalidCharacter`] if any character is not an ASCII
167///   digit or upper-case letter.
168///
169/// # Examples
170///
171/// ```
172/// use regit_identifiers::checkdigit::isin_check_digit;
173///
174/// // Apple Inc., ISIN US0378331005 — body "US037833100", check digit 5.
175/// assert_eq!(isin_check_digit("US037833100").unwrap(), '5');
176/// ```
177pub fn isin_check_digit(body: &str) -> Result<char, ValidationError> {
178    const LEN: usize = 11;
179    let found = body.chars().count();
180    if found != LEN {
181        return Err(ValidationError::WrongLength {
182            expected: LEN,
183            found,
184        });
185    }
186    for (i, ch) in body.chars().enumerate() {
187        if !(ch.is_ascii_digit() || ch.is_ascii_uppercase()) {
188            return Err(ValidationError::InvalidCharacter {
189                position: i + 1,
190                found: ch,
191            });
192        }
193    }
194    // Every character is an ASCII alphanumeric, so the body is exactly 11
195    // ASCII bytes. Expand right-to-left: a digit emits one expanded digit; a
196    // letter emits the two digits of 10 + (c - 'A'), with the units digit
197    // lying to the right of the tens digit in the expanded string.
198    let mut sum = 0u32;
199    let mut doubled = true;
200    for &b in body.as_bytes().iter().rev() {
201        let value = charset::alnum_value(b); // digit -> 0..=9, letter -> 10..=35
202        if value < 10 {
203            sum += luhn_contribution(value, doubled);
204            doubled = !doubled;
205        } else {
206            sum += luhn_contribution(value % 10, doubled);
207            doubled = !doubled;
208            sum += luhn_contribution(value / 10, doubled);
209            doubled = !doubled;
210        }
211    }
212    Ok(digit_char(10 - (sum % 10)))
213}
214
215// ─── CUSIP — ANSI X9.6 ───────────────────────────────────────────────────────
216
217/// Computes the CUSIP check digit (ANSI X9.6) of an 8-character body.
218///
219/// The algorithm is the "modulus 10 double add double": the body is scanned
220/// left-to-right and, with 1-based positions, odd positions take weight 1
221/// and even positions weight 2. Each weighted product is folded to
222/// `floor(p / 10) + (p mod 10)`, the products are summed, and the check
223/// digit is `(10 - (sum mod 10)) mod 10`. The body alphabet is the digits,
224/// the upper-case letters, and the three special characters `*`, `@`, `#`.
225/// The same algorithm computes a CINS check digit.
226///
227/// # Errors
228///
229/// - [`ValidationError::WrongLength`] if the body is not exactly 8
230///   characters.
231/// - [`ValidationError::InvalidCharacter`] if any character is not a digit,
232///   an upper-case letter, or one of `*`, `@`, `#`.
233///
234/// # Examples
235///
236/// ```
237/// use regit_identifiers::checkdigit::cusip_check_digit;
238///
239/// // Apple Inc., CUSIP 037833100 — body "03783310", check digit 0.
240/// assert_eq!(cusip_check_digit("03783310").unwrap(), '0');
241/// ```
242pub fn cusip_check_digit(body: &str) -> Result<char, ValidationError> {
243    const LEN: usize = 8;
244    let found = body.chars().count();
245    if found != LEN {
246        return Err(ValidationError::WrongLength {
247            expected: LEN,
248            found,
249        });
250    }
251    for (i, ch) in body.chars().enumerate() {
252        let legal = ch.is_ascii_digit() || ch.is_ascii_uppercase() || matches!(ch, '*' | '@' | '#');
253        if !legal {
254            return Err(ValidationError::InvalidCharacter {
255                position: i + 1,
256                found: ch,
257            });
258        }
259    }
260    let mut sum = 0u32;
261    for (i, &b) in body.as_bytes().iter().enumerate() {
262        // 1-based position i + 1: odd -> weight 1, even -> weight 2.
263        let weight = if i % 2 == 0 { 1 } else { 2 };
264        let product = cusip_value(b) * weight;
265        sum += product / 10 + product % 10;
266    }
267    Ok(digit_char(10 - (sum % 10)))
268}
269
270// ─── SEDOL — London Stock Exchange ───────────────────────────────────────────
271
272/// Computes the SEDOL check digit of a 6-character body.
273///
274/// The six characters are weighted left-to-right by the fixed vector
275/// `[1, 3, 1, 7, 3, 9]` and the weighted values are summed; the check digit
276/// is `(10 - (sum mod 10)) mod 10`. Unlike the ISIN, CUSIP, and FIGI
277/// algorithms, SEDOL does **not** fold a two-digit weighted product to its
278/// digit sum. The body alphabet is the digits and the consonants — a SEDOL
279/// never contains a vowel.
280///
281/// # Errors
282///
283/// - [`ValidationError::WrongLength`] if the body is not exactly 6
284///   characters.
285/// - [`ValidationError::InvalidCharacter`] if any character is not a digit
286///   or an upper-case consonant (a vowel is rejected here).
287///
288/// # Examples
289///
290/// ```
291/// use regit_identifiers::checkdigit::sedol_check_digit;
292///
293/// // BAE Systems, SEDOL 0263494 — body "026349", check digit 4.
294/// assert_eq!(sedol_check_digit("026349").unwrap(), '4');
295/// ```
296pub fn sedol_check_digit(body: &str) -> Result<char, ValidationError> {
297    const LEN: usize = 6;
298    const WEIGHTS: [u32; LEN] = [1, 3, 1, 7, 3, 9];
299    let found = body.chars().count();
300    if found != LEN {
301        return Err(ValidationError::WrongLength {
302            expected: LEN,
303            found,
304        });
305    }
306    for (i, ch) in body.chars().enumerate() {
307        let legal = ch.is_ascii_digit() || (ch.is_ascii_uppercase() && !is_vowel(ch));
308        if !legal {
309            return Err(ValidationError::InvalidCharacter {
310                position: i + 1,
311                found: ch,
312            });
313        }
314    }
315    let mut sum = 0u32;
316    for (&b, &weight) in body.as_bytes().iter().zip(WEIGHTS.iter()) {
317        sum += charset::alnum_value(b) * weight;
318    }
319    Ok(digit_char(10 - (sum % 10)))
320}
321
322// ─── LEI — ISO 17442 / ISO 7064 MOD 97-10 ────────────────────────────────────
323
324/// Computes the two LEI check digits (ISO 7064 MOD 97-10) of an
325/// 18-character body.
326///
327/// The body and the literal string `"00"` are expanded (a digit stays
328/// itself; a letter becomes `10 + (c - 'A')`) into one large integer `M`;
329/// the check digits are `98 - (M mod 97)`, written as two digits. The
330/// modulus is computed by the streaming recurrence `acc = (acc * 10 + d) mod
331/// 97` for a digit and `acc = (acc * 100 + v) mod 97` for an expanded
332/// letter, so the 38-or-so-digit integer is never actually formed.
333///
334/// # Errors
335///
336/// - [`ValidationError::WrongLength`] if the body is not exactly 18
337///   characters.
338/// - [`ValidationError::InvalidCharacter`] if any character is not an ASCII
339///   digit or upper-case letter.
340///
341/// # Examples
342///
343/// ```
344/// use regit_identifiers::checkdigit::lei_check_digits;
345///
346/// // Bloomberg Finance L.P., LEI 5493001KJTIIGC8Y1R12 —
347/// // body "5493001KJTIIGC8Y1R", check digits "12".
348/// assert_eq!(lei_check_digits("5493001KJTIIGC8Y1R").unwrap(), ['1', '2']);
349/// ```
350pub fn lei_check_digits(body: &str) -> Result<[char; 2], ValidationError> {
351    const LEN: usize = 18;
352    let found = body.chars().count();
353    if found != LEN {
354        return Err(ValidationError::WrongLength {
355            expected: LEN,
356            found,
357        });
358    }
359    for (i, ch) in body.chars().enumerate() {
360        if !(ch.is_ascii_digit() || ch.is_ascii_uppercase()) {
361            return Err(ValidationError::InvalidCharacter {
362                position: i + 1,
363                found: ch,
364            });
365        }
366    }
367    // Streaming ISO 7064 MOD 97-10. A digit contributes one decimal place,
368    // an expanded letter (10..=35) contributes two. The accumulator is a
369    // residue mod 97, so it is at most 96 and the largest intermediate value
370    // is 96 * 100 + 35 = 9635 — far within `u32`.
371    let mut acc = 0u32;
372    for &b in body.as_bytes() {
373        let value = charset::alnum_value(b);
374        if value < 10 {
375            acc = (acc * 10 + value) % 97;
376        } else {
377            acc = (acc * 100 + value) % 97;
378        }
379    }
380    // Append the two check positions as the literal "00".
381    acc = (acc * 100) % 97;
382    let check = 98 - acc; // in 2..=98
383    Ok([digit_char(check / 10), digit_char(check % 10)])
384}
385
386// ─── FIGI — ANSI X9.145 ──────────────────────────────────────────────────────
387
388/// Computes the FIGI check digit (ANSI X9.145) of an 11-character body.
389///
390/// The algorithm is a modulus-10 double add double scanned right-to-left,
391/// but — unlike a plain Luhn — the rightmost character carries weight 1, not
392/// 2; the weight then alternates 2, 1, 2, ... Every decimal digit of each
393/// weighted product is added to the running sum, and the check digit is
394/// `(10 - (sum mod 10)) mod 10`. The body alphabet is the digits and the
395/// consonants — a FIGI never contains a vowel.
396///
397/// # Errors
398///
399/// - [`ValidationError::WrongLength`] if the body is not exactly 11
400///   characters.
401/// - [`ValidationError::InvalidCharacter`] if any character is not a digit
402///   or an upper-case consonant (a vowel is rejected here).
403///
404/// # Examples
405///
406/// ```
407/// use regit_identifiers::checkdigit::figi_check_digit;
408///
409/// // IBM, FIGI BBG000BLNNH6 — body "BBG000BLNNH", check digit 6.
410/// assert_eq!(figi_check_digit("BBG000BLNNH").unwrap(), '6');
411/// ```
412pub fn figi_check_digit(body: &str) -> Result<char, ValidationError> {
413    const LEN: usize = 11;
414    let found = body.chars().count();
415    if found != LEN {
416        return Err(ValidationError::WrongLength {
417            expected: LEN,
418            found,
419        });
420    }
421    for (i, ch) in body.chars().enumerate() {
422        let legal = ch.is_ascii_digit() || (ch.is_ascii_uppercase() && !is_vowel(ch));
423        if !legal {
424            return Err(ValidationError::InvalidCharacter {
425                position: i + 1,
426                found: ch,
427            });
428        }
429    }
430    // Right-to-left: the rightmost character (position 0) has weight 1, then
431    // the weight alternates 2, 1, 2, ... Every decimal digit of the weighted
432    // product is summed (the product cannot exceed 35 * 2 = 70).
433    let mut sum = 0u32;
434    let mut doubled = false; // the rightmost character has weight 1
435    for &b in body.as_bytes().iter().rev() {
436        let weight = if doubled { 2 } else { 1 };
437        let product = charset::alnum_value(b) * weight;
438        sum += product / 10 + product % 10;
439        doubled = !doubled;
440    }
441    Ok(digit_char(10 - (sum % 10)))
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    // ─── Luhn ────────────────────────────────────────────────────────────
449
450    #[test]
451    fn luhn_canonical_example() {
452        assert_eq!(luhn_checksum("7992739871").unwrap(), 3);
453    }
454
455    #[test]
456    fn luhn_rejects_empty_and_non_digit() {
457        assert_eq!(luhn_checksum(""), Err(ValidationError::Empty));
458        assert_eq!(
459            luhn_checksum("12A4"),
460            Err(ValidationError::InvalidCharacter {
461                position: 3,
462                found: 'A',
463            })
464        );
465    }
466
467    // ─── ISIN ────────────────────────────────────────────────────────────
468
469    #[test]
470    fn isin_worked_example_apple() {
471        // US0378331005 — body "US037833100", check digit 5 (hand-computed).
472        assert_eq!(isin_check_digit("US037833100").unwrap(), '5');
473    }
474
475    #[test]
476    fn isin_golden_vectors() {
477        // Real instruments; the check digit is each ISIN's final character.
478        assert_eq!(isin_check_digit("US594918104").unwrap(), '5'); // US5949181045, Microsoft
479        assert_eq!(isin_check_digit("GB000263494").unwrap(), '6'); // GB0002634946, BAE Systems
480        assert_eq!(isin_check_digit("DE000BAY001").unwrap(), '7'); // DE000BAY0017, Bayer
481    }
482
483    #[test]
484    fn isin_rejects_wrong_length() {
485        assert_eq!(
486            isin_check_digit("US03783310"),
487            Err(ValidationError::WrongLength {
488                expected: 11,
489                found: 10,
490            })
491        );
492    }
493
494    #[test]
495    fn isin_rejects_bad_character() {
496        assert_eq!(
497            isin_check_digit("US0378331/0"),
498            Err(ValidationError::InvalidCharacter {
499                position: 10,
500                found: '/',
501            })
502        );
503        // Lower case is rejected — identifiers are upper-case only.
504        assert!(matches!(
505            isin_check_digit("us037833100"),
506            Err(ValidationError::InvalidCharacter { .. })
507        ));
508    }
509
510    // ─── CUSIP ───────────────────────────────────────────────────────────
511
512    #[test]
513    fn cusip_worked_example_apple() {
514        // 037833100 — body "03783310", check digit 0 (hand-computed).
515        assert_eq!(cusip_check_digit("03783310").unwrap(), '0');
516    }
517
518    #[test]
519    fn cusip_golden_vectors() {
520        // Real instruments; the check digit is each CUSIP's final character.
521        assert_eq!(cusip_check_digit("59491810").unwrap(), '4'); // 594918104, Microsoft
522        assert_eq!(cusip_check_digit("38259P50").unwrap(), '8'); // 38259P508, Alphabet
523    }
524
525    #[test]
526    fn cusip_rejects_wrong_length_and_char() {
527        assert_eq!(
528            cusip_check_digit("0378331"),
529            Err(ValidationError::WrongLength {
530                expected: 8,
531                found: 7,
532            })
533        );
534        assert!(matches!(
535            cusip_check_digit("0378331."),
536            Err(ValidationError::InvalidCharacter { .. })
537        ));
538    }
539
540    // ─── SEDOL ───────────────────────────────────────────────────────────
541
542    #[test]
543    fn sedol_worked_example_bae() {
544        // 0263494 — body "026349", check digit 4 (hand-computed).
545        assert_eq!(sedol_check_digit("026349").unwrap(), '4');
546    }
547
548    #[test]
549    fn sedol_golden_vectors() {
550        assert_eq!(sedol_check_digit("B0WNLY").unwrap(), '7'); // B0WNLY7
551        assert_eq!(sedol_check_digit("054052").unwrap(), '8'); // 0540528
552    }
553
554    #[test]
555    fn sedol_rejects_vowel() {
556        // A vowel can never appear in a SEDOL body.
557        assert_eq!(
558            sedol_check_digit("B0WNLA"),
559            Err(ValidationError::InvalidCharacter {
560                position: 6,
561                found: 'A',
562            })
563        );
564    }
565
566    #[test]
567    fn sedol_rejects_wrong_length() {
568        assert_eq!(
569            sedol_check_digit("02634"),
570            Err(ValidationError::WrongLength {
571                expected: 6,
572                found: 5,
573            })
574        );
575    }
576
577    // ─── LEI ─────────────────────────────────────────────────────────────
578
579    #[test]
580    fn lei_worked_example_bloomberg() {
581        // 5493001KJTIIGC8Y1R12 — body "5493001KJTIIGC8Y1R", check "12".
582        assert_eq!(lei_check_digits("5493001KJTIIGC8Y1R").unwrap(), ['1', '2']);
583    }
584
585    #[test]
586    fn lei_golden_vectors() {
587        // 549300DTUYXVMJXZNY75 — a second real LEI beyond the worked example.
588        assert_eq!(lei_check_digits("549300DTUYXVMJXZNY").unwrap(), ['7', '5']);
589    }
590
591    #[test]
592    fn lei_rejects_wrong_length_and_char() {
593        assert_eq!(
594            lei_check_digits("5493001KJTIIGC8Y1"),
595            Err(ValidationError::WrongLength {
596                expected: 18,
597                found: 17,
598            })
599        );
600        assert!(matches!(
601            lei_check_digits("5493001KJTIIGC8Y1-"),
602            Err(ValidationError::InvalidCharacter { .. })
603        ));
604    }
605
606    // ─── FIGI ────────────────────────────────────────────────────────────
607
608    #[test]
609    fn figi_worked_example_ibm() {
610        // BBG000BLNNH6 — body "BBG000BLNNH", check digit 6 (hand-computed).
611        assert_eq!(figi_check_digit("BBG000BLNNH").unwrap(), '6');
612    }
613
614    #[test]
615    fn figi_golden_vectors() {
616        assert_eq!(figi_check_digit("BBG000B9XRY").unwrap(), '4'); // BBG000B9XRY4
617        assert_eq!(figi_check_digit("BBG000BVPV8").unwrap(), '4'); // BBG000BVPV84
618        assert_eq!(figi_check_digit("BBG0013T5HY").unwrap(), '0'); // BBG0013T5HY0
619    }
620
621    #[test]
622    fn figi_rejects_vowel() {
623        // FIGI bodies forbid vowels.
624        assert!(matches!(
625            figi_check_digit("BBG00OBLNNH"),
626            Err(ValidationError::InvalidCharacter { .. })
627        ));
628    }
629
630    #[test]
631    fn figi_rejects_wrong_length() {
632        assert_eq!(
633            figi_check_digit("BBG000BLNN"),
634            Err(ValidationError::WrongLength {
635                expected: 11,
636                found: 10,
637            })
638        );
639    }
640
641    // ─── Cross-cutting ───────────────────────────────────────────────────
642
643    #[test]
644    fn every_check_digit_is_an_ascii_digit() {
645        assert!(isin_check_digit("US037833100").unwrap().is_ascii_digit());
646        assert!(cusip_check_digit("03783310").unwrap().is_ascii_digit());
647        assert!(sedol_check_digit("026349").unwrap().is_ascii_digit());
648        assert!(figi_check_digit("BBG000BLNNH").unwrap().is_ascii_digit());
649        let lei = lei_check_digits("5493001KJTIIGC8Y1R").unwrap();
650        assert!(lei[0].is_ascii_digit() && lei[1].is_ascii_digit());
651    }
652
653    #[test]
654    fn non_ascii_input_is_rejected_not_panicked() {
655        // A multi-byte character must be rejected cleanly, never panic.
656        assert!(isin_check_digit("US03783310é").is_err());
657        assert!(cusip_check_digit("0378331é").is_err());
658        assert!(lei_check_digits("5493001KJTIIGC8Y1é").is_err());
659    }
660}