AkurAI Build
Menu

Public repository / master

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

Latest change 68c23789cce8e807c988190dcb1e6a3699cd5ed6 - OLI-156: path-prefix locales, hreflang alternates, sitemap xhtml:link by Ólafur Búi Ólafsson

//! i18n wiring for the CLI: locale negotiation + render-context injection.
//!
//! [`I18nState`] is loaded once at `serve` / `dev` startup from
//! `backend/locales/` and held in the server closure.  Each request calls
//! [`I18nState::extras`] to build the `__locale` + `__messages` context
//! extras that the template engine reads via `{{ t "key" }}`.
//!
//! When `backend/locales/` is absent the state is `None` and all render
//! paths behave exactly as before (zero behaviour change).

#![forbid(unsafe_code)]

use akurai_i18n::Catalogs;
use akurai_json::Value;
use std::collections::HashMap;
use std::path::Path;

// ── I18nState ──────────────────────────────────────────────────────────────

/// Loaded i18n state: a validated [`Catalogs`] (for default-locale detection
/// and fallback-aware translation) plus raw JSON message maps (to build the
/// `__messages` object for every key in the active locale).
pub struct I18nState {
    /// Validated catalogs — `default_locale()` and `translate()` with fallback.
    catalogs: Catalogs,
    /// `locale → Value::Object(key → Str)` — raw parsed message maps.
    raw: HashMap<String, Value>,
    /// Sorted list of available locale codes (e.g. `["en", "is"]`).
    locales: Vec<String>,
}

impl I18nState {
    /// Load from `dir`, returning `None` if the directory does not exist, is
    /// empty of `.json` files, or any file fails to parse.
    ///
    /// Emits a startup warning to stderr on parse failure instead of aborting.
    pub fn load(dir: &Path) -> Option<I18nState> {
        if !dir.is_dir() {
            return None;
        }
        // Delegate validation + default-locale logic to the i18n crate.
        let catalogs = match Catalogs::load_dir(dir) {
            Ok(c) => c,
            Err(e) => {
                eprintln!("  i18n: failed to load catalogs: {e}");
                return None;
            }
        };
        // Build raw message maps for __messages injection.
        let mut raw: HashMap<String, Value> = HashMap::new();
        let mut locales: Vec<String> = Vec::new();
        let Ok(entries) = std::fs::read_dir(dir) else {
            return None;
        };
        for entry in entries.flatten() {
            let path = entry.path();
            if path.extension().and_then(|e| e.to_str()) != Some("json") {
                continue;
            }
            let Some(locale) = path
                .file_stem()
                .and_then(|s| s.to_str())
                .map(str::to_string)
            else {
                continue;
            };
            let Ok(text) = std::fs::read_to_string(&path) else {
                continue;
            };
            let Ok(value) = akurai_json::parse(&text) else {
                continue;
            };
            locales.push(locale.clone());
            raw.insert(locale, value);
        }
        locales.sort();
        Some(I18nState {
            catalogs,
            raw,
            locales,
        })
    }

    /// Sorted available locale codes.
    pub fn locales(&self) -> &[String] {
        &self.locales
    }

    /// The catalog default locale (served at `/` in path-prefix mode).
    pub fn default_locale(&self) -> &str {
        self.catalogs.default_locale()
    }

    /// Build the `__locale` + `__messages` context extras for the given
    /// `Accept-Language` header value (may be empty or the empty string for
    /// default-locale rendering).
    pub fn extras(&self, accept_language: &str) -> Vec<(String, Value)> {
        let avail: Vec<&str> = self.locales.iter().map(String::as_str).collect();
        let locale = negotiate_locale(accept_language, &avail, self.catalogs.default_locale());
        let messages = self.messages_object(&locale);
        vec![
            ("__locale".into(), Value::Str(locale)),
            ("__messages".into(), messages),
        ]
    }

    /// Build extras for an explicit language preference (the `akurai-lang`
    /// cookie set by the language toggle). When `lang` is `None` or not an
    /// available locale, the catalog's **default locale** is used — so the suite
    /// defaults to its default language (Icelandic) regardless of the browser's
    /// `Accept-Language`, and the user's toggle is the only override.
    pub fn extras_for_lang(&self, lang: Option<&str>) -> Vec<(String, Value)> {
        let locale = lang
            .and_then(|l| {
                self.locales
                    .iter()
                    .find(|a| a.eq_ignore_ascii_case(l))
                    .cloned()
            })
            .unwrap_or_else(|| self.catalogs.default_locale().to_string());
        let messages = self.messages_object(&locale);
        vec![
            ("__locale".into(), Value::Str(locale)),
            ("__messages".into(), messages),
        ]
    }

