diff --git a/Cargo.lock b/Cargo.lock index fa325ad0f..93529b44c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -91,6 +91,12 @@ version = "1.0.79" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "080e9890a082662b09c1ad45f567faeeb47f22b5fb23895fbe1e651e718e25ca" +[[package]] +name = "arrayvec" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711" + [[package]] name = "askama" version = "0.12.1" @@ -1393,6 +1399,16 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-format" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a652d9771a63711fd3c3deb670acfbe5c30a4072e664d7a3bf5a9e1056ac72c3" +dependencies = [ + "arrayvec", + "itoa", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -1973,6 +1989,7 @@ dependencies = [ "itertools 0.12.1", "log", "memoize", + "num-format", "nutype", "pretty_assertions", "pretty_env_logger", diff --git a/Cargo.toml b/Cargo.toml index b5a2969be..e1f1343c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,6 +64,7 @@ bip39 = { version = "2.0.0", features = ["serde"] } time-util = { version = "0.3.4", features = ["chrono"] } assert-json-diff = "2.0.2" url = { version = "2.5.0", features = ["serde"] } +num-format = "0.4.4" [build-dependencies] uniffi = { version = "0.26.0", features = ["build"] } diff --git a/src/core/error/common_error.rs b/src/core/error/common_error.rs index e62ea2703..20f25f89e 100644 --- a/src/core/error/common_error.rs +++ b/src/core/error/common_error.rs @@ -315,6 +315,9 @@ pub enum CommonError { #[error("Invalid UUID (v4), got: {bad_value}")] InvalidUUIDv4 { bad_value: String } = 10086, + + #[error("Unrecognized Locale Identifier: {bad_value}")] + UnrecognizedLocaleIdentifier { bad_value: String } = 10087, } /* diff --git a/src/core/types/decimal192.rs b/src/core/types/decimal192.rs index 021727b6a..9a79a913d 100644 --- a/src/core/types/decimal192.rs +++ b/src/core/types/decimal192.rs @@ -281,80 +281,48 @@ impl Decimal { } } -/// Defines the rounding strategy used when you round e.g. `Decimal192`. -/// -/// Following the same naming convention as https://docs.rs/rust_decimal/latest/rust_decimal/enum.RoundingStrategy.html. -#[derive( - Clone, Copy, Debug, PartialEq, Eq, enum_iterator::Sequence, uniffi::Enum, -)] -pub enum RoundingMode { - /// The number is always rounded toward positive infinity, e.g. `3.1 -> 4`, `-3.1 -> -3`. - ToPositiveInfinity, - - /// The number is always rounded toward negative infinity, e.g. `3.1 -> 3`, `-3.1 -> -4`. - ToNegativeInfinity, - - /// The number is always rounded toward zero, e.g. `3.1 -> 3`, `-3.1 -> -3`. - ToZero, - - /// The number is always rounded away from zero, e.g. `3.1 -> 4`, `-3.1 -> -4`. - AwayFromZero, +impl Decimal192 { + pub const MACHINE_READABLE_DECIMAL_SEPARATOR: &'static str = "."; - /// The number is rounded to the nearest, and when it is halfway between two others, it's rounded toward zero, e.g. `3.5 -> 3`, `-3.5 -> -3`. - ToNearestMidpointTowardZero, - - /// The number is rounded to the nearest, and when it is halfway between two others, it's rounded away from zero, e.g. `3.5 -> 4`, `-3.5 -> -4`. - ToNearestMidpointAwayFromZero, - - /// The number is rounded to the nearest, and when it is halfway between two others, it's rounded toward the nearest even number. Also known as "Bankers Rounding". - ToNearestMidpointToEven, -} + /// Parse a local respecting string + pub fn new_with_formatted_string( + formatted_string: impl AsRef, + locale: LocaleConfig, + ) -> Result { + let formatted_string = formatted_string.as_ref().to_owned(); + // Pad with a leading zero, to make numbers with leading decimal separator parsable + let mut string = format!("0{}", formatted_string); -impl From for ScryptoRoundingMode { - fn from(value: RoundingMode) -> Self { - match value { - RoundingMode::ToPositiveInfinity => { - ScryptoRoundingMode::ToPositiveInfinity - } - RoundingMode::ToNegativeInfinity => { - ScryptoRoundingMode::ToNegativeInfinity - } - RoundingMode::ToZero => ScryptoRoundingMode::ToZero, - RoundingMode::AwayFromZero => ScryptoRoundingMode::AwayFromZero, - RoundingMode::ToNearestMidpointTowardZero => { - ScryptoRoundingMode::ToNearestMidpointTowardZero - } - RoundingMode::ToNearestMidpointAwayFromZero => { - ScryptoRoundingMode::ToNearestMidpointAwayFromZero - } - RoundingMode::ToNearestMidpointToEven => { - ScryptoRoundingMode::ToNearestMidpointToEven - } + // If the locale recognizes a grouping separator, we strip that from the string + if let Some(grouping_separator) = locale.grouping_separator { + string = string.replace(&grouping_separator, ""); } - } -} - -impl From for RoundingMode { - fn from(value: ScryptoRoundingMode) -> Self { - match value { - ScryptoRoundingMode::ToPositiveInfinity => { - RoundingMode::ToPositiveInfinity - } - ScryptoRoundingMode::ToNegativeInfinity => { - RoundingMode::ToNegativeInfinity - } - ScryptoRoundingMode::ToZero => RoundingMode::ToZero, - ScryptoRoundingMode::AwayFromZero => RoundingMode::AwayFromZero, - ScryptoRoundingMode::ToNearestMidpointTowardZero => { - RoundingMode::ToNearestMidpointTowardZero - } - ScryptoRoundingMode::ToNearestMidpointAwayFromZero => { - RoundingMode::ToNearestMidpointAwayFromZero - } - ScryptoRoundingMode::ToNearestMidpointToEven => { - RoundingMode::ToNearestMidpointToEven + // `num` crate defines some pretty specific grouping separators: `"\u{a0}"` and `"\u{202f}"` for + // for some locales, but in unit tests we might use _normal_ space (`"U+0020"`), so we remove + // those (being a bit lenient...). + string = string.replace(' ', ""); + + // If the locale recognizes a decimal separator that is different from the machine readable one, we replace it with that + if let Some(decimal_separator) = locale.decimal_separator { + if decimal_separator != Self::MACHINE_READABLE_DECIMAL_SEPARATOR { + // If `decimal_separator != Self::MACHINE_READABLE_DECIMAL_SEPARATOR`, + // but if the string contains it, it might have been used incorrectly as + // a grouping separator. i.e. often "." is used in Swedish as a grouping + // separator, even though a space is the canonical one. So BEFORE + // we replace occurrences of decimal separator with "." + // (`Self::MACHINE_READABLE_DECIMAL_SEPARATOR`), we replace + // occurrences of `Self::MACHINE_READABLE_DECIMAL_SEPARATOR` with "". + string = string + .replace(Self::MACHINE_READABLE_DECIMAL_SEPARATOR, ""); + + string = string.replace( + &decimal_separator, + Self::MACHINE_READABLE_DECIMAL_SEPARATOR, + ); } } + + string.parse::() } } @@ -383,6 +351,16 @@ pub fn new_decimal_from_string(string: String) -> Result { Decimal192::new(string) } +/// Tries to creates a new `Decimal192` from a formatted String for +/// a specific locale. +#[uniffi::export] +pub fn new_decimal_from_formatted_string( + formatted_string: String, + locale: LocaleConfig, +) -> Result { + Decimal192::new_with_formatted_string(formatted_string, locale) +} + /// Creates a new `Decimal192` from a u32 integer. #[uniffi::export] pub fn new_decimal_from_u32(value: u32) -> Decimal192 { @@ -559,7 +537,6 @@ mod test_inner { #[cfg(test)] mod test_decimal { - use enum_iterator::all; use super::*; use crate::prelude::*; @@ -754,16 +731,44 @@ mod test_decimal { } #[test] - fn rounding_mode_conversion() { - let test = |m: RoundingMode| { + fn test_parse_formatted_decimal() { + let test = |s: &str, l: &LocaleConfig, exp: &str| { assert_eq!( - Into::::into(Into::::into( - m - )), - m + Decimal192::new_with_formatted_string(s, l.clone()).unwrap(), + exp.parse::().unwrap() ) }; - all::().for_each(test); + let fail = |s: &str, l: &LocaleConfig| { + assert!(Decimal192::new_with_formatted_string(s, l.clone()).is_err()) + }; + let swedish = LocaleConfig::swedish(); + let us = LocaleConfig::us(); + test(",005", &swedish, "0.005"); + test(".005", &us, "0.005"); + test("1,001", &swedish, "1.001"); + test("1,001", &us, "1001"); + test("1\u{a0}001,45", &swedish, "1001.45"); + test("1 001,45", &swedish, "1001.45"); + test("1.001,45", &swedish, "1001.45"); + test("1.001,45", &us, "1.00145"); + + fail("1.000.000", &us); + test("1.000.000", &swedish, "1000000"); + + fail("1.000.000,23", &us); + test("1.000.000,23", &swedish, "1000000.23"); + + test("1 000 000,23", &us, "100000023"); + test("1 000 000,23", &swedish, "1000000.23"); + + test("1 000 000.23", &us, "1000000.23"); + test("1 000 000.23", &swedish, "100000023"); + + fail("1,000,000", &swedish); + test("1,000,000", &us, "1000000"); + + fail("1,000,000.23", &swedish); + test("1,000,000.23", &us, "1000000.23"); } } @@ -1116,4 +1121,28 @@ mod uniffi_tests { assert_eq!(decimal_clamped_to_zero(&-SUT::one()), SUT::zero()); assert_eq!(decimal_clamped_to_zero(&SUT::one()), SUT::one()); } + + #[test] + fn from_formatted_string() { + let test = |s: &str, l: &LocaleConfig, exp: &str| { + assert_eq!( + new_decimal_from_formatted_string(s.to_owned(), l.clone()) + .unwrap(), + exp.parse::().unwrap() + ) + }; + let fail = |s: &str, l: &LocaleConfig| { + assert!(new_decimal_from_formatted_string(s.to_owned(), l.clone()) + .is_err()) + }; + let swedish = LocaleConfig::swedish(); + let us = LocaleConfig::us(); + test(",005", &swedish, "0.005"); + test(".005", &us, "0.005"); + test("1,001", &swedish, "1.001"); + test("1,001", &us, "1001"); + + fail("1,000,000.23", &swedish); + test("1,000,000.23", &us, "1000000.23"); + } } diff --git a/src/core/types/locale_config.rs b/src/core/types/locale_config.rs new file mode 100644 index 000000000..8ff1002a1 --- /dev/null +++ b/src/core/types/locale_config.rs @@ -0,0 +1,92 @@ +use crate::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, Hash, uniffi::Record)] +pub struct LocaleConfig { + pub decimal_separator: Option, + pub grouping_separator: Option, +} + +impl LocaleConfig { + pub fn new( + decimal_separator: impl Into>, + grouping_separator: impl Into>, + ) -> Self { + Self { + decimal_separator: decimal_separator.into(), + grouping_separator: grouping_separator.into(), + } + } +} + +impl Default for LocaleConfig { + fn default() -> Self { + Self::new(".".to_owned(), " ".to_owned()) + } +} + +impl From for LocaleConfig { + fn from(value: num_format::Locale) -> Self { + Self::new( + Some(value.decimal().to_owned()), + Some(value.separator().to_owned()), + ) + } +} + +impl LocaleConfig { + /// Tries to create a + /// A BCP-47 language identifier such as `"en_US_POSIX"`, `"sv_FI"` or `"zh_Hant_MO"`, + /// see: [list][list] + /// + /// [list]: https://docs.rs/num-format/0.4.4/src/num_format/locale.rs.html#5565-6444 + pub fn from_identifier(identifier: impl AsRef) -> Result { + let identifier = identifier.as_ref().to_owned(); + num_format::Locale::from_name(identifier.clone()) + .map_err(|_| CommonError::UnrecognizedLocaleIdentifier { + bad_value: identifier, + }) + .map(Into::::into) + } +} + +#[cfg(test)] +impl LocaleConfig { + pub fn swedish() -> Self { + Self::from_identifier("sv").expect("Sweden exists") + } + pub fn us() -> Self { + Self::from_identifier("en_US_POSIX").expect("US exists") + } +} + +#[cfg(test)] +mod tests { + use crate::prelude::*; + + #[test] + fn swedish() { + assert_eq!(LocaleConfig::swedish().decimal_separator.unwrap(), ","); + assert_eq!( + LocaleConfig::swedish().grouping_separator.unwrap(), + "\u{a0}" + ); + } + + #[test] + fn english_us() { + assert_eq!(LocaleConfig::us().decimal_separator.unwrap(), "."); + assert_eq!(LocaleConfig::us().grouping_separator.unwrap(), ","); + } + + #[test] + fn default_uses_spaces_as_grouping_separator() { + let sut = LocaleConfig::default(); + assert_eq!(&sut.grouping_separator.unwrap(), " "); + } + + #[test] + fn default_uses_dot_as_decimal_separator() { + let sut = LocaleConfig::default(); + assert_eq!(&sut.decimal_separator.unwrap(), "."); + } +} diff --git a/src/core/types/mod.rs b/src/core/types/mod.rs index 94ffd1afa..ab8b4e5e4 100644 --- a/src/core/types/mod.rs +++ b/src/core/types/mod.rs @@ -4,7 +4,9 @@ mod entity_kind; mod hex_32bytes; mod identified_vec_via; mod keys; +mod locale_config; mod logged_result; +mod rounding_mode; mod safe_to_log; pub use bag_of_bytes::*; @@ -13,5 +15,7 @@ pub use entity_kind::*; pub use hex_32bytes::*; pub use identified_vec_via::*; pub use keys::*; +pub use locale_config::*; pub use logged_result::*; +pub use rounding_mode::*; pub use safe_to_log::*; diff --git a/src/core/types/rounding_mode.rs b/src/core/types/rounding_mode.rs new file mode 100644 index 000000000..2da620c09 --- /dev/null +++ b/src/core/types/rounding_mode.rs @@ -0,0 +1,100 @@ +use crate::prelude::*; +use radix_engine_common::math::RoundingMode as ScryptoRoundingMode; + +/// Defines the rounding strategy used when you round e.g. `Decimal192`. +/// +/// Following the same naming convention as https://docs.rs/rust_decimal/latest/rust_decimal/enum.RoundingStrategy.html. +#[derive( + Clone, Copy, Debug, PartialEq, Eq, enum_iterator::Sequence, uniffi::Enum, +)] +pub enum RoundingMode { + /// The number is always rounded toward positive infinity, e.g. `3.1 -> 4`, `-3.1 -> -3`. + ToPositiveInfinity, + + /// The number is always rounded toward negative infinity, e.g. `3.1 -> 3`, `-3.1 -> -4`. + ToNegativeInfinity, + + /// The number is always rounded toward zero, e.g. `3.1 -> 3`, `-3.1 -> -3`. + ToZero, + + /// The number is always rounded away from zero, e.g. `3.1 -> 4`, `-3.1 -> -4`. + AwayFromZero, + + /// The number is rounded to the nearest, and when it is halfway between two others, it's rounded toward zero, e.g. `3.5 -> 3`, `-3.5 -> -3`. + ToNearestMidpointTowardZero, + + /// The number is rounded to the nearest, and when it is halfway between two others, it's rounded away from zero, e.g. `3.5 -> 4`, `-3.5 -> -4`. + ToNearestMidpointAwayFromZero, + + /// The number is rounded to the nearest, and when it is halfway between two others, it's rounded toward the nearest even number. Also known as "Bankers Rounding". + ToNearestMidpointToEven, +} + +impl From for ScryptoRoundingMode { + fn from(value: RoundingMode) -> Self { + match value { + RoundingMode::ToPositiveInfinity => { + ScryptoRoundingMode::ToPositiveInfinity + } + RoundingMode::ToNegativeInfinity => { + ScryptoRoundingMode::ToNegativeInfinity + } + RoundingMode::ToZero => ScryptoRoundingMode::ToZero, + RoundingMode::AwayFromZero => ScryptoRoundingMode::AwayFromZero, + RoundingMode::ToNearestMidpointTowardZero => { + ScryptoRoundingMode::ToNearestMidpointTowardZero + } + RoundingMode::ToNearestMidpointAwayFromZero => { + ScryptoRoundingMode::ToNearestMidpointAwayFromZero + } + RoundingMode::ToNearestMidpointToEven => { + ScryptoRoundingMode::ToNearestMidpointToEven + } + } + } +} + +impl From for RoundingMode { + fn from(value: ScryptoRoundingMode) -> Self { + match value { + ScryptoRoundingMode::ToPositiveInfinity => { + RoundingMode::ToPositiveInfinity + } + ScryptoRoundingMode::ToNegativeInfinity => { + RoundingMode::ToNegativeInfinity + } + ScryptoRoundingMode::ToZero => RoundingMode::ToZero, + ScryptoRoundingMode::AwayFromZero => RoundingMode::AwayFromZero, + ScryptoRoundingMode::ToNearestMidpointTowardZero => { + RoundingMode::ToNearestMidpointTowardZero + } + ScryptoRoundingMode::ToNearestMidpointAwayFromZero => { + RoundingMode::ToNearestMidpointAwayFromZero + } + ScryptoRoundingMode::ToNearestMidpointToEven => { + RoundingMode::ToNearestMidpointToEven + } + } + } +} + +#[cfg(test)] +mod tests { + use enum_iterator::all; + + use super::*; + use crate::prelude::*; + + #[test] + fn rounding_mode_conversion() { + let test = |m: RoundingMode| { + assert_eq!( + Into::::into(Into::::into( + m + )), + m + ) + }; + all::().for_each(test); + } +}