use std::fmt;
use std::str::FromStr;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum CurrencyError {
#[error("{value:?} is not three letters, so it is not an ISO 4217 code")]
Malformed {
value: String,
},
#[error("{code} is a reserved ISO 4217 code and names no currency")]
NotSpendable {
code: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Currency([u8; 3]);
#[cfg(feature = "serde")]
impl serde::Serialize for Currency {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(self.code())
}
}
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for Currency {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = <std::borrow::Cow<'de, str> as serde::Deserialize>::deserialize(d)?;
Self::new(&s).map_err(serde::de::Error::custom)
}
}
const NOT_SPENDABLE: [&[u8; 3]; 6] = [b"XAG", b"XAU", b"XPD", b"XPT", b"XTS", b"XXX"];
impl Currency {
pub const EUR: Self = Self(*b"EUR");
pub fn new(code: &str) -> Result<Self, CurrencyError> {
let [a, b, c] = *code.as_bytes() else {
return Err(CurrencyError::Malformed {
value: code.to_owned(),
});
};
if ![a, b, c].iter().all(u8::is_ascii_alphabetic) {
return Err(CurrencyError::Malformed {
value: code.to_owned(),
});
}
let upper = [
a.to_ascii_uppercase(),
b.to_ascii_uppercase(),
c.to_ascii_uppercase(),
];
if NOT_SPENDABLE.binary_search(&&upper).is_ok() {
return Err(CurrencyError::NotSpendable {
code: String::from_utf8_lossy(&upper).into_owned(),
});
}
Ok(Self(upper))
}
#[must_use]
pub fn code(&self) -> &str {
std::str::from_utf8(&self.0).unwrap_or("???")
}
#[must_use]
pub fn is_euro(&self) -> bool {
self.0 == *b"EUR"
}
}
impl fmt::Display for Currency {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.code())
}
}
impl FromStr for Currency {
type Err = CurrencyError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::new(s)
}
}
impl TryFrom<&str> for Currency {
type Error = CurrencyError;
fn try_from(s: &str) -> Result<Self, Self::Error> {
Self::new(s)
}
}
impl Default for Currency {
fn default() -> Self {
Self::EUR
}
}
#[cfg(test)]
mod tests {
use super::{Currency, CurrencyError, NOT_SPENDABLE};
#[test]
fn the_placeholder_table_is_sorted_for_binary_search() {
assert!(
NOT_SPENDABLE.windows(2).all(|w| w[0] < w[1]),
"the table must stay sorted or the binary search silently misses"
);
}
#[test]
fn three_letters_in_any_case_are_accepted_and_normalised() {
for s in ["USD", "usd", "Usd", "uSD"] {
assert_eq!(Currency::new(s).unwrap().code(), "USD", "{s}");
}
}
#[test]
fn anything_that_is_not_three_ascii_letters_is_malformed() {
for bad in ["", "US", "EURO", "US1", "€UR", "ÄÖÜ", " EU", "EU "] {
assert!(
matches!(Currency::new(bad), Err(CurrencyError::Malformed { .. })),
"{bad:?} must be malformed"
);
}
}
#[test]
fn reserved_codes_are_refused_with_their_own_error() {
for code in ["XXX", "xxx", "XTS", "XAU", "XAG", "XPT", "XPD"] {
assert!(
matches!(Currency::new(code), Err(CurrencyError::NotSpendable { .. })),
"{code} must be refused as a placeholder"
);
}
}
#[test]
fn euro_is_recognised_however_it_was_built() {
assert!(Currency::EUR.is_euro());
assert!(Currency::new("eur").unwrap().is_euro());
assert!(Currency::default().is_euro());
assert!(!Currency::new("USD").unwrap().is_euro());
assert_eq!(Currency::EUR.to_string(), "EUR");
}
#[cfg(feature = "serde")]
#[test]
fn serde_round_trips_as_a_string_and_still_validates() {
let usd = Currency::new("USD").unwrap();
let json = serde_json::to_string(&usd).unwrap();
assert_eq!(json, "\"USD\"");
assert_eq!(serde_json::from_str::<Currency>(&json).unwrap(), usd);
for bad in ["\"XXX\"", "\"EURO\"", "\"\"", "[85,83,68]"] {
assert!(
serde_json::from_str::<Currency>(bad).is_err(),
"{bad} must not deserialise into a Currency"
);
}
assert_eq!(
serde_json::from_str::<Currency>("\"chf\"").unwrap(),
Currency::new("CHF").unwrap()
);
}
#[test]
fn a_three_letter_code_never_panics_on_display() {
for a in b'A'..=b'Z' {
for b in b'A'..=b'Z' {
for c in b'A'..=b'Z' {
let s = String::from_utf8(vec![a, b, c]).unwrap();
if let Ok(cur) = Currency::new(&s) {
assert_eq!(cur.code(), s);
}
}
}
}
}
}