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}