use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Locale {
#[default]
EnUs,
EnGb,
DeCh,
FrCh,
ItCh,
DeDe,
FrFr,
ItIt,
UkUa,
SrLatn,
SrCyrl,
}
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Currency {
Chf,
#[default]
Eur,
Usd,
Gbp,
Uah,
Rsd,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Lang {
En,
De,
Fr,
It,
Uk,
SrLatn,
SrCyrl,
}
impl Locale {
const fn seps(self) -> (&'static str, &'static str) {
match self {
Locale::EnUs | Locale::EnGb => (",", "."),
Locale::DeCh | Locale::FrCh | Locale::ItCh => ("'", "."),
Locale::DeDe | Locale::ItIt | Locale::SrLatn | Locale::SrCyrl => (".", ","),
Locale::FrFr => ("\u{202f}", ","),
Locale::UkUa => ("\u{a0}", ","),
}
}
const fn lang(self) -> Lang {
match self {
Locale::EnUs | Locale::EnGb => Lang::En,
Locale::DeCh | Locale::DeDe => Lang::De,
Locale::FrCh | Locale::FrFr => Lang::Fr,
Locale::ItCh | Locale::ItIt => Lang::It,
Locale::UkUa => Lang::Uk,
Locale::SrLatn => Lang::SrLatn,
Locale::SrCyrl => Lang::SrCyrl,
}
}
#[must_use]
pub fn from_tag(tag: &str) -> Option<Locale> {
let t = tag.to_ascii_lowercase().replace('_', "-");
let mut parts = t.split('-');
let lang = parts.next().unwrap_or("");
let region = parts.next().unwrap_or("");
Some(match (lang, region) {
("en", "gb") => Locale::EnGb,
("en", _) => Locale::EnUs,
("de", "ch") => Locale::DeCh,
("de", _) => Locale::DeDe,
("fr", "ch") => Locale::FrCh,
("fr", _) => Locale::FrFr,
("it", "ch") => Locale::ItCh,
("it", _) => Locale::ItIt,
("uk", _) => Locale::UkUa,
("sr", "latn") => Locale::SrLatn,
("sr", _) => Locale::SrCyrl,
_ => return None,
})
}
}
fn group_digits(digits: &str, sep: &str) -> String {
let len = digits.len();
let mut out = String::with_capacity(len + len / 3 * sep.len());
for (i, ch) in digits.chars().enumerate() {
if i > 0 && (len - i).is_multiple_of(3) {
out.push_str(sep);
}
out.push(ch);
}
out
}
#[must_use]
pub fn format_number(value: f64, decimals: usize, locale: Locale) -> String {
let (group, dec) = locale.seps();
let s = format!("{:.*}", decimals, value.abs());
let (int_part, frac_part) = match s.split_once('.') {
Some((i, f)) => (i, Some(f)),
None => (s.as_str(), None),
};
let negative = value < 0.0 && s.bytes().any(|b| b != b'0' && b != b'.');
let mut out = String::new();
if negative {
out.push('-');
}
out.push_str(&group_digits(int_part, group));
if let Some(f) = frac_part {
out.push_str(dec);
out.push_str(f);
}
out
}
#[must_use]
pub fn format_int(value: i64, locale: Locale) -> String {
let (group, _) = locale.seps();
let digits = value.unsigned_abs().to_string();
let mut out = String::new();
if value < 0 {
out.push('-');
}
out.push_str(&group_digits(&digits, group));
out
}
#[must_use]
pub fn format_currency(value: f64, currency: Currency, locale: Locale) -> String {
let num = format_number(value, 2, locale);
match currency {
Currency::Chf => format!("CHF {num}"),
Currency::Usd => format!("${num}"),
Currency::Gbp => format!("£{num}"),
Currency::Eur => match locale {
Locale::DeDe | Locale::FrFr | Locale::ItIt => format!("{num} €"),
_ => format!("€{num}"),
},
Currency::Uah => format!("{num} ₴"),
Currency::Rsd => match locale {
Locale::SrLatn => format!("{num} din."),
Locale::SrCyrl => format!("{num} дин."),
_ => format!("{num} RSD"),
},
}
}
const MONTHS_EN: [&str; 12] = [
"January", "February", "March", "April", "May", "June", "July", "August", "September",
"October", "November", "December",
];
const MONTHS_DE: [&str; 12] = [
"Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober",
"November", "Dezember",
];
const MONTHS_FR: [&str; 12] = [
"janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août", "septembre",
"octobre", "novembre", "décembre",
];
const MONTHS_IT: [&str; 12] = [
"gennaio", "febbraio", "marzo", "aprile", "maggio", "giugno", "luglio", "agosto", "settembre",
"ottobre", "novembre", "dicembre",
];
const MONTHS_UK: [&str; 12] = [
"січень", "лютий", "березень", "квітень", "травень", "червень", "липень", "серпень",
"вересень", "жовтень", "листопад", "грудень",
];
const MONTHS_SR_LATN: [&str; 12] = [
"januar", "februar", "mart", "april", "maj", "jun", "jul", "avgust", "septembar", "oktobar",
"novembar", "decembar",
];
const MONTHS_SR_CYRL: [&str; 12] = [
"јануар", "фебруар", "март", "април", "мај", "јун", "јул", "август", "септембар", "октобар",
"новембар", "децембар",
];
#[must_use]
pub fn month_name(month: u32, locale: Locale) -> &'static str {
let idx = (month.clamp(1, 12) - 1) as usize;
match locale.lang() {
Lang::En => MONTHS_EN[idx],
Lang::De => MONTHS_DE[idx],
Lang::Fr => MONTHS_FR[idx],
Lang::It => MONTHS_IT[idx],
Lang::Uk => MONTHS_UK[idx],
Lang::SrLatn => MONTHS_SR_LATN[idx],
Lang::SrCyrl => MONTHS_SR_CYRL[idx],
}
}
#[must_use]
pub fn format_date(year: i32, month: u32, day: u32, locale: Locale) -> String {
match locale {
Locale::EnUs => format!("{month:02}/{day:02}/{year}"),
Locale::EnGb | Locale::FrFr | Locale::ItIt => format!("{day:02}/{month:02}/{year}"),
Locale::DeCh | Locale::FrCh | Locale::ItCh | Locale::DeDe | Locale::UkUa => {
format!("{day:02}.{month:02}.{year}")
}
Locale::SrLatn | Locale::SrCyrl => format!("{day:02}.{month:02}.{year}."),
}
}
#[must_use]
pub fn format_date_long(year: i32, month: u32, day: u32, locale: Locale) -> String {
let m = month_name(month, locale);
match locale.lang() {
Lang::En => match locale {
Locale::EnUs => format!("{m} {day}, {year}"),
_ => format!("{day} {m} {year}"),
},
Lang::De | Lang::SrLatn | Lang::SrCyrl => format!("{day}. {m} {year}"),
Lang::Fr | Lang::It | Lang::Uk => format!("{day} {m} {year}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn swiss_grouping_uses_apostrophe() {
assert_eq!(format_int(80000, Locale::DeCh), "80'000");
assert_eq!(format_int(1234567, Locale::FrCh), "1'234'567");
assert_eq!(format_number(1234567.5, 2, Locale::ItCh), "1'234'567.50");
}
#[test]
fn western_number_conventions() {
assert_eq!(format_number(1234567.5, 2, Locale::EnUs), "1,234,567.50");
assert_eq!(format_number(1234567.5, 2, Locale::DeDe), "1.234.567,50");
assert_eq!(format_number(1234.5, 2, Locale::FrFr), "1\u{202f}234,50");
assert_eq!(format_number(12.0, 0, Locale::EnUs), "12");
assert_eq!(format_number(999.999, 2, Locale::EnUs), "1,000.00"); }
#[test]
fn negatives_and_zero() {
assert_eq!(format_int(-1234, Locale::EnUs), "-1,234");
assert_eq!(format_number(-0.001, 2, Locale::EnUs), "0.00"); assert_eq!(format_number(-12.5, 1, Locale::DeCh), "-12.5");
}
#[test]
fn currency_placement() {
assert_eq!(format_currency(1234.5, Currency::Chf, Locale::DeCh), "CHF 1'234.50");
assert_eq!(format_currency(1234.5, Currency::Usd, Locale::EnUs), "$1,234.50");
assert_eq!(format_currency(1234.5, Currency::Gbp, Locale::EnGb), "£1,234.50");
assert_eq!(format_currency(1234.5, Currency::Eur, Locale::DeDe), "1.234,50 €");
assert_eq!(format_currency(1234.5, Currency::Eur, Locale::EnUs), "€1,234.50");
}
#[test]
fn dates() {
assert_eq!(format_date(2026, 1, 5, Locale::DeCh), "05.01.2026");
assert_eq!(format_date(2026, 1, 5, Locale::EnUs), "01/05/2026");
assert_eq!(format_date(2026, 12, 31, Locale::ItIt), "31/12/2026");
assert_eq!(format_date_long(2026, 1, 5, Locale::DeCh), "5. Januar 2026");
assert_eq!(format_date_long(2026, 3, 5, Locale::FrCh), "5 mars 2026");
assert_eq!(format_date_long(2026, 1, 5, Locale::EnUs), "January 5, 2026");
}
#[test]
fn serbian() {
assert_eq!(format_int(1234567, Locale::SrLatn), "1.234.567");
assert_eq!(format_number(1234.5, 2, Locale::SrCyrl), "1.234,50");
assert_eq!(format_date(2026, 12, 31, Locale::SrLatn), "31.12.2026.");
assert_eq!(format_currency(1234.5, Currency::Rsd, Locale::SrLatn), "1.234,50 din.");
assert_eq!(format_currency(1234.5, Currency::Rsd, Locale::SrCyrl), "1.234,50 дин.");
assert_eq!(format_currency(1234.5, Currency::Rsd, Locale::EnUs), "1,234.50 RSD");
assert_eq!(format_date_long(2026, 1, 5, Locale::SrLatn), "5. januar 2026");
assert_eq!(format_date_long(2026, 1, 5, Locale::SrCyrl), "5. јануар 2026");
}
#[test]
fn ukrainian() {
assert_eq!(format_int(1234567, Locale::UkUa), "1\u{a0}234\u{a0}567");
assert_eq!(format_number(1234.5, 2, Locale::UkUa), "1\u{a0}234,50");
assert_eq!(format_currency(1234.5, Currency::Uah, Locale::UkUa), "1\u{a0}234,50 ₴");
assert_eq!(format_date(2026, 12, 31, Locale::UkUa), "31.12.2026");
assert_eq!(month_name(1, Locale::UkUa), "січень");
assert_eq!(format_date_long(2026, 5, 5, Locale::UkUa), "5 травень 2026");
}
#[test]
fn defaults() {
assert_eq!(Locale::default(), Locale::EnUs);
assert_eq!(Currency::default(), Currency::Eur);
}
#[test]
fn tag_parsing() {
assert_eq!(Locale::from_tag("de-CH"), Some(Locale::DeCh));
assert_eq!(Locale::from_tag("uk"), Some(Locale::UkUa));
assert_eq!(Locale::from_tag("uk-UA"), Some(Locale::UkUa));
assert_eq!(Locale::from_tag("fr_FR"), Some(Locale::FrFr));
assert_eq!(Locale::from_tag("EN-us"), Some(Locale::EnUs));
assert_eq!(Locale::from_tag("it"), Some(Locale::ItIt));
assert_eq!(Locale::from_tag("sr-Latn-RS"), Some(Locale::SrLatn));
assert_eq!(Locale::from_tag("sr"), Some(Locale::SrCyrl));
assert_eq!(Locale::from_tag("sr-RS"), Some(Locale::SrCyrl));
assert_eq!(Locale::from_tag("ja-JP"), None);
}
}