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(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
58pub enum Weekday {
59 Sunday,
60 Monday,
61 Tuesday,
62 Wednesday,
63 Thursday,
64 Friday,
65 Saturday,
66}
67
68impl Weekday {
69 #[must_use]
71 pub const fn sun0(self) -> u8 {
72 self as u8
73 }
74}
75
76#[derive(Clone, Copy, PartialEq, Eq)]
77enum Lang {
78 En,
79 De,
80 Fr,
81 It,
82 Uk,
83 SrLatn,
84 SrCyrl,
85}
86
87impl Locale {
88 const fn seps(self) -> (&'static str, &'static str) {
90 match self {
91 Locale::EnUs | Locale::EnGb => (",", "."),
92 Locale::DeCh | Locale::FrCh | Locale::ItCh => ("'", "."),
94 Locale::DeDe | Locale::ItIt | Locale::SrLatn | Locale::SrCyrl => (".", ","),
95 Locale::FrFr => ("\u{202f}", ","),
97 Locale::UkUa => ("\u{a0}", ","),
99 }
100 }
101
102 const fn lang(self) -> Lang {
103 match self {
104 Locale::EnUs | Locale::EnGb => Lang::En,
105 Locale::DeCh | Locale::DeDe => Lang::De,
106 Locale::FrCh | Locale::FrFr => Lang::Fr,
107 Locale::ItCh | Locale::ItIt => Lang::It,
108 Locale::UkUa => Lang::Uk,
109 Locale::SrLatn => Lang::SrLatn,
110 Locale::SrCyrl => Lang::SrCyrl,
111 }
112 }
113
114 #[must_use]
118 pub fn from_tag(tag: &str) -> Option<Locale> {
119 let t = tag.to_ascii_lowercase().replace('_', "-");
120 let mut parts = t.split('-');
121 let lang = parts.next().unwrap_or("");
122 let region = parts.next().unwrap_or("");
123 Some(match (lang, region) {
124 ("en", "gb") => Locale::EnGb,
125 ("en", _) => Locale::EnUs,
126 ("de", "ch") => Locale::DeCh,
127 ("de", _) => Locale::DeDe,
128 ("fr", "ch") => Locale::FrCh,
129 ("fr", _) => Locale::FrFr,
130 ("it", "ch") => Locale::ItCh,
131 ("it", _) => Locale::ItIt,
132 ("uk", _) => Locale::UkUa,
133 ("sr", "latn") => Locale::SrLatn,
135 ("sr", _) => Locale::SrCyrl,
136 _ => return None,
137 })
138 }
139}
140
141fn group_digits(digits: &str, sep: &str) -> String {
143 let len = digits.len();
144 let mut out = String::with_capacity(len + len / 3 * sep.len());
145 for (i, ch) in digits.chars().enumerate() {
146 if i > 0 && (len - i).is_multiple_of(3) {
147 out.push_str(sep);
148 }
149 out.push(ch);
150 }
151 out
152}
153
154#[must_use]
162pub fn format_number(value: f64, decimals: usize, locale: Locale) -> String {
163 let (group, dec) = locale.seps();
164 let s = format!("{:.*}", decimals, value.abs());
165 let (int_part, frac_part) = match s.split_once('.') {
166 Some((i, f)) => (i, Some(f)),
167 None => (s.as_str(), None),
168 };
169 let negative = value < 0.0 && s.bytes().any(|b| b != b'0' && b != b'.');
171 let mut out = String::new();
172 if negative {
173 out.push('-');
174 }
175 out.push_str(&group_digits(int_part, group));
176 if let Some(f) = frac_part {
177 out.push_str(dec);
178 out.push_str(f);
179 }
180 out
181}
182
183#[must_use]
191pub fn format_int(value: i64, locale: Locale) -> String {
192 let (group, _) = locale.seps();
193 let digits = value.unsigned_abs().to_string();
194 let mut out = String::new();
195 if value < 0 {
196 out.push('-');
197 }
198 out.push_str(&group_digits(&digits, group));
199 out
200}
201
202#[must_use]
211pub fn format_currency(value: f64, currency: Currency, locale: Locale) -> String {
212 let num = format_number(value, 2, locale);
213 match currency {
214 Currency::Chf => format!("CHF {num}"),
215 Currency::Usd => format!("${num}"),
216 Currency::Gbp => format!("£{num}"),
217 Currency::Eur => match locale {
218 Locale::DeDe | Locale::FrFr | Locale::ItIt => format!("{num} €"),
219 _ => format!("€{num}"),
220 },
221 Currency::Uah => format!("{num} ₴"),
222 Currency::Rsd => match locale {
223 Locale::SrLatn => format!("{num} din."),
224 Locale::SrCyrl => format!("{num} дин."),
225 _ => format!("{num} RSD"),
226 },
227 }
228}
229
230const MONTHS_EN: [&str; 12] = [
231 "January", "February", "March", "April", "May", "June", "July", "August", "September",
232 "October", "November", "December",
233];
234const MONTHS_DE: [&str; 12] = [
235 "Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober",
236 "November", "Dezember",
237];
238const MONTHS_FR: [&str; 12] = [
239 "janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août", "septembre",
240 "octobre", "novembre", "décembre",
241];
242const MONTHS_IT: [&str; 12] = [
243 "gennaio", "febbraio", "marzo", "aprile", "maggio", "giugno", "luglio", "agosto", "settembre",
244 "ottobre", "novembre", "dicembre",
245];
246const MONTHS_UK: [&str; 12] = [
248 "січень", "лютий", "березень", "квітень", "травень", "червень", "липень", "серпень",
249 "вересень", "жовтень", "листопад", "грудень",
250];
251const MONTHS_SR_LATN: [&str; 12] = [
252 "januar", "februar", "mart", "april", "maj", "jun", "jul", "avgust", "septembar", "oktobar",
253 "novembar", "decembar",
254];
255const MONTHS_SR_CYRL: [&str; 12] = [
256 "јануар", "фебруар", "март", "април", "мај", "јун", "јул", "август", "септембар", "октобар",
257 "новембар", "децембар",
258];
259
260#[must_use]
262pub fn month_name(month: u32, locale: Locale) -> &'static str {
263 let idx = (month.clamp(1, 12) - 1) as usize;
264 match locale.lang() {
265 Lang::En => MONTHS_EN[idx],
266 Lang::De => MONTHS_DE[idx],
267 Lang::Fr => MONTHS_FR[idx],
268 Lang::It => MONTHS_IT[idx],
269 Lang::Uk => MONTHS_UK[idx],
270 Lang::SrLatn => MONTHS_SR_LATN[idx],
271 Lang::SrCyrl => MONTHS_SR_CYRL[idx],
272 }
273}
274
275impl Locale {
276 #[must_use]
278 pub const fn week_start(self) -> Weekday {
279 match self {
280 Locale::EnUs => Weekday::Sunday,
281 _ => Weekday::Monday,
282 }
283 }
284}
285
286const WEEKDAYS_EN: [&str; 7] = ["S", "M", "T", "W", "T", "F", "S"];
288const WEEKDAYS_DE: [&str; 7] = ["S", "M", "D", "M", "D", "F", "S"];
289const WEEKDAYS_FR: [&str; 7] = ["D", "L", "M", "M", "J", "V", "S"];
290const WEEKDAYS_IT: [&str; 7] = ["D", "L", "M", "M", "G", "V", "S"];
291const WEEKDAYS_UK: [&str; 7] = ["Нд", "Пн", "Вт", "Ср", "Чт", "Пт", "Сб"];
293const WEEKDAYS_SR_LATN: [&str; 7] = ["N", "P", "U", "S", "Č", "P", "S"];
294const WEEKDAYS_SR_CYRL: [&str; 7] = ["Н", "П", "У", "С", "Ч", "П", "С"];
295
296#[must_use]
299pub fn weekday_short(sun0: u8, locale: Locale) -> &'static str {
300 let idx = usize::from(sun0 % 7);
301 match locale.lang() {
302 Lang::En => WEEKDAYS_EN[idx],
303 Lang::De => WEEKDAYS_DE[idx],
304 Lang::Fr => WEEKDAYS_FR[idx],
305 Lang::It => WEEKDAYS_IT[idx],
306 Lang::Uk => WEEKDAYS_UK[idx],
307 Lang::SrLatn => WEEKDAYS_SR_LATN[idx],
308 Lang::SrCyrl => WEEKDAYS_SR_CYRL[idx],
309 }
310}
311
312#[must_use]
314pub fn month_year(year: u32, month: u32, locale: Locale) -> String {
315 let name = month_name(month, locale);
316 let mut chars = name.chars();
317 let capitalized: String = chars.next().map(|c| c.to_uppercase().chain(chars).collect()).unwrap_or_default();
318 format!("{capitalized} {year}")
319}
320
321#[must_use]
329pub fn format_date(year: i32, month: u32, day: u32, locale: Locale) -> String {
330 match locale {
331 Locale::EnUs => format!("{month:02}/{day:02}/{year}"),
332 Locale::EnGb | Locale::FrFr | Locale::ItIt => format!("{day:02}/{month:02}/{year}"),
333 Locale::DeCh | Locale::FrCh | Locale::ItCh | Locale::DeDe | Locale::UkUa => {
334 format!("{day:02}.{month:02}.{year}")
335 }
336 Locale::SrLatn | Locale::SrCyrl => format!("{day:02}.{month:02}.{year}."),
338 }
339}
340
341#[must_use]
349pub fn format_date_long(year: i32, month: u32, day: u32, locale: Locale) -> String {
350 let m = month_name(month, locale);
351 match locale.lang() {
352 Lang::En => match locale {
353 Locale::EnUs => format!("{m} {day}, {year}"),
354 _ => format!("{day} {m} {year}"),
355 },
356 Lang::De | Lang::SrLatn | Lang::SrCyrl => format!("{day}. {m} {year}"),
358 Lang::Fr | Lang::It | Lang::Uk => format!("{day} {m} {year}"),
359 }
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365
366 #[test]
367 fn swiss_grouping_uses_apostrophe() {
368 assert_eq!(format_int(80000, Locale::DeCh), "80'000");
369 assert_eq!(format_int(1234567, Locale::FrCh), "1'234'567");
370 assert_eq!(format_number(1234567.5, 2, Locale::ItCh), "1'234'567.50");
371 }
372
373 #[test]
374 fn western_number_conventions() {
375 assert_eq!(format_number(1234567.5, 2, Locale::EnUs), "1,234,567.50");
376 assert_eq!(format_number(1234567.5, 2, Locale::DeDe), "1.234.567,50");
377 assert_eq!(format_number(1234.5, 2, Locale::FrFr), "1\u{202f}234,50");
378 assert_eq!(format_number(12.0, 0, Locale::EnUs), "12");
379 assert_eq!(format_number(999.999, 2, Locale::EnUs), "1,000.00"); }
381
382 #[test]
383 fn negatives_and_zero() {
384 assert_eq!(format_int(-1234, Locale::EnUs), "-1,234");
385 assert_eq!(format_number(-0.001, 2, Locale::EnUs), "0.00"); assert_eq!(format_number(-12.5, 1, Locale::DeCh), "-12.5");
387 }
388
389 #[test]
390 fn currency_placement() {
391 assert_eq!(format_currency(1234.5, Currency::Chf, Locale::DeCh), "CHF 1'234.50");
392 assert_eq!(format_currency(1234.5, Currency::Usd, Locale::EnUs), "$1,234.50");
393 assert_eq!(format_currency(1234.5, Currency::Gbp, Locale::EnGb), "£1,234.50");
394 assert_eq!(format_currency(1234.5, Currency::Eur, Locale::DeDe), "1.234,50 €");
395 assert_eq!(format_currency(1234.5, Currency::Eur, Locale::EnUs), "€1,234.50");
396 }
397
398 #[test]
399 fn dates() {
400 assert_eq!(format_date(2026, 1, 5, Locale::DeCh), "05.01.2026");
401 assert_eq!(format_date(2026, 1, 5, Locale::EnUs), "01/05/2026");
402 assert_eq!(format_date(2026, 12, 31, Locale::ItIt), "31/12/2026");
403 assert_eq!(format_date_long(2026, 1, 5, Locale::DeCh), "5. Januar 2026");
404 assert_eq!(format_date_long(2026, 3, 5, Locale::FrCh), "5 mars 2026");
405 assert_eq!(format_date_long(2026, 1, 5, Locale::EnUs), "January 5, 2026");
406 }
407
408 #[test]
409 fn serbian() {
410 assert_eq!(format_int(1234567, Locale::SrLatn), "1.234.567");
412 assert_eq!(format_number(1234.5, 2, Locale::SrCyrl), "1.234,50");
413 assert_eq!(format_date(2026, 12, 31, Locale::SrLatn), "31.12.2026.");
414 assert_eq!(format_currency(1234.5, Currency::Rsd, Locale::SrLatn), "1.234,50 din.");
415 assert_eq!(format_currency(1234.5, Currency::Rsd, Locale::SrCyrl), "1.234,50 дин.");
416 assert_eq!(format_currency(1234.5, Currency::Rsd, Locale::EnUs), "1,234.50 RSD");
417 assert_eq!(format_date_long(2026, 1, 5, Locale::SrLatn), "5. januar 2026");
419 assert_eq!(format_date_long(2026, 1, 5, Locale::SrCyrl), "5. јануар 2026");
420 }
421
422 #[test]
423 fn ukrainian() {
424 assert_eq!(format_int(1234567, Locale::UkUa), "1\u{a0}234\u{a0}567");
426 assert_eq!(format_number(1234.5, 2, Locale::UkUa), "1\u{a0}234,50");
427 assert_eq!(format_currency(1234.5, Currency::Uah, Locale::UkUa), "1\u{a0}234,50 ₴");
428 assert_eq!(format_date(2026, 12, 31, Locale::UkUa), "31.12.2026");
430 assert_eq!(month_name(1, Locale::UkUa), "січень");
431 assert_eq!(format_date_long(2026, 5, 5, Locale::UkUa), "5 травень 2026");
432 }
433
434 #[test]
435 fn defaults() {
436 assert_eq!(Locale::default(), Locale::EnUs);
437 assert_eq!(Currency::default(), Currency::Eur);
438 }
439
440 #[test]
441 fn tag_parsing() {
442 assert_eq!(Locale::from_tag("de-CH"), Some(Locale::DeCh));
443 assert_eq!(Locale::from_tag("uk"), Some(Locale::UkUa));
444 assert_eq!(Locale::from_tag("uk-UA"), Some(Locale::UkUa));
445 assert_eq!(Locale::from_tag("fr_FR"), Some(Locale::FrFr));
446 assert_eq!(Locale::from_tag("EN-us"), Some(Locale::EnUs));
447 assert_eq!(Locale::from_tag("it"), Some(Locale::ItIt));
448 assert_eq!(Locale::from_tag("sr-Latn-RS"), Some(Locale::SrLatn));
449 assert_eq!(Locale::from_tag("sr"), Some(Locale::SrCyrl));
450 assert_eq!(Locale::from_tag("sr-RS"), Some(Locale::SrCyrl));
451 assert_eq!(Locale::from_tag("ja-JP"), None);
452 }
453
454 #[test]
455 fn week_start_and_weekday_labels() {
456 assert_eq!(Locale::EnUs.week_start(), Weekday::Sunday);
457 assert_eq!(Locale::EnGb.week_start(), Weekday::Monday);
458 assert_eq!(Locale::SrLatn.week_start(), Weekday::Monday);
459 assert_eq!(Weekday::Tuesday.sun0(), 2);
460 let sr: Vec<&str> = (1..8).map(|i| weekday_short(i, Locale::SrLatn)).collect();
461 assert_eq!(sr, ["P", "U", "S", "Č", "P", "S", "N"], "Monday-first Serbian, wraps past Saturday");
462 let en: Vec<&str> = (0..7).map(|i| weekday_short(i, Locale::EnUs)).collect();
463 assert_eq!(en, ["S", "M", "T", "W", "T", "F", "S"]);
464 assert_eq!(weekday_short(1, Locale::SrCyrl), "П");
465 assert_eq!(weekday_short(1, Locale::UkUa), "Пн");
466 assert_eq!(weekday_short(4, Locale::ItIt), "G");
467 }
468
469 #[test]
470 fn month_year_capitalizes() {
471 assert_eq!(month_year(2026, 6, Locale::EnUs), "June 2026");
472 assert_eq!(month_year(2026, 9, Locale::SrLatn), "Septembar 2026");
473 assert_eq!(month_year(2026, 9, Locale::SrCyrl), "Септембар 2026");
474 assert_eq!(month_year(2026, 6, Locale::UkUa), "Червень 2026");
475 }
476}