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}