use crate::checkdigit;
use crate::errors::ValidationError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Figi {
bytes: [u8; Self::LENGTH],
}
impl Figi {
pub const LENGTH: usize = 12;
const FORBIDDEN_PREFIXES: [&'static [u8]; 7] =
[b"BS", b"BM", b"GG", b"GB", b"GH", b"KY", b"VG"];
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 < 2 {
ch.is_ascii_uppercase() && !is_vowel(ch)
} else if i == 2 {
ch.is_ascii()
} else if i == Self::LENGTH - 1 {
ch.is_ascii_digit()
} else {
ch.is_ascii_digit() || (ch.is_ascii_uppercase() && !is_vowel(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 prefix = &bytes[0..2];
if Self::FORBIDDEN_PREFIXES.contains(&prefix) {
return Err(ValidationError::Structure {
rule: "FIGI provider prefix must not be an ISIN country code",
});
}
if bytes[2] != b'G' {
return Err(ValidationError::Structure {
rule: "FIGI position 3 must be the letter G",
});
}
let body = core::str::from_utf8(&bytes[0..11]).unwrap_or("");
let expected = checkdigit::figi_check_digit(body)?;
let supplied = char::from(bytes[11]);
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 provider_prefix(&self) -> &str {
core::str::from_utf8(&self.bytes[0..3]).unwrap_or("")
}
#[must_use]
#[inline]
pub fn body(&self) -> &str {
core::str::from_utf8(&self.bytes[3..11]).unwrap_or("")
}
#[must_use]
#[inline]
pub fn check_digit(&self) -> char {
char::from(self.bytes[11])
}
#[must_use]
#[inline]
pub fn is_bloomberg(&self) -> bool {
&self.bytes[0..3] == b"BBG"
}
}
#[inline]
fn is_vowel(ch: char) -> bool {
matches!(ch, 'A' | 'E' | 'I' | 'O' | 'U')
}
impl core::fmt::Display for Figi {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(self.as_str())
}
}
impl core::str::FromStr for Figi {
type Err = ValidationError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
}
}
impl AsRef<str> for Figi {
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] = &[
"BBG000BLNNH6", "BBG000B9XRY4",
"BBG000BVPV84",
];
#[test]
fn parses_golden_figis() {
for &s in GOLDEN {
let figi = Figi::parse(s).unwrap_or_else(|e| panic!("{s} should parse: {e}"));
assert_eq!(figi.as_str(), s);
}
}
#[test]
fn segment_accessors() {
let figi = Figi::parse("BBG000BLNNH6").unwrap();
assert_eq!(figi.provider_prefix(), "BBG");
assert_eq!(figi.body(), "000BLNNH");
assert_eq!(figi.check_digit(), '6');
assert_eq!(figi.as_bytes(), b"BBG000BLNNH6");
assert_eq!(Figi::LENGTH, 12);
}
#[test]
fn is_bloomberg_detects_bbg_prefix() {
assert!(Figi::parse("BBG000BLNNH6").unwrap().is_bloomberg());
}
#[test]
fn rejects_bad_check_digit() {
assert_eq!(
Figi::parse("BBG000BLNNH5"),
Err(ValidationError::BadCheckDigit {
expected: '6',
found: '5',
})
);
}
#[test]
fn rejects_wrong_length() {
assert_eq!(
Figi::parse("BBG000BLNNH"),
Err(ValidationError::WrongLength {
expected: 12,
found: 11,
})
);
assert_eq!(
Figi::parse(""),
Err(ValidationError::WrongLength {
expected: 12,
found: 0,
})
);
}
#[test]
fn rejects_forbidden_prefix() {
assert_eq!(
Figi::parse("BSG000BLNNH6"),
Err(ValidationError::Structure {
rule: "FIGI provider prefix must not be an ISIN country code",
})
);
}
#[test]
fn rejects_non_g_third_character() {
assert!(matches!(
Figi::parse("BBA000BLNNH6"),
Err(ValidationError::Structure { .. })
));
}
#[test]
fn rejects_vowel_in_body() {
assert!(matches!(
Figi::parse("BBG00OBLNNH6"),
Err(ValidationError::InvalidCharacter { position: 6, .. })
));
}
#[test]
fn rejects_vowel_in_prefix() {
assert!(matches!(
Figi::parse("ABG000BLNNH6"),
Err(ValidationError::InvalidCharacter { position: 1, .. })
));
}
#[test]
fn rejects_lower_case() {
assert!(matches!(
Figi::parse("bbg000blnnh6"),
Err(ValidationError::InvalidCharacter { position: 1, .. })
));
}
#[test]
fn rejects_non_digit_check_position() {
assert!(matches!(
Figi::parse("BBG000BLNNHX"),
Err(ValidationError::InvalidCharacter { position: 12, .. })
));
}
#[test]
fn rejects_non_ascii_without_panic() {
assert!(Figi::parse("BBG000BLNNHé").is_err());
assert!(Figi::parse("ÉBG000BLNNH6").is_err());
}
#[test]
fn round_trips_through_str() {
for &s in GOLDEN {
assert_eq!(Figi::parse(s).unwrap().as_str(), s);
}
}
#[test]
fn from_str_matches_parse() {
assert_eq!(Figi::from_str("BBG000BLNNH6"), Figi::parse("BBG000BLNNH6"));
assert!(Figi::from_str("nonsense").is_err());
}
#[test]
fn display_renders_identifier() {
let figi = Figi::parse("BBG000BLNNH6").unwrap();
assert_eq!(display(figi).as_str(), "BBG000BLNNH6");
}
#[test]
fn as_ref_str() {
let figi = Figi::parse("BBG000BLNNH6").unwrap();
let s: &str = figi.as_ref();
assert_eq!(s, "BBG000BLNNH6");
}
#[test]
fn validate_agrees_with_parse() {
assert!(Figi::validate("BBG000BLNNH6").is_ok());
assert!(Figi::validate("BBG000BLNNH5").is_err());
}
#[test]
fn from_bytes_unchecked_round_trip() {
let figi = Figi::from_bytes_unchecked(*b"BBG000BLNNH6");
assert_eq!(figi, Figi::parse("BBG000BLNNH6").unwrap());
}
#[test]
fn is_copy_and_eq_and_hashable() {
let a = Figi::parse("BBG000BLNNH6").unwrap();
let b = a; assert_eq!(a, b);
assert_ne!(a, Figi::parse("BBG000B9XRY4").unwrap());
let keys = [a, b];
assert_eq!(keys[0], keys[1]);
}
}