Skip to main content

mobiler_core/
format.rs

1//! Locale-aware number, currency, and date formatting — pure, synchronous, dependency-light.
2//!
3//! `view()` builds the `Widget` tree synchronously, but device capabilities/plugins are async, so
4//! locale formatting can't be delegated to the platform's `NumberFormatter`/`Intl` at render time —
5//! it has to run in the core. This module hand-rolls the conventions for the locales Mobiler apps
6//! actually target (Swiss de/fr/it + the common Western locales) instead of pulling in a full ICU
7//! data bundle, keeping the wasm web shell small.
8//!
9//! ```
10//! use mobiler_core::format::{format_currency, Currency, Locale};
11//! assert_eq!(format_currency(1234.5, Currency::Chf, Locale::DeCh), "CHF 1'234.50");
12//! assert_eq!(format_currency(1234.5, Currency::Eur, Locale::DeDe), "1.234,50 €");
13//! ```
14
15use serde::{Deserialize, Serialize};
16
17/// A formatting locale — drives digit grouping, the decimal mark, currency placement, and the
18/// date order + month names. Map a device language tag (e.g. from a `locale` plugin) to one with
19/// [`Locale::from_tag`].
20#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
21pub enum Locale {
22    #[default]
23    EnUs,
24    EnGb,
25    /// Swiss German — `1'234.50`, `31.12.2026`.
26    DeCh,
27    /// Swiss French — `1'234.50`, `31.12.2026`.
28    FrCh,
29    /// Swiss Italian — `1'234.50`, `31.12.2026`.
30    ItCh,
31    DeDe,
32    FrFr,
33    ItIt,
34    /// Ukrainian — `1 234,50` (no-break space grouping), `31.12.2026`, `січень`.
35    UkUa,
36    /// Serbian, Latin script — `1.234,50`, `31.12.2026.`, `januar`.
37    SrLatn,
38    /// Serbian, Cyrillic script — `1.234,50`, `31.12.2026.`, `јануар`.
39    SrCyrl,
40}
41
42/// A currency. Placement (symbol leading vs trailing) follows the [`Locale`].
43#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
44pub enum Currency {
45    Chf,
46    #[default]
47    Eur,
48    Usd,
49    Gbp,
50    /// Ukrainian hryvnia — trails the amount (`1 234,50 ₴`).
51    Uah,
52    /// Serbian dinar — trails (`din.` in Latin, `дин.` in Cyrillic, else `RSD`).
53    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    /// `(group separator, decimal separator)`.
69    const fn seps(self) -> (&'static str, &'static str) {
70        match self {
71            Locale::EnUs | Locale::EnGb => (",", "."),
72            // Swiss: apostrophe grouping, period decimal (e.g. SSM's "CHF 80'000").
73            Locale::DeCh | Locale::FrCh | Locale::ItCh => ("'", "."),
74            Locale::DeDe | Locale::ItIt | Locale::SrLatn | Locale::SrCyrl => (".", ","),
75            // French: narrow no-break space grouping, comma decimal.
76            Locale::FrFr => ("\u{202f}", ","),
77            // Ukrainian: no-break space grouping, comma decimal.
78            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    /// Best-effort map of a BCP-47 language tag (case-insensitive, e.g. `"de-CH"`, `"fr"`,
95    /// `"en-US"`) to a supported [`Locale`]. Region wins when known; otherwise the language's
96    /// most common locale is used. Returns `None` for unrecognized languages.
97    #[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            // Serbian: script wins; default to Cyrillic (the official script) when unspecified.
114            ("sr", "latn") => Locale::SrLatn,
115            ("sr", _) => Locale::SrCyrl,
116            _ => return None,
117        })
118    }
119}
120
121/// Group an unsigned integer's digit string with `sep` every three digits from the right.
122fn 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) % 3 == 0 {
127            out.push_str(sep);
128        }
129        out.push(ch);
130    }
131    out
132}
133
134/// Format a floating-point number with a fixed number of decimal places, grouped per `locale`.
135///
136/// ```
137/// use mobiler_core::format::{format_number, Locale};
138/// assert_eq!(format_number(1234567.5, 2, Locale::DeCh), "1'234'567.50");
139/// assert_eq!(format_number(1234567.5, 2, Locale::DeDe), "1.234.567,50");
140/// ```
141#[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    // Determine sign from the rounded result so -0.00 reads as "0.00".
150    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/// Format an integer, grouped per `locale`.
164///
165/// ```
166/// use mobiler_core::format::{format_int, Locale};
167/// assert_eq!(format_int(80000, Locale::DeCh), "80'000");
168/// assert_eq!(format_int(-1234, Locale::EnUs), "-1,234");
169/// ```
170#[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/// Format a monetary amount (always two fraction digits). Symbol placement follows `locale`:
183/// CHF/USD/GBP lead; EUR trails in de-DE/fr-FR/it-IT and leads elsewhere.
184///
185/// ```
186/// use mobiler_core::format::{format_currency, Currency, Locale};
187/// assert_eq!(format_currency(1234.5, Currency::Chf, Locale::FrCh), "CHF 1'234.50");
188/// assert_eq!(format_currency(1234.5, Currency::Usd, Locale::EnUs), "$1,234.50");
189/// ```
190#[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];
226// Ukrainian standalone (nominative) month names.
227const 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/// The localized full month name (`month` is 1–12, clamped).
241#[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/// Numeric date in the locale's conventional order/separator.
256///
257/// ```
258/// use mobiler_core::format::{format_date, Locale};
259/// assert_eq!(format_date(2026, 1, 5, Locale::DeCh), "05.01.2026");
260/// assert_eq!(format_date(2026, 1, 5, Locale::EnUs), "01/05/2026");
261/// ```
262#[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        // Serbian uses a trailing dot: "31.12.2026."
271        Locale::SrLatn | Locale::SrCyrl => format!("{day:02}.{month:02}.{year}."),
272    }
273}
274
275/// Long date with the localized month name (e.g. `"5. Januar 2026"`, `"January 5, 2026"`).
276///
277/// ```
278/// use mobiler_core::format::{format_date_long, Locale};
279/// assert_eq!(format_date_long(2026, 1, 5, Locale::DeCh), "5. Januar 2026");
280/// assert_eq!(format_date_long(2026, 1, 5, Locale::EnUs), "January 5, 2026");
281/// ```
282#[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        // German + Serbian use the ordinal dot after the day; French/Italian/Ukrainian do not.
291        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"); // rounds up + regroups
314    }
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"); // rounds to zero → no sign
320        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        // Latin + Cyrillic share number/date conventions (".", ",", trailing-dot date).
345        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        // Month names differ by script.
352        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        // No-break space grouping, comma decimal, trailing ₴.
359        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        // dd.MM.yyyy (no trailing dot), nominative month name, day-month-year long form.
363        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}