Skip to main content

anycms_i18n/
backend.rs

1//! Translation backend implementations.
2
3use std::collections::HashMap;
4#[cfg(feature = "fs-loader")]
5use std::path::Path;
6
7use crate::core::Reloadable;
8use crate::error::I18nError;
9use crate::flat_backend::FlatBackend;
10
11// ---- TOML parsing ----
12
13/// Parse a TOML string into a flat `key → value` map.
14///
15/// Nested TOML tables are flattened to dot-separated keys.
16/// Plural groups (containing zero/one/two/few/many/other) are stored as `key.category`.
17fn parse_toml(locale: &str, content: &str) -> Result<HashMap<String, String>, I18nError> {
18    let data: toml::map::Map<String, toml::Value> =
19        toml::from_str(content).map_err(|e| I18nError::TomlParseError {
20            locale: locale.to_string(),
21            source: e,
22        })?;
23
24    let mut messages = HashMap::new();
25    flatten_toml(&data, "", &mut messages);
26    Ok(messages)
27}
28
29/// Recursively flatten nested TOML tables into `a.b.c` dot-separated keys.
30fn flatten_toml(
31    values: &toml::map::Map<String, toml::Value>,
32    prefix: &str,
33    out: &mut HashMap<String, String>,
34) {
35    for (key, value) in values {
36        let full_key = if prefix.is_empty() {
37            key.clone()
38        } else {
39            format!("{prefix}.{key}")
40        };
41
42        match value {
43            toml::Value::String(s) => {
44                out.insert(full_key, s.clone());
45            }
46            toml::Value::Table(table) => {
47                // Check if this is a plural group (contains zero/one/two/few/many/other)
48                let is_plural = table.keys().any(|k| {
49                    matches!(
50                        k.as_str(),
51                        "zero" | "one" | "two" | "few" | "many" | "other"
52                    )
53                });
54
55                if is_plural {
56                    // Store each plural form as "key.category"
57                    for (cat, val) in table {
58                        if let toml::Value::String(s) = val {
59                            out.insert(format!("{full_key}.{cat}"), s.clone());
60                        }
61                    }
62                } else {
63                    // Recurse into nested table
64                    flatten_toml(table, &full_key, out);
65                }
66            }
67            toml::Value::Integer(i) => {
68                out.insert(full_key, i.to_string());
69            }
70            _ => {
71                // Skip arrays, floats, bools, datetimes
72            }
73        }
74    }
75}
76
77// ---- TomlBackend ----
78
79/// A compile-time-embeddable TOML translation backend.
80///
81/// Stores translations in a [`FlatBackend`] (thread-safe `DashMap`).
82/// Supports both compile-time embedding (via `include_str!`)
83/// and runtime file loading.
84pub struct TomlBackend {
85    inner: FlatBackend,
86}
87
88impl Default for TomlBackend {
89    fn default() -> Self {
90        Self::new()
91    }
92}
93
94impl TomlBackend {
95    /// Create an empty backend.
96    pub fn new() -> Self {
97        Self {
98            inner: FlatBackend::new(),
99        }
100    }
101
102    /// Build from a slice of `(locale, toml_content)` pairs.
103    ///
104    /// Typical usage with compile-time embedding:
105    /// ```rust,ignore
106    /// TomlBackend::from_embedded(&[
107    ///     ("en", include_str!("../../locales/en.toml")),
108    ///     ("zh-CN", include_str!("../../locales/zh-CN.toml")),
109    /// ])
110    /// ```
111    pub fn from_embedded(pairs: &[(&str, &str)]) -> Result<Self, I18nError> {
112        let backend = Self::new();
113        for (locale, content) in pairs {
114            backend.add_locale_from_str(locale, content)?;
115        }
116        Ok(backend)
117    }
118
119    /// Build by loading `.toml` files from a directory at runtime.
120    ///
121    /// Expects files named `<locale>.toml`, e.g. `en.toml`, `zh-CN.toml`.
122    #[cfg(feature = "fs-loader")]
123    pub fn from_dir(path: impl AsRef<Path>) -> Result<Self, I18nError> {
124        let backend = Self::new();
125        backend
126            .inner
127            .load_dir(path, "toml", &|locale, content| parse_toml(locale, content))?;
128        Ok(backend)
129    }
130
131    /// Like [`from_dir`](Self::from_dir), but returns an empty backend when the
132    /// directory does not exist instead of erroring.
133    ///
134    /// Use this when compiled/embedded translations serve as defaults and the
135    /// runtime directory is an optional override layer.
136    #[cfg(feature = "fs-loader")]
137    pub fn try_from_dir(path: impl AsRef<Path>) -> Result<Self, I18nError> {
138        let backend = Self::new();
139        backend
140            .inner
141            .try_load_dir(path, "toml", &|locale, content| parse_toml(locale, content))?;
142        Ok(backend)
143    }
144
145    /// Parse a TOML string and merge it into the backend under the given locale.
146    pub fn add_locale_from_str(&self, locale: &str, content: &str) -> Result<(), I18nError> {
147        let messages = parse_toml(locale, content)?;
148        self.inner.add_locale(locale, messages);
149        Ok(())
150    }
151
152    /// Get a reference to the inner [`FlatBackend`].
153    pub fn inner(&self) -> &FlatBackend {
154        &self.inner
155    }
156}
157
158impl crate::core::Backend for TomlBackend {
159    fn get(&self, locale: &str, key: &str) -> Option<String> {
160        self.inner.get(locale, key)
161    }
162
163    fn available_locales(&self) -> Vec<String> {
164        self.inner.available_locales()
165    }
166
167    fn has_locale(&self, locale: &str) -> bool {
168        self.inner.has_locale(locale)
169    }
170
171    fn dump(&self, locale: &str) -> HashMap<String, String> {
172        self.inner.dump(locale)
173    }
174}
175
176impl Reloadable for TomlBackend {
177    fn reload_from_str(&self, locale: &str, content: &str) -> Result<(), I18nError> {
178        self.add_locale_from_str(locale, content)
179    }
180
181    fn file_extension(&self) -> &'static str {
182        "toml"
183    }
184}
185
186// ---- ChainedBackend ----
187
188/// A backend that chains multiple backends together.
189///
190/// Looks up translations in order; the first backend that returns
191/// a result wins. This allows e.g. database overrides to take
192/// priority over file-based translations.
193pub struct ChainedBackend {
194    backends: Vec<std::sync::Arc<dyn crate::core::Backend>>,
195}
196
197impl ChainedBackend {
198    /// Create a new chained backend from an ordered list of backends.
199    ///
200    /// Backends are queried in order; the first match wins.
201    pub fn new(backends: Vec<std::sync::Arc<dyn crate::core::Backend>>) -> Self {
202        Self { backends }
203    }
204
205    /// Add a backend to the chain (highest priority — queried first).
206    pub fn push_front(&mut self, backend: std::sync::Arc<dyn crate::core::Backend>) {
207        self.backends.insert(0, backend);
208    }
209
210    /// Add a backend to the end of the chain (lowest priority).
211    pub fn push_back(&mut self, backend: std::sync::Arc<dyn crate::core::Backend>) {
212        self.backends.push(backend);
213    }
214}
215
216impl crate::core::Backend for ChainedBackend {
217    fn get(&self, locale: &str, key: &str) -> Option<String> {
218        for backend in &self.backends {
219            if let Some(value) = backend.get(locale, key) {
220                return Some(value);
221            }
222        }
223        None
224    }
225
226    fn available_locales(&self) -> Vec<String> {
227        let mut locales = std::collections::HashSet::new();
228        for backend in &self.backends {
229            for locale in backend.available_locales() {
230                locales.insert(locale);
231            }
232        }
233        locales.into_iter().collect()
234    }
235
236    fn has_locale(&self, locale: &str) -> bool {
237        self.backends.iter().any(|b| b.has_locale(locale))
238    }
239
240    fn dump(&self, locale: &str) -> HashMap<String, String> {
241        // `backends` is ordered [highest priority, ..., lowest priority].
242        // Merge from lowest priority to highest so that higher-priority keys
243        // overwrite lower-priority ones.
244        let mut merged = HashMap::new();
245        for backend in self.backends.iter().rev() {
246            for (k, v) in backend.dump(locale) {
247                merged.insert(k, v);
248            }
249        }
250        merged
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use crate::core::Backend;
258    use std::sync::Arc;
259
260    #[test]
261    fn test_flatten_simple() {
262        let toml_str = r#"
263app.title = "My App"
264app.desc = "A cool app"
265"#;
266        let backend = TomlBackend::new();
267        backend.add_locale_from_str("en", toml_str).unwrap();
268
269        assert_eq!(backend.get("en", "app.title").unwrap(), "My App");
270        assert_eq!(backend.get("en", "app.desc").unwrap(), "A cool app");
271    }
272
273    #[test]
274    fn test_flatten_nested_table() {
275        let toml_str = r#"
276[errors]
277not_found = "Not found"
278unauthorized = "Unauthorized"
279
280[errors.auth]
281expired = "Session expired"
282"#;
283        let backend = TomlBackend::new();
284        backend.add_locale_from_str("en", toml_str).unwrap();
285
286        assert_eq!(backend.get("en", "errors.not_found").unwrap(), "Not found");
287        assert_eq!(
288            backend.get("en", "errors.auth.expired").unwrap(),
289            "Session expired"
290        );
291    }
292
293    #[test]
294    fn test_plural_group() {
295        let toml_str = r#"
296[items]
297zero = "No items"
298one = "One item"
299other = "%{count} items"
300"#;
301        let backend = TomlBackend::new();
302        backend.add_locale_from_str("en", toml_str).unwrap();
303
304        assert_eq!(backend.get("en", "items.zero").unwrap(), "No items");
305        assert_eq!(backend.get("en", "items.one").unwrap(), "One item");
306        assert_eq!(backend.get("en", "items.other").unwrap(), "%{count} items");
307    }
308
309    #[test]
310    fn test_toml_backend_implements_reloadable() {
311        let backend = TomlBackend::new();
312        assert_eq!(backend.file_extension(), "toml");
313        backend.reload_from_str("en", r#"hello = "Hi""#).unwrap();
314        assert_eq!(backend.get("en", "hello").unwrap(), "Hi");
315    }
316
317    #[test]
318    fn test_chained_backend() {
319        let b1 = {
320            let b = TomlBackend::new();
321            b.add_locale_from_str("en", r#"hello = "Hi""#).unwrap();
322            b.add_locale_from_str("zh-CN", r#"hello = "你好""#).unwrap();
323            Arc::new(b) as Arc<dyn crate::core::Backend>
324        };
325        let b2 = {
326            let b = TomlBackend::new();
327            b.add_locale_from_str(
328                "en",
329                r#"hello = "Hello"
330bye = "Goodbye""#,
331            )
332            .unwrap();
333            Arc::new(b) as Arc<dyn crate::core::Backend>
334        };
335
336        let chained = ChainedBackend::new(vec![b1, b2]);
337
338        // b1 has priority for "en.hello"
339        assert_eq!(chained.get("en", "hello").unwrap(), "Hi");
340        // falls through to b2 for "en.bye"
341        assert_eq!(chained.get("en", "bye").unwrap(), "Goodbye");
342        // both have the locale
343        assert!(chained.has_locale("zh-CN"));
344    }
345
346    #[cfg(feature = "fs-loader")]
347    #[test]
348    fn test_try_from_dir_missing_directory() {
349        let backend = TomlBackend::try_from_dir("/no/such/i18n/dir").unwrap();
350        // Should succeed with an empty backend
351        assert!(backend.available_locales().is_empty());
352    }
353
354    #[cfg(feature = "fs-loader")]
355    #[test]
356    fn test_try_from_dir_existing_directory() {
357        let dir = tempfile::tempdir().unwrap();
358        std::fs::write(
359            dir.path().join("en.toml"),
360            r#"hello = "Hi"
361bye = "Goodbye""#,
362        )
363        .unwrap();
364
365        let backend = TomlBackend::try_from_dir(dir.path()).unwrap();
366        assert_eq!(backend.get("en", "hello").unwrap(), "Hi");
367        assert_eq!(backend.get("en", "bye").unwrap(), "Goodbye");
368    }
369
370    #[test]
371    fn test_chained_backend_dump_priority() {
372        // b1 = highest priority; b2 = lowest priority.
373        let b1 = {
374            let b = TomlBackend::new();
375            b.add_locale_from_str("en", r#"a = "A1""#).unwrap();
376            Arc::new(b) as Arc<dyn crate::core::Backend>
377        };
378        let b2 = {
379            let b = TomlBackend::new();
380            b.add_locale_from_str(
381                "en",
382                r#"a = "A2"
383b = "B""#,
384            )
385            .unwrap();
386            Arc::new(b) as Arc<dyn crate::core::Backend>
387        };
388
389        let chained = ChainedBackend::new(vec![b1, b2]);
390        let dumped = chained.dump("en");
391
392        // High priority (b1) wins for "a"; low priority (b2) provides "b".
393        assert_eq!(dumped.get("a").unwrap(), "A1");
394        assert_eq!(dumped.get("b").unwrap(), "B");
395        assert_eq!(dumped.len(), 2);
396
397        // Missing locale -> empty map.
398        assert!(chained.dump("zh-CN").is_empty());
399    }
400
401    #[test]
402    fn test_toml_backend_dump() {
403        let backend = TomlBackend::new();
404        backend
405            .add_locale_from_str(
406                "en",
407                r#"hello = "Hi"
408bye = "Goodbye""#,
409            )
410            .unwrap();
411
412        let dumped = backend.dump("en");
413        assert_eq!(dumped.get("hello").unwrap(), "Hi");
414        assert_eq!(dumped.get("bye").unwrap(), "Goodbye");
415        assert_eq!(dumped.len(), 2);
416
417        // Missing locale -> empty map.
418        assert!(backend.dump("zh-CN").is_empty());
419    }
420
421    #[cfg(feature = "fs-loader")]
422    #[test]
423    fn test_compiled_plus_optional_runtime_override() {
424        // Simulate: compiled defaults + optional runtime override
425        let compiled = {
426            let b = TomlBackend::new();
427            b.add_locale_from_str(
428                "en",
429                r#"hello = "Hello"
430bye = "Goodbye"
431welcome = "Welcome""#,
432            )
433            .unwrap();
434            Arc::new(b) as Arc<dyn crate::core::Backend>
435        };
436
437        // Runtime dir overrides "hello" only, doesn't have "bye" or "welcome"
438        let dir = tempfile::tempdir().unwrap();
439        std::fs::write(dir.path().join("en.toml"), r#"hello = "Hi (overridden)""#).unwrap();
440
441        let runtime = TomlBackend::try_from_dir(dir.path()).unwrap();
442
443        let chained = ChainedBackend::new(vec![
444            Arc::new(runtime) as Arc<dyn crate::core::Backend>,
445            compiled,
446        ]);
447
448        // Runtime override wins for "hello"
449        assert_eq!(chained.get("en", "hello").unwrap(), "Hi (overridden)");
450        // Falls through to compiled for "bye" and "welcome"
451        assert_eq!(chained.get("en", "bye").unwrap(), "Goodbye");
452        assert_eq!(chained.get("en", "welcome").unwrap(), "Welcome");
453    }
454}