null-kane 3.0.0

currency crate with the option to add your own currency localization logic
Documentation
pub mod calculation;
pub mod constructor;

use std::{cmp::Ordering, num::ParseIntError};

#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};

/// A trait for defining currency localization methods.
pub trait CurrencyLocale {
    /// Retrieves the separator used for the currency.
    fn separator(&self) -> char;
    /// Retrieves the thousand separator used for the currency.
    fn thousand_separator(&self) -> char;
    /// Retrieves the currency symbol.
    fn currency_symbol(&self) -> &'static str;
}

/// Represents a currency value with specified localization.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
#[cfg_attr(feature = "serde", derive(Deserialize, Serialize))]
pub struct Currency<L: CurrencyLocale> {
    negative: bool,
    amount: usize,
    locale: L,
}

impl<L: CurrencyLocale + PartialEq> PartialOrd for Currency<L> {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
        if self.locale != other.locale {
            return None;
        }

        match (
            self.negative,
            other.negative,
            self.amount.cmp(&other.amount),
        ) {
            (true, false, Ordering::Less)
            | (true, false, Ordering::Equal)
            | (true, false, Ordering::Greater)
            | (false, false, Ordering::Less)
            | (true, true, Ordering::Greater) => Some(Ordering::Less),

            (false, false, Ordering::Equal) | (true, true, Ordering::Equal) => {
                Some(Ordering::Equal)
            }

            (false, true, Ordering::Less)
            | (false, true, Ordering::Equal)
            | (false, false, Ordering::Greater)
            | (false, true, Ordering::Greater)
            | (true, true, Ordering::Less) => Some(Ordering::Greater),
        }
    }
}

impl<L> std::fmt::Display for Currency<L>
where
    L: CurrencyLocale,
{
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let mut buffer = self.full().to_string();
        if buffer.len() > 3 {
            let len = buffer.len() - 2;
            for idx in (1..len).rev().step_by(3) {
                buffer.insert(idx, self.locale.thousand_separator());
            }
        }
        if self.negative {
            write!(f, "-")?;
        }
        write!(
            f,
            "{}{}{:02} {}",
            buffer,
            self.locale.separator(),
            self.part(),
            self.locale.currency_symbol()
        )
    }
}

impl<L: CurrencyLocale> Currency<L> {
    /// Constructs a new Currency instance.
    ///
    /// # Arguments
    ///
    /// * `negative` - Indicates if the currency value is negative.
    /// * `value` - Indicates the 100th of a curreny, for example 1099 would be 10.99
    /// * `locale` - The localization information for the currency.
    ///
    /// # Returns
    ///
    /// A new `Currency` instance.
    #[must_use]
    pub fn new(negative: bool, amount: usize, locale: L) -> Self {
        Self {
            negative,
            amount,
            locale,
        }
    }

    /// Updates the localization information of the currency.
    ///
    /// # Arguments
    ///
    /// * `locale` - The updated localization information.
    ///
    /// # Returns
    ///
    /// The updated `Currency` instance with the new localization.
    #[must_use]
    pub fn with_locale(mut self, locale: L) -> Self {
        self.locale = locale;
        self
    }

    /// returns the full value of the currency
    #[inline]
    pub fn full(&self) -> usize {
        self.amount / 100
    }

    /// returns the fraction value of the currency
    #[inline]
    pub fn part(&self) -> usize {
        self.amount % 100
    }

    /// returns the current value as integer
    #[inline]
    pub fn amount(&self) -> isize {
        let amount = self.amount as isize;
        if self.negative {
            -amount
        } else {
            amount
        }
    }

    /// Parses a currency given a local.
    ///
    /// # Panics
    ///
    /// This panics if the seperators or the currency symbold don't match the given local or if the
    /// number could not parsed to a isizes
    pub fn parse(value: impl AsRef<str>, locale: L) -> Result<Self, CurrencyParseError>
    where
        L: Default,
    {
        let val = value.as_ref();
        if !val.contains(locale.currency_symbol()) {
            Err(CurrencyParseError::LocaleNotMatching)
        } else {
            let value = val
                .chars()
                .filter(|&c| {
                    !(locale.currency_symbol().contains(c)
                        || c == locale.separator()
                        || c == locale.thousand_separator()
                        || c.is_whitespace())
                })
                .collect::<String>()
                .parse::<isize>()
                .map_err(|e| CurrencyParseError::ParseValue(e))?;

            Ok(Currency::from(value).with_locale(locale))
        }
    }
}

#[derive(Clone, Debug)]
pub enum CurrencyParseError {
    LocaleNotMatching,
    ParseValue(ParseIntError),
}

#[cfg(test)]
mod test {
    use super::*;

    #[derive(Clone, Copy, Default, Debug, PartialEq)]
    enum CurrencyL {
        #[default]
        Eu,
        Us,
    }

    impl CurrencyLocale for CurrencyL {
        fn separator(&self) -> char {
            match self {
                CurrencyL::Eu => ',',
                CurrencyL::Us => '.',
            }
        }

        fn thousand_separator(&self) -> char {
            match self {
                CurrencyL::Eu => '.',
                CurrencyL::Us => ',',
            }
        }

