use crate::checkdigit;
use crate::country;
use crate::cusip::Cusip;
use crate::errors::ConversionError;
use crate::isin::Isin;
use crate::sedol::Sedol;
use crate::valor::Valor;
const NSIN_LEN: usize = 9;
const BODY_LEN: usize = 11;
pub fn build_isin(country: &str, nsin: &str) -> Result<Isin, ConversionError> {
if country.len() != 2 || !country.is_ascii() {
return Err(ConversionError::NotConvertible {
reason: "country prefix must be exactly two characters",
});
}
if !country::is_isin_prefix(country) {
return Err(ConversionError::UnsupportedCountry);
}
if !nsin.is_ascii() {
return Err(ConversionError::NotConvertible {
reason: "national number must be ASCII",
});
}
let nsin_bytes = nsin.as_bytes();
if nsin_bytes.is_empty() || nsin_bytes.len() > NSIN_LEN {
return Err(ConversionError::NotConvertible {
reason: "national number must be 1 to 9 characters",
});
}
let mut body = [b'0'; BODY_LEN];
body[0] = country.as_bytes()[0];
body[1] = country.as_bytes()[1];
let pad = NSIN_LEN - nsin_bytes.len();
if let Some(slot) = body.get_mut(2 + pad..BODY_LEN) {
slot.copy_from_slice(nsin_bytes);
}
let body_str = core::str::from_utf8(&body).unwrap_or("");
let check = checkdigit::isin_check_digit(body_str)?;
let mut full = [0u8; Isin::LENGTH];
if let Some(slot) = full.get_mut(0..BODY_LEN) {
slot.copy_from_slice(&body);
}
full[BODY_LEN] = check as u8;
let full_str = core::str::from_utf8(&full).unwrap_or("");
Ok(Isin::parse(full_str)?)
}
pub fn isin_to_cusip(isin: &Isin) -> Result<Cusip, ConversionError> {
match isin.country_code() {
"US" | "CA" => Ok(Cusip::parse(isin.nsin())?),
_ => Err(ConversionError::UnsupportedCountry),
}
}
pub fn cusip_to_isin(cusip: &Cusip, country: &str) -> Result<Isin, ConversionError> {
build_isin(country, cusip.as_str())
}
pub fn isin_to_sedol(isin: &Isin) -> Result<Sedol, ConversionError> {
match isin.country_code() {
"GB" | "IE" => {}
_ => return Err(ConversionError::UnsupportedCountry),
}
let nsin = isin.nsin();
let rest = nsin
.strip_prefix("00")
.ok_or(ConversionError::NotConvertible {
reason: "GB/IE NSIN must begin with 00 padding a SEDOL",
})?;
Ok(Sedol::parse(rest)?)
}
pub fn sedol_to_isin(sedol: &Sedol, country: &str) -> Result<Isin, ConversionError> {
let mut nsin = [b'0'; NSIN_LEN];
if let Some(slot) = nsin.get_mut(2..NSIN_LEN) {
slot.copy_from_slice(sedol.as_bytes());
}
let nsin_str = core::str::from_utf8(&nsin).unwrap_or("");
build_isin(country, nsin_str)
}
pub fn isin_to_valor(isin: &Isin) -> Result<Valor, ConversionError> {
match isin.country_code() {
"CH" | "LI" => {}
_ => return Err(ConversionError::UnsupportedCountry),
}
let nsin = isin.nsin();
let trimmed = nsin.trim_start_matches('0');
let valor = if trimmed.is_empty() {
"0"
} else {
trimmed
};
Ok(Valor::parse(valor)?)
}
pub fn valor_to_isin(valor: &Valor, country: &str) -> Result<Isin, ConversionError> {
build_isin(country, valor.as_str())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::errors::ValidationError;
#[test]
fn build_isin_apple() {
let isin = build_isin("US", "037833100").unwrap();
assert_eq!(isin.as_str(), "US0378331005");
}
#[test]
fn build_isin_agrees_with_isin_parse() {
for &(country, nsin, expected) in &[
("US", "037833100", "US0378331005"),
("US", "594918104", "US5949181045"),
("GB", "000263494", "GB0002634946"),
("DE", "000BAY001", "DE000BAY0017"),
("CH", "001213853", "CH0012138530"),
] {
let built = build_isin(country, nsin).unwrap();
assert_eq!(built.as_str(), expected);
assert_eq!(built, Isin::parse(expected).unwrap());
}
}
#[test]
fn build_isin_left_pads_short_nsin() {
let isin = build_isin("CH", "1213853").unwrap();
assert_eq!(isin.as_str(), "CH0012138530");
assert_eq!(isin.nsin(), "001213853");
}
#[test]
fn build_isin_accepts_single_character_nsin() {
let isin = build_isin("US", "1").unwrap();
assert_eq!(isin.nsin(), "000000001");
}
#[test]
fn build_isin_rejects_unknown_country() {
assert_eq!(
build_isin("ZZ", "037833100"),
Err(ConversionError::UnsupportedCountry)
);
}
#[test]
fn build_isin_rejects_wrong_country_length() {
assert!(matches!(
build_isin("USA", "037833100"),
Err(ConversionError::NotConvertible { .. })
));
assert!(matches!(
build_isin("U", "037833100"),
Err(ConversionError::NotConvertible { .. })
));
}
#[test]
fn build_isin_rejects_empty_and_overlong_nsin() {
assert!(matches!(
build_isin("US", ""),
Err(ConversionError::NotConvertible { .. })
));
assert!(matches!(
build_isin("US", "0123456789"),
Err(ConversionError::NotConvertible { .. })
));
}
#[test]
fn build_isin_rejects_bad_nsin_character() {
assert!(matches!(
build_isin("US", "03783310a"),
Err(ConversionError::Validation(_))
));
}
#[test]
fn build_isin_rejects_non_ascii() {
assert!(matches!(
build_isin("US", "0378331é"),
Err(ConversionError::NotConvertible { .. })
));
assert!(matches!(
build_isin("ÉS", "037833100"),
Err(ConversionError::NotConvertible { .. })
));
}
#[test]
fn isin_to_cusip_apple() {
let isin = Isin::parse("US0378331005").unwrap();
assert_eq!(isin_to_cusip(&isin).unwrap().as_str(), "037833100");
}
#[test]
fn isin_to_cusip_accepts_canada() {
let isin = cusip_to_isin(&Cusip::parse("037833100").unwrap(), "CA").unwrap();
assert_eq!(isin.country_code(), "CA");
assert_eq!(isin_to_cusip(&isin).unwrap().as_str(), "037833100");
}
#[test]
fn isin_to_cusip_rejects_non_us_ca() {
let isin = Isin::parse("GB0002634946").unwrap();
assert_eq!(
isin_to_cusip(&isin),
Err(ConversionError::UnsupportedCountry)
);
let de = Isin::parse("DE000BAY0017").unwrap();
assert_eq!(isin_to_cusip(&de), Err(ConversionError::UnsupportedCountry));
}
#[test]
fn cusip_to_isin_apple() {
let cusip = Cusip::parse("037833100").unwrap();
assert_eq!(
cusip_to_isin(&cusip, "US").unwrap().as_str(),
"US0378331005"
);
}
#[test]
fn cusip_to_isin_rejects_unknown_country() {
let cusip = Cusip::parse("037833100").unwrap();
assert_eq!(
cusip_to_isin(&cusip, "ZZ"),
Err(ConversionError::UnsupportedCountry)
);
}
#[test]
fn cusip_isin_round_trip() {
for &s in &["US0378331005", "US5949181045"] {
let isin = Isin::parse(s).unwrap();
let cusip = isin_to_cusip(&isin).unwrap();
let back = cusip_to_isin(&cusip, isin.country_code()).unwrap();
assert_eq!(back, isin);
}
for &c in &["037833100", "594918104", "38259P508"] {
let cusip = Cusip::parse(c).unwrap();
let isin = cusip_to_isin(&cusip, "US").unwrap();
assert_eq!(isin_to_cusip(&isin).unwrap(), cusip);
}
}
#[test]
fn isin_to_sedol_bae() {
let isin = Isin::parse("GB0002634946").unwrap();
assert_eq!(isin_to_sedol(&isin).unwrap().as_str(), "0263494");
}
#[test]
fn isin_to_sedol_accepts_ireland() {
let isin = sedol_to_isin(&Sedol::parse("0263494").unwrap(), "IE").unwrap();
assert_eq!(isin.country_code(), "IE");
assert_eq!(isin_to_sedol(&isin).unwrap().as_str(), "0263494");
}
#[test]
fn isin_to_sedol_rejects_non_gb_ie() {
let isin = Isin::parse("US0378331005").unwrap();
assert_eq!(
isin_to_sedol(&isin),
Err(ConversionError::UnsupportedCountry)
);
}
#[test]
fn isin_to_sedol_rejects_missing_00_padding() {
let isin = build_isin("GB", "123456789").unwrap();
assert!(matches!(
isin_to_sedol(&isin),
Err(ConversionError::NotConvertible { .. })
));
}
#[test]
fn isin_to_sedol_rejects_invalid_sedol_body() {
let isin = build_isin("GB", "00B0WNLA7").unwrap();
assert!(matches!(
isin_to_sedol(&isin),
Err(ConversionError::Validation(_))
));
}
#[test]
fn sedol_to_isin_bae() {
let sedol = Sedol::parse("0263494").unwrap();
assert_eq!(
sedol_to_isin(&sedol, "GB").unwrap().as_str(),
"GB0002634946"
);
}
#[test]
fn sedol_to_isin_rejects_unknown_country() {
let sedol = Sedol::parse("0263494").unwrap();
assert_eq!(
sedol_to_isin(&sedol, "ZZ"),
Err(ConversionError::UnsupportedCountry)
);
}
#[test]
fn sedol_isin_round_trip() {
let isin = Isin::parse("GB0002634946").unwrap();
let sedol = isin_to_sedol(&isin).unwrap();
let back = sedol_to_isin(&sedol, isin.country_code()).unwrap();
assert_eq!(back, isin);
for &s in &["0263494", "0540528", "B0WNLY7"] {
let sedol = Sedol::parse(s).unwrap();
let isin = sedol_to_isin(&sedol, "GB").unwrap();
assert_eq!(isin_to_sedol(&isin).unwrap(), sedol);
}
}
#[test]
fn isin_to_valor_strips_leading_zeros() {
let isin = Isin::parse("CH0012138530").unwrap();
assert_eq!(isin_to_valor(&isin).unwrap().as_str(), "1213853");
}
#[test]
fn isin_to_valor_accepts_liechtenstein() {
let isin = valor_to_isin(&Valor::parse("1213853").unwrap(), "LI").unwrap();
assert_eq!(isin.country_code(), "LI");
assert_eq!(isin_to_valor(&isin).unwrap().as_str(), "1213853");
}
#[test]
fn isin_to_valor_all_zero_nsin_yields_zero() {
let isin = build_isin("CH", "000000000").unwrap();
let valor = isin_to_valor(&isin).unwrap();
assert_eq!(valor.as_str(), "0");
assert_eq!(valor.as_u64(), 0);
}
#[test]
fn isin_to_valor_rejects_non_ch_li() {
let isin = Isin::parse("US0378331005").unwrap();
assert_eq!(
isin_to_valor(&isin),
Err(ConversionError::UnsupportedCountry)
);
}
#[test]
fn isin_to_valor_rejects_non_numeric_nsin() {
let isin = build_isin("CH", "00ABC1234").unwrap();
assert!(matches!(
isin_to_valor(&isin),
Err(ConversionError::Validation(_))
));
}
#[test]
fn valor_to_isin_pads_to_nine_digits() {
let valor = Valor::parse("1213853").unwrap();
let isin = valor_to_isin(&valor, "CH").unwrap();
assert_eq!(isin.as_str(), "CH0012138530");
assert_eq!(isin.nsin(), "001213853");
}
#[test]
fn valor_to_isin_rejects_unknown_country() {
let valor = Valor::parse("1213853").unwrap();
assert_eq!(
valor_to_isin(&valor, "ZZ"),
Err(ConversionError::UnsupportedCountry)
);
}
#[test]
fn valor_isin_round_trip() {
let isin = Isin::parse("CH0012138530").unwrap();
let valor = isin_to_valor(&isin).unwrap();
let back = valor_to_isin(&valor, isin.country_code()).unwrap();
assert_eq!(back, isin);
for &v in &["1213853", "908440", "24476758", "123456789", "7"] {
let valor = Valor::parse(v).unwrap();
let isin = valor_to_isin(&valor, "CH").unwrap();
assert_eq!(isin_to_valor(&isin).unwrap(), valor);
}
}
#[test]
fn check_digit_schemes_are_independent() {
let cusip = Cusip::parse("037833100").unwrap();
assert_eq!(cusip.check_digit(), '0');
let isin = cusip_to_isin(&cusip, "US").unwrap();
assert_eq!(isin.check_digit(), '5');
}
#[test]
fn conversion_error_carries_validation_source() {
let isin = build_isin("GB", "000000000").unwrap();
match isin_to_sedol(&isin) {
Ok(s) => {
assert!(Sedol::validate(s.as_str()).is_ok());
}
Err(ConversionError::Validation(ValidationError::BadCheckDigit { .. })) => {}
Err(other) => panic!("unexpected error: {other}"),
}
}
#[test]
fn every_built_isin_reparses() {
for &(country, nsin) in &[
("US", "037833100"),
("GB", "000263494"),
("CH", "001213853"),
("XS", "174878390"),
] {
if let Ok(isin) = build_isin(country, nsin) {
assert!(Isin::validate(isin.as_str()).is_ok());
}
}
}
}