Skip to main content

cratefield_i18n/
lib.rs

1//! `cratefield-i18n`: the strings a venture owns, in the languages it owns
2//! them in (issue #190).
3//!
4//! Nothing here is notification-specific. A [`Catalog`] answers "render
5//! message `booking-confirmed`, attribute `.title`, in `id-ID`", and the
6//! caller decides what to do with the answer — a push title, a mail
7//! subject, a waitlist template.
8//!
9//! ```
10//! use cratefield_i18n::{Args, Catalog, FluentCatalog, TITLE, localize};
11//!
12//! let catalog = FluentCatalog::builder()
13//!     .default_locale("en")
14//!     .locale("en", "booking-confirmed =\n    .title = Booking confirmed\n")
15//!     .locale("id", "booking-confirmed =\n    .title = Pesanan dikonfirmasi\n")
16//!     .build()
17//!     .expect("the catalog parses");
18//!
19//! let asked = "id-ID".parse().expect("a language tag");
20//! let rendered = localize(&catalog, &asked, "booking-confirmed", TITLE, &Args::new());
21//! assert_eq!(rendered.text, "Pesanan dikonfirmasi");
22//! assert_eq!(rendered.locale.to_string(), "id"); // id-ID negotiated down to id
23//! assert!(!rendered.missing);
24//! ```
25//!
26//! # The three things this crate exists to get right
27//!
28//! **Thread safety.** The default `FluentBundle` is built on `Rc` and
29//! `RefCell`: it is neither `Send` nor `Sync`, so it cannot live behind a
30//! port that is. `SendWrapper` does not fix it — that grants `Send` alone,
31//! and panics if the value is dropped on another thread. This crate uses
32//! the **concurrent** memoizer (`Arc`/`RwLock`) instead, and a static
33//! assertion in the `fluent` module pins it so the day somebody swaps the import
34//! back is a compile error rather than a runtime one.
35//!
36//! **Direction.** `unic-langid` parses language identifiers and carries no
37//! directionality data at all: there is no `is_rtl()` to call. Direction
38//! therefore comes from an explicit script and language list here — see
39//! [`direction`] — and is tested.
40//!
41//! **A missing key is visible.** [`localize`] never returns an empty
42//! string for a key the catalog does not have. It renders the key itself
43//! and sets [`Localized::missing`], so the caller can say so on the bus and
44//! whoever reads the notification sees `booking-confirmed.title` rather
45//! than a blank line.
46//!
47//! # What a caller must not do with the answer
48//!
49//! [`Args`] values come from the caller of a notification — a coach's name,
50//! a place, a booking reference. A rendered string is therefore **personal
51//! data**: it belongs in the message that is delivered and nowhere else. No
52//! error, event or log line in this crate ever carries an argument value or
53//! a rendered string, and a caller must keep that boundary too.
54
55#![forbid(unsafe_code)]
56
57mod accept;
58mod direction;
59mod fixture;
60mod fluent;
61
62pub use accept::accept_language;
63pub use direction::{Direction, direction};
64pub use fixture::StaticCatalog;
65pub use fluent::{CatalogError, FluentCatalog, FluentCatalogBuilder};
66pub use unic_langid::LanguageIdentifier;
67
68/// The attribute a title is stored under: `booking-confirmed.title`.
69pub const TITLE: &str = "title";
70/// The attribute a body is stored under.
71pub const BODY: &str = "body";
72/// The attribute a mail subject is stored under, where a venture wants one
73/// that is not the title.
74pub const SUBJECT: &str = "subject";
75
76/// The placeables a message is rendered with: `{ $coach }`, `{ $count }`.
77///
78/// A JSON object rather than a type of this crate's own, because that is
79/// what a caller already has — an event payload, a `serde_json::json!`
80/// literal — and because a number has to stay a number: Fluent selects the
81/// plural form from it, and `"2"` as a string selects `other` in every
82/// language.
83pub type Args = serde_json::Map<String, serde_json::Value>;
84
85/// A catalog of a venture's own strings.
86///
87/// Implemented by [`FluentCatalog`] over embedded `.ftl` sources, and by
88/// [`StaticCatalog`] for tests that do not want a parser in the way.
89///
90/// `Send + Sync` is the whole point: a catalog is built once at cold start
91/// and read from every request and every drain.
92pub trait Catalog: Send + Sync {
93    /// Every locale this catalog can render, the default included.
94    fn locales(&self) -> &[LanguageIdentifier];
95
96    /// The locale a request that negotiates to nothing falls back to. It
97    /// is the one locale a venture promises is complete.
98    fn default_locale(&self) -> &LanguageIdentifier;
99
100    /// Renders one attribute in exactly `locale` — no negotiation, no
101    /// fallback. `None` when this locale has no such message or no such
102    /// attribute on it.
103    fn render(
104        &self,
105        locale: &LanguageIdentifier,
106        key: &str,
107        attribute: &str,
108        args: &Args,
109    ) -> Option<String>;
110
111    /// Whether `locale` has `key.attribute` at all.
112    ///
113    /// Separate from [`Catalog::render`] because the completeness check
114    /// (`fz doctor`) asks about thousands of pairs and cares about none of
115    /// the answers: formatting each one would run the resolver over
116    /// arguments nobody supplied.
117    fn has(&self, locale: &LanguageIdentifier, key: &str, attribute: &str) -> bool {
118        self.render(locale, key, attribute, &Args::new()).is_some()
119    }
120
121    /// The locale in this catalog that best answers `requested`, by RFC
122    /// 4647 language-range matching: `id-ID` matches `id`, and anything
123    /// that matches nothing falls back to [`Catalog::default_locale`].
124    fn negotiate(&self, requested: &LanguageIdentifier) -> LanguageIdentifier {
125        let default = self.default_locale();
126        fluent_langneg::negotiate_languages(
127            std::slice::from_ref(requested),
128            self.locales(),
129            Some(default),
130            fluent_langneg::NegotiationStrategy::Filtering,
131        )
132        .first()
133        .map_or_else(|| default.clone(), |found| (*found).clone())
134    }
135}
136
137/// One rendered string, and what it took to get there.
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct Localized {
140    /// The text to deliver. For a missing key this is the key itself —
141    /// never an empty string.
142    pub text: String,
143    /// The locale it was actually rendered in, after negotiation and any
144    /// fallback to the default. This is what `lang`/`dir` and
145    /// `Content-Language` must be set from: a request for `ar` that fell
146    /// back to English is an English message, and telling a mail client it
147    /// is Arabic would right-align it.
148    pub locale: LanguageIdentifier,
149    /// The catalog had no such key or attribute in any locale, so
150    /// [`Localized::text`] is the key. Never silent: the caller is expected
151    /// to say so where somebody will see it.
152    pub missing: bool,
153}
154
155/// Renders `key.attribute` for a recipient who asked for `requested`.
156///
157/// Three steps, in this order: negotiate `requested` down to a locale the
158/// catalog has; render there; and if that locale is missing this particular
159/// message, fall back to the catalog's default locale. Only if the default
160/// does not have it either is the answer [`Localized::missing`], because at
161/// that point no translation of it exists anywhere.
162///
163/// The fallback is per **message**, not per catalog, which is what makes a
164/// half-translated locale useful: an `id` catalog that has
165/// `booking-confirmed` but not `coach-notes` renders the first in
166/// Indonesian and the second in English, rather than forcing a venture to
167/// choose between shipping an incomplete locale and shipping none.
168#[must_use]
169pub fn localize(
170    catalog: &dyn Catalog,
171    requested: &LanguageIdentifier,
172    key: &str,
173    attribute: &str,
174    args: &Args,
175) -> Localized {
176    let negotiated = catalog.negotiate(requested);
177    if let Some(text) = catalog.render(&negotiated, key, attribute, args) {
178        return Localized {
179            text,
180            locale: negotiated,
181            missing: false,
182        };
183    }
184    let default = catalog.default_locale().clone();
185    if default != negotiated
186        && let Some(text) = catalog.render(&default, key, attribute, args)
187    {
188        return Localized {
189            text,
190            locale: default,
191            missing: false,
192        };
193    }
194    Localized {
195        text: missing_text(key, attribute),
196        locale: default,
197        missing: true,
198    }
199}
200
201/// What a message no locale has renders as: the key and the attribute,
202/// exactly as a caller wrote them.
203///
204/// Deliberately not an empty string and not the venture's name for
205/// "something went wrong". A push whose body is blank looks delivered; one
206/// that reads `booking-confirmed.body` is a bug report from the person who
207/// received it, and it names the key somebody has to add.
208#[must_use]
209pub fn missing_text(key: &str, attribute: &str) -> String {
210    format!("{key}.{attribute}")
211}
212
213/// One `key.attribute` a locale does not have, as the completeness check
214/// reports it.
215///
216/// Carries identifiers only — a locale, a message id, an attribute name.
217/// Never a rendered string: those are built from caller-supplied arguments
218/// and are personal data, and this type exists to be printed.
219#[derive(Debug, Clone, PartialEq, Eq)]
220pub struct Missing {
221    /// The locale that is missing it.
222    pub locale: LanguageIdentifier,
223    /// The message id.
224    pub key: String,
225    /// The attribute on it.
226    pub attribute: String,
227}
228
229impl std::fmt::Display for Missing {
230    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
231        write!(f, "{}: {}.{}", self.locale, self.key, self.attribute)
232    }
233}
234
235/// Every `key.attribute` pair that some locale in `catalog` cannot render,
236/// in locale order then key order.
237///
238/// This is the build-time half of "a missing key is visible": the run-time
239/// half renders the key and says so on the bus, and this one lists them
240/// before a deployment ever sends anything. A venture passes the keys it
241/// actually uses, because a catalog cannot know which messages a caller
242/// names.
243#[must_use]
244pub fn missing_messages(catalog: &dyn Catalog, keys: &[&str], attributes: &[&str]) -> Vec<Missing> {
245    let mut missing = Vec::new();
246    for locale in catalog.locales() {
247        for key in keys {
248            for attribute in attributes {
249                if !catalog.has(locale, key, attribute) {
250                    missing.push(Missing {
251                        locale: locale.clone(),
252                        key: (*key).to_owned(),
253                        attribute: (*attribute).to_owned(),
254                    });
255                }
256            }
257        }
258    }
259    missing
260}
261
262/// The one place a language tag enters this system from outside it.
263///
264/// A locale arrives from a request body, an `Accept-Language` header or a
265/// database column, and every one of those is arbitrary text. Parsing it
266/// here means a `LanguageIdentifier` is the only form that reaches a
267/// column, a log or a rendered page — so a header of 4 KB of HTML, or a
268/// device token somebody put in the wrong field, becomes `None` and the
269/// caller uses its default rather than storing what it was sent.
270///
271/// Canonicalising is part of the guarantee: `EN-us` and `en-US` are one
272/// locale, and a column that held both would negotiate the same recipient
273/// two different ways on two different days.
274#[must_use]
275pub fn parse_locale(raw: &str) -> Option<LanguageIdentifier> {
276    let trimmed = raw.trim();
277    // A language subtag is 2-8 characters and the whole tag is short; the
278    // parser would reject a long input anyway, but not before allocating
279    // over it.
280    if trimmed.is_empty() || trimmed.len() > 64 {
281        return None;
282    }
283    trimmed.parse().ok()
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    fn langid(tag: &str) -> LanguageIdentifier {
291        tag.parse().expect("a language tag")
292    }
293
294    fn catalog() -> StaticCatalog {
295        StaticCatalog::new("en")
296            .with("en", "booking-confirmed", TITLE, "Booking confirmed")
297            .with("en", "booking-confirmed", BODY, "See you on { $day }")
298            .with("en", "coach-notes", TITLE, "New notes")
299            .with("id", "booking-confirmed", TITLE, "Pesanan dikonfirmasi")
300            .with("id", "booking-confirmed", BODY, "Sampai jumpa { $day }")
301    }
302
303    #[test]
304    fn a_regional_tag_negotiates_down_to_the_language_it_is() {
305        let rendered = localize(
306            &catalog(),
307            &langid("id-ID"),
308            "booking-confirmed",
309            TITLE,
310            &Args::new(),
311        );
312        assert_eq!(rendered.text, "Pesanan dikonfirmasi");
313        assert_eq!(rendered.locale, langid("id"));
314    }
315
316    #[test]
317    fn a_locale_the_catalog_does_not_have_falls_back_to_the_default() {
318        let rendered = localize(
319            &catalog(),
320            &langid("fr-CA"),
321            "booking-confirmed",
322            TITLE,
323            &Args::new(),
324        );
325        assert_eq!(rendered.text, "Booking confirmed");
326        assert_eq!(
327            rendered.locale,
328            langid("en"),
329            "the locale reported must be the one it was rendered in, or the caller sets \
330             lang=\"fr\" on English text"
331        );
332        assert!(!rendered.missing);
333    }
334
335    #[test]
336    fn a_half_translated_locale_falls_back_per_message_not_per_catalog() {
337        // `coach-notes` exists in `en` only. The point of the per-message
338        // fallback: `id` stays useful for everything it does have.
339        let rendered = localize(
340            &catalog(),
341            &langid("id"),
342            "coach-notes",
343            TITLE,
344            &Args::new(),
345        );
346        assert_eq!(rendered.text, "New notes");
347        assert_eq!(rendered.locale, langid("en"));
348        assert!(!rendered.missing);
349    }
350
351    #[test]
352    fn a_key_no_locale_has_renders_as_the_key_and_says_so() {
353        let rendered = localize(
354            &catalog(),
355            &langid("id"),
356            "room-starting",
357            BODY,
358            &Args::new(),
359        );
360        assert!(rendered.missing, "a key nothing has must not pass silently");
361        assert_eq!(rendered.text, "room-starting.body");
362        assert!(
363            !rendered.text.is_empty(),
364            "an empty body looks delivered; the key is a bug report"
365        );
366    }
367
368    #[test]
369    fn garbage_is_refused_at_the_boundary_rather_than_stored() {
370        // The failure arm. Every one of these would otherwise reach a
371        // column, and from there a log and a rendered page.
372        for raw in [
373            "",
374            "   ",
375            "not a language",
376            "<script>alert(1)</script>",
377            "en; DROP TABLE notifications_subscriptions",
378            "e",
379            "../../etc/passwd",
380        ] {
381            assert!(
382                parse_locale(raw).is_none(),
383                "{raw:?} is not a language tag and must not become one"
384            );
385        }
386        // And the long one, which the parser would reject anyway but only
387        // after copying it.
388        assert!(parse_locale(&"a".repeat(4096)).is_none());
389    }
390
391    #[test]
392    fn a_tag_that_parses_is_stored_canonicalised() {
393        // `EN-us` and `en-US` are one locale. Two spellings in a column
394        // negotiate the same recipient two different ways.
395        let parsed = parse_locale("EN-us").expect("a valid tag");
396        assert_eq!(parsed.to_string(), "en-US");
397        assert_eq!(
398            parse_locale(" id-ID ").map(|l| l.to_string()).as_deref(),
399            Some("id-ID")
400        );
401    }
402
403    #[test]
404    fn the_completeness_check_lists_ids_and_never_a_rendered_string() {
405        let missing = missing_messages(
406            &catalog(),
407            &["booking-confirmed", "coach-notes"],
408            &[TITLE, BODY],
409        );
410        let listed: Vec<String> = missing.iter().map(ToString::to_string).collect();
411        assert_eq!(
412            listed,
413            [
414                "en: coach-notes.body",
415                "id: coach-notes.title",
416                "id: coach-notes.body",
417            ],
418            "{listed:?}"
419        );
420        for entry in &missing {
421            assert!(
422                !entry.to_string().contains("See you"),
423                "the report is identifiers only: {entry}"
424            );
425        }
426    }
427}