222 lines
6.4 KiB
Rust
222 lines
6.4 KiB
Rust
/// Number, date, and currency formatting per locale.
|
|
///
|
|
/// Formatting is locale-sensitive: number grouping, decimal separators,
|
|
/// currency symbol placement, and date ordering all vary by locale.
|
|
/// Use these formatters rather than hardcoding formatting logic.
|
|
|
|
use crate::locale::Locale;
|
|
|
|
/// Format a number with locale-appropriate grouping and decimals.
|
|
///
|
|
/// Examples:
|
|
/// - en-US: 1,234,567.89
|
|
/// - de-DE: 1.234.567,89
|
|
/// - fr-FR: 1 234 567,89
|
|
pub fn format_number(value: f64, locale: &Locale, decimal_places: usize) -> String {
|
|
let (group_sep, decimal_sep) = separators_for_locale(locale);
|
|
|
|
let rounded = round_to(value, decimal_places);
|
|
let is_negative = rounded < 0.0;
|
|
let abs_value = rounded.abs();
|
|
|
|
let int_part = abs_value.trunc() as u64;
|
|
let frac_part = ((abs_value.fract() * 10f64.powi(decimal_places as i32)).round()) as u64;
|
|
|
|
let int_str = format_integer_with_grouping(int_part, group_sep);
|
|
|
|
let result = if decimal_places > 0 {
|
|
format!(
|
|
"{}{}{}",
|
|
int_str,
|
|
decimal_sep,
|
|
format!("{:0>width$}", frac_part, width = decimal_places)
|
|
)
|
|
} else {
|
|
int_str
|
|
};
|
|
|
|
if is_negative {
|
|
format!("-{}", result)
|
|
} else {
|
|
result
|
|
}
|
|
}
|
|
|
|
/// Format a currency value with locale-appropriate symbol and placement.
|
|
///
|
|
/// Examples:
|
|
/// - en-US / USD: $1,234.56
|
|
/// - de-DE / EUR: 1.234,56 €
|
|
/// - ja / JPY: ¥1,235
|
|
pub fn format_currency(value: f64, locale: &Locale, currency_code: &str) -> String {
|
|
let (symbol, prefix, decimals) = currency_info(currency_code);
|
|
let formatted = format_number(value, locale, decimals);
|
|
|
|
if prefix {
|
|
format!("{}{}", symbol, formatted)
|
|
} else {
|
|
format!("{} {}", formatted, symbol)
|
|
}
|
|
}
|
|
|
|
/// Format an integer with locale-appropriate grouping separators.
|
|
pub fn format_integer(value: i64, locale: &Locale) -> String {
|
|
let (group_sep, _) = separators_for_locale(locale);
|
|
let is_negative = value < 0;
|
|
let abs_val = value.unsigned_abs();
|
|
let grouped = format_integer_with_grouping(abs_val, group_sep);
|
|
if is_negative {
|
|
format!("-{}", grouped)
|
|
} else {
|
|
grouped
|
|
}
|
|
}
|
|
|
|
/// Format a percentage (0.85 → "85%", locale-aware).
|
|
pub fn format_percent(value: f64, locale: &Locale, decimal_places: usize) -> String {
|
|
let pct = value * 100.0;
|
|
let (_, decimal_sep) = separators_for_locale(locale);
|
|
let int_part = pct.trunc() as u64;
|
|
let frac = ((pct.fract() * 10f64.powi(decimal_places as i32)).round()) as u64;
|
|
|
|
if decimal_places > 0 {
|
|
format!(
|
|
"{}{}{}%",
|
|
int_part,
|
|
decimal_sep,
|
|
format!("{:0>width$}", frac, width = decimal_places)
|
|
)
|
|
} else {
|
|
format!("{}%", int_part)
|
|
}
|
|
}
|
|
|
|
// --- Internal helpers ---
|
|
|
|
fn separators_for_locale(locale: &Locale) -> (char, char) {
|
|
match locale.language.as_str() {
|
|
// Comma grouping, period decimal (en-US style)
|
|
"en" | "ja" | "ko" | "zh" | "th" => (',', '.'),
|
|
// Period grouping, comma decimal (European style)
|
|
"de" | "nl" | "it" | "pt" | "es" | "tr" | "pl" | "ru" | "uk" | "el" => ('.', ','),
|
|
// Thin space grouping, comma decimal (French style)
|
|
"fr" | "sv" | "no" | "nb" | "da" | "fi" => ('\u{202F}', ','),
|
|
// Default: comma grouping, period decimal
|
|
_ => (',', '.'),
|
|
}
|
|
}
|
|
|
|
fn format_integer_with_grouping(value: u64, sep: char) -> String {
|
|
let s = value.to_string();
|
|
if s.len() <= 3 {
|
|
return s;
|
|
}
|
|
let mut result = String::new();
|
|
let chars: Vec<char> = s.chars().collect();
|
|
let len = chars.len();
|
|
for (i, &ch) in chars.iter().enumerate() {
|
|
if i > 0 && (len - i) % 3 == 0 {
|
|
result.push(sep);
|
|
}
|
|
result.push(ch);
|
|
}
|
|
result
|
|
}
|
|
|
|
fn round_to(value: f64, places: usize) -> f64 {
|
|
let factor = 10f64.powi(places as i32);
|
|
(value * factor).round() / factor
|
|
}
|
|
|
|
fn currency_info(code: &str) -> (&'static str, bool, usize) {
|
|
// (symbol, prefix, decimal_places)
|
|
match code.to_uppercase().as_str() {
|
|
"USD" => ("$", true, 2),
|
|
"EUR" => ("€", false, 2),
|
|
"GBP" => ("£", true, 2),
|
|
"JPY" => ("¥", true, 0),
|
|
"CNY" => ("¥", true, 2),
|
|
"KRW" => ("₩", true, 0),
|
|
"INR" => ("₹", true, 2),
|
|
"CHF" => ("CHF", true, 2),
|
|
"CAD" => ("CA$", true, 2),
|
|
"AUD" => ("A$", true, 2),
|
|
"BRL" => ("R$", true, 2),
|
|
"MXN" => ("MX$", true, 2),
|
|
"RUB" => ("₽", false, 2),
|
|
"SEK" => ("kr", false, 2),
|
|
"NOK" => ("kr", false, 2),
|
|
"DKK" => ("kr", false, 2),
|
|
"PLN" => ("zł", false, 2),
|
|
"TRY" => ("₺", true, 2),
|
|
"SAR" => ("﷼", false, 2),
|
|
"AED" => ("د.إ", false, 2),
|
|
_ => ("¤", true, 2), // generic currency sign for unknown codes
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn format_number_en_us() {
|
|
let locale = Locale::en_us();
|
|
assert_eq!(format_number(1234567.89, &locale, 2), "1,234,567.89");
|
|
}
|
|
|
|
#[test]
|
|
fn format_number_de() {
|
|
let locale = Locale::new("de-DE");
|
|
assert_eq!(format_number(1234.56, &locale, 2), "1.234,56");
|
|
}
|
|
|
|
#[test]
|
|
fn format_number_no_decimals() {
|
|
let locale = Locale::en_us();
|
|
assert_eq!(format_number(42.0, &locale, 0), "42");
|
|
}
|
|
|
|
#[test]
|
|
fn format_number_negative() {
|
|
let locale = Locale::en_us();
|
|
assert_eq!(format_number(-1000.0, &locale, 2), "-1,000.00");
|
|
}
|
|
|
|
#[test]
|
|
fn format_currency_usd() {
|
|
let locale = Locale::en_us();
|
|
assert_eq!(format_currency(1234.56, &locale, "USD"), "$1,234.56");
|
|
}
|
|
|
|
#[test]
|
|
fn format_currency_jpy_no_decimals() {
|
|
let locale = Locale::ja();
|
|
assert_eq!(format_currency(1234.0, &locale, "JPY"), "¥1,234");
|
|
}
|
|
|
|
#[test]
|
|
fn format_integer_groups() {
|
|
let locale = Locale::en_us();
|
|
assert_eq!(format_integer(1000000, &locale), "1,000,000");
|
|
}
|
|
|
|
#[test]
|
|
fn format_integer_small() {
|
|
let locale = Locale::en_us();
|
|
assert_eq!(format_integer(42, &locale), "42");
|
|
}
|
|
|
|
#[test]
|
|
fn format_percent_whole() {
|
|
let locale = Locale::en_us();
|
|
assert_eq!(format_percent(0.85, &locale, 0), "85%");
|
|
}
|
|
|
|
#[test]
|
|
fn format_percent_with_decimal() {
|
|
let locale = Locale::en_us();
|
|
assert_eq!(format_percent(0.856, &locale, 1), "85.6%");
|
|
}
|
|
}
|