use super::error::{CharacterClass, IsinError};
pub(super) const BASE_LEN: usize = 11;
#[cfg_attr(
not(any(feature = "arbitrary", feature = "proptest")),
allow(dead_code)
)]
pub(crate) const LETTERS: &[u8; 26] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ";
#[cfg_attr(
not(any(feature = "arbitrary", feature = "proptest")),
allow(dead_code)
)]
pub(crate) const ALPHANUMERIC: &[u8; 36] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
pub(super) fn validate(candidate: &[u8; 12]) -> Result<(), IsinError> {
validate_character_classes(candidate)?;
validate_check_digit(candidate)?;
Ok(())
}
fn validate_character_classes(candidate: &[u8; 12]) -> Result<(), IsinError> {
for (i, &byte) in candidate.iter().enumerate() {
let (is_valid, expected) = if i < 2 {
(byte.is_ascii_uppercase(), CharacterClass::Letter)
} else if i < BASE_LEN {
(
byte.is_ascii_digit() || byte.is_ascii_uppercase(),
CharacterClass::Alphanumeric,
)
} else {
(byte.is_ascii_digit(), CharacterClass::Digit)
};
if !is_valid {
return Err(IsinError::InvalidCharacter {
character: byte as char,
position: (i + 1) as u8,
expected,
});
}
}
Ok(())
}
fn validate_check_digit(candidate: &[u8; 12]) -> Result<(), IsinError> {
let expected = compute_check_digit(&candidate[..BASE_LEN]);
let found = candidate[BASE_LEN] - b'0';
if expected != found {
return Err(IsinError::InvalidCheckDigit { expected, found });
}
Ok(())
}
#[cfg_attr(
not(any(feature = "arbitrary", feature = "proptest")),
allow(dead_code)
)]
pub(super) fn compute_check_digit(base: &[u8]) -> u8 {
debug_assert_eq!(base.len(), BASE_LEN);
let mut sum = 0u32;
let mut double = true;
for &c in base.iter().rev() {
if c.is_ascii_digit() {
sum += luhn_step((c - b'0') as u32, double);
double = !double;
} else {
let value = (c - b'A' + 10) as u32;
sum += luhn_step(value % 10, double);
double = !double;
sum += luhn_step(value / 10, double);
double = !double;
}
}
((10 - (sum % 10)) % 10) as u8
}
#[cfg_attr(
not(any(feature = "arbitrary", feature = "proptest")),
allow(dead_code)
)]
pub(crate) fn build_valid_isin_bytes(country: [usize; 2], nsin: &[usize]) -> [u8; 12] {
debug_assert_eq!(nsin.len(), BASE_LEN - 2);
let mut base = [0u8; BASE_LEN];
base[0] = LETTERS[country[0]];
base[1] = LETTERS[country[1]];
for (slot, idx) in base[2..].iter_mut().zip(nsin) {
*slot = ALPHANUMERIC[*idx];
}
let check = compute_check_digit(&base);
let mut bytes = [0u8; 12];
bytes[..BASE_LEN].copy_from_slice(&base);
bytes[BASE_LEN] = check + b'0';
bytes
}
#[inline]
fn luhn_step(value: u32, double: bool) -> u32 {
if double {
let doubled = value * 2;
if doubled > 9 { doubled - 9 } else { doubled }
} else {
value
}
}
#[cfg(test)]
mod tests {
use super::*;
fn candidate(s: &str) -> [u8; 12] {
let bytes = s.as_bytes();
let mut out = [0u8; 12];
out.copy_from_slice(bytes);
out
}
fn reference_check_digit(base: &str) -> u8 {
let mut digits = alloc::vec::Vec::new();
for &c in base.as_bytes() {
if c.is_ascii_digit() {
digits.push((c - b'0') as u32);
} else {
let v = (c - b'A' + 10) as u32;
digits.push(v / 10);
digits.push(v % 10);
}
}
let mut sum = 0u32;
let n = digits.len();
for (i, &d) in digits.iter().enumerate() {
let from_right = n - i; let mut v = d;
if from_right % 2 == 1 {
v *= 2;
if v > 9 {
v -= 9;
}
}
sum += v;
}
((10 - (sum % 10)) % 10) as u8
}
#[test]
fn accepts_known_real_world_isins() {
for s in [
"US0378331005", "US0231351067", "BRPETRACNOR9", "GB0002634946", "DE0001102333", "JP3633400001", "AU000000BHP4", "CH0012221716", ] {
assert!(validate(&candidate(s)).is_ok(), "{s} should be valid");
}
}
#[test]
fn computes_the_documented_apple_check_digit() {
assert_eq!(compute_check_digit(b"US037833100"), 5);
}
#[test]
fn computes_an_all_letter_nsin_check_digit() {
assert_eq!(compute_check_digit(b"BRPETRACNOR"), 9);
}
#[test]
fn single_pass_matches_the_reference_implementation() {
for base in [
"US037833100",
"US023135106",
"BRPETRACNOR",
"GB000263494",
"AU000000BHP",
"AA000000000",
"ZZZZZZZZZZZ",
] {
assert_eq!(
compute_check_digit(base.as_bytes()),
reference_check_digit(base),
"{base}"
);
}
}
#[test]
fn rejects_lowercase_country_code() {
let err = validate(&candidate("uS0378331005")).unwrap_err();
assert_eq!(
err,
IsinError::InvalidCharacter {
character: 'u',
position: 1,
expected: CharacterClass::Letter,
}
);
}
#[test]
fn rejects_digit_in_country_code() {
let err = validate(&candidate("1S0378331005")).unwrap_err();
assert_eq!(
err,
IsinError::InvalidCharacter {
character: '1',
position: 1,
expected: CharacterClass::Letter,
}
);
}
#[test]
fn rejects_letter_in_check_digit_position() {
let err = validate(&candidate("US037833100X")).unwrap_err();
assert_eq!(
err,
IsinError::InvalidCharacter {
character: 'X',
position: 12,
expected: CharacterClass::Digit,
}
);
}
#[test]
fn rejects_wrong_check_digit() {
let err = validate(&candidate("US0378331006")).unwrap_err();
assert_eq!(
err,
IsinError::InvalidCheckDigit {
expected: 5,
found: 6,
}
);
}
#[test]
fn rejects_adjacent_transposition() {
assert!(validate(&candidate("US3078331005")).is_err());
}
}