1use serde::{Deserialize, Serialize};
16
17#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
21pub enum Locale {
22 #[default]
23 EnUs,
24 EnGb,
25 DeCh,
27 FrCh,
29 ItCh,
31 DeDe,
32 FrFr,
33 ItIt,
34 UkUa,
36 SrLatn,
38 SrCyrl,
40}
41
42#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
44pub enum Currency {
45 Chf,
46 #[default]
47 Eur,
48 Usd,
49 Gbp,
50 Uah,
52 Rsd,
54}
55
56#[derive(Clone, Copy, PartialEq, Eq)]
57enum Lang {
58 En,
59 De,
60 Fr,
61 It,
62 Uk,
63 SrLatn,
64 SrCyrl,
65}
66
67impl Locale {
68 const fn seps(self) -> (&'static str, &'static str) {
70 match self {
71 Locale::EnUs | Locale::EnGb => (",", "."),
72 Locale::DeCh | Locale::FrCh | Locale::ItCh => ("'", "."),
74 Locale::DeDe | Locale::ItIt | Locale::SrLatn | Locale::SrCyrl => (".", ","),
75 Locale::FrFr => ("\u{202f}", ","),
77 Locale::UkUa => ("\u{a0}", ","),
79 }
80 }
81
82 const fn lang(self) -> Lang {
83 match self {
84 Locale::EnUs | Locale::EnGb => Lang::En,
85 Locale::DeCh | Locale::DeDe => Lang::De,
86 Locale::FrCh | Locale::FrFr => Lang::Fr,
87 Locale::ItCh | Locale::ItIt => Lang::It,
88 Locale::UkUa => Lang::Uk,
89 Locale::SrLatn => Lang::SrLatn,
90 Locale::SrCyrl => Lang::SrCyrl,
91 }
92 }
93
94 #[must_use]
98 pub fn from_tag(tag: &str) -> Option<Locale> {
99 let t = tag.to_ascii_lowercase().replace('_', "-");
100 let mut parts = t.split('-');
101 let lang = parts.next().unwrap_or("");
102 let region = parts.next().unwrap_or("");
103 Some(match (lang, region) {
104 ("en", "gb") => Locale::EnGb,
105 ("en", _) => Locale::EnUs,
106 ("de", "ch") => Locale::DeCh,
107 ("de", _) => Locale::DeDe,
108 ("fr", "ch") => Locale::FrCh,
109 ("fr", _) => Locale::FrFr,
110 ("it", "ch") => Locale::ItCh,
111 ("it", _) => Locale::ItIt,
112 ("uk", _) => Locale::UkUa,
113 ("sr", "latn") => Locale::SrLatn,
115 ("sr", _) => Locale::SrCyrl,
116 _ => return None,
117 })
118 }
119}
120
121fn group_digits(digits: &str, sep: &str) -> String {
123 let len = digits.len();
124 let mut out = String::with_capacity(len + len / 3 * sep.len());
125 for (i, ch) in digits.chars().enumerate() {
126 if i > 0 && (len - i).is_multiple_of(3) {
127 out.push_str(sep);
128 }
129 out.push(ch);
130 }
131 out
132}
133
134#[must_use]
142pub fn format_number(value: f64, decimals: usize, locale: Locale) -> String {
143 let (group, dec) = locale.seps();
144 let s = format!("{:.*}", decimals, value.abs());
145 let (int_part, frac_part) = match s.split_once('.') {
146 Some((i, f)) => (i, Some(f)),
147 None => (s.as_str(), None),
148 };
149 let negative = value < 0.0 && s.bytes().any(|b| b != b'0' && b != b'.');
151 let mut out = String::new();
152 if negative {
153 out.push('-');
154 }
155 out.push_str(&group_digits(int_part, group));
156 if let Some(f) = frac_part {
157 out.push_str(dec);
158 out.push_str(f);
159 }
160 out
161}
162
163#[must_use]
171pub fn format_int(value: i64, locale: Locale) -> String {
172 let (group, _) = locale.seps();
173 let digits = value.unsigned_abs().to_string();
174 let mut out = String::new();
175 if value < 0 {
176 out.push('-');
177 }
178 out.push_str(&group_digits(&digits, group));
179 out
180}
181
182#[must_use]
191pub fn format_currency(value: f64, currency: Currency, locale: Locale) -> String {
192 let num = format_number(value, 2, locale);
193 match currency {
194 Currency::Chf => format!("CHF {num}"),
195 Currency::Usd => format!("${num}"),
196 Currency::Gbp => format!("£{num}"),
197 Currency::Eur => match locale {
198 Locale::DeDe | Locale::FrFr | Locale::ItIt => format!("{num} €"),
199 _ => format!("€{num}"),
200 },
201 Currency::Uah => format!("{num} ₴"),
202 Currency::Rsd => match locale {
203 Locale::SrLatn => format!("{num} din."),
204 Locale::SrCyrl => format!("{num} дин."),
205 _ => format!("{num} RSD"),
206 },
207 }
208}
209
210const MONTHS_EN: [&str; 12] = [
211 "January", "February", "March", "April", "May", "June", "July", "August", "September",
212 "October", "November", "December",
213];
214const MONTHS_DE: [&str; 12] = [
215 "Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober",
216 "November", "Dezember",
217];
218const MONTHS_FR: [&str; 12] = [
219 "janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août", "septembre",
220 "octobre", "novembre", "décembre",
221];
222const MONTHS_IT: [&str; 12] = [
223 "gennaio", "febbraio", "marzo", "aprile", "maggio", "giugno", "luglio", "agosto", "settembre",
224 "ottobre", "novembre", "dicembre",
225];
226const MONTHS_UK: [&str; 12] = [
228 "січень", "лютий", "березень", "квітень", "травень", "червень", "липень", "серпень",
229 "вересень", "жовтень", "листопад", "грудень",
230];
231const MONTHS_SR_LATN: [&str; 12] = [
232 "januar", "februar", "mart", "april", "maj", "jun", "jul", "avgust", "septembar", "oktobar",
233 "novembar", "decembar",
234];
235const MONTHS_SR_CYRL: [&str; 12] = [
236 "јануар", "фебруар", "март", "април", "мај", "јун", "јул", "август", "септембар", "октобар",
237 "новембар", "децембар",
238];
239
240#[must_use]
242pub fn month_name(month: u32, locale: Locale) -> &'static str {
243 let idx = (month.clamp(1, 12) - 1) as usize;
244 match locale.lang() {
245 Lang::En => MONTHS_EN[idx],
246 Lang::De => MONTHS_DE[idx],
247 Lang::Fr => MONTHS_FR[idx],
248 Lang::It => MONTHS_IT[idx],
249 Lang::Uk => MONTHS_UK[idx],
250 Lang::SrLatn => MONTHS_SR_LATN[idx],
251 Lang::SrCyrl => MONTHS_SR_CYRL[idx],
252 }
253}
254
255#[must_use]
263pub fn format_date(year: i32, month: u32, day: u32, locale: Locale) -> String {
264 match locale {
265 Locale::EnUs => format!("{month:02}/{day:02}/{year}"),
266 Locale::EnGb | Locale::FrFr | Locale::ItIt => format!("{day:02}/{month:02}/{year}"),
267 Locale::DeCh | Locale::FrCh | Locale::ItCh | Locale::DeDe | Locale::UkUa => {
268 format!("{day:02}.{month:02}.{year}")
269 }
270 Locale::SrLatn | Locale::SrCyrl => format!("{day:02}.{month:02}.{year}."),
272 }
273}
274
275#[must_use]
283pub fn format_date_long(year: i32, month: u32, day: u32, locale: Locale) -> String {
284 let m = month_name(month, locale);
285 match locale.lang() {
286 Lang::En => match locale {
287 Locale::EnUs => format!("{m} {day}, {year}"),
288 _ => format!("{day} {m} {year}"),
289 },
290 Lang::De | Lang::SrLatn | Lang::SrCyrl => format!("{day}. {m} {year}"),
292 Lang::Fr | Lang::It | Lang::Uk => format!("{day} {m} {year}"),
293 }
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299
300 #[test]
301 fn swiss_grouping_uses_apostrophe() {
302 assert_eq!(format_int(80000, Locale::DeCh), "80'000");
303 assert_eq!(format_int(1234567, Locale::FrCh), "1'234'567");
304 assert_eq!(format_number(1234567.5, 2, Locale::ItCh), "1'234'567.50");
305 }
306
307 #[test]
308 fn western_number_conventions() {
309 assert_eq!(format_number(1234567.5, 2, Locale::EnUs), "1,234,567.50");
310 assert_eq!(format_number(1234567.5, 2, Locale::DeDe), "1.234.567,50");
311 assert_eq!(format_number(1234.5, 2, Locale::FrFr), "1\u{202f}234,50");
312 assert_eq!(format_number(12.0, 0, Locale::EnUs), "12");
313 assert_eq!(format_number(999.999, 2, Locale::EnUs), "1,000.00"); }
315
316 #[test]
317 fn negatives_and_zero() {
318 assert_eq!(format_int(-1234, Locale::EnUs), "-1,234");
319 assert_eq!(format_number(-0.001, 2, Locale::EnUs), "0.00"); assert_eq!(format_number(-12.5, 1, Locale::DeCh), "-12.5");
321 }
322
323 #[test]
324 fn currency_placement() {
325 assert_eq!(format_currency(1234.5, Currency::Chf, Locale::DeCh), "CHF 1'234.50");
326 assert_eq!(format_currency(1234.5, Currency::Usd, Locale::EnUs), "$1,234.50");
327 assert_eq!(format_currency(1234.5, Currency::Gbp, Locale::EnGb), "£1,234.50");
328 assert_eq!(format_currency(1234.5, Currency::Eur, Locale::DeDe), "1.234,50 €");
329 assert_eq!(format_currency(1234.5, Currency::Eur, Locale::EnUs), "€1,234.50");
330 }
331
332 #[test]
333 fn dates() {
334 assert_eq!(format_date(2026, 1, 5, Locale::DeCh), "05.01.2026");
335 assert_eq!(format_date(2026, 1, 5, Locale::EnUs), "01/05/2026");
336 assert_eq!(format_date(2026, 12, 31, Locale::ItIt), "31/12/2026");
337 assert_eq!(format_date_long(2026, 1, 5, Locale::DeCh), "5. Januar 2026");
338 assert_eq!(format_date_long(2026, 3, 5, Locale::FrCh), "5 mars 2026");
339 assert_eq!(format_date_long(2026, 1, 5, Locale::EnUs), "January 5, 2026");
340 }
341
342 #[test]
343 fn serbian() {
344 assert_eq!(format_int(1234567, Locale::SrLatn), "1.234.567");
346 assert_eq!(format_number(1234.5, 2, Locale::SrCyrl), "1.234,50");
347 assert_eq!(format_date(2026, 12, 31, Locale::SrLatn), "31.12.2026.");
348 assert_eq!(format_currency(1234.5, Currency::Rsd, Locale::SrLatn), "1.234,50 din.");
349 assert_eq!(format_currency(1234.5, Currency::Rsd, Locale::SrCyrl), "1.234,50 дин.");
350 assert_eq!(format_currency(1234.5, Currency::Rsd, Locale::EnUs), "1,234.50 RSD");
351 assert_eq!(format_date_long(2026, 1, 5, Locale::SrLatn), "5. januar 2026");
353 assert_eq!(format_date_long(2026, 1, 5, Locale::SrCyrl), "5. јануар 2026");
354 }
355
356 #[test]
357 fn ukrainian() {
358 assert_eq!(format_int(1234567, Locale::UkUa), "1\u{a0}234\u{a0}567");
360 assert_eq!(format_number(1234.5, 2, Locale::UkUa), "1\u{a0}234,50");
361 assert_eq!(format_currency(1234.5, Currency::Uah, Locale::UkUa), "1\u{a0}234,50 ₴");
362 assert_eq!(format_date(2026, 12, 31, Locale::UkUa), "31.12.2026");
364 assert_eq!(month_name(1, Locale::UkUa), "січень");
365 assert_eq!(format_date_long(2026, 5, 5, Locale::UkUa), "5 травень 2026");
366 }
367
368 #[test]
369 fn defaults() {
370 assert_eq!(Locale::default(), Locale::EnUs);
371 assert_eq!(Currency::default(), Currency::Eur);
372 }
373
374 #[test]
375 fn tag_parsing() {
376 assert_eq!(Locale::from_tag("de-CH"), Some(Locale::DeCh));
377 assert_eq!(Locale::from_tag("uk"), Some(Locale::UkUa));
378 assert_eq!(Locale::from_tag("uk-UA"), Some(Locale::UkUa));
379 assert_eq!(Locale::from_tag("fr_FR"), Some(Locale::FrFr));
380 assert_eq!(Locale::from_tag("EN-us"), Some(Locale::EnUs));
381 assert_eq!(Locale::from_tag("it"), Some(Locale::ItIt));
382 assert_eq!(Locale::from_tag("sr-Latn-RS"), Some(Locale::SrLatn));
383 assert_eq!(Locale::from_tag("sr"), Some(Locale::SrCyrl));
384 assert_eq!(Locale::from_tag("sr-RS"), Some(Locale::SrCyrl));
385 assert_eq!(Locale::from_tag("ja-JP"), None);
386 }
387}