AkurAI Build
Menu

Public repository / master

AkurAI-Framework / crates/i18n/src/catalog.rs

Latest change 4310a8373c9b2e01b559c8a42bd21b36b0fb950e - prefer 'is' as the default locale when present in i18n catalogs by Ólafur Búi Ólafsson

//! Locale catalogs: loading, lookup, and `{placeholder}` interpolation.

use crate::error::Error;
use akurai_json::Value;
use std::collections::HashMap;
use std::path::Path;

/// Messages for a single locale: key → translated string.
#[derive(Debug)]
pub struct Catalog {
    messages: HashMap<String, String>,
}

impl Catalog {
    /// Build a `Catalog` from a parsed JSON `Value` (must be a top-level object).
    fn from_json(v: Value) -> Result<Catalog, Error> {
        match v {
            Value::Object(pairs) => {
                let mut messages = HashMap::new();
                for (k, v) in pairs {
                    match v {
                        Value::Str(s) => {
                            messages.insert(k, s);
                        }
                        _ => {
                            return Err(Error::new(format!(
                                "message value for key '{k}' must be a JSON string"
                            )))
                        }
                    }
                }
                Ok(Catalog { messages })
            }
            _ => Err(Error::new("catalog JSON root must be an object")),
        }
    }

    /// Look up `key` in this locale; returns `None` if not found.
    pub fn get(&self, key: &str) -> Option<&str> {
        self.messages.get(key).map(String::as_str)
    }
}

/// A set of [`Catalog`]s — one per locale — loaded from a directory of JSON files.
#[derive(Debug)]
///
/// Lookup falls back from the requested locale → default locale → the key itself
/// so templates always produce *something* even when a translation is missing.
pub struct Catalogs {
    locales: HashMap<String, Catalog>,
    default_locale: String,
}

impl Catalogs {
    /// Load every `*.json` file in `dir` as a locale catalog.
    ///
    /// The file stem becomes the locale code, e.g. `en.json` → `"en"`.
    /// The default locale is `"en"` when present, otherwise the
    /// lexicographically smallest locale found.
    ///
    /// Returns an error if the directory cannot be read, a file cannot be
    /// parsed, or no `.json` files are found.
    pub fn load_dir(dir: &Path) -> Result<Catalogs, Error> {
        let mut locales: HashMap<String, Catalog> = HashMap::new();

        let iter = std::fs::read_dir(dir)
            .map_err(|e| Error::new(format!("cannot read directory '{}': {e}", dir.display())))?;

        for entry in iter {
            let entry = entry.map_err(|e| Error::new(format!("directory entry error: {e}")))?;
            let path = entry.path();

            if path.extension().and_then(|e| e.to_str()) != Some("json") {
                continue;
            }

            let locale = path
                .file_stem()
                .and_then(|s| s.to_str())
                .ok_or_else(|| Error::new("locale filename is not valid UTF-8"))?
                .to_string();

            let src = std::fs::read_to_string(&path)
                .map_err(|e| Error::new(format!("cannot read '{}': {e}", path.display())))?;

            let value = akurai_json::parse(&src)
                .map_err(|e| Error::new(format!("JSON error in '{}': {e}", path.display())))?;

            locales.insert(locale, Catalog::from_json(value)?);
        }

        if locales.is_empty() {
            return Err(Error::new(format!(
                "no *.json locale files found in '{}'",
                dir.display()
            )));
        }

        let default_locale = if locales.contains_key("is") {
            "is".to_string()
        } else if locales.contains_key("en") {
            "en".to_string()
        } else {
            let mut names: Vec<&String> = locales.keys().collect();
            names.sort();
            names[0].clone()
        };

        Ok(Catalogs {
            locales,
            default_locale,
        })
    }

    /// Construct `Catalogs` directly from a locale map and an explicit default.
    ///
    /// Useful for testing or programmatic catalog construction.
    pub fn from_map(
        locales: HashMap<String, HashMap<String, String>>,
        default_locale: impl Into<String>,
    ) -> Catalogs {
        let locales = locales
            .into_iter()
            .map(|(locale, msgs)| (locale, Catalog { messages: msgs }))
            .collect();
        Catalogs {
            locales,
            default_locale: default_locale.into(),
        }
    }

    /// The default locale name (e.g. `"en"`).
    pub fn default_locale(&self) -> &str {
        &self.default_locale
    }

