use std::{
cmp::Ordering,
fmt::{Debug, Display},
str::FromStr,
};
use miette::Diagnostic;
use thiserror::Error;
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub struct IsoNumericCode(u32);
impl IsoNumericCode {
#[must_use]
pub fn value(self) -> u32 {
self.0
}
}
impl Display for IsoNumericCode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:03}", self.0)
}
}
impl From<IsoNumericCode> for u32 {
fn from(code: IsoNumericCode) -> Self {
code.0
}
}
impl TryFrom<u32> for IsoNumericCode {
type Error = IsoNumericCodeError;
fn try_from(code: u32) -> Result<Self, Self::Error> {
if code > 999 {
return Err(IsoNumericCodeError::InvalidCode { code });
}
Ok(Self(code))
}
}
#[derive(Clone, Copy, Eq, PartialEq, Hash, PartialOrd, Ord)]
pub struct IsoAlphabeticCode([u8; 3]);
impl IsoAlphabeticCode {
#[must_use]
#[expect(
clippy::missing_panics_doc,
reason = "the stored bytes are always valid UTF-8"
)]
pub fn as_str(&self) -> &str {
std::str::from_utf8(&self.0).expect("only ASCII letters are ever stored")
}
}
impl TryFrom<[u8; 3]> for IsoAlphabeticCode {
type Error = IsoAlphabeticCodeError;
fn try_from(code: [u8; 3]) -> Result<Self, Self::Error> {
if !code.iter().all(u8::is_ascii_uppercase) {
return Err(IsoAlphabeticCodeError::invalid(&code));
}
Ok(Self(code))
}
}
impl TryFrom<&str> for IsoAlphabeticCode {
type Error = IsoAlphabeticCodeError;
fn try_from(code: &str) -> Result<Self, Self::Error> {
let bytes: [u8; 3] = code
.as_bytes()
.try_into()
.map_err(|_| IsoAlphabeticCodeError::invalid(code.as_bytes()))?;
Self::try_from(bytes)
}
}
impl FromStr for IsoAlphabeticCode {
type Err = IsoAlphabeticCodeError;
fn from_str(code: &str) -> Result<Self, Self::Err> {
Self::try_from(code)
}
}
impl AsRef<str> for IsoAlphabeticCode {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl From<IsoAlphabeticCode> for [u8; 3] {
fn from(code: IsoAlphabeticCode) -> Self {
code.0
}
}
impl Display for IsoAlphabeticCode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl Debug for IsoAlphabeticCode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "IsoAlphabeticCode({self})")
}
}
#[derive(Clone, Copy, Eq, PartialEq, Hash)]
pub struct Currency {
alphabetic_code: IsoAlphabeticCode,
numeric_code: IsoNumericCode,
minor_digits: u32,
symbol: &'static str,
}
impl Currency {
#[must_use]
pub fn alphabetic_code(self) -> IsoAlphabeticCode {
self.alphabetic_code
}
#[must_use]
pub fn numeric_code(self) -> IsoNumericCode {
self.numeric_code
}
#[must_use]
pub fn minor_digits(self) -> u32 {
self.minor_digits
}
#[must_use]
pub fn symbol(self) -> &'static str {
self.symbol
}
}
include!(concat!(env!("OUT_DIR"), "/iso_currencies.rs"));
impl Display for Currency {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.alphabetic_code)
}
}
impl Debug for Currency {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Currency({self})")
}
}
impl Ord for Currency {
fn cmp(&self, other: &Self) -> Ordering {
self.alphabetic_code.cmp(&other.alphabetic_code)
}
}
impl PartialOrd for Currency {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl From<Currency> for IsoAlphabeticCode {
fn from(currency: Currency) -> Self {
currency.alphabetic_code
}
}
impl From<Currency> for IsoNumericCode {
fn from(currency: Currency) -> Self {
currency.numeric_code
}
}
impl TryFrom<IsoAlphabeticCode> for Currency {
type Error = CurrencyError;
fn try_from(code: IsoAlphabeticCode) -> Result<Self, Self::Error> {
Currency::from_alphabetic_code(code.as_str()).ok_or_else(|| {
CurrencyError::UnknownAlphabeticCode {
code: code.as_str().to_owned(),
}
})
}
}
impl TryFrom<IsoNumericCode> for Currency {
type Error = CurrencyError;
fn try_from(code: IsoNumericCode) -> Result<Self, Self::Error> {
Currency::from_numeric_code(code.0)
.ok_or(CurrencyError::UnknownNumericCode { code: code.0 })
}
}
impl FromStr for Currency {
type Err = ParseCurrencyError;
fn from_str(code: &str) -> Result<Self, Self::Err> {
let code = IsoAlphabeticCode::try_from(code)
.map_err(|source| ParseCurrencyError::Code { source })?;
Currency::try_from(code).map_err(|source| ParseCurrencyError::UnknownCurrency { source })
}
}
impl TryFrom<&str> for Currency {
type Error = ParseCurrencyError;
fn try_from(code: &str) -> Result<Self, Self::Error> {
code.parse()
}
}
#[derive(Clone, Debug, Diagnostic, Error, Eq, PartialEq)]
#[diagnostic(url(docsrs))]
#[non_exhaustive]
pub enum CurrencyError {
#[error("no ISO 4217 currency uses the code {code:?}")]
#[diagnostic(
code(lucre::currency::unknown_alphabetic_code),
help("`Currency::all()` lists every currency this crate knows")
)]
#[non_exhaustive]
UnknownAlphabeticCode {
code: String,
},
#[error("no ISO 4217 currency uses the number {code}")]
#[diagnostic(
code(lucre::currency::unknown_numeric_code),
help("`Currency::all()` lists every currency this crate knows")
)]
#[non_exhaustive]
UnknownNumericCode {
code: u32,
},
}
#[derive(Clone, Debug, Diagnostic, Error, Eq, PartialEq)]
#[diagnostic(url(docsrs))]
#[non_exhaustive]
pub enum ParseCurrencyError {
#[error("not an ISO 4217 code")]
#[diagnostic(code(lucre::currency::parse::code), forward(source))]
#[non_exhaustive]
Code {
source: IsoAlphabeticCodeError,
},
#[error("names no ISO 4217 currency")]
#[diagnostic(code(lucre::currency::parse::unknown_currency), forward(source))]
#[non_exhaustive]
UnknownCurrency {
source: CurrencyError,
},
}
#[derive(Clone, Debug, Diagnostic, Error, Eq, PartialEq)]
#[diagnostic(url(docsrs))]
#[non_exhaustive]
pub enum IsoAlphabeticCodeError {
#[error("an alphabetic currency code is three capital letters, but got {code:?}")]
#[diagnostic(
code(lucre::currency::alphabetic_code::invalid),
help("codes are written in capitals, as in `USD` or `EUR`")
)]
#[non_exhaustive]
InvalidCode {
code: String,
},
}
impl IsoAlphabeticCodeError {
fn invalid(code: &[u8]) -> Self {
let code = std::str::from_utf8(code)
.map_or_else(|_| code.escape_ascii().to_string(), str::to_owned);
Self::InvalidCode { code }
}
}
#[derive(Clone, Copy, Debug, Diagnostic, Error, Eq, PartialEq)]
#[diagnostic(url(docsrs))]
#[non_exhaustive]
pub enum IsoNumericCodeError {
#[error("a numeric currency code is at most 999, but got {code}")]
#[diagnostic(
code(lucre::currency::numeric_code::invalid),
help("codes run from 0 to 999, as in `840` for `USD`")
)]
#[non_exhaustive]
InvalidCode {
code: u32,
},
}
#[cfg(test)]
mod tests {
use std::error::Error as _;
use super::*;
#[test]
fn currency_accessors_test() {
let currency = Currency::USD;
assert_eq!(currency.alphabetic_code().as_str(), "USD");
assert_eq!(currency.numeric_code().value(), 840);
assert_eq!(currency.minor_digits(), 2);
assert_eq!(currency.symbol(), "$");
}
#[test]
fn currency_lookup_test() {
assert_eq!(Currency::from_alphabetic_code("USD"), Some(Currency::USD));
assert_eq!(Currency::from_alphabetic_code("ZZZ"), None);
assert_eq!(Currency::from_numeric_code(978), Some(Currency::EUR));
assert_eq!(Currency::from_numeric_code(1), None);
}
#[test]
fn currency_catalog_test() {
assert!(Currency::all().contains(&Currency::USD));
assert!(Currency::all().contains(&Currency::XAU));
assert_eq!(Currency::BHD.minor_digits(), 3);
assert_eq!(Currency::XAU.minor_digits(), 0);
}
#[test]
fn alphabetic_code_try_from_bytes_test() {
assert_eq!(
IsoAlphabeticCode::try_from(*b"USD"),
Ok(Currency::USD.alphabetic_code())
);
assert_eq!(
IsoAlphabeticCode::try_from(*b"usd"),
Err(IsoAlphabeticCodeError::InvalidCode {
code: "usd".to_owned()
})
);
assert_eq!(
IsoAlphabeticCode::try_from(*b"840"),
Err(IsoAlphabeticCodeError::InvalidCode {
code: "840".to_owned()
})
);
assert_eq!(
IsoAlphabeticCode::try_from([0xC3, 0xA9, b'A']),
Err(IsoAlphabeticCodeError::InvalidCode {
code: "éA".to_owned()
})
);
assert_eq!(
IsoAlphabeticCode::try_from([0xFF, 0xFE, 0xFD]),
Err(IsoAlphabeticCodeError::InvalidCode {
code: r"\xff\xfe\xfd".to_owned()
})
);
assert_eq!(
IsoAlphabeticCode::try_from([0xFF, 0xFF, 0xFF]),
Err(IsoAlphabeticCodeError::InvalidCode {
code: r"\xff\xff\xff".to_owned()
})
);
}
#[test]
fn alphabetic_code_try_from_str_test() {
assert_eq!(
IsoAlphabeticCode::try_from("USD"),
Ok(Currency::USD.alphabetic_code())
);
assert_eq!("USD".parse(), Ok(Currency::USD.alphabetic_code()));
assert_eq!(
"US".parse::<IsoAlphabeticCode>(),
Err(IsoAlphabeticCodeError::InvalidCode {
code: "US".to_owned()
})
);
assert_eq!(
"USDD".parse::<IsoAlphabeticCode>(),
Err(IsoAlphabeticCodeError::InvalidCode {
code: "USDD".to_owned()
})
);
assert_eq!(
"€UR".parse::<IsoAlphabeticCode>(),
Err(IsoAlphabeticCodeError::InvalidCode {
code: "€UR".to_owned()
})
);
}
#[test]
fn code_error_messages_quote_the_input_test() {
let alphabetic = IsoAlphabeticCode::try_from("us").unwrap_err();
let numeric = IsoNumericCode::try_from(1000).unwrap_err();
assert_eq!(
alphabetic.to_string(),
"an alphabetic currency code is three capital letters, but got \"us\""
);
assert_eq!(
numeric.to_string(),
"a numeric currency code is at most 999, but got 1000"
);
}
#[test]
fn code_conversions_test() {
let currency = Currency::USD;
assert_eq!(
IsoAlphabeticCode::from(currency),
currency.alphabetic_code()
);
assert_eq!(IsoNumericCode::from(currency), currency.numeric_code());
assert_eq!(<[u8; 3]>::from(currency.alphabetic_code()), *b"USD");
assert_eq!(u32::from(currency.numeric_code()), 840);
assert_eq!(currency.alphabetic_code().as_ref() as &str, "USD");
}
#[test]
fn numeric_code_try_from_u32_test() {
assert_eq!(
IsoNumericCode::try_from(840),
Ok(Currency::USD.numeric_code())
);
assert_eq!(
IsoNumericCode::try_from(0).map(IsoNumericCode::value),
Ok(0)
);
assert_eq!(
IsoNumericCode::try_from(999).map(IsoNumericCode::value),
Ok(999)
);
assert_eq!(
IsoNumericCode::try_from(1000),
Err(IsoNumericCodeError::InvalidCode { code: 1000 })
);
}
#[test]
fn every_numeric_code_round_trips_through_u32_test() {
for currency in Currency::all() {
let code = currency.numeric_code();
assert_eq!(IsoNumericCode::try_from(code.value()), Ok(code));
}
}
#[test]
fn every_alphabetic_code_round_trips_through_bytes_test() {
for currency in Currency::all() {
let code = currency.alphabetic_code();
let bytes: [u8; 3] = code.into();
assert_eq!(IsoAlphabeticCode::try_from(bytes), Ok(code));
}
}
#[test]
fn currency_try_from_codes_test() {
let usd = Currency::USD.alphabetic_code();
assert_eq!(Currency::try_from(usd), Ok(Currency::USD));
assert_eq!(
Currency::try_from(Currency::EUR.numeric_code()),
Ok(Currency::EUR)
);
let unassigned = IsoAlphabeticCode::try_from(*b"ZZZ").unwrap();
assert_eq!(
Currency::try_from(unassigned),
Err(CurrencyError::UnknownAlphabeticCode {
code: "ZZZ".to_owned()
})
);
assert_eq!(
Currency::try_from(IsoNumericCode::try_from(1).unwrap()),
Err(CurrencyError::UnknownNumericCode { code: 1 })
);
}
#[test]
fn currency_from_str_test() {
assert_eq!("USD".parse(), Ok(Currency::USD));
assert_eq!(Currency::try_from("EUR"), Ok(Currency::EUR));
}
#[test]
fn currency_from_str_keeps_a_misspelled_code_from_an_unassigned_one_test() {
assert_eq!(
"ZZZ".parse::<Currency>(),
Err(ParseCurrencyError::UnknownCurrency {
source: CurrencyError::UnknownAlphabeticCode {
code: "ZZZ".to_owned()
}
})
);
assert_eq!(
"usd".parse::<Currency>(),
Err(ParseCurrencyError::Code {
source: IsoAlphabeticCodeError::InvalidCode {
code: "usd".to_owned()
}
})
);
}
#[test]
fn currency_refusal_quotes_the_code_back_test() {
let misspelled = "usd".parse::<Currency>().unwrap_err();
let unassigned = "ZZZ".parse::<Currency>().unwrap_err();
let unassigned_number =
Currency::try_from(IsoNumericCode::try_from(1).unwrap()).unwrap_err();
assert_eq!(
misspelled.source().unwrap().to_string(),
r#"an alphabetic currency code is three capital letters, but got "usd""#
);
assert_eq!(
unassigned.source().unwrap().to_string(),
r#"no ISO 4217 currency uses the code "ZZZ""#
);
assert_eq!(
unassigned_number.to_string(),
"no ISO 4217 currency uses the number 1"
);
}
#[test]
fn currency_display_round_trips_through_from_str_test() {
for currency in Currency::all() {
assert_eq!(currency.to_string().parse(), Ok(*currency));
}
}
#[test]
fn debug_spells_a_code_test() {
assert_eq!(
format!("{:?}", Currency::USD.alphabetic_code()),
"IsoAlphabeticCode(USD)"
);
}
#[test]
fn debug_spells_a_currency_test() {
assert_eq!(format!("{:?}", Currency::USD), "Currency(USD)");
}
}