use crate::charset;
use crate::errors::ValidationError;
#[inline]
fn digit_char(value: u32) -> char {
char::from(b'0' + u8::try_from(value % 10).unwrap_or(0))
}
#[inline]
fn luhn_contribution(d: u32, doubled: bool) -> u32 {
let weighted = if doubled { d * 2 } else { d };
if weighted > 9 { weighted - 9 } else { weighted }
}
#[inline]
fn is_vowel(ch: char) -> bool {
matches!(ch, 'A' | 'E' | 'I' | 'O' | 'U')
}
#[inline]
fn cusip_value(b: u8) -> u32 {
match b {
b'*' => 36,
b'@' => 37,
b'#' => 38,
_ => charset::alnum_value(b),
}
}
pub fn luhn_checksum(digits: &str) -> Result<u8, ValidationError> {
if digits.is_empty() {
return Err(ValidationError::Empty);
}
for (i, ch) in digits.chars().enumerate() {
if !ch.is_ascii_digit() {
return Err(ValidationError::InvalidCharacter {
position: i + 1,
found: ch,
});
}
}
let mut sum = 0u32;
let mut doubled = true;
for &b in digits.as_bytes().iter().rev() {
sum += luhn_contribution(charset::digit_value(b), doubled);
doubled = !doubled;
}
Ok(u8::try_from((10 - (sum % 10)) % 10).unwrap_or(0))
}
pub fn isin_check_digit(body: &str) -> Result<char, ValidationError> {
const LEN: usize = 11;
let found = body.chars().count();
if found != LEN {
return Err(ValidationError::WrongLength {
expected: LEN,
found,
});
}
for (i, ch) in body.chars().enumerate() {
if !(ch.is_ascii_digit() || ch.is_ascii_uppercase()) {
return Err(ValidationError::InvalidCharacter {
position: i + 1,
found: ch,
});
}
}
let mut sum = 0u32;
let mut doubled = true;
for &b in body.as_bytes().iter().rev() {
let value = charset::alnum_value(b); if value < 10 {
sum += luhn_contribution(value, doubled);
doubled = !doubled;
} else {
sum += luhn_contribution(value % 10, doubled);
doubled = !doubled;
sum += luhn_contribution(value / 10, doubled);
doubled = !doubled;
}
}
Ok(digit_char(10 - (sum % 10)))
}
pub fn cusip_check_digit(body: &str) -> Result<char, ValidationError> {
const LEN: usize = 8;
let found = body.chars().count();
if found != LEN {
return Err(ValidationError::WrongLength {
expected: LEN,
found,
});
}
for (i, ch) in body.chars().enumerate() {
let legal = ch.is_ascii_digit() || ch.is_ascii_uppercase() || matches!(ch, '*' | '@' | '#');
if !legal {
return Err(ValidationError::InvalidCharacter {
position: i + 1,
found: ch,
});
}
}
let mut sum = 0u32;
for (i, &b) in body.as_bytes().iter().enumerate() {
let weight = if i % 2 == 0 { 1 } else { 2 };
let product = cusip_value(b) * weight;
sum += product / 10 + product % 10;
}
Ok(digit_char(10 - (sum % 10)))
}
pub fn sedol_check_digit(body: &str) -> Result<char, ValidationError> {
const LEN: usize = 6;
const WEIGHTS: [u32; LEN] = [1, 3, 1, 7, 3, 9];
let found = body.chars().count();
if found != LEN {
return Err(ValidationError::WrongLength {
expected: LEN,
found,
});
}
for (i, ch) in body.chars().enumerate() {
let legal = ch.is_ascii_digit() || (ch.is_ascii_uppercase() && !is_vowel(ch));
if !legal {
return Err(ValidationError::InvalidCharacter {
position: i + 1,
found: ch,
});
}
}
let mut sum = 0u32;
for (&b, &weight) in body.as_bytes().iter().zip(WEIGHTS.iter()) {
sum += charset::alnum_value(b) * weight;
}
Ok(digit_char(10 - (sum % 10)))
}
pub fn lei_check_digits(body: &str) -> Result<[char; 2], ValidationError> {
const LEN: usize = 18;
let found = body.chars().count();
if found != LEN {
return Err(ValidationError::WrongLength {
expected: LEN,
found,
});
}
for (i, ch) in body.chars().enumerate() {
if !(ch.is_ascii_digit() || ch.is_ascii_uppercase()) {
return Err(ValidationError::InvalidCharacter {
position: i + 1,
found: ch,
});
}
}
let mut acc = 0u32;
for &b in body.as_bytes() {
let value = charset::alnum_value(b);
if value < 10 {
acc = (acc * 10 + value) % 97;
} else {
acc = (acc * 100 + value) % 97;
}
}
acc = (acc * 100) % 97;
let check = 98 - acc; Ok([digit_char(check / 10), digit_char(check % 10)])
}
pub fn figi_check_digit(body: &str) -> Result<char, ValidationError> {
const LEN: usize = 11;
let found = body.chars().count();
if found != LEN {
return Err(ValidationError::WrongLength {
expected: LEN,
found,
});
}
for (i, ch) in body.chars().enumerate() {
let legal = ch.is_ascii_digit() || (ch.is_ascii_uppercase() && !is_vowel(ch));
if !legal {
return Err(ValidationError::InvalidCharacter {
position: i + 1,
found: ch,
});
}
}
let mut sum = 0u32;
let mut doubled = false; for &b in body.as_bytes().iter().rev() {
let weight = if doubled { 2 } else { 1 };
let product = charset::alnum_value(b) * weight;
sum += product / 10 + product % 10;
doubled = !doubled;
}
Ok(digit_char(10 - (sum % 10)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn luhn_canonical_example() {
assert_eq!(luhn_checksum("7992739871").unwrap(), 3);
}
#[test]
fn luhn_rejects_empty_and_non_digit() {
assert_eq!(luhn_checksum(""), Err(ValidationError::Empty));
assert_eq!(
luhn_checksum("12A4"),
Err(ValidationError::InvalidCharacter {
position: 3,
found: 'A',
})
);
}
#[test]
fn isin_worked_example_apple() {
assert_eq!(isin_check_digit("US037833100").unwrap(), '5');
}
#[test]
fn isin_golden_vectors() {
assert_eq!(isin_check_digit("US594918104").unwrap(), '5'); assert_eq!(isin_check_digit("GB000263494").unwrap(), '6'); assert_eq!(isin_check_digit("DE000BAY001").unwrap(), '7'); }
#[test]
fn isin_rejects_wrong_length() {
assert_eq!(
isin_check_digit("US03783310"),
Err(ValidationError::WrongLength {
expected: 11,
found: 10,
})
);
}
#[test]
fn isin_rejects_bad_character() {
assert_eq!(
isin_check_digit("US0378331/0"),
Err(ValidationError::InvalidCharacter {
position: 10,
found: '/',
})
);
assert!(matches!(
isin_check_digit("us037833100"),
Err(ValidationError::InvalidCharacter { .. })
));
}
#[test]
fn cusip_worked_example_apple() {
assert_eq!(cusip_check_digit("03783310").unwrap(), '0');
}
#[test]
fn cusip_golden_vectors() {
assert_eq!(cusip_check_digit("59491810").unwrap(), '4'); assert_eq!(cusip_check_digit("38259P50").unwrap(), '8'); }
#[test]
fn cusip_rejects_wrong_length_and_char() {
assert_eq!(
cusip_check_digit("0378331"),
Err(ValidationError::WrongLength {
expected: 8,
found: 7,
})
);
assert!(matches!(
cusip_check_digit("0378331."),
Err(ValidationError::InvalidCharacter { .. })
));
}
#[test]
fn sedol_worked_example_bae() {
assert_eq!(sedol_check_digit("026349").unwrap(), '4');
}
#[test]
fn sedol_golden_vectors() {
assert_eq!(sedol_check_digit("B0WNLY").unwrap(), '7'); assert_eq!(sedol_check_digit("054052").unwrap(), '8'); }
#[test]
fn sedol_rejects_vowel() {
assert_eq!(
sedol_check_digit("B0WNLA"),
Err(ValidationError::InvalidCharacter {
position: 6,
found: 'A',
})
);
}
#[test]
fn sedol_rejects_wrong_length() {
assert_eq!(
sedol_check_digit("02634"),
Err(ValidationError::WrongLength {
expected: 6,
found: 5,
})
);
}
#[test]
fn lei_worked_example_bloomberg() {
assert_eq!(lei_check_digits("5493001KJTIIGC8Y1R").unwrap(), ['1', '2']);
}
#[test]
fn lei_golden_vectors() {
assert_eq!(lei_check_digits("549300DTUYXVMJXZNY").unwrap(), ['7', '5']);
}
#[test]
fn lei_rejects_wrong_length_and_char() {
assert_eq!(
lei_check_digits("5493001KJTIIGC8Y1"),
Err(ValidationError::WrongLength {
expected: 18,
found: 17,
})
);
assert!(matches!(
lei_check_digits("5493001KJTIIGC8Y1-"),
Err(ValidationError::InvalidCharacter { .. })
));
}
#[test]
fn figi_worked_example_ibm() {
assert_eq!(figi_check_digit("BBG000BLNNH").unwrap(), '6');
}
#[test]
fn figi_golden_vectors() {
assert_eq!(figi_check_digit("BBG000B9XRY").unwrap(), '4'); assert_eq!(figi_check_digit("BBG000BVPV8").unwrap(), '4'); assert_eq!(figi_check_digit("BBG0013T5HY").unwrap(), '0'); }
#[test]
fn figi_rejects_vowel() {
assert!(matches!(
figi_check_digit("BBG00OBLNNH"),
Err(ValidationError::InvalidCharacter { .. })
));
}
#[test]
fn figi_rejects_wrong_length() {
assert_eq!(
figi_check_digit("BBG000BLNN"),
Err(ValidationError::WrongLength {
expected: 11,
found: 10,
})
);
}
#[test]
fn every_check_digit_is_an_ascii_digit() {
assert!(isin_check_digit("US037833100").unwrap().is_ascii_digit());
assert!(cusip_check_digit("03783310").unwrap().is_ascii_digit());
assert!(sedol_check_digit("026349").unwrap().is_ascii_digit());
assert!(figi_check_digit("BBG000BLNNH").unwrap().is_ascii_digit());
let lei = lei_check_digits("5493001KJTIIGC8Y1R").unwrap();
assert!(lei[0].is_ascii_digit() && lei[1].is_ascii_digit());
}
#[test]
fn non_ascii_input_is_rejected_not_panicked() {
assert!(isin_check_digit("US03783310é").is_err());
assert!(cusip_check_digit("0378331é").is_err());
assert!(lei_check_digits("5493001KJTIIGC8Y1é").is_err());
}
}