use std::str::FromStr;
use rust_decimal::Decimal;
use thiserror::Error;
use crate::{Currency, Money};
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct Parser {
assumed: Option<Currency>,
group_separator: char,
decimal_separator: char,
}
impl Parser {
#[must_use]
pub const fn new() -> Self {
Self {
assumed: None,
group_separator: ',',
decimal_separator: '.',
}
}
#[must_use]
pub const fn separators(mut self, group: char, decimal: char) -> Self {
self.group_separator = group;
self.decimal_separator = decimal;
self
}
#[must_use]
pub const fn assume_currency(mut self, currency: &Currency) -> Self {
self.assumed = Some(*currency);
self
}
pub fn parse(&self, text: &str) -> Result<Money, ParseMoneyError> {
let mut s = text.trim();
let mut negative = false;
if let Some(inner) = s.strip_prefix('(').and_then(|r| r.strip_suffix(')')) {
negative = true;
s = inner.trim();
}
if let Some(rest) = s.strip_prefix('-') {
if negative {
return Err(ParseMoneyError::InvalidAmount);
}
negative = true;
s = rest.trim_start();
}
let first = s
.find(|c: char| c.is_ascii_digit())
.ok_or(ParseMoneyError::InvalidAmount)?;
let last = s
.rfind(|c: char| c.is_ascii_digit())
.ok_or(ParseMoneyError::InvalidAmount)?;
let mut prefix = s[..first].trim_end();
let suffix = s[last + 1..].trim_start();
if let Some(stripped) = prefix.strip_suffix('-') {
if negative {
return Err(ParseMoneyError::InvalidAmount);
}
negative = true;
prefix = stripped.trim_end();
}
let currency = self.identify(prefix, suffix)?;
let amount = self.read_amount(&s[first..=last], negative)?;
Ok(Money::from_decimal(amount, ¤cy))
}
fn identify(&self, prefix: &str, suffix: &str) -> Result<Currency, ParseMoneyError> {
let mut currency = None;
let mut symbols = [None, None];
for (slot, token) in symbols.iter_mut().zip([prefix, suffix]) {
if token.is_empty() {
continue;
}
match Currency::from_alphabetic_code(&token.to_ascii_uppercase()) {
Some(coded) => {
if currency.is_some_and(|c| c != coded) {
return Err(ParseMoneyError::ConflictingCurrencies);
}
currency = Some(coded);
}
None => *slot = Some(token),
}
}
for token in symbols.into_iter().flatten() {
let claimed = currency
.or(self.assumed)
.filter(|c| c.symbol() == token)
.ok_or_else(|| ParseMoneyError::UnknownCurrency(token.to_string()))?;
currency = Some(claimed);
}
currency
.or(self.assumed)
.ok_or(ParseMoneyError::MissingCurrency)
}
fn read_amount(&self, span: &str, negative: bool) -> Result<Decimal, ParseMoneyError> {
let mut normalized = String::with_capacity(span.len() + 1);
if negative {
normalized.push('-');
}
let mut in_fraction = false;
for c in span.chars() {
if c.is_ascii_digit() {
normalized.push(c);
} else if c == self.decimal_separator && !in_fraction {
in_fraction = true;
normalized.push('.');
} else if c == self.group_separator && !in_fraction {
} else {
return Err(ParseMoneyError::InvalidAmount);
}
}
Decimal::from_str(&normalized).map_err(|_| ParseMoneyError::InvalidAmount)
}
}
impl Default for Parser {
fn default() -> Self {
Self::new()
}
}
impl FromStr for Money {
type Err = ParseMoneyError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Parser::new().parse(s)
}
}
#[derive(Clone, Debug, Error, Eq, PartialEq)]
#[non_exhaustive]
pub enum ParseMoneyError {
#[error("no currency named and none assumed")]
MissingCurrency,
#[error("unrecognized currency {0:?}")]
UnknownCurrency(String),
#[error("more than one currency named")]
ConflictingCurrencies,
#[error("malformed or unrepresentable amount")]
InvalidAmount,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Format;
use rust_decimal::prelude::*;
#[test]
fn code_suffix_test() {
let money: Money = "1,500.00 USD".parse().unwrap();
assert_eq!(money, Money::from_minor(150000, &Currency::USD));
}
#[test]
fn code_prefix_test() {
let money: Money = "USD 1,500.00".parse().unwrap();
assert_eq!(money, Money::from_minor(150000, &Currency::USD));
}
#[test]
fn code_without_space_test() {
assert_eq!(
"1.50USD".parse::<Money>().unwrap(),
Money::from_minor(150, &Currency::USD)
);
assert_eq!(
"USD1.50".parse::<Money>().unwrap(),
Money::from_minor(150, &Currency::USD)
);
}
#[test]
fn code_is_case_insensitive_test() {
assert_eq!(
"1.50 usd".parse::<Money>().unwrap(),
Money::from_minor(150, &Currency::USD)
);
}
#[test]
fn surrounding_whitespace_test() {
assert_eq!(
" 1.50 USD ".parse::<Money>().unwrap(),
Money::from_minor(150, &Currency::USD)
);
}
#[test]
fn negative_minus_placements_test() {
let expected = Money::from_minor(-150, &Currency::USD);
assert_eq!("-1.50 USD".parse::<Money>().unwrap(), expected);
assert_eq!("-USD 1.50".parse::<Money>().unwrap(), expected);
assert_eq!("USD -1.50".parse::<Money>().unwrap(), expected);
}
#[test]
fn negative_parentheses_test() {
assert_eq!(
"(1,500.00 USD)".parse::<Money>().unwrap(),
Money::from_minor(-150000, &Currency::USD)
);
}
#[test]
fn doubled_sign_is_invalid_test() {
assert_eq!(
"--1.50 USD".parse::<Money>(),
Err(ParseMoneyError::InvalidAmount)
);
assert_eq!(
"(-1.50 USD)".parse::<Money>(),
Err(ParseMoneyError::InvalidAmount)
);
}
#[test]
fn missing_currency_test() {
assert_eq!(
"1.50".parse::<Money>(),
Err(ParseMoneyError::MissingCurrency)
);
}
#[test]
fn unknown_currency_test() {
assert_eq!(
"1.50 ZZZ".parse::<Money>(),
Err(ParseMoneyError::UnknownCurrency("ZZZ".to_string()))
);
}
#[test]
fn conflicting_currencies_test() {
assert_eq!(
"EUR 1.50 USD".parse::<Money>(),
Err(ParseMoneyError::ConflictingCurrencies)
);
}
#[test]
fn symbol_agreeing_with_code_test() {
assert_eq!(
"$1.50 USD".parse::<Money>().unwrap(),
Money::from_minor(150, &Currency::USD)
);
assert_eq!(
"€1.50 USD".parse::<Money>(),
Err(ParseMoneyError::UnknownCurrency("€".to_string()))
);
}
#[test]
fn symbol_without_assumption_is_unknown_test() {
assert_eq!(
"$1.50".parse::<Money>(),
Err(ParseMoneyError::UnknownCurrency("$".to_string()))
);
}
#[test]
fn assumed_currency_test() {
let parser = Parser::new().assume_currency(&Currency::USD);
assert_eq!(
parser.parse("1.50").unwrap(),
Money::from_minor(150, &Currency::USD)
);
assert_eq!(
parser.parse("$1.50").unwrap(),
Money::from_minor(150, &Currency::USD)
);
assert_eq!(
parser.parse("$-1.50").unwrap(),
Money::from_minor(-150, &Currency::USD)
);
}
#[test]
fn code_overrides_assumed_currency_test() {
let parser = Parser::new().assume_currency(&Currency::USD);
assert_eq!(
parser.parse("1.50 EUR").unwrap(),
Money::from_minor(150, &Currency::EUR)
);
}
#[test]
fn wrong_symbol_for_assumed_currency_test() {
let parser = Parser::new().assume_currency(&Currency::EUR);
assert_eq!(
parser.parse("$1.50"),
Err(ParseMoneyError::UnknownCurrency("$".to_string()))
);
}
#[test]
fn separators_test() {
let parser = Parser::new().separators('.', ',');
assert_eq!(
parser.parse("1.500,00 EUR").unwrap(),
Money::from_minor(150000, &Currency::EUR)
);
}
#[test]
fn grouping_spacing_is_not_checked_test() {
assert_eq!(
"1,00,0.50 USD".parse::<Money>().unwrap(),
Money::from_decimal(dec!(1000.50), &Currency::USD)
);
}
#[test]
fn group_separator_in_fraction_is_invalid_test() {
assert_eq!(
"1.5,0 USD".parse::<Money>(),
Err(ParseMoneyError::InvalidAmount)
);
}
#[test]
fn second_decimal_mark_is_invalid_test() {
assert_eq!(
"1.5.0 USD".parse::<Money>(),
Err(ParseMoneyError::InvalidAmount)
);
}
#[test]
fn no_digits_is_invalid_test() {
assert_eq!("USD".parse::<Money>(), Err(ParseMoneyError::InvalidAmount));
assert_eq!("".parse::<Money>(), Err(ParseMoneyError::InvalidAmount));
}
#[test]
fn amount_beyond_decimal_range_is_invalid_test() {
assert_eq!(
"99999999999999999999999999999999999999 USD".parse::<Money>(),
Err(ParseMoneyError::InvalidAmount)
);
}
#[test]
fn digits_are_kept_verbatim_test() {
let money: Money = "1.2345 USD".parse().unwrap();
assert_eq!(money.amount(), dec!(1.2345));
}
#[test]
fn zero_minor_digit_currency_test() {
assert_eq!(
"5 JPY".parse::<Money>().unwrap(),
Money::from_major(5, &Currency::JPY)
);
}
#[test]
fn round_trips_format_renderings_test() {
let money = Money::from_minor(-1234567, &Currency::USD);
let parser = Parser::new().assume_currency(&Currency::USD);
for format in [
Format::new(),
Format::new().symbol(),
Format::new().amount_only(),
Format::new().prefix().no_space(),
Format::new().symbol().parentheses(),
Format::new().no_grouping(),
Format::new().grouping(&[3, 2]),
] {
let rendered = money.format_with(&format).to_string();
assert_eq!(
parser.parse(&rendered).unwrap(),
money,
"rendering {rendered:?}"
);
}
}
}