use crate::errors::ValidationError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Cfi {
bytes: [u8; Self::LENGTH],
}
impl Cfi {
pub const LENGTH: usize = 6;
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() {
if !ch.is_ascii_uppercase() {
return Err(ValidationError::InvalidCharacter {
position: i + 1,
found: ch,
});
}
}
let mut bytes = [0u8; Self::LENGTH];
bytes.copy_from_slice(s.as_bytes());
if category_name_of(bytes[0]).is_none() {
return Err(ValidationError::Structure {
rule: "CFI category must be one of E C D R O F S H I J K L T M",
});
}
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 category(&self) -> char {
char::from(self.bytes[0])
}
#[must_use]
#[inline]
pub fn category_name(&self) -> &'static str {
category_name_of(self.bytes[0]).unwrap_or("")
}
#[must_use]
#[inline]
pub fn group(&self) -> char {
char::from(self.bytes[1])
}
#[must_use]
#[inline]
pub fn attributes(&self) -> &str {
core::str::from_utf8(&self.bytes[2..6]).unwrap_or("")
}
}
fn category_name_of(b: u8) -> Option<&'static str> {
match b {
b'E' => Some("Equities"),
b'C' => Some("Collective investment vehicles"),
b'D' => Some("Debt instruments"),
b'R' => Some("Entitlements (rights)"),
b'O' => Some("Listed options"),
b'F' => Some("Futures"),
b'S' => Some("Swaps"),
b'H' => Some("Non-listed and complex listed options"),
b'I' => Some("Spot"),
b'J' => Some("Forwards"),
b'K' => Some("Strategies"),
b'L' => Some("Financing"),
b'T' => Some("Referential instruments"),
b'M' => Some("Others (miscellaneous)"),
_ => None,
}
}
impl core::fmt::Display for Cfi {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(self.as_str())
}
}
impl core::str::FromStr for Cfi {
type Err = ValidationError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
}
}
impl AsRef<str> for Cfi {
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] = &[
"ESVUFR", "DBFUGR", "OCASPS", "CIOIES", ];
const CATEGORY_VECTORS: &[(&str, char, &str)] = &[
("EXXXXX", 'E', "Equities"),
("CXXXXX", 'C', "Collective investment vehicles"),
("DXXXXX", 'D', "Debt instruments"),
("RXXXXX", 'R', "Entitlements (rights)"),
("OXXXXX", 'O', "Listed options"),
("FXXXXX", 'F', "Futures"),
("SXXXXX", 'S', "Swaps"),
("HXXXXX", 'H', "Non-listed and complex listed options"),
("IXXXXX", 'I', "Spot"),
("JXXXXX", 'J', "Forwards"),
("KXXXXX", 'K', "Strategies"),
("LXXXXX", 'L', "Financing"),
("TXXXXX", 'T', "Referential instruments"),
("MXXXXX", 'M', "Others (miscellaneous)"),
];
#[test]
fn parses_golden_cfis() {
for &s in GOLDEN {
let cfi = Cfi::parse(s).unwrap_or_else(|e| panic!("{s} should parse: {e}"));
assert_eq!(cfi.as_str(), s);
}
}
#[test]
fn segment_accessors() {
let cfi = Cfi::parse("ESVUFR").unwrap();
assert_eq!(cfi.category(), 'E');
assert_eq!(cfi.category_name(), "Equities");
assert_eq!(cfi.group(), 'S');
assert_eq!(cfi.attributes(), "VUFR");
assert_eq!(cfi.as_bytes(), b"ESVUFR");
assert_eq!(Cfi::LENGTH, 6);
}
#[test]
fn accepts_all_category_letters() {
for &(s, letter, name) in CATEGORY_VECTORS {
let cfi = Cfi::parse(s).unwrap_or_else(|e| panic!("{s} should parse: {e}"));
assert_eq!(cfi.category(), letter);
assert_eq!(cfi.category_name(), name);
}
}
#[test]
fn accepts_any_group_and_attributes() {
let cfi = Cfi::parse("EZZZZZ").unwrap();
assert_eq!(cfi.group(), 'Z');
assert_eq!(cfi.attributes(), "ZZZZ");
}
#[test]
fn rejects_wrong_length() {
assert_eq!(
Cfi::parse("ESVUF"),
Err(ValidationError::WrongLength {
expected: 6,
found: 5,
})
);
assert_eq!(
Cfi::parse("ESVUFRR"),
Err(ValidationError::WrongLength {
expected: 6,
found: 7,
})
);
assert_eq!(
Cfi::parse(""),
Err(ValidationError::WrongLength {
expected: 6,
found: 0,
})
);
}
#[test]
fn rejects_digit() {
assert!(matches!(
Cfi::parse("ESVUF1"),
Err(ValidationError::InvalidCharacter { position: 6, .. })
));
}
#[test]
fn rejects_lower_case() {
assert!(matches!(
Cfi::parse("esvufr"),
Err(ValidationError::InvalidCharacter { position: 1, .. })
));
}
#[test]
fn rejects_unknown_category() {
assert_eq!(
Cfi::parse("QSVUFR"),
Err(ValidationError::Structure {
rule: "CFI category must be one of E C D R O F S H I J K L T M",
})
);
}
#[test]
fn rejects_non_ascii_without_panic() {
assert!(Cfi::parse("ESVUFÉ").is_err());
assert!(Cfi::parse("ÉSVUFR").is_err());
}
#[test]
fn round_trips_through_str() {
for &s in GOLDEN {
assert_eq!(Cfi::parse(s).unwrap().as_str(), s);
}
}
#[test]
fn from_str_matches_parse() {
assert_eq!(Cfi::from_str("ESVUFR"), Cfi::parse("ESVUFR"));
assert!(Cfi::from_str("nonsense").is_err());
}
#[test]
fn display_renders_identifier() {
let cfi = Cfi::parse("ESVUFR").unwrap();
assert_eq!(display(cfi).as_str(), "ESVUFR");
}
#[test]
fn as_ref_str() {
let cfi = Cfi::parse("ESVUFR").unwrap();
let s: &str = cfi.as_ref();
assert_eq!(s, "ESVUFR");
}
#[test]
fn from_bytes_unchecked_round_trip() {
let cfi = Cfi::from_bytes_unchecked(*b"ESVUFR");
assert_eq!(cfi, Cfi::parse("ESVUFR").unwrap());
}
#[test]
fn validate_matches_parse() {
assert!(Cfi::validate("ESVUFR").is_ok());
assert!(Cfi::validate("QSVUFR").is_err());
assert!(Cfi::validate("ESVUF").is_err());
}
#[test]
fn is_copy_and_eq_and_hashable() {
let a = Cfi::parse("ESVUFR").unwrap();
let b = a; assert_eq!(a, b);
assert_ne!(a, Cfi::parse("DBFUGR").unwrap());
let keys = [a, b];
assert_eq!(keys[0], keys[1]);
}
}