    /// Build a `Value::Object` from all known message keys for `locale`,
    /// using the catalog's fallback chain (locale → default → key).
    fn messages_object(&self, locale: &str) -> Value {
        // Collect the union of all keys across all locale catalogs.
        let mut all_keys: Vec<String> = Vec::new();
        for v in self.raw.values() {
            if let Value::Object(pairs) = v {
                for (k, _) in pairs {
                    if !all_keys.contains(k) {
                        all_keys.push(k.clone());
                    }
                }
            }
        }
        all_keys.sort();
        let pairs: Vec<(String, Value)> = all_keys
            .into_iter()
            .map(|key| {
                let translated = self.catalogs.translate(locale, &key).to_string();
                (key, Value::Str(translated))
            })
            .collect();
        Value::Object(pairs)
    }
}

// ── negotiate_locale ────────────────────────────────────────────────────────

/// Pick the best locale from an `Accept-Language` header.
///
/// # Algorithm
/// 1. Parse comma-separated language tags; extract `;q=` quality factors
///    (default `1.0`).
/// 2. Sort descending by quality (stable — ties preserve wire order).
/// 3. For each tag in order:
///    a. Try an exact case-insensitive match against `available`.
///    b. Try the primary subtag only (e.g. `is-IS` → `is`).
/// 4. Fall back to `default` if nothing matched or the header was empty.
///
/// Returns an owned `String` (clone of the matched entry or `default`).
pub fn negotiate_locale(accept_language: &str, available: &[&str], default: &str) -> String {
    if accept_language.trim().is_empty() || available.is_empty() {
        return default.to_string();
    }

    // Parse tags with quality weights.
    let mut tags: Vec<(String, f32)> = accept_language
        .split(',')
        .filter_map(|part| {
            let part = part.trim();
            if part.is_empty() {
                return None;
            }
            let (tag_raw, q) = match part.split_once(';') {
                Some((t, params)) => {
                    let q_val = params
                        .split(';')
                        .find_map(|p| {
                            p.trim()
                                .strip_prefix("q=")
                                .and_then(|v| v.parse::<f32>().ok())
                        })
                        .unwrap_or(1.0_f32);
                    (t.trim(), q_val)
                }
                None => (part, 1.0_f32),
            };
            if tag_raw.is_empty() {
                return None;
            }
            Some((tag_raw.to_ascii_lowercase(), q))
        })
        .collect();

    // Stable sort descending by quality.
    tags.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));

    // Match tags against available locales.
    for (tag, _) in &tags {
        // Exact match (case-insensitive).
        if let Some(locale) = available.iter().find(|l| l.to_ascii_lowercase() == *tag) {
            return locale.to_string();
        }
        // Primary subtag match: `is-IS` → try `is`.
        let primary = tag.split('-').next().unwrap_or(tag.as_str());
        if let Some(locale) = available.iter().find(|l| l.to_ascii_lowercase() == primary) {
            return locale.to_string();
        }
    }

    default.to_string()
}

// ── tests ───────────────────────────────────────────────────────────────────

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

    // ── negotiate_locale unit tests ────────────────────────────────────────

    #[test]
    fn empty_header_returns_default() {
        assert_eq!(negotiate_locale("", &["en", "is"], "en"), "en");
    }

    #[test]
    fn whitespace_only_returns_default() {
        assert_eq!(negotiate_locale("   ", &["en", "is"], "en"), "en");
    }

    #[test]
    fn simple_match() {
        assert_eq!(negotiate_locale("is", &["en", "is"], "en"), "is");
    }

    #[test]
    fn highest_quality_wins() {
        // `is` at implicit q=1.0 > `en` at q=0.9
        assert_eq!(negotiate_locale("is, en;q=0.9", &["en", "is"], "en"), "is");
    }

    #[test]
    fn explicit_quality_ordering() {
        // en;q=0.8 < is;q=0.9 even though en appears first
        assert_eq!(
            negotiate_locale("en;q=0.8, is;q=0.9", &["en", "is"], "en"),
            "is"
        );
    }

    #[test]
    fn primary_subtag_match() {
        // `is-IS` has no direct match, but primary `is` is available
        assert_eq!(negotiate_locale("is-IS", &["en", "is"], "en"), "is");
    }

    #[test]
    fn primary_subtag_with_quality() {
        assert_eq!(
            negotiate_locale("is-IS;q=0.9, en;q=0.5", &["en", "is"], "en"),
            "is"
        );
    }

    #[test]
    fn no_match_returns_default() {
        assert_eq!(negotiate_locale("fr, de", &["en", "is"], "en"), "en");
    }

    #[test]
    fn case_insensitive_exact_match() {
        assert_eq!(negotiate_locale("IS", &["en", "is"], "en"), "is");
        assert_eq!(negotiate_locale("EN", &["en", "is"], "en"), "en");
    }

    #[test]
    fn empty_available_returns_default() {
        assert_eq!(negotiate_locale("is, en", &[], "en"), "en");
    }

    #[test]
    fn wildcard_star_falls_through_to_default() {
        // `*` is not in available, falls back to default
        assert_eq!(negotiate_locale("*", &["en", "is"], "en"), "en");
    }

    #[test]
    fn multiple_candidates_first_available_wins() {
        // fr not available, de not available, en available
        assert_eq!(
            negotiate_locale("fr, de, en;q=0.5", &["en", "is"], "en"),
            "en"
        );
    }

    // ── I18nState integration tests ────────────────────────────────────────

    /// Scratch base using CARGO_TARGET_TMPDIR when set, otherwise crate target/.
    fn scratch_base() -> std::path::PathBuf {
        match std::env::var_os("CARGO_TARGET_TMPDIR") {
            Some(dir) => std::path::PathBuf::from(dir),
            None => std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
                .join("target")
                .join("i18n-test-tmp"),
        }
    }

    fn make_locales_dir(name: &str, files: &[(&str, &str)]) -> std::path::PathBuf {
        let dir = scratch_base().join(name);
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        for (filename, content) in files {
            std::fs::write(dir.join(filename), content).unwrap();
        }
        dir
    }

    #[test]
    fn load_none_when_dir_absent() {
        let dir = scratch_base().join("no-such-dir-i18n");
        assert!(I18nState::load(&dir).is_none());
    }

    #[test]
    fn load_and_extras_inject_locale_and_messages() {
        let dir = make_locales_dir(
            "i18n_inject",
            &[
                (
                    "en.json",
                    r#"{"footer.tagline":"A pure-Rust framework.","nav.docs":"Docs"}"#,
                ),
                (
                    "is.json",
                    r#"{"footer.tagline":"Hrein Rust umgjörð.","nav.docs":"Skjöl"}"#,
                ),
            ],
        );
        let state = I18nState::load(&dir).expect("should load");

        // Icelandic extras.
        let extras = state.extras("is");
        let locale = extras
            .iter()
            .find(|(k, _)| k == "__locale")
            .map(|(_, v)| v.as_str().unwrap_or(""))
            .unwrap_or("");
        assert_eq!(locale, "is");

        let messages = extras
            .iter()
            .find(|(k, _)| k == "__messages")
            .map(|(_, v)| v)
            .expect("__messages present");
        let tagline = messages
            .get("footer.tagline")
            .and_then(|v| v.as_str())
            .unwrap_or("");
        assert_eq!(tagline, "Hrein Rust umgjörð.");

        // English extras.
        let extras_en = state.extras("en");
        let messages_en = extras_en
            .iter()
            .find(|(k, _)| k == "__messages")
            .map(|(_, v)| v)
            .expect("__messages present");
        let tagline_en = messages_en
            .get("footer.tagline")
            .and_then(|v| v.as_str())
            .unwrap_or("");
        assert_eq!(tagline_en, "A pure-Rust framework.");
    }

    #[test]
    fn extras_negotiate_from_accept_language_header() {
        let dir = make_locales_dir(
            "i18n_negotiate_header",
            &[
                ("en.json", r#"{"key":"hello"}"#),
                ("is.json", r#"{"key":"halló"}"#),
            ],
        );
        let state = I18nState::load(&dir).expect("should load");

        // is-IS primary subtag → is
        let extras = state.extras("is-IS, en;q=0.5");
        let locale = extras
            .iter()
            .find(|(k, _)| k == "__locale")
            .and_then(|(_, v)| v.as_str())
            .unwrap_or("");
        assert_eq!(locale, "is");
    }

    #[test]
    fn missing_key_falls_back_to_key_text() {
        let dir = make_locales_dir(
            "i18n_fallback_key",
            &[("en.json", r#"{"known":"Known value"}"#)],
        );
        let state = I18nState::load(&dir).expect("should load");
        let extras = state.extras("");
        let messages = extras
            .iter()
            .find(|(k, _)| k == "__messages")
            .map(|(_, v)| v)
            .expect("__messages");
        // `known` is present
        assert_eq!(
            messages.get("known").and_then(|v| v.as_str()),
            Some("Known value")
        );
    }

    #[test]
    fn template_render_with_injected_messages() {
        use akurai_json::Value;
        use akurai_template::Engine;

        let dir = make_locales_dir(
            "i18n_template_render",
            &[
                ("en.json", r#"{"footer.tagline":"A pure-Rust framework."}"#),
                ("is.json", r#"{"footer.tagline":"Hrein Rust umgjörð."}"#),
            ],
        );
        let state = I18nState::load(&dir).expect("should load");

        let mut engine = Engine::new();
        engine
            .register("footer", r#"<p>{{ t "footer.tagline" }}</p>"#)
            .expect("register");

        // Render with Icelandic extras injected into context.
        let extras = state.extras("is");
        let mut ctx_pairs: Vec<(String, Value)> = vec![];
        ctx_pairs.extend(extras);
        let ctx = Value::Object(ctx_pairs);
        let html = engine.render("footer", &ctx).expect("render");
        assert_eq!(html, "<p>Hrein Rust umgjörð.</p>");

        // Render with English extras.
        let extras_en = state.extras("en");
        let ctx_en = Value::Object(extras_en);
        let html_en = engine.render("footer", &ctx_en).expect("render");
        assert_eq!(html_en, "<p>A pure-Rust framework.</p>");
    }
}