    /// Translate `key` for `locale`.
    ///
    /// Fallback chain:
    /// 1. `locale` catalog
    /// 2. `default_locale` catalog (skipped if `locale == default_locale`)
    /// 3. The key itself (never panics, never returns empty for a non-empty key)
    pub fn translate<'a>(&'a self, locale: &str, key: &'a str) -> &'a str {
        if let Some(msg) = self.locales.get(locale).and_then(|c| c.get(key)) {
            return msg;
        }
        if locale != self.default_locale {
            if let Some(msg) = self
                .locales
                .get(self.default_locale.as_str())
                .and_then(|c| c.get(key))
            {
                return msg;
            }
        }
        key
    }

    /// Translate `key` for `locale`, then replace `{name}` placeholders.
    ///
    /// `args` is a slice of `("placeholder_name", "substitution_value")` pairs.
    /// Only `{exact_name}` patterns are replaced; unknown placeholders are left as-is.
    ///
    /// ```
    /// use akurai_i18n::Catalogs;
    /// use std::collections::HashMap;
    ///
    /// let mut msgs = HashMap::new();
    /// msgs.insert("greeting".into(), "Hello, {name}!".into());
    /// let catalogs = Catalogs::from_map(
    ///     [("en".into(), msgs)].into_iter().collect(),
    ///     "en",
    /// );
    /// assert_eq!(
    ///     catalogs.translate_args("en", "greeting", &[("name", "Óli")]),
    ///     "Hello, Óli!"
    /// );
    /// ```
    pub fn translate_args(&self, locale: &str, key: &str, args: &[(&str, &str)]) -> String {
        let template = self.translate(locale, key);
        interpolate(template, args)
    }
}

/// Replace `{name}` placeholders in `template` with values from `args`.
///
/// Each `(name, value)` pair replaces every `{name}` occurrence.  Unmatched
/// placeholders are left verbatim.
pub(crate) fn interpolate(template: &str, args: &[(&str, &str)]) -> String {
    if args.is_empty() || !template.contains('{') {
        return template.to_string();
    }
    let mut result = template.to_string();
    for (name, value) in args {
        result = result.replace(&format!("{{{name}}}"), value);
    }
    result
}

#[cfg(test)]
mod tests {
    use super::*;

    fn en_catalog() -> Catalog {
        let mut messages = HashMap::new();
        messages.insert("hello".into(), "Hello".into());
        messages.insert("greeting".into(), "Hello, {name}!".into());
        Catalog { messages }
    }

    #[test]
    fn catalog_get_hit_and_miss() {
        let c = en_catalog();
        assert_eq!(c.get("hello"), Some("Hello"));
        assert_eq!(c.get("missing"), None);
    }

    #[test]
    fn interpolate_replaces_placeholder() {
        assert_eq!(
            interpolate("Hello, {name}!", &[("name", "Óli")]),
            "Hello, Óli!"
        );
    }

    #[test]
    fn interpolate_multiple_and_unknown() {
        assert_eq!(
            interpolate("{a} + {b} = {c}", &[("a", "1"), ("b", "2")]),
            "1 + 2 = {c}"
        );
    }

    #[test]
    fn interpolate_no_args_fast_path() {
        assert_eq!(interpolate("plain text", &[]), "plain text");
    }

    #[test]
    fn catalogs_translate_hit() {
        let catalogs = Catalogs::from_map(
            [("en".into(), [("hello".into(), "Hello".into())].into())].into(),
            "en",
        );
        assert_eq!(catalogs.translate("en", "hello"), "Hello");
    }

    #[test]
    fn catalogs_translate_fallback_to_default() {
        let en: HashMap<String, String> = [("hello".into(), "Hello".into())].into();
        let is: HashMap<String, String> = HashMap::new(); // no key
        let catalogs = Catalogs::from_map([("en".into(), en), ("is".into(), is)].into(), "en");
        assert_eq!(catalogs.translate("is", "hello"), "Hello");
    }

    #[test]
    fn catalogs_translate_fallback_to_key() {
        let catalogs = Catalogs::from_map([("en".into(), HashMap::new())].into(), "en");
        assert_eq!(catalogs.translate("en", "unknown.key"), "unknown.key");
        assert_eq!(catalogs.translate("fr", "unknown.key"), "unknown.key");
    }

    #[test]
    fn translate_args_end_to_end() {
        let msgs: HashMap<String, String> = [("greeting".into(), "Hello, {name}!".into())].into();
        let catalogs = Catalogs::from_map([("en".into(), msgs)].into(), "en");
        assert_eq!(
            catalogs.translate_args("en", "greeting", &[("name", "Óli")]),
            "Hello, Óli!"
        );
    }
}