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