Skip to main content

whitaker_common/i18n/
loader.rs

1//! Localization message loading and retrieval.
2//!
3//! This module defines the [`Localizer`] struct for resolving translated
4//! messages from Fluent bundles, along with supporting types for arguments
5//! and lookup errors.
6
7use std::borrow::Cow;
8use std::collections::HashMap;
9use std::str::FromStr;
10
11use fluent_templates::{Loader, fluent_bundle::FluentValue};
12use thiserror::Error;
13
14use super::locales::supports_locale;
15use super::{FALLBACK_LANGUAGE, FALLBACK_LITERAL, LOADER, LanguageIdentifier};
16
17/// HashMap wrapper used when passing Fluent arguments to lookups.
18pub type Arguments<'a> = HashMap<Cow<'a, str>, FluentValue<'a>>;
19
20/// Error raised when localization data cannot satisfy a caller request.
21#[derive(Clone, Debug, Error, PartialEq, Eq)]
22pub enum I18nError {
23    /// Raised when the requested message slug is missing for the resolved locale.
24    #[error("message `{key}` missing for locale `{locale}`")]
25    MissingMessage { key: String, locale: String },
26}
27
28/// Resolve localization messages for a specific locale.
29///
30/// The loader eagerly falls back to `en-GB` when the requested locale is not
31/// recognized. This mirrors the planned lookup order that surfaces explicit
32/// configuration, environment overrides, and finally the bundled fallback.
33#[derive(Clone, Debug)]
34pub struct Localizer {
35    language: LanguageIdentifier,
36    language_tag: String,
37    fallback_used: bool,
38}
39
40impl Localizer {
41    /// Create a localizer for `locale`, falling back to [`crate::i18n::FALLBACK_LOCALE`].
42    ///
43    /// ```
44    /// use whitaker_common::i18n::{available_locales, Localizer};
45    ///
46    /// let locale = Localizer::new(Some("cy"));
47    /// assert!(available_locales().contains(&"cy".to_string()));
48    /// assert_eq!(locale.locale(), "cy");
49    /// assert!(!locale.used_fallback());
50    ///
51    /// let fallback = Localizer::new(Some("zz"));
52    /// assert_eq!(fallback.locale(), "en-GB");
53    /// assert!(fallback.used_fallback());
54    /// ```
55    #[must_use]
56    pub fn new(locale: Option<&str>) -> Self {
57        match locale {
58            Some(value) if supports_locale(value) => match LanguageIdentifier::from_str(value) {
59                Ok(identifier) => {
60                    let language_tag = identifier.to_string();
61
62                    Self {
63                        language: identifier,
64                        language_tag,
65                        fallback_used: false,
66                    }
67                }
68                Err(_) => Self::fallback(),
69            },
70            _ => Self::fallback(),
71        }
72    }
73
74    /// Return the resolved locale identifier.
75    #[must_use]
76    pub fn language(&self) -> &LanguageIdentifier {
77        &self.language
78    }
79
80    /// Return the resolved locale as a string slice.
81    #[must_use]
82    pub fn locale(&self) -> &str {
83        &self.language_tag
84    }
85
86    /// Whether the fallback locale was used.
87    #[must_use]
88    pub fn used_fallback(&self) -> bool {
89        self.fallback_used
90    }
91
92    /// Fetch the translated message for `key`.
93    pub fn message(&self, key: &str) -> Result<String, I18nError> {
94        self.lookup(key, None, None)
95    }
96
97    /// Fetch the translated message with Fluent arguments.
98    pub fn message_with_args(&self, key: &str, args: &Arguments<'_>) -> Result<String, I18nError> {
99        self.lookup(key, None, Some(args))
100    }
101
102    /// Fetch a translated attribute, e.g. `function.primary`.
103    pub fn attribute(&self, key: &str, attribute: &str) -> Result<String, I18nError> {
104        self.lookup(key, Some(attribute), None)
105    }
106
107    /// Fetch a translated attribute with Fluent arguments.
108    pub fn attribute_with_args(
109        &self,
110        key: &str,
111        attribute: &str,
112        args: &Arguments<'_>,
113    ) -> Result<String, I18nError> {
114        self.lookup(key, Some(attribute), Some(args))
115    }
116
117    fn lookup(
118        &self,
119        key: &str,
120        attribute: Option<&str>,
121        args: Option<&Arguments<'_>>,
122    ) -> Result<String, I18nError> {
123        let lookup_key = attribute
124            .map(|attr| format!("{key}.{attr}"))
125            .unwrap_or_else(|| key.to_string());
126
127        let maybe_value = match args {
128            Some(arguments) => {
129                let owned_arguments = promote_arguments(arguments);
130                LOADER.try_lookup_with_args(&self.language, lookup_key.as_str(), &owned_arguments)
131            }
132            None => LOADER.try_lookup(&self.language, lookup_key.as_str()),
133        };
134
135        maybe_value.ok_or_else(|| I18nError::MissingMessage {
136            key: lookup_key,
137            locale: self.language_tag.clone(),
138        })
139    }
140
141    fn fallback() -> Self {
142        Self {
143            language: FALLBACK_LANGUAGE.clone(),
144            language_tag: FALLBACK_LITERAL.to_string(),
145            fallback_used: true,
146        }
147    }
148}
149
150fn promote_arguments(
151    arguments: &Arguments<'_>,
152) -> HashMap<Cow<'static, str>, FluentValue<'static>> {
153    arguments
154        .iter()
155        .map(|(key, value)| (Cow::Owned(key.as_ref().to_string()), promote_value(value)))
156        .collect()
157}
158
159fn promote_value(value: &FluentValue<'_>) -> FluentValue<'static> {
160    match value {
161        FluentValue::String(text) => FluentValue::String(Cow::Owned(text.as_ref().to_string())),
162        FluentValue::Number(number) => FluentValue::Number(number.clone()),
163        FluentValue::Custom(custom) => FluentValue::Custom(custom.duplicate()),
164        FluentValue::None => FluentValue::None,
165        FluentValue::Error => FluentValue::Error,
166    }
167}