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/// A day of the week — the first column of a localized calendar ([`Locale::week_start`]).
57#[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    /// 0 = Sunday … 6 = Saturday (the index `weekday_short` and the calendar layout use).
70    #[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    /// `(group separator, decimal separator)`.
89    const fn seps(self) -> (&'static str, &'static str) {
90        match self {
91            Locale::EnUs | Locale::EnGb => (",", "."),
92            // Swiss: apostrophe grouping, period decimal (e.g. SSM's "CHF 80'000").
93            Locale::DeCh | Locale::FrCh | Locale::ItCh => ("'", "."),
94            Locale::DeDe | Locale::ItIt | Locale::SrLatn | Locale::SrCyrl => (".", ","),
95            // French: narrow no-break space grouping, comma decimal.
96            Locale::FrFr => ("\u{202f}", ","),
97            // Ukrainian: no-break space grouping, comma decimal.
98            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    /// Best-effort map of a BCP-47 language tag (case-insensitive, e.g. `"de-CH"`, `"fr"`,
115    /// `"en-US"`) to a supported [`Locale`]. Region wins when known; otherwise the language's
116    /// most common locale is used. Returns `None` for unrecognized languages.
117    #[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            // Serbian: script wins; default to Cyrillic (the official script) when unspecified.
134            ("sr", "latn") => Locale::SrLatn,
135            ("sr", _) => Locale::SrCyrl,
136            _ => return None,
137        })
138    }
139}
140
141/// Group an unsigned integer's digit string with `sep` every three digits from the right.
142fn 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/// Format a floating-point number with a fixed number of decimal places, grouped per `locale`.
155///
156/// ```
157/// use mobiler_core::format::{format_number, Locale};
158/// assert_eq!(format_number(1234567.5, 2, Locale::DeCh), "1'234'567.50");
159/// assert_eq!(format_number(1234567.5, 2, Locale::DeDe), "1.234.567,50");
160/// ```
161#[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    // Determine sign from the rounded result so -0.00 reads as "0.00".
170    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/// Format an integer, grouped per `locale`.
184///
185/// ```
186/// use mobiler_core::format::{format_int, Locale};
187/// assert_eq!(format_int(80000, Locale::DeCh), "80'000");
188/// assert_eq!(format_int(-1234, Locale::EnUs), "-1,234");
189/// ```
190#[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/// Format a monetary amount (always two fraction digits). Symbol placement follows `locale`:
203/// CHF/USD/GBP lead; EUR trails in de-DE/fr-FR/it-IT and leads elsewhere.
204///
205/// ```
206/// use mobiler_core::format::{format_currency, Currency, Locale};
207/// assert_eq!(format_currency(1234.5, Currency::Chf, Locale::FrCh), "CHF 1'234.50");
208/// assert_eq!(format_currency(1234.5, Currency::Usd, Locale::EnUs), "$1,234.50");
209/// ```
210#[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];
246// Ukrainian standalone (nominative) month names.
247const 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/// The localized full month name (`month` is 1–12, clamped).
261#[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    /// The first day of the week: Sunday for US English, Monday for every other supported locale.
277    #[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
286// Narrow weekday labels for a calendar header, Sunday-first (index = `Weekday::sun0`).
287const 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"];
291// Ukrainian calendars use the two-letter forms (single letters are ambiguous).
292const WEEKDAYS_UK: [&str; 7] = ["Нд", "Пн", "Вт", "Ср", "Чт", "Пт", "Сб"];
293const WEEKDAYS_SR_LATN: [&str; 7] = ["N", "P", "U", "S", "Č", "P", "S"];
294const WEEKDAYS_SR_CYRL: [&str; 7] = ["Н", "П", "У", "С", "Ч", "П", "С"];
295
296/// The localized narrow weekday label for a calendar header. `sun0` is 0 = Sunday … 6 = Saturday
297/// and wraps, so `weekday_short(start + i, …)` walks a week from any start day.
298#[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/// A calendar title: the localized month name, capitalized, then the year (`"Septembar 2026"`).
313#[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/// Numeric date in the locale's conventional order/separator.
322///
323/// ```
324/// use mobiler_core::format::{format_date, Locale};
325/// assert_eq!(format_date(2026, 1, 5, Locale::DeCh), "05.01.2026");
326/// assert_eq!(format_date(2026, 1, 5, Locale::EnUs), "01/05/2026");
327/// ```
328#[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        // Serbian uses a trailing dot: "31.12.2026."
337        Locale::SrLatn | Locale::SrCyrl => format!("{day:02}.{month:02}.{year}."),
338    }
339}
340
341/// Long date with the localized month name (e.g. `"5. Januar 2026"`, `"January 5, 2026"`).
342///
343/// ```
344/// use mobiler_core::format::{format_date_long, Locale};
345/// assert_eq!(format_date_long(2026, 1, 5, Locale::DeCh), "5. Januar 2026");
346/// assert_eq!(format_date_long(2026, 1, 5, Locale::EnUs), "January 5, 2026");
347/// ```
348#[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        // German + Serbian use the ordinal dot after the day; French/Italian/Ukrainian do not.
357        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"); // rounds up + regroups
380    }
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"); // rounds to zero → no sign
386        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        // Latin + Cyrillic share number/date conventions (".", ",", trailing-dot date).
411        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        // Month names differ by script.
418        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        // No-break space grouping, comma decimal, trailing ₴.
425        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        // dd.MM.yyyy (no trailing dot), nominative month name, day-month-year long form.
429        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}