use crate::checkdigit;
use crate::errors::ValidationError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Cusip {
bytes: [u8; Self::LENGTH],
}
impl Cusip {
pub const LENGTH: usize = 9;
pub fn parse(s: &str) -> Result<Self, ValidationError> {
let found = s.chars().count();
if found != Self::LENGTH {
return Err(ValidationError::WrongLength {
expected: Self::LENGTH,
found,
});
}
for (i, ch) in s.chars().enumerate() {
let legal = if i == Self::LENGTH - 1 {
ch.is_ascii_digit()
} else {
ch.is_ascii_digit() || ch.is_ascii_uppercase() || matches!(ch, '*' | '@' | '#')
};
if !legal {
return Err(ValidationError::InvalidCharacter {
position: i + 1,
found: ch,
});
}
}
let mut bytes = [0u8; Self::LENGTH];
bytes.copy_from_slice(s.as_bytes());
let body = core::str::from_utf8(&bytes[0..8]).unwrap_or("");
let expected = checkdigit::cusip_check_digit(body)?;
let supplied = char::from(bytes[8]);
if expected != supplied {
return Err(ValidationError::BadCheckDigit {
expected,
found: supplied,
});
}
Ok(Self { bytes })
}
pub fn validate(s: &str) -> Result<(), ValidationError> {
Self::parse(s).map(|_| ())
}
#[must_use]
pub const fn from_bytes_unchecked(bytes: [u8; Self::LENGTH]) -> Self {
Self { bytes }
}
#[must_use]
#[inline]
pub fn as_str(&self) -> &str {
core::str::from_utf8(&self.bytes).unwrap_or("")
}
#[must_use]
#[inline]
pub fn as_bytes(&self) -> &[u8] {
&self.bytes
}
#[must_use]
#[inline]
pub fn issuer(&self) -> &str {
core::str::from_utf8(&self.bytes[0..6]).unwrap_or("")
}
#[must_use]
#[inline]
pub fn issue(&self) -> &str {
core::str::from_utf8(&self.bytes[6..8]).unwrap_or("")
}
#[must_use]
#[inline]
pub fn check_digit(&self) -> char {
char::from(self.bytes[8])
}
#[must_use]
#[inline]
pub fn is_cins(&self) -> bool {
self.bytes[0].is_ascii_uppercase()
}
#[must_use]
#[inline]
pub fn is_domestic(&self) -> bool {
!self.is_cins()
}
#[must_use]
pub fn cins_region(&self) -> Option<&'static str> {
if !self.is_cins() {
return None;
}
match self.bytes[0] {
b'A' => Some("Austria"),
b'B' => Some("Belgium"),
b'C' => Some("Canada"),
b'D' => Some("Germany"),
b'E' => Some("Spain"),
b'F' => Some("France"),
b'G' => Some("United Kingdom"),
b'H' => Some("Switzerland"),
b'J' => Some("Japan"),
b'K' => Some("Denmark"),
b'L' => Some("Luxembourg"),
b'M' => Some("Middle East"),
b'N' => Some("Netherlands"),
b'P' => Some("South America"),
b'Q' => Some("Australia"),
b'R' => Some("Norway"),
b'S' => Some("South Africa"),
b'T' => Some("Italy"),
b'U' => Some("United States"),
b'V' => Some("Africa-Other"),
b'W' => Some("Sweden"),
b'X' => Some("Europe-Other"),
b'Y' => Some("Asia"),
_ => None,
}
}
}
impl core::fmt::Display for Cusip {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(self.as_str())
}
}
impl core::str::FromStr for Cusip {
type Err = ValidationError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
}
}
impl AsRef<str> for Cusip {
fn as_ref(&self) -> &str {
self.as_str()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::display;
use core::str::FromStr;
const GOLDEN: &[&str] = &[
"037833100", "594918104", "38259P508", ];
#[test]
fn parses_golden_cusips() {
for &s in GOLDEN {
let cusip = Cusip::parse(s).unwrap_or_else(|e| panic!("{s} should parse: {e}"));
assert_eq!(cusip.as_str(), s);
}
}
#[test]
fn segment_accessors() {
let cusip = Cusip::parse("037833100").unwrap();
assert_eq!(cusip.issuer(), "037833");
assert_eq!(cusip.issue(), "10");
assert_eq!(cusip.check_digit(), '0');
assert_eq!(cusip.as_bytes(), b"037833100");
assert_eq!(Cusip::LENGTH, 9);
}
#[test]
fn rejects_bad_check_digit() {
assert_eq!(
Cusip::parse("037833101"),
Err(ValidationError::BadCheckDigit {
expected: '0',
found: '1',
})
);
}
#[test]
fn rejects_wrong_length() {
assert_eq!(
Cusip::parse("03783310"),
Err(ValidationError::WrongLength {
expected: 9,
found: 8,
})
);
assert_eq!(
Cusip::parse(""),
Err(ValidationError::WrongLength {
expected: 9,
found: 0,
})
);
}
#[test]
fn rejects_lower_case() {
assert!(matches!(
Cusip::parse("a37833100"),
Err(ValidationError::InvalidCharacter { position: 1, .. })
));
}
#[test]
fn rejects_non_digit_check_position() {
assert!(matches!(
Cusip::parse("03783310X"),
Err(ValidationError::InvalidCharacter { position: 9, .. })
));
}
#[test]
fn rejects_illegal_body_character() {
assert!(matches!(
Cusip::parse("0378/3100"),
Err(ValidationError::InvalidCharacter { position: 5, .. })
));
}
#[test]
fn accepts_special_body_characters() {
let body = "12345*@#";
let check = checkdigit::cusip_check_digit(body).unwrap();
let mut raw = [0u8; 9];
raw[0..8].copy_from_slice(body.as_bytes());
raw[8] = check as u8;
let s = core::str::from_utf8(&raw).unwrap();
let cusip = Cusip::parse(s).unwrap();
assert_eq!(cusip.issuer(), "12345*");
assert_eq!(cusip.issue(), "@#");
}
#[test]
fn rejects_non_ascii_without_panic() {
assert!(Cusip::parse("03783310é").is_err());
assert!(Cusip::parse("é37833100").is_err());
}
#[test]
fn is_cins_detects_leading_letter() {
assert!(!Cusip::parse("037833100").unwrap().is_cins());
let body = "U3783310";
let check = checkdigit::cusip_check_digit(body).unwrap();
let mut raw = [0u8; 9];
raw[0..8].copy_from_slice(body.as_bytes());
raw[8] = check as u8;
let s = core::str::from_utf8(&raw).unwrap();
assert!(Cusip::parse(s).unwrap().is_cins());
}
#[test]
fn is_domestic_complements_is_cins() {
for s in ["037833100", "594918104", "38259P508"] {
let c = Cusip::parse(s).unwrap();
assert!(c.is_domestic());
assert!(!c.is_cins());
}
let body = "U3783310";
let check = checkdigit::cusip_check_digit(body).unwrap();
let mut raw = [0u8; 9];
raw[0..8].copy_from_slice(body.as_bytes());
raw[8] = check as u8;
let cins = Cusip::parse(core::str::from_utf8(&raw).unwrap()).unwrap();
assert!(!cins.is_domestic());
assert!(cins.is_cins());
}
#[test]
fn cins_region_maps_leading_letter() {
assert_eq!(Cusip::parse("037833100").unwrap().cins_region(), None);
let body = "U3783310";
let check = checkdigit::cusip_check_digit(body).unwrap();
let mut raw = [0u8; 9];
raw[0..8].copy_from_slice(body.as_bytes());
raw[8] = check as u8;
let s = core::str::from_utf8(&raw).unwrap();
assert_eq!(
Cusip::parse(s).unwrap().cins_region(),
Some("United States")
);
}
#[test]
fn cins_region_covers_every_assigned_letter() {
let assigned = [
(b'A', "Austria"),
(b'B', "Belgium"),
(b'C', "Canada"),
(b'D', "Germany"),
(b'E', "Spain"),
(b'F', "France"),
(b'G', "United Kingdom"),
(b'H', "Switzerland"),
(b'J', "Japan"),
(b'K', "Denmark"),
(b'L', "Luxembourg"),
(b'M', "Middle East"),
(b'N', "Netherlands"),
(b'P', "South America"),
(b'Q', "Australia"),
(b'R', "Norway"),
(b'S', "South Africa"),
(b'T', "Italy"),
(b'U', "United States"),
(b'V', "Africa-Other"),
(b'W', "Sweden"),
(b'X', "Europe-Other"),
(b'Y', "Asia"),
];
for (letter, region) in assigned {
let cusip = Cusip::from_bytes_unchecked([
letter, b'1', b'1', b'1', b'1', b'1', b'1', b'1', b'1',
]);
assert!(cusip.is_cins());
assert_eq!(cusip.cins_region(), Some(region));
}
}
#[test]
fn cins_region_none_for_unassigned_letter() {
for letter in [b'I', b'O', b'Z'] {
let cusip = Cusip::from_bytes_unchecked([
letter, b'1', b'1', b'1', b'1', b'1', b'1', b'1', b'1',
]);
assert!(cusip.is_cins());
assert_eq!(cusip.cins_region(), None);
}
}
#[test]
fn round_trips_through_str() {
for &s in GOLDEN {
assert_eq!(Cusip::parse(s).unwrap().as_str(), s);
}
}
#[test]
fn from_str_matches_parse() {
assert_eq!(Cusip::from_str("037833100"), Cusip::parse("037833100"));
assert!(Cusip::from_str("nonsense").is_err());
}
#[test]
fn display_renders_identifier() {
let cusip = Cusip::parse("037833100").unwrap();
assert_eq!(display(cusip).as_str(), "037833100");
}
#[test]
fn as_ref_str() {
let cusip = Cusip::parse("037833100").unwrap();
let s: &str = cusip.as_ref();
assert_eq!(s, "037833100");
}
#[test]
fn validate_agrees_with_parse() {
assert!(Cusip::validate("037833100").is_ok());
assert!(Cusip::validate("037833101").is_err());
}
#[test]
fn from_bytes_unchecked_round_trip() {
let cusip = Cusip::from_bytes_unchecked(*b"037833100");
assert_eq!(cusip, Cusip::parse("037833100").unwrap());
}
#[test]
fn is_copy_and_eq_and_hashable() {
let a = Cusip::parse("037833100").unwrap();
let b = a; assert_eq!(a, b);
assert_ne!(a, Cusip::parse("594918104").unwrap());
let keys = [a, b];
assert_eq!(keys[0], keys[1]);
}
}