Skip to main content

sioul_core/
i18n.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Every sentence Sioul shows, in your language.
5//!
6//! The core decides and returns facts: lanes, reasons, counts. This module
7//! turns them into sentences with Project Fluent, which handles plurals and
8//! agreement per language ("un courriel est arrivé", "deux courriels sont
9//! arrivés") where gettext's plural forms alone cannot. Each language is one
10//! file, `locales/<language>/sioul.ftl`; English stands in for any message a
11//! translation lacks (docs/i18n.md).
12
13use crate::budget::{BudgetStatus, Period, ReserveStatus, Verdict};
14use crate::projects::{ProjectStore, RouteField, RouteMatch};
15use crate::money::Money;
16use crate::codes::CodeKind;
17use crate::porch::{Reason, Summary};
18use crate::trust::{Proof, Trust};
19use fluent_bundle::concurrent::FluentBundle;
20use fluent_bundle::{FluentArgs, FluentResource};
21use jiff::Zoned;
22use jiff::civil::Date;
23use unic_langid::LanguageIdentifier;
24
25/// The languages Sioul speaks, with their translations, built into the program.
26pub const LANGUAGES: &[(&str, &str)] = &[
27    ("en", include_str!("../locales/en/sioul.ftl")),
28    ("fr", include_str!("../locales/fr/sioul.ftl")),
29];
30
31type Bundle = FluentBundle<FluentResource>;
32
33/// Empty arguments, for callers that do not depend on Fluent themselves.
34pub fn args() -> FluentArgs<'static> {
35    FluentArgs::new()
36}
37
38/// Sentences in one language.
39pub struct Translator {
40    language: String,
41    bundle: Bundle,
42    /// English, for a message this language lacks: parsed the first time
43    /// English has one it lacks, never when the language is English. Each
44    /// language parsed holds some 3 MB, and every message is in both files
45    /// (tools/check-messages.py), so a running Sioul rarely needs it.
46    fallback: std::sync::OnceLock<Bundle>,
47}
48
49impl Translator {
50    /// A translator for a language code ("fr", "fr_FR.UTF-8", "en-GB"); English if Sioul lacks it.
51    pub fn new(requested: &str) -> Translator {
52        let wanted = base_language(requested);
53        let language = if LANGUAGES.iter().any(|(l, _)| *l == wanted) { wanted } else { "en".to_string() };
54        Translator { bundle: bundle(&language), fallback: std::sync::OnceLock::new(), language }
55    }
56
57    /// English, when it holds the message `id` this language lacks; none
58    /// for English itself, nor for a message English lacks too (a count past twelve).
59    fn fallback(&self, id: &str) -> Option<&Bundle> {
60        if self.language == "en" {
61            return None;
62        }
63        if let Some(english) = self.fallback.get() {
64            return Some(english);
65        }
66        defined(LANGUAGES[0].1, id).then(|| self.fallback.get_or_init(|| bundle("en")))
67    }
68
69    /// The language actually used.
70    pub fn language(&self) -> &str {
71        &self.language
72    }
73
74    /// One message with its arguments; the English one if this language lacks it.
75    pub fn text(&self, id: &str, args: Option<&FluentArgs>) -> String {
76        format(&self.bundle, id, None, args)
77            .or_else(|| self.fallback(id).and_then(|english| format(english, id, None, args)))
78            .unwrap_or_else(|| id.to_string())
79    }
80
81    /// One attribute of a message (`.cap`, `.plural`…); the English one if this
82    /// language lacks it; none if neither has it.
83    pub fn attribute(&self, id: &str, attribute: &str) -> Option<String> {
84        format(&self.bundle, id, Some(attribute), None)
85            .or_else(|| self.fallback(id).and_then(|english| format(english, id, Some(attribute), None)))
86    }
87
88    /// A number in words: "two", "Two"; in French also "une", "Une". Digits beyond twelve.
89    pub fn count(&self, n: usize, capital: bool, feminine: bool) -> String {
90        let id = format!("count-{n}");
91        let wanted: &[&str] = match (capital, feminine) {
92            (true, true) => &["fcap", "cap"],
93            (true, false) => &["cap"],
94            (false, true) => &["f"],
95            (false, false) => &[],
96        };
97        wanted
98            .iter()
99            .find_map(|a| format(&self.bundle, &id, Some(a), None))
100            .or_else(|| format(&self.bundle, &id, None, None))
101            .unwrap_or_else(|| n.to_string())
102    }
103
104    /// The arguments every counted sentence receives: `n`, `count`, `Count`, `countf`, `Countf`.
105    pub fn counted(&self, n: usize) -> FluentArgs<'static> {
106        let mut args = FluentArgs::new();
107        args.set("n", n);
108        args.set("count", self.count(n, false, false));
109        args.set("Count", self.count(n, true, false));
110        args.set("countf", self.count(n, false, true));
111        args.set("Countf", self.count(n, true, true));
112        args
113    }
114
115    /// What came, in a few calm sentences.
116    pub fn summary(&self, s: &Summary, projects: Option<&ProjectStore>) -> String {
117        if s.total == 0 {
118            let mut sentences = vec![self.text("summary-nothing", None)];
119            self.push_counted(&mut sentences, "summary-low", s.low);
120            return sentences.join(" ");
121        }
122        let mut sentences = vec![self.text("summary-total", Some(&self.counted(s.total)))];
123        self.push_counted(&mut sentences, "summary-codes", s.codes);
124        if !s.projects.is_empty() {
125            sentences.push(self.project_sentence(s, projects));
126        }
127        self.push_counted(&mut sentences, "summary-people", s.people);
128        self.push_counted(&mut sentences, "summary-screener", s.screener);
129        self.push_counted(&mut sentences, "summary-filed", s.filed);
130        self.push_counted(&mut sentences, "summary-set-aside", s.set_aside);
131        self.push_counted(&mut sentences, "summary-low", s.low);
132        sentences.join(" ")
133    }
134
135    fn push_counted(&self, sentences: &mut Vec<String>, id: &str, n: usize) {
136        if n > 0 {
137            sentences.push(self.text(id, Some(&self.counted(n))));
138        }
139    }
140
141    /// "Three belong to projects: Taxes (two), Health cover (one)."
142    fn project_sentence(&self, s: &Summary, projects: Option<&ProjectStore>) -> String {
143        let n: usize = s.projects.iter().map(|(_, k)| k).sum();
144        let several = s.projects.len() > 1;
145        let parts: Vec<String> = s
146            .projects
147            .iter()
148            .map(|(id, k)| {
149                let title = project_title(id, projects);
150                if !several {
151                    return title;
152                }
153                let mut args = self.counted(*k);
154                args.set("title", title);
155                self.text("summary-project-part", Some(&args))
156            })
157            .collect();
158        let mut args = self.counted(n);
159        args.set("k", s.projects.len());
160        args.set("projects", parts.join(", "));
161        self.text("summary-projects", Some(&args))
162    }
163
164    /// One reason, as a phrase.
165    pub fn reason(&self, reason: &Reason, projects: Option<&ProjectStore>) -> String {
166        match reason {
167            Reason::Trust(proof) => self.text(proof_id(*proof), None),
168            Reason::Forged => self.text("reason-forged", None),
169            Reason::Spam { source, score } => self.spam_reason(source, *score),
170            // Your own filter's word, never why nor how sure (the owner's decision: no "why").
171            Reason::LearnedSpam { .. } => self.text("reason-learned-spam", None),
172            Reason::Unsure { .. } => self.text("reason-unsure", None),
173            Reason::LearnedHam { .. } => self.text("reason-learned-ham", None),
174            Reason::MovedToJunk => self.text("reason-moved-to-junk", None),
175            Reason::Blocked => self.text("reason-blocked", None),
176            Reason::Impersonation { brand, domain } => {
177                let mut args = FluentArgs::new();
178                args.set("brand", brand.clone());
179                args.set("domain", domain.clone());
180                self.text("reason-impersonation", Some(&args))
181            }
182            Reason::ExpiresSoon(kind) => self.text("reason-expires-soon", Some(&self.kind_args(*kind))),
183            Reason::UnverifiedCode(kind) => self.text("reason-unverified-code", Some(&self.kind_args(*kind))),
184            Reason::Project { project_id, matched } => {
185                let mut args = FluentArgs::new();
186                args.set("title", project_title(project_id, projects));
187                args.set("why", matched.iter().map(|m| self.route_match(m)).collect::<Vec<_>>().join(", "));
188                self.text("reason-project", Some(&args))
189            }
190            Reason::Hostile => self.text("reason-hostile", None),
191            Reason::Public(topic) => {
192                let mut args = FluentArgs::new();
193                args.set("topic", self.topic(*topic));
194                self.text("reason-public", Some(&args))
195            }
196            Reason::Newsletter => self.text("reason-newsletter", None),
197            Reason::Automatic => self.text("reason-automatic", None),
198            Reason::FirstMessage => self.text("reason-first-message", None),
199            Reason::NotAuthenticated => self.text("reason-not-authenticated", None),
200            Reason::KnownPerson => self.text("reason-known", None),
201            Reason::FromYourself => self.text("reason-from-yourself", None),
202            Reason::LowPriority => self.text("reason-low-priority", None),
203        }
204    }
205
206    fn spam_reason(&self, source: &str, score: Option<f32>) -> String {
207        let mut args = FluentArgs::new();
208        args.set("source", source.to_string());
209        let id = score.map_or("reason-spam", |s| {
210            args.set("score", self.decimal(s));
211            "reason-spam-score"
212        });
213        self.text(id, Some(&args))
214    }
215
216    /// A decimal with this language's separator: "9.1", "9,1".
217    pub fn decimal(&self, value: f32) -> String {
218        format!("{value:.1}").replace('.', &self.text("decimal-separator", None))
219    }
220
221    fn route_match(&self, m: &RouteMatch) -> String {
222        let id = match m.field {
223            RouteField::SenderDomain => "route-sender-domain",
224            RouteField::Sender => "route-sender",
225            RouteField::Subject => "route-subject",
226            RouteField::Text => "route-text",
227            RouteField::Attachment => "route-attachment",
228            RouteField::Thread => "route-thread",
229        };
230        let mut args = FluentArgs::new();
231        args.set("value", m.value.clone());
232        self.text(id, Some(&args))
233    }
234
235    /// `kind`, `Kind` (capitalised) and `akind` (with its article: "a code", "un code").
236    fn kind_args(&self, kind: CodeKind) -> FluentArgs<'static> {
237        let id = kind_id(kind);
238        let mut args = FluentArgs::new();
239        args.set("kind", self.text(id, None));
240        args.set("Kind", self.attribute(id, "cap").unwrap_or_else(|| self.text(id, None)));
241        args.set("akind", self.attribute(id, "a").unwrap_or_else(|| self.text(id, None)));
242        args
243    }
244
245    /// "Code from La Banque Exemple (verified): 482913, valid about 10 minutes".
246    pub fn right_now(&self, kind: CodeKind, sender: &str, trust: Trust, code: Option<&str>, minutes: Option<u32>) -> String {
247        let mut args = self.kind_args(kind);
248        args.set("sender", sender.to_string());
249        args.set("trust", self.trust(trust));
250        let id = code.map_or("right-now-item", |c| {
251            args.set("code", c.to_string());
252            "right-now-item-code"
253        });
254        let line = self.text(id, Some(&args));
255        minutes.map_or(line.clone(), |m| format!("{line}, {}", self.validity(m)))
256    }
257
258    /// "valid about 10 minutes", "valid about 2 hours", "valid about 2 days".
259    pub fn validity(&self, minutes: u32) -> String {
260        let mut args = FluentArgs::new();
261        let id = if minutes < 120 {
262            args.set("minutes", minutes);
263            "right-now-valid"
264        } else if minutes < 48 * 60 {
265            args.set("hours", minutes.div_ceil(60));
266            "right-now-valid-hours"
267        } else {
268            args.set("days", minutes.div_ceil(24 * 60));
269            "right-now-valid-days"
270        };
271        self.text(id, Some(&args))
272    }
273
274    /// "verified", "not verified", "forged".
275    pub fn trust(&self, trust: Trust) -> String {
276        let id = match trust {
277            Trust::Verified => "trust-verified",
278            Trust::Unverified => "trust-unverified",
279            Trust::Forged => "trust-forged",
280        };
281        self.text(id, None)
282    }
283
284    /// "on Tuesday 6 October at 10:00", "le mardi 6 octobre à 10:00".
285    pub fn when(&self, at: &Zoned) -> String {
286        let mut args = FluentArgs::new();
287        args.set("date", self.date(at, false));
288        self.text("when-on", Some(&args))
289    }
290
291    /// A date, long ("Tuesday 6 October at 10:00") or short ("Tue 6 Oct 10:00").
292    pub fn date(&self, at: &Zoned, short: bool) -> String {
293        let weekday = format!("weekday-{}", at.weekday().to_monday_one_offset());
294        let month = format!("month-{}", at.month());
295        let name = |id: &str| if short { self.attribute(id, "short").unwrap_or_else(|| self.text(id, None)) } else { self.text(id, None) };
296        let mut args = FluentArgs::new();
297        args.set("weekday", name(&weekday));
298        args.set("day", at.day());
299        args.set("month", name(&month));
300        args.set("time", at.strftime("%H:%M").to_string());
301        self.text(if short { "date-short" } else { "date-long" }, Some(&args))
302    }
303}
304
305impl Translator {
306    /// "€1,234.56", "1 234,56 €"; whole amounts without cents.
307    pub fn money(&self, m: Money) -> String {
308        let mut args = FluentArgs::new();
309        args.set("value", self.digits(m));
310        self.text(if m.is_negative() { "money-negative" } else { "money-positive" }, Some(&args))
311    }
312
313    /// An amount in another currency, signed, without symbol: "−20", "−1 234,50".
314    pub fn number(&self, m: Money) -> String {
315        let sign = if m.is_negative() { "−" } else { "" };
316        format!("{sign}{}", self.digits(m))
317    }
318
319    /// The digits of an amount, grouped, without sign.
320    fn digits(&self, m: Money) -> String {
321        let (units, cents) = m.split();
322        let grouped = group_thousands(units, &self.text("thousands-separator", None));
323        if cents == 0 { grouped } else { format!("{grouped}{}{cents:02}", self.text("decimal-separator", None)) }
324    }
325
326    /// What a shielded message is about: "work", "a question".
327    pub fn topic(&self, topic: crate::shield::Topic) -> String {
328        let id = match topic {
329            crate::shield::Topic::Work => "topic-work",
330            crate::shield::Topic::Support => "topic-support",
331            crate::shield::Topic::Press => "topic-press",
332            crate::shield::Topic::Thanks => "topic-thanks",
333            crate::shield::Topic::Donation => "topic-donation",
334            crate::shield::Topic::Other => "topic-other",
335        };
336        self.text(id, None)
337    }
338
339    /// "Monday 5 October", "lundi 5 octobre".
340    pub fn day(&self, d: Date) -> String {
341        let mut args = FluentArgs::new();
342        args.set("weekday", self.text(&format!("weekday-{}", d.weekday().to_monday_one_offset()), None));
343        args.set("day", d.day());
344        args.set("month", self.text(&format!("month-{}", d.month()), None));
345        self.text("date-day", Some(&args))
346    }
347
348    /// "Monday 5 October", with its year when it is not `today`'s: "Monday 1 March 2027".
349    pub fn day_in(&self, d: Date, today: Date) -> String {
350        if d.year() == today.year() { self.day(d) } else { format!("{} {}", self.day(d), d.year()) }
351    }
352
353    /// "Mon", "lun.".
354    pub fn weekday_short(&self, d: Date) -> String {
355        let id = format!("weekday-{}", d.weekday().to_monday_one_offset());
356        self.attribute(&id, "short").unwrap_or_else(|| self.text(&id, None))
357    }
358
359    /// "Jun", "juin"; "Jul", "juil.": a month's short name, as a graph's axis says it.
360    pub fn month_short(&self, d: Date) -> String {
361        let id = format!("month-{}", d.month());
362        self.attribute(&id, "short").unwrap_or_else(|| self.text(&id, None))
363    }
364
365    /// "31 October", "31 octobre".
366    pub fn day_month(&self, d: Date) -> String {
367        let mut args = FluentArgs::new();
368        args.set("day", d.day());
369        args.set("month", self.text(&format!("month-{}", d.month()), None));
370        self.text("date-day-month", Some(&args))
371    }
372
373    /// "October 2026", "octobre 2026".
374    pub fn month_year(&self, d: Date) -> String {
375        let mut args = FluentArgs::new();
376        args.set("month", self.text(&format!("month-{}", d.month()), None));
377        args.set("year", d.year().to_string());
378        self.text("month-year", Some(&args))
379    }
380
381    /// One budget's period, in a sentence, and what a reserve does at its end.
382    pub fn budget_lines(&self, title: &str, period: Period, s: &BudgetStatus, reserve_title: Option<&str>) -> Vec<String> {
383        let end = self.day_month(s.end);
384        let mut verdict_args = FluentArgs::new();
385        verdict_args.set("gap", self.money(s.gap.abs()));
386        let verdict = match s.verdict {
387            Verdict::Better => "verdict-better",
388            Verdict::AsPlanned => "verdict-as-planned",
389            Verdict::Short => "verdict-short",
390        };
391        let mut args = FluentArgs::new();
392        args.set("title", title.to_string());
393        args.set("period", self.text(if period == Period::Month { "period-month" } else { "period-year" }, None));
394        args.set("so_far", self.money(s.so_far));
395        args.set("projected", self.money(s.projected));
396        args.set("end", end.clone());
397        args.set("target", self.money(s.target));
398        args.set("verdict", self.text(verdict, Some(&verdict_args)));
399        let mut lines = vec![self.text("budget-line", Some(&args))];
400        if s.earmarked != Money::ZERO {
401            let mut args = FluentArgs::new();
402            args.set("amount", self.money(s.earmarked));
403            lines.push(self.text("budget-earmarked", Some(&args)));
404        }
405        if s.opening != Money::ZERO {
406            let mut args = FluentArgs::new();
407            args.set("opening", self.money(s.opening));
408            lines.push(self.text("budget-opening", Some(&args)));
409        }
410        if let (Some((_, amount)), Some(reserve)) = (&s.reserve_transfer, reserve_title) {
411            let mut args = FluentArgs::new();
412            args.set("reserve", reserve.to_string());
413            args.set("amount", self.money(amount.abs()));
414            args.set("end", end);
415            lines.push(self.text(if amount.is_negative() { "budget-swept" } else { "budget-drawn" }, Some(&args)));
416        }
417        lines
418    }
419
420    /// One reserve, in a sentence or two.
421    pub fn reserve_lines(&self, title: &str, s: &ReserveStatus, today: Date) -> Vec<String> {
422        let mut args = FluentArgs::new();
423        args.set("title", title.to_string());
424        args.set("balance", self.money(s.balance_now));
425        args.set("month_done", self.money(s.month_done));
426        args.set("month_planned", self.money(s.month_planned));
427        args.set("year_end_date", self.day_month(today.last_of_year()));
428        args.set("year_end", self.money(s.year_end));
429        let mut lines = vec![self.text("reserve-line", Some(&args))];
430        match s.months_left {
431            Some(months) => {
432                // A number, for its plural: "about a month", "about 16 months".
433                let mut args = FluentArgs::new();
434                args.set("months", months);
435                lines.push(self.text("reserve-lasts-months", Some(&args)));
436            }
437            None => lines.push(self.text("reserve-quiet", None)),
438        }
439        lines
440    }
441}
442
443/// "1234567" → "1,234,567" with this language's separator.
444fn group_thousands(n: i64, separator: &str) -> String {
445    let digits = n.to_string();
446    let mut out = String::new();
447    for (i, c) in digits.chars().enumerate() {
448        if i > 0 && (digits.len() - i) % 3 == 0 {
449            out.push_str(separator);
450        }
451        out.push(c);
452    }
453    out
454}
455
456fn proof_id(proof: Proof) -> &'static str {
457    match proof {
458        Proof::DmarcPass => "reason-dmarc-pass",
459        Proof::DmarcFail => "reason-dmarc-fail",
460        Proof::DmarcFailNoPolicy => "reason-dmarc-fail-no-policy",
461        Proof::DmarcFailList => "reason-dmarc-fail-list",
462        Proof::DkimPass => "reason-dkim-pass",
463        Proof::BothFailed => "reason-both-failed",
464        Proof::NothingProves => "reason-nothing-proves",
465        Proof::NoResults => "reason-no-results",
466    }
467}
468
469fn kind_id(kind: CodeKind) -> &'static str {
470    match kind {
471        CodeKind::Code => "code-kind-code",
472        CodeKind::Password => "code-kind-password",
473        CodeKind::PasswordReset => "code-kind-reset",
474        CodeKind::SignInLink => "code-kind-link",
475        CodeKind::Confirmation => "code-kind-confirm",
476    }
477}
478
479fn project_title(id: &str, projects: Option<&ProjectStore>) -> String {
480    projects.and_then(|c| c.get(id)).map_or_else(|| id.to_string(), |c| c.title.clone())
481}
482
483/// "fr_FR.UTF-8" → "fr"; "en-GB" → "en".
484fn base_language(code: &str) -> String {
485    code.split(['_', '-', '.', '@']).next().unwrap_or("").trim().to_ascii_lowercase()
486}
487
488/// The language of the session: LC_ALL, LC_MESSAGES, then LANG; English if none.
489pub fn system_language() -> String {
490    ["LC_ALL", "LC_MESSAGES", "LANG"]
491        .iter()
492        .filter_map(|v| std::env::var(v).ok())
493        .map(|v| base_language(&v))
494        .find(|l| !l.is_empty() && l != "c" && l != "posix")
495        .unwrap_or_else(|| "en".to_string())
496}
497
498/// A probability as a whole percentage, never above what it is: 0.9496 is
499/// 94, so that a doubt never reads as the spam threshold it stays below.
500pub fn percent(p: f32) -> String {
501    let p = (f64::from(p) * 100.0 + 1e-4).floor().clamp(0.0, 100.0);
502    format!("{p:.0}")
503}
504
505fn bundle(language: &str) -> Bundle {
506    let id: LanguageIdentifier = language.parse().unwrap_or_default();
507    let mut bundle = Bundle::new_concurrent(vec![id]);
508    // Fluent wraps variables in Unicode isolation marks for mixed scripts; they
509    // show as stray characters in terminals. Off until a right-to-left language arrives.
510    bundle.set_use_isolating(false);
511    let source = LANGUAGES.iter().find(|(l, _)| *l == language).map_or("", |(_, s)| *s);
512    let resource = FluentResource::try_new(source.to_string()).unwrap_or_else(|(partial, _)| partial);
513    // A message defined twice keeps its first definition; nothing else can fail here.
514    let _ = bundle.add_resource(resource);
515    bundle
516}
517
518/// Whether a Fluent file defines the message `id` (a line "id = …"), read
519/// without parsing the file.
520fn defined(source: &str, id: &str) -> bool {
521    source.match_indices(id).any(|(at, _)| {
522        (at == 0 || source.as_bytes()[at - 1] == b'\n') && source[at + id.len()..].trim_start_matches([' ', '\t']).starts_with('=')
523    })
524}
525
526fn format(bundle: &Bundle, id: &str, attribute: Option<&str>, args: Option<&FluentArgs>) -> Option<String> {
527    let message = bundle.get_message(id)?;
528    let pattern = match attribute {
529        Some(a) => message.get_attribute(a)?.value(),
530        None => message.value()?,
531    };
532    let mut errors = Vec::new();
533    Some(bundle.format_pattern(pattern, args, &mut errors).into_owned())
534}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539
540    #[test]
541    fn every_language_parses_and_has_every_message() {
542        for (language, source) in LANGUAGES {
543            let parsed = FluentResource::try_new(source.to_string());
544            assert!(parsed.is_ok(), "{language}: syntax errors");
545        }
546        let english = FluentResource::try_new(LANGUAGES[0].1.to_string()).unwrap();
547        let ids: Vec<String> = english
548            .entries()
549            .filter_map(|e| match e {
550                fluent_syntax::ast::Entry::Message(m) => Some(m.id.name.to_string()),
551                _ => None,
552            })
553            .collect();
554        for (language, _) in LANGUAGES {
555            let t = Translator::new(language);
556            for id in &ids {
557                assert!(t.bundle.has_message(id), "{language} lacks {id}");
558            }
559        }
560    }
561
562    /// A message said twice: the first one is shown, whichever was meant.
563    #[test]
564    fn no_message_twice() {
565        for (language, source) in LANGUAGES {
566            let parsed = FluentResource::try_new(source.to_string()).unwrap();
567            let mut seen = std::collections::HashSet::new();
568            for entry in parsed.entries() {
569                if let fluent_syntax::ast::Entry::Message(m) = entry {
570                    assert!(seen.insert(m.id.name.to_string()), "{language}: {} twice", m.id.name);
571                }
572            }
573        }
574    }
575
576    /// English is parsed for another language only when it holds a message
577    /// that language lacks; a message both have, or neither, parses nothing.
578    #[test]
579    fn english_parsed_only_when_needed() {
580        assert!(defined("a = 1\nab = 2\n", "ab"));
581        assert!(defined("ab=2", "ab"));
582        assert!(!defined("ab = 2\n", "b"));
583        assert!(!defined("a-b = 2\n", "a"));
584        assert!(!defined("# a = 1\n", "a"));
585        let fr = Translator::new("fr");
586        assert_eq!(fr.text("count-1", None), "un");
587        // A count past twelve, in no file (built here, as the code builds it).
588        let past = format!("count-{}", 13);
589        assert_eq!(fr.text(&past, None), past);
590        assert!(fr.fallback.get().is_none());
591        assert!(Translator::new("en").fallback("count-1").is_none());
592    }
593
594    #[test]
595    fn counts_agree_with_their_noun() {
596        let fr = Translator::new("fr_FR.UTF-8");
597        assert_eq!(fr.language(), "fr");
598        assert_eq!(fr.count(1, true, true), "Une");
599        assert_eq!(fr.count(1, true, false), "Un");
600        assert_eq!(fr.count(2, true, true), "Deux");
601        assert_eq!(fr.count(20, false, false), "20");
602        assert_eq!(Translator::new("de").language(), "en");
603    }
604
605    #[test]
606    fn money_in_each_language() {
607        assert_eq!(Translator::new("en").money(Money(-123456)), "−€1,234.56");
608        assert_eq!(Translator::new("fr").money(Money(-123456)), "−1\u{202f}234,56\u{a0}€");
609        assert_eq!(Translator::new("fr").money(Money(25000)), "250\u{a0}€");
610    }
611
612    #[test]
613    fn months_short_and_counted() {
614        let june: Date = "2026-06-01".parse().unwrap();
615        let july: Date = "2026-07-01".parse().unwrap();
616        let fr = Translator::new("fr");
617        assert_ne!(fr.month_short(june), fr.month_short(july), "juin and juil. told apart");
618        let status = |months: i64| ReserveStatus { id: String::new(), balance_now: Money::ZERO, month_done: Money::ZERO, month_planned: Money::ZERO, year_done: Money::ZERO, year_planned: Money::ZERO, year_end: Money::ZERO, months_left: Some(months) };
619        let en = Translator::new("en");
620        assert_eq!(en.reserve_lines("Savings", &status(1), june)[1], "At this pace it lasts about a month.");
621        assert_eq!(en.reserve_lines("Savings", &status(16), june)[1], "At this pace it lasts about 16 months.");
622        assert_eq!(fr.reserve_lines("Livret", &status(0), june)[1], "À ce rythme, elle est déjà à son plancher.");
623        assert_eq!(fr.reserve_lines("Livret", &status(1), june)[1], "À ce rythme, elle tient environ un mois.");
624    }
625
626    #[test]
627    fn dates_in_each_language() {
628        let at: Zoned = "2026-10-06T10:00[Europe/Paris]".parse().unwrap();
629        assert_eq!(Translator::new("en").when(&at), "on Tuesday 6 October at 10:00");
630        assert_eq!(Translator::new("fr").when(&at), "le mardi 6 octobre à 10:00");
631        assert_eq!(Translator::new("fr").date(&at, true), "mar. 6 oct. 10:00");
632        // French says the first day of a month "1er".
633        let first = jiff::civil::Date::constant(2026, 11, 1);
634        assert_eq!(Translator::new("fr").day_month(first), "1er novembre");
635        assert_eq!(Translator::new("en").day_month(first), "1 November");
636    }
637}