use thiserror::Error;
use num_bigint::BigInt;
use super::{BigDecimalValue, Locale, NumberPointType, NumberValue, Utf16String};
#[derive(Debug, Error)]
pub enum NumberUtilsError {
#[error("{message}")]
InvalidArgument {
message: String,
},
}
pub struct NumberUtils;
impl NumberUtils {
pub fn format(
target: Option<&NumberValue>,
min_integer_digits: Option<i32>,
thousands_point_type: Option<NumberPointType>,
fraction_digits: Option<i32>,
decimal_point_type: Option<NumberPointType>,
locale: Option<&Locale>,
) -> Result<Option<Utf16String>, NumberUtilsError> {
let Some(target) = target else {
return Ok(None);
};
let thousands_point_type =
required(thousands_point_type, "Thousands point type cannot be null")?;
let fraction_digits = required(fraction_digits, "Fraction digits cannot be null")?;
let decimal_point_type = required(decimal_point_type, "Decimal point type cannot be null")?;
let locale = locale.ok_or_else(|| invalid("Locale cannot be null"))?;
if fraction_digits < 0 {
return Err(invalid(
"Minimum fraction digits must be greater than or equal to zero",
));
}
if min_integer_digits.is_some_and(|digits| digits < 0) {
return Err(invalid(
"Minimum integer digits must be greater than or equal to zero",
));
}
let rendered = match number_as_decimal(target) {
Some(value) => value
.with_scale_half_even(fraction_digits)
.to_plain_string(),
None => number_as_f64(target).to_string(),
};
let negative = rendered.starts_with('-');
let rendered = rendered.strip_prefix('-').unwrap_or(&rendered);
let (integer, fraction) = rendered
.split_once('.')
.map_or((rendered, None), |(integer, fraction)| {
(integer, Some(fraction))
});
let mut integer = integer.to_owned();
let fraction = fraction.map(str::to_owned);
let min_integer_digits = min_integer_digits.unwrap_or(0).max(0) as usize;
if integer.len() < min_integer_digits {
integer.insert_str(0, &"0".repeat(min_integer_digits - integer.len()));
}
if thousands_point_type != NumberPointType::None {
integer = group_integer(
&integer,
point_character(thousands_point_type, locale, false),
);
}
let mut rendered = String::new();
if negative {
rendered.push('-');
}
rendered.push_str(&integer);
if fraction_digits > 0 {
rendered.push(point_character(decimal_point_type, locale, true));
rendered.push_str(fraction.as_deref().unwrap_or(""));
}
Ok(Some(Utf16String::from_rust_str(&rendered)))
}
pub fn sequence(from: Option<i32>, to: Option<i32>) -> Result<Vec<i32>, NumberUtilsError> {
let from = required(from, "Value to start the sequence from cannot be null")?;
let to = required(to, "Value to generate the sequence up to cannot be null")?;
Self::sequence_with_step(Some(from), Some(to), Some(if from <= to { 1 } else { -1 }))
}
pub fn sequence_with_step(
from: Option<i32>,
to: Option<i32>,
step: Option<i32>,
) -> Result<Vec<i32>, NumberUtilsError> {
let from = required(from, "Value to start the sequence from cannot be null")?;
let to = required(to, "Value to generate the sequence up to cannot be null")?;
let step = required(step, "Step to generate the sequence cannot be null")?;
if from == to {
return Ok(vec![from]);
}
if step == 0 {
return Err(invalid(format!(
"Cannot create sequence from {from} to {to} with step {step}"
)));
}
let mut values = Vec::new();
if from < to && step > 0 {
let mut value = from;
while value <= to {
values.push(value);
let next = value.wrapping_add(step);
if next <= value {
break;
}
value = next;
}
} else if from > to && step < 0 {
let mut value = from;
while value >= to {
values.push(value);
let next = value.wrapping_add(step);
if next >= value {
break;
}
value = next;
}
}
Ok(values)
}
pub fn format_currency(
target: Option<&NumberValue>,
locale: Option<&Locale>,
) -> Result<Option<Utf16String>, NumberUtilsError> {
let locale = locale.ok_or_else(|| invalid("Locale cannot be null"))?;
let Some(target) = target else {
return Ok(None);
};
let number = Self::format(
Some(target),
Some(1),
Some(NumberPointType::Default),
Some(currency_fraction_digits(locale)),
Some(NumberPointType::Default),
Some(locale),
)?
.expect("non-null target");
let symbol = currency_symbol(locale);
let text = if currency_symbol_after(locale) {
format!("{} {symbol}", number.to_string_lossy())
} else {
format!("{symbol}{}", number.to_string_lossy())
};
Ok(Some(Utf16String::from_rust_str(&text)))
}
pub fn format_percent(
target: Option<&NumberValue>,
min_integer_digits: Option<i32>,
fraction_digits: Option<i32>,
locale: Option<&Locale>,
) -> Result<Option<Utf16String>, NumberUtilsError> {
let fraction_digits = required(fraction_digits, "Fraction digits cannot be null")?;
let locale = locale.ok_or_else(|| invalid("Locale cannot be null"))?;
let Some(target) = target else {
return Ok(None);
};
let percent = number_as_decimal(target).map_or_else(
|| NumberValue::Double(number_as_f64(target) * 100.0),
|value| {
NumberValue::BigDecimal(BigDecimalValue::from_unscaled(
value.unscaled_value().clone() * BigInt::from(100_u8),
value.scale(),
))
},
);
let number = Self::format(
Some(&percent),
min_integer_digits,
Some(NumberPointType::Default),
Some(fraction_digits),
Some(NumberPointType::Default),
Some(locale),
)?
.expect("non-null target");
let separator = if locale_uses_space_before_percent(locale) {
" "
} else {
""
};
Ok(Some(Utf16String::from_rust_str(&format!(
"{}{separator}%",
number.to_string_lossy()
))))
}
}
fn required<T>(value: Option<T>, message: &str) -> Result<T, NumberUtilsError> {
value.ok_or_else(|| invalid(message))
}
fn invalid(message: impl Into<String>) -> NumberUtilsError {
NumberUtilsError::InvalidArgument {
message: message.into(),
}
}
fn number_as_f64(number: &NumberValue) -> f64 {
match number {
NumberValue::BigDecimal(value) => value.to_string().parse().unwrap_or(f64::NAN),
NumberValue::BigInteger(value) => value.to_string().parse().unwrap_or(f64::INFINITY),
NumberValue::Byte(value) => f64::from(*value),
NumberValue::Short(value) => f64::from(*value),
NumberValue::Integer(value) => f64::from(*value),
NumberValue::Long(value) => *value as f64,
NumberValue::Float(value) => f64::from(*value),
NumberValue::Double(value) => *value,
NumberValue::Other { double_value, .. } => *double_value,
}
}
fn number_as_decimal(number: &NumberValue) -> Option<BigDecimalValue> {
match number {
NumberValue::BigDecimal(value) => Some(value.clone()),
NumberValue::BigInteger(value) => Some(BigDecimalValue::from_unscaled(value.clone(), 0)),
NumberValue::Byte(value) => Some(BigDecimalValue::from_unscaled(BigInt::from(*value), 0)),
NumberValue::Short(value) => Some(BigDecimalValue::from_unscaled(BigInt::from(*value), 0)),
NumberValue::Integer(value) => {
Some(BigDecimalValue::from_unscaled(BigInt::from(*value), 0))
}
NumberValue::Long(value) => Some(BigDecimalValue::from_unscaled(BigInt::from(*value), 0)),
NumberValue::Float(value) => BigDecimalValue::parse(&value.to_string()).ok(),
NumberValue::Double(value) => BigDecimalValue::parse(&value.to_string()).ok(),
NumberValue::Other { double_value, .. } => {
BigDecimalValue::parse(&double_value.to_string()).ok()
}
}
}
fn group_integer(integer: &str, separator: char) -> String {
let mut result = String::with_capacity(integer.len() + integer.len() / 3);
let first_group = match integer.len() % 3 {
0 => 3,
value => value,
};
for (index, character) in integer.chars().enumerate() {
if index != 0 && index >= first_group && (index - first_group) % 3 == 0 {
result.push(separator);
}
result.push(character);
}
result
}
fn point_character(point_type: NumberPointType, locale: &Locale, decimal: bool) -> char {
match point_type {
NumberPointType::Point => '.',
NumberPointType::Comma => ',',
NumberPointType::Whitespace => ' ',
NumberPointType::Default => {
if locale_uses_decimal_comma(locale) {
if decimal { ',' } else { '.' }
} else if decimal {
'.'
} else {
','
}
}
NumberPointType::None => '?',
}
}
fn locale_uses_decimal_comma(locale: &Locale) -> bool {
matches!(
locale.get_language().to_string_lossy().as_str(),
"ar" | "bg"
| "cs"
| "da"
| "de"
| "el"
| "es"
| "fi"
| "fr"
| "hu"
| "id"
| "it"
| "nl"
| "no"
| "pl"
| "pt"
| "ro"
| "ru"
| "sk"
| "sl"
| "sv"
| "tr"
| "uk"
| "vi"
)
}
fn currency_symbol(locale: &Locale) -> &'static str {
match locale.get_country().to_string_lossy().as_str() {
"US" => "$",
"GB" => "£",
"JP" => "¥",
"CN" => "¥",
"KR" => "₩",
"CH" => "CHF",
"IN" => "₹",
"CA" => "CA$",
"AU" => "A$",
_ if locale_uses_decimal_comma(locale) => "€",
_ => "¤",
}
}
fn currency_fraction_digits(locale: &Locale) -> i32 {
if matches!(locale.get_country().to_string_lossy().as_str(), "JP" | "KR") {
0
} else {
2
}
}
fn currency_symbol_after(locale: &Locale) -> bool {
locale_uses_decimal_comma(locale)
}
fn locale_uses_space_before_percent(locale: &Locale) -> bool {
matches!(
locale.get_language().to_string_lossy().as_str(),
"fr" | "ru" | "uk" | "pl" | "cs" | "sk"
)
}