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, PartialEq, Eq)]
21pub enum Locale {
22    EnUs,
23    EnGb,
24    /// Swiss German — `1'234.50`, `31.12.2026`.
25    DeCh,
26    /// Swiss French — `1'234.50`, `31.12.2026`.
27    FrCh,
28    /// Swiss Italian — `1'234.50`, `31.12.2026`.
29    ItCh,
30    DeDe,
31    FrFr,
32    ItIt,
33    /// Serbian, Latin script — `1.234,50`, `31.12.2026.`, `januar`.
34    SrLatn,
35    /// Serbian, Cyrillic script — `1.234,50`, `31.12.2026.`, `јануар`.
36    SrCyrl,
37}
38
39/// A currency. Placement (symbol leading vs trailing) follows the [`Locale`].
40#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
41pub enum Currency {
42    Chf,
43    Eur,
44    Usd,
45    Gbp,
46    /// Serbian dinar — trails (`din.` in Latin, `дин.` in Cyrillic, else `RSD`).
47    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    /// `(group separator, decimal separator)`.
62    const fn seps(self) -> (&'static str, &'static str) {
63        match self {
64            Locale::EnUs | Locale::EnGb => (",", "."),
65            // Swiss: apostrophe grouping, period decimal (e.g. SSM's "CHF 80'000").
66            Locale::DeCh | Locale::FrCh | Locale::ItCh => ("'", "."),
67            Locale::DeDe | Locale::ItIt | Locale::SrLatn | Locale::SrCyrl => (".", ","),
68            // French: narrow no-break space grouping, comma decimal.
69            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    /// Best-effort map of a BCP-47 language tag (case-insensitive, e.g. `"de-CH"`, `"fr"`,
85    /// `"en-US"`) to a supported [`Locale`]. Region wins when known; otherwise the language's
86    /// most common locale is used. Returns `None` for unrecognized languages.
87    #[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            // Serbian: script wins; default to Cyrillic (the official script) when unspecified.
103            ("sr", "latn") => Locale::SrLatn,
104            ("sr", _) => Locale::SrCyrl,
105            _ => return None,
106        })
107    }
108}
109
110/// Group an unsigned integer's digit string with `sep` every three digits from the right.
111fn 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/// Format a floating-point number with a fixed number of decimal places, grouped per `locale`.
124///
125/// ```
126/// use mobiler_core::format::{format_number, Locale};
127/// assert_eq!(format_number(1234567.5, 2, Locale::DeCh), "1'234'567.50");
128/// assert_eq!(format_number(1234567.5, 2, Locale::DeDe), "1.234.567,50");
129/// ```
130#[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    // Determine sign from the rounded result so -0.00 reads as "0.00".
139    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/// Format an integer, grouped per `locale`.
153///
154/// ```
155/// use mobiler_core::format::{format_int, Locale};
156/// assert_eq!(format_int(80000, Locale::DeCh), "80'000");
157/// assert_eq!(format_int(-1234, Locale::EnUs), "-1,234");
158/// ```
159#[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/// Format a monetary amount (always two fraction digits). Symbol placement follows `locale`:
172/// CHF/USD/GBP lead; EUR trails in de-DE/fr-FR/it-IT and leads elsewhere.
173///
174/// ```
175/// use mobiler_core::format::{format_currency, Currency, Locale};
176/// assert_eq!(format_currency(1234.5, Currency::Chf, Locale::FrCh), "CHF 1'234.50");
177/// assert_eq!(format_currency(1234.5, Currency::Usd, Locale::EnUs), "$1,234.50");
178/// ```
179#[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/// The localized full month name (`month` is 1–12, clamped).
224#[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/// Numeric date in the locale's conventional order/separator.
238///
239/// ```
240/// use mobiler_core::format::{format_date, Locale};
241/// assert_eq!(format_date(2026, 1, 5, Locale::DeCh), "05.01.2026");
242/// assert_eq!(format_date(2026, 1, 5, Locale::EnUs), "01/05/2026");
243/// ```
244#[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        // Serbian uses a trailing dot: "31.12.2026."
253        Locale::SrLatn | Locale::SrCyrl => format!("{day:02}.{month:02}.{year}."),
254    }
255}
256
257/// Long date with the localized month name (e.g. `"5. Januar 2026"`, `"January 5, 2026"`).
258///
259/// ```
260/// use mobiler_core::format::{format_date_long, Locale};
261/// assert_eq!(format_date_long(2026, 1, 5, Locale::DeCh), "5. Januar 2026");
262/// assert_eq!(format_date_long(2026, 1, 5, Locale::EnUs), "January 5, 2026");
263/// ```
264#[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        // German + Serbian use the ordinal dot after the day; French/Italian do not.
273        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"); // rounds up + regroups
296    }
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"); // rounds to zero → no sign
302        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        // Latin + Cyrillic share number/date conventions (".", ",", trailing-dot date).
327        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        // Month names differ by script.
334        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}