        fn currency_symbol(&self) -> &'static str {
            match self {
                CurrencyL::Eu => "",
                CurrencyL::Us => "$",
            }
        }
    }

    #[test]
    fn parse_currency() {
        let curr = Currency::parse("22.000,44 €", CurrencyL::Eu).unwrap();

        assert_eq!(
            curr,
            Currency {
                negative: false,
                amount: 2200044,
                locale: CurrencyL::Eu
            }
        )
    }

    #[test]
    fn parse_currency_non_utf8_whitespace() {
        let curr = Currency::parse("22.000,44\u{a0}", CurrencyL::Eu).unwrap();

        assert_eq!(
            curr,
            Currency {
                negative: false,
                amount: 2200044,
                locale: CurrencyL::Eu
            }
        )
    }

    #[test]
    fn parse_currency_prefix_notation() {
        let curr = Currency::parse("€22,44", CurrencyL::Eu).unwrap();

        assert_eq!(
            curr,
            Currency {
                negative: false,
                amount: 2244,
                locale: CurrencyL::Eu
            }
        )
    }

    #[test]
    fn parse_currency_prefix_notation_other() {
        let curr = Currency::parse("$22,44", CurrencyL::Us).unwrap();

        assert_eq!(
            curr,
            Currency {
                negative: false,
                amount: 2244,
                locale: CurrencyL::Us
            }
        );
    }

    #[test]
    #[should_panic]
    fn parse_currency_wrong_currency_symbol() {
        Currency::parse("$22,44", CurrencyL::Eu).unwrap();
    }

    #[test]
    fn print_currency() {
        let mut curr = Currency::new(false, 0, CurrencyL::Eu);
        for (full, full_string) in [
            (2_00, "2"),
            (20_00, "20"),
            (200_00, "200"),
            (2000_00, "2.000"),
            (20_000_00, "20.000"),
            (200_000_00, "200.000"),
            (2_000_000_00, "2.000.000"),
            (20_000_000_00, "20.000.000"),
            (200_000_000_00, "200.000.000"),
        ] {
            curr.amount = full;
            assert_eq!(format!("{full_string},00 €"), curr.to_string());
        }

        let curr = Currency::new(false, 202, CurrencyL::Eu);
        assert_eq!("2,02 €", &curr.to_string());
    }

    #[test]
    fn construct_f32() {
        let first_val = 100.8_f32;
        let second_val = 191.0_f32;

        let expected = Currency::<CurrencyL>::from(first_val + second_val);
        assert_eq!(expected, Currency::new(false, 291_80, CurrencyL::Eu));
    }

    #[test]
    fn compare_both_negative_equal() {
        let curr1 = Currency::new(true, 2_22, CurrencyL::Eu);
        let curr2 = Currency::new(true, 2_22, CurrencyL::Eu);
        assert!(curr1 == curr2);
    }

    #[test]
    fn compare_both_negative_equal_full_diff_part() {
        let curr1 = Currency::new(true, 2_21, CurrencyL::Eu);
        let curr2 = Currency::new(true, 2_22, CurrencyL::Eu);
        assert!(curr1 > curr2);
    }

    #[test]
    fn compare_both_negative_diff_full_equal_part() {
        let curr1 = Currency::new(true, 1_22, CurrencyL::Eu);
        let curr2 = Currency::new(true, 2_22, CurrencyL::Eu);
        assert!(curr1 > curr2);
    }

    #[test]
    fn compare_both_negative_diff_full_greater_part() {
        let curr1 = Currency::new(true, 1_89, CurrencyL::Eu);
        let curr2 = Currency::new(true, 2_22, CurrencyL::Eu);
        assert!(curr1 > curr2);
    }

    #[test]
    fn compare_diff_negative_equal() {
        let curr1 = Currency::new(false, 2_22, CurrencyL::Eu);
        let curr2 = Currency::new(true, 2_22, CurrencyL::Eu);
        assert!(curr1 > curr2);
    }

    #[test]
    fn compare_diff_negative_equal_full_diff_part() {
        let curr1 = Currency::new(false, 2_24, CurrencyL::Eu);
        let curr2 = Currency::new(true, 2_22, CurrencyL::Eu);
        assert!(curr1 > curr2);
    }

    #[test]
    fn compare_diff_negative_greater_values() {
        let curr1 = Currency::new(false, 1_11, CurrencyL::Eu);
        let curr2 = Currency::new(true, 2_22, CurrencyL::Eu);
        assert!(curr1 > curr2);
    }

    #[test]
    fn compare_equal_full_less_part() {
        let curr1 = Currency::new(false, 2_21, CurrencyL::Eu);
        let curr2 = Currency::new(false, 2_22, CurrencyL::Eu);
        assert!(curr1 < curr2);
    }

    #[test]
    fn compare_equal_full_greater_part() {
        let curr1 = Currency::new(false, 2_23, CurrencyL::Eu);
        let curr2 = Currency::new(false, 2_22, CurrencyL::Eu);
        assert!(curr1 > curr2);
    }

    #[test]
    fn compare_diff_full_equal_part() {
        let curr1 = Currency::new(false, 3_22, CurrencyL::Eu);
        let curr2 = Currency::new(false, 2_22, CurrencyL::Eu);
        assert!(curr1 > curr2);
    }

    #[test]
    fn compare_diff_full_greater_part() {
        let curr1 = Currency::new(false, 3_22, CurrencyL::Eu);
        let curr2 = Currency::new(false, 2_89, CurrencyL::Eu);
        assert!(curr1 > curr2);
    }
}