i18n-runtime 0.2.0

Fast i18n runtime for Rust (phf-backed generated support)
Documentation
use std::collections::HashMap;

/// Locale wrapper and normalization utilities.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Locale(String);

impl Locale {
    pub fn new<S: Into<String>>(s: S) -> Self {
        Locale(s.into())
    }
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

/// Primary runtime struct.
/// Two usage modes:
/// 1) runtime JSON mode: `I18n::from_json_dir(path)`
/// 2) generated mode: `I18n::from_generated_registry(registry)`
///
/// Lookup API:
/// - get_by_str_key(locale, "welcome")
/// - if using generated keys, consumer can call get_by_str_key as well,
///   or they can use the generated MessageKey enum to call get_by_str_key(key.as_str()).
pub struct I18n {
    // dynamic runtime maps: locale_tag -> (key -> value)
    pub runtime_maps: HashMap<String, HashMap<String, String>>,

    // optional generated registry (compiled phf maps) provided by consumer (static lifetime)
    // stored as slice of (tag, &phf::Map<_,_>)
    pub generated_registry:
        Option<&'static [(&'static str, &'static phf::Map<&'static str, &'static str>)]>,

    // fallback tag (e.g. "en")
    pub fallback: String,
}

impl I18n {
    /// Create an empty I18n with fallback.
    pub fn new<S: Into<String>>(fallback: S) -> Self {
        Self {
            runtime_maps: HashMap::new(),
            generated_registry: None,
            fallback: fallback.into(),
        }
    }

    /// Build from JSON files in a directory (consumer mode).
    /// Expects files like `en.json`, `en-IN.json`, etc.
    /// This uses `serde_json` at runtime (feature "runtime-json").
    #[cfg(feature = "runtime-json")]
    pub fn from_json_dir<P: AsRef<std::path::Path>>(
        dir: P,
        fallback: &str,
    ) -> Result<Self, String> {
        let mut s = Self::new(fallback.to_string());
        let dir = dir.as_ref();
        if !dir.exists() {
            return Err(format!("dir {} does not exist", dir.display()));
        }
        for entry in std::fs::read_dir(dir).map_err(|e| e.to_string())? {
            let entry = entry.map_err(|e| e.to_string())?;
            let p = entry.path();
            if p.extension().and_then(|e| e.to_str()) != Some("json") {
                continue;
            }
            let tag = p
                .file_stem()
                .and_then(|s| s.to_str())
                .ok_or_else(|| "bad filename".to_string())?;
            let content = std::fs::read_to_string(&p).map_err(|e| e.to_string())?;
            let obj: serde_json::Map<String, serde_json::Value> =
                serde_json::from_str(&content).map_err(|e| e.to_string())?;
            let mut map = HashMap::new();
            for (k, v) in obj.into_iter() {
                if let Some(sv) = v.as_str() {
                    map.insert(k, sv.to_string());
                } else {
                    return Err(format!("value for key {} in {} not string", k, p.display()));
                }
            }
            s.runtime_maps.insert(tag.to_string(), map);
        }
        Ok(s)
    }

    /// Use compiled registry generated by codegen.
    /// The registry is a static slice of (&'static str, &'static phf::Map<..>).
    pub fn from_generated_registry(
        registry: &'static [(&'static str, &'static phf::Map<&'static str, &'static str>)],
        fallback: &str,
    ) -> Self {
        Self {
            runtime_maps: HashMap::new(),
            generated_registry: Some(registry),
            fallback: fallback.into(),
        }
    }

    /// Normalize a tag to canonical form (language lower-case, others upper-case)
    fn normalize_tag(tag: &str) -> String {
        let parts: Vec<&str> = tag.split('-').filter(|p| !p.is_empty()).collect();
        if parts.is_empty() {
            return "".into();
        }
        let mut out: Vec<String> = Vec::new();
        for (i, p) in parts.iter().enumerate() {
            if i == 0 {
                out.push(p.to_lowercase());
            } else {
                out.push(p.to_uppercase());
            }
        }
        out.join("-")
    }

    /// Resolve the best phf::Map from the generated registry (if present) using fallback chain:
    /// en-IN-BR -> en-IN -> en
    fn resolve_generated_map(
        &self,
        tag: &str,
    ) -> Option<&'static phf::Map<&'static str, &'static str>> {
        let reg = self.generated_registry?;
        let mut tag = Self::normalize_tag(tag);
        if tag.is_empty() {
            return None;
        }
        loop {
            for (t, map) in reg.iter() {
                if *t == tag {
                    return Some(*map);
                }
            }
            if let Some(idx) = tag.rfind('-') {
                tag.truncate(idx);
            } else {
                break;
            }
        }
        None
    }

    /// Resolve a runtime map (HashMap) for tag chain
    fn resolve_runtime_map(&self, tag: &str) -> Option<&HashMap<String, String>> {
        let mut tag = Self::normalize_tag(tag);
        if tag.is_empty() {
            return None;
        }
        loop {
            if let Some(m) = self.runtime_maps.get(&tag) {
                return Some(m);
            }
            if let Some(idx) = tag.rfind('-') {
                tag.truncate(idx);
            } else {
                break;
            }
        }
        None
    }

    /// Get by string key. Checks generated registry first (if present), then runtime maps, then fallback chains.
    pub fn get_by_str_key(&self, locale: &Locale, key: &str) -> Option<&'static str> {
        // 1) generated registry
        if let Some(m) = self.resolve_generated_map(locale.as_str()) {
            if let Some(v) = m.get(key) {
                return Some(*v);
            }
        }

        // 2) runtime maps
        if let Some(map) = self.resolve_runtime_map(locale.as_str()) {
            if let Some(_) = map.get(key) {
                // We can't return &'static str from owned String map; convert by leaking as fallback option is not safe.
                // So runtime mode returns None to indicate not found in generated registry, but runtime user can get owned String.
                // For convenience provide a runtime-only method get_owned.
                // Here we return None to keep signature consistent.
                return None;
            }
        }

        // 3) try fallback configured
        if let Some(m) = self.resolve_generated_map(&self.fallback) {
            if let Some(v) = m.get(key) {
                return Some(*v);
            }
        }
        None
    }

    /// Runtime-only convenience: get owned String (for runtime JSON mode).
    /// Searches runtime maps (locale chain), then fallback.
    pub fn get_owned(&self, locale: &Locale, key: &str) -> Option<String> {
        // try runtime maps
        if let Some(map) = self.resolve_runtime_map(locale.as_str()) {
            if let Some(v) = map.get(key) {
                return Some(v.clone());
            }
        }
        // fallback runtime
        if let Some(map) = self.resolve_runtime_map(&self.fallback) {
            if let Some(v) = map.get(key) {
                return Some(v.clone());
            }
        }
        // generated registry fallback as last resort (return &'static -> owned)
        if let Some(m) = self.resolve_generated_map(locale.as_str()) {
            if let Some(v) = m.get(key) {
                return Some(v.to_string());
            }
        }
        if let Some(m) = self.resolve_generated_map(&self.fallback) {
            if let Some(v) = m.get(key) {
                return Some(v.to_string());
            }
        }
        None
    }
}