1use serde::{Deserialize, Serialize};
16
17#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
21pub enum Locale {
22 EnUs,
23 EnGb,
24 DeCh,
26 FrCh,
28 ItCh,
30 DeDe,
31 FrFr,
32 ItIt,
33 SrLatn,
35 SrCyrl,
37}
38
39#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
41pub enum Currency {
42 Chf,
43 Eur,
44 Usd,
45 Gbp,
46 Rsd,
48}
49
50#[derive(Clone, Copy, PartialEq, Eq)]
51enum Lang {
52 En,
53 De,
54 Fr,
55 It,
56 SrLatn,
57 SrCyrl,
58}
59
60impl Locale {
61 const fn seps(self) -> (&'static str, &'static str) {
63 match self {
64 Locale::EnUs | Locale::EnGb => (",", "."),
65 Locale::DeCh | Locale::FrCh | Locale::ItCh => ("'", "."),
67 Locale::DeDe | Locale::ItIt | Locale::SrLatn | Locale::SrCyrl => (".", ","),
68 Locale::FrFr => ("\u{202f}", ","),
70 }
71 }
72
73 const fn lang(self) -> Lang {
74 match self {
75 Locale::EnUs | Locale::EnGb => Lang::En,
76 Locale::DeCh | Locale::DeDe => Lang::De,
77 Locale::FrCh | Locale::FrFr => Lang::Fr,
78 Locale::ItCh | Locale::ItIt => Lang::It,
79 Locale::SrLatn => Lang::SrLatn,
80 Locale::SrCyrl => Lang::SrCyrl,
81 }
82 }
83
84 #[must_use]
88 pub fn from_tag(tag: &str) -> Option<Locale> {
89 let t = tag.to_ascii_lowercase().replace('_', "-");
90 let mut parts = t.split('-');
91 let lang = parts.next().unwrap_or("");
92 let region = parts.next().unwrap_or("");
93 Some(match (lang, region) {
94 ("en", "gb") => Locale::EnGb,
95 ("en", _) => Locale::EnUs,
96 ("de", "ch") => Locale::DeCh,
97 ("de", _) => Locale::DeDe,
98 ("fr", "ch") => Locale::FrCh,
99 ("fr", _) => Locale::FrFr,
100 ("it", "ch") => Locale::ItCh,
101 ("it", _) => Locale::ItIt,
102 ("sr", "latn") => Locale::SrLatn,
104 ("sr", _) => Locale::SrCyrl,
105 _ => return None,
106 })
107 }
108}
109
110fn group_digits(digits: &str, sep: &str) -> String {
112 let len = digits.len();
113 let mut out = String::with_capacity(len + len / 3 * sep.len());
114 for (i, ch) in digits.chars().enumerate() {
115 if i > 0 && (len - i) % 3 == 0 {
116 out.push_str(sep);
117 }
118 out.push(ch);
119 }
120 out
121}
122
123#[must_use]
131pub fn format_number(value: f64, decimals: usize, locale: Locale) -> String {
132 let (group, dec) = locale.seps();
133 let s = format!("{:.*}", decimals, value.abs());
134 let (int_part, frac_part) = match s.split_once('.') {
135 Some((i, f)) => (i, Some(f)),
136 None => (s.as_str(), None),
137 };
138 let negative = value < 0.0 && s.bytes().any(|b| b != b'0' && b != b'.');
140 let mut out = String::new();
141 if negative {
142 out.push('-');
143 }
144 out.push_str(&group_digits(int_part, group));
145 if let Some(f) = frac_part {
146 out.push_str(dec);
147 out.push_str(f);
148 }
149 out
150}
151
152#[must_use]
160pub fn format_int(value: i64, locale: Locale) -> String {
161 let (group, _) = locale.seps();
162 let digits = value.unsigned_abs().to_string();
163 let mut out = String::new();
164 if value < 0 {
165 out.push('-');
166 }
167 out.push_str(&group_digits(&digits, group));
168 out
169}
170
171#[must_use]
180pub fn format_currency(value: f64, currency: Currency, locale: Locale) -> String {
181 let num = format_number(value, 2, locale);
182 match currency {
183 Currency::Chf => format!("CHF {num}"),
184 Currency::Usd => format!("${num}"),
185 Currency::Gbp => format!("£{num}"),
186 Currency::Eur => match locale {
187 Locale::DeDe | Locale::FrFr | Locale::ItIt => format!("{num} €"),
188 _ => format!("€{num}"),
189 },
190 Currency::Rsd => match locale {
191 Locale::SrLatn => format!("{num} din."),
192 Locale::SrCyrl => format!("{num} дин."),
193 _ => format!("{num} RSD"),
194 },
195 }
196}
197
198const MONTHS_EN: [&str; 12] = [
199 "January", "February", "March", "April", "May", "June", "July", "August", "September",
200 "October", "November", "December",
201];
202const MONTHS_DE: [&str; 12] = [
203 "Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober",
204 "November", "Dezember",
205];
206const MONTHS_FR: [&str; 12] = [
207 "janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août", "septembre",
208 "octobre", "novembre", "décembre",
209];
210const MONTHS_IT: [&str; 12] = [
211 "gennaio", "febbraio", "marzo", "aprile", "maggio", "giugno", "luglio", "agosto", "settembre",
212 "ottobre", "novembre", "dicembre",
213];
214const MONTHS_SR_LATN: [&str; 12] = [
215 "januar", "februar", "mart", "april", "maj", "jun", "jul", "avgust", "septembar", "oktobar",
216 "novembar", "decembar",
217];
218const MONTHS_SR_CYRL: [&str; 12] = [
219 "јануар", "фебруар", "март", "април", "мај", "јун", "јул", "август", "септембар", "октобар",
220 "новембар", "децембар",
221];
222
223#[must_use]
225pub fn month_name(month: u32, locale: Locale) -> &'static str {
226 let idx = (month.clamp(1, 12) - 1) as usize;
227 match locale.lang() {
228 Lang::En => MONTHS_EN[idx],
229 Lang::De => MONTHS_DE[idx],
230 Lang::Fr => MONTHS_FR[idx],
231 Lang::It => MONTHS_IT[idx],
232 Lang::SrLatn => MONTHS_SR_LATN[idx],
233 Lang::SrCyrl => MONTHS_SR_CYRL[idx],
234 }
235}
236
237#[must_use]
245pub fn format_date(year: i32, month: u32, day: u32, locale: Locale) -> String {
246 match locale {
247 Locale::EnUs => format!("{month:02}/{day:02}/{year}"),
248 Locale::EnGb | Locale::FrFr | Locale::ItIt => format!("{day:02}/{month:02}/{year}"),
249 Locale::DeCh | Locale::FrCh | Locale::ItCh | Locale::DeDe => {
250 format!("{day:02}.{month:02}.{year}")
251 }
252 Locale::SrLatn | Locale::SrCyrl => format!("{day:02}.{month:02}.{year}."),
254 }
255}
256
257#[must_use]
265pub fn format_date_long(year: i32, month: u32, day: u32, locale: Locale) -> String {
266 let m = month_name(month, locale);
267 match locale.lang() {
268 Lang::En => match locale {
269 Locale::EnUs => format!("{m} {day}, {year}"),
270 _ => format!("{day} {m} {year}"),
271 },
272 Lang::De | Lang::SrLatn | Lang::SrCyrl => format!("{day}. {m} {year}"),
274 Lang::Fr | Lang::It => format!("{day} {m} {year}"),
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281
282 #[test]
283 fn swiss_grouping_uses_apostrophe() {
284 assert_eq!(format_int(80000, Locale::DeCh), "80'000");
285 assert_eq!(format_int(1234567, Locale::FrCh), "1'234'567");
286 assert_eq!(format_number(1234567.5, 2, Locale::ItCh), "1'234'567.50");
287 }
288
289 #[test]
290 fn western_number_conventions() {
291 assert_eq!(format_number(1234567.5, 2, Locale::EnUs), "1,234,567.50");
292 assert_eq!(format_number(1234567.5, 2, Locale::DeDe), "1.234.567,50");
293 assert_eq!(format_number(1234.5, 2, Locale::FrFr), "1\u{202f}234,50");
294 assert_eq!(format_number(12.0, 0, Locale::EnUs), "12");
295 assert_eq!(format_number(999.999, 2, Locale::EnUs), "1,000.00"); }
297
298 #[test]
299 fn negatives_and_zero() {
300 assert_eq!(format_int(-1234, Locale::EnUs), "-1,234");
301 assert_eq!(format_number(-0.001, 2, Locale::EnUs), "0.00"); assert_eq!(format_number(-12.5, 1, Locale::DeCh), "-12.5");
303 }
304
305 #[test]
306 fn currency_placement() {
307 assert_eq!(format_currency(1234.5, Currency::Chf, Locale::DeCh), "CHF 1'234.50");
308 assert_eq!(format_currency(1234.5, Currency::Usd, Locale::EnUs), "$1,234.50");
309 assert_eq!(format_currency(1234.5, Currency::Gbp, Locale::EnGb), "£1,234.50");
310 assert_eq!(format_currency(1234.5, Currency::Eur, Locale::DeDe), "1.234,50 €");
311 assert_eq!(format_currency(1234.5, Currency::Eur, Locale::EnUs), "€1,234.50");
312 }
313
314 #[test]
315 fn dates() {
316 assert_eq!(format_date(2026, 1, 5, Locale::DeCh), "05.01.2026");
317 assert_eq!(format_date(2026, 1, 5, Locale::EnUs), "01/05/2026");
318 assert_eq!(format_date(2026, 12, 31, Locale::ItIt), "31/12/2026");
319 assert_eq!(format_date_long(2026, 1, 5, Locale::DeCh), "5. Januar 2026");
320 assert_eq!(format_date_long(2026, 3, 5, Locale::FrCh), "5 mars 2026");
321 assert_eq!(format_date_long(2026, 1, 5, Locale::EnUs), "January 5, 2026");
322 }
323
324 #[test]
325 fn serbian() {
326 assert_eq!(format_int(1234567, Locale::SrLatn), "1.234.567");
328 assert_eq!(format_number(1234.5, 2, Locale::SrCyrl), "1.234,50");
329 assert_eq!(format_date(2026, 12, 31, Locale::SrLatn), "31.12.2026.");
330 assert_eq!(format_currency(1234.5, Currency::Rsd, Locale::SrLatn), "1.234,50 din.");
331 assert_eq!(format_currency(1234.5, Currency::Rsd, Locale::SrCyrl), "1.234,50 дин.");
332 assert_eq!(format_currency(1234.5, Currency::Rsd, Locale::EnUs), "1,234.50 RSD");
333 assert_eq!(format_date_long(2026, 1, 5, Locale::SrLatn), "5. januar 2026");
335 assert_eq!(format_date_long(2026, 1, 5, Locale::SrCyrl), "5. јануар 2026");
336 }
337
338 #[test]
339 fn tag_parsing() {
340 assert_eq!(Locale::from_tag("de-CH"), Some(Locale::DeCh));
341 assert_eq!(Locale::from_tag("fr_FR"), Some(Locale::FrFr));
342 assert_eq!(Locale::from_tag("EN-us"), Some(Locale::EnUs));
343 assert_eq!(Locale::from_tag("it"), Some(Locale::ItIt));
344 assert_eq!(Locale::from_tag("sr-Latn-RS"), Some(Locale::SrLatn));
345 assert_eq!(Locale::from_tag("sr"), Some(Locale::SrCyrl));
346 assert_eq!(Locale::from_tag("sr-RS"), Some(Locale::SrCyrl));
347 assert_eq!(Locale::from_tag("ja-JP"), None);
348 }
349}