haven 0.1.1

Actix + React + Vite integration for server-rendered applications
Documentation
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;

use serde_json::Value;

use crate::error::RendererError;

#[derive(Debug, Clone)]
pub(crate) struct TranslationConfig {
    pub translations_dir: PathBuf,
    pub default_locale: String,
    pub fallback_locale: String,
}

#[derive(Debug, Clone)]
pub(crate) struct TranslationStore {
    catalogs: HashMap<String, HashMap<String, String>>,
    default_locale: String,
    fallback_locale: String,
}

impl TranslationStore {
    pub fn load(config: &TranslationConfig) -> Result<Arc<Self>, RendererError> {
        let mut catalogs = HashMap::new();
        if config.translations_dir.exists() {
            for locale_entry in std::fs::read_dir(&config.translations_dir)? {
                let locale_entry = locale_entry?;
                if !locale_entry.file_type()?.is_dir() {
                    continue;
                }

                let locale = locale_entry.file_name().to_string_lossy().to_string();
                let mut messages = HashMap::new();
                load_locale_dir(&locale_entry.path(), None, &mut messages)?;
                catalogs.insert(locale, messages);
            }
        }

        Ok(Arc::new(Self {
            catalogs,
            default_locale: config.default_locale.clone(),
            fallback_locale: config.fallback_locale.clone(),
        }))
    }

    pub fn translate(&self, locale: &str, key: &str, params: &Value) -> String {
        self.lookup(locale, key)
            .or_else(|| self.lookup(&self.fallback_locale, key))
            .or_else(|| self.lookup(&self.default_locale, key))
            .map(|message| interpolate(message, params))
            .unwrap_or_else(|| key.to_string())
    }

    fn lookup(&self, locale: &str, key: &str) -> Option<&str> {
        self.catalogs
            .get(locale)
            .and_then(|messages| messages.get(key))
            .map(String::as_str)
    }
}

fn load_locale_dir(
    dir: &Path,
    namespace_prefix: Option<String>,
    out: &mut HashMap<String, String>,
) -> Result<(), RendererError> {
    let mut entries = std::fs::read_dir(dir)?.collect::<Result<Vec<_>, _>>()?;
    entries.sort_by_key(|entry| entry.file_name());

    for entry in entries {
        let path = entry.path();
        let file_type = entry.file_type()?;

        if file_type.is_dir() {
            let segment = entry.file_name().to_string_lossy().to_string();
            let namespace = match &namespace_prefix {
                Some(prefix) => format!("{prefix}.{segment}"),
                None => segment,
            };
            load_locale_dir(&path, Some(namespace), out)?;
            continue;
        }

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

        let stem = path.file_stem().and_then(|v| v.to_str()).ok_or_else(|| {
            RendererError::Io(format!("invalid translation filename: {}", path.display()))
        })?;
        let namespace = match &namespace_prefix {
            Some(prefix) => format!("{prefix}.{stem}"),
            None => stem.to_string(),
        };
        let value: Value = serde_json::from_str(&std::fs::read_to_string(&path)?)?;
        flatten_messages(&namespace, &value, out);
    }

    Ok(())
}

fn flatten_messages(prefix: &str, value: &Value, out: &mut HashMap<String, String>) {
    match value {
        Value::Object(map) => {
            for (key, value) in map {
                let next = format!("{prefix}.{key}");
                flatten_messages(&next, value, out);
            }
        }
        Value::String(text) => {
            out.insert(prefix.to_string(), text.clone());
        }
        _ => {}
    }
}

fn interpolate(template: &str, params: &Value) -> String {
    let Some(map) = params.as_object() else {
        return template.to_string();
    };

    let mut output = String::with_capacity(template.len());
    let mut rest = template;

    while let Some(start) = rest.find('{') {
        output.push_str(&rest[..start]);
        rest = &rest[start + 1..];

        let Some(end) = rest.find('}') else {
            output.push('{');
            output.push_str(rest);
            return output;
        };

        let raw_key = &rest[..end];
        let key = raw_key.trim();
        match map.get(key) {
            Some(value) => output.push_str(&interpolation_value(value)),
            None => {
                output.push('{');
                output.push_str(raw_key);
                output.push('}');
            }
        }

        rest = &rest[end + 1..];
    }

    output.push_str(rest);
    output
}

fn interpolation_value(value: &Value) -> String {
    match value {
        Value::String(value) => value.clone(),
        _ => value.to_string(),
    }
}

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

    fn store() -> TranslationStore {
        TranslationStore {
            catalogs: HashMap::from([
                (
                    "en".into(),
                    HashMap::from([
                        ("common.welcome".into(), "Hello {name}".into()),
                        ("common.fallback".into(), "Fallback".into()),
                    ]),
                ),
                (
                    "fr".into(),
                    HashMap::from([("common.welcome".into(), "Bonjour {name}".into())]),
                ),
            ]),
            default_locale: "en".into(),
            fallback_locale: "en".into(),
        }
    }

    #[test]
    fn translates_with_interpolation() {
        let result = store().translate(
            "fr",
            "common.welcome",
            &serde_json::json!({ "name": "Ada" }),
        );
        assert_eq!(result, "Bonjour Ada");
    }

    #[test]
    fn falls_back_to_default_locale() {
        let result = store().translate("de", "common.fallback", &Value::Null);
        assert_eq!(result, "Fallback");
    }

    #[test]
    fn missing_key_returns_key() {
        let result = store().translate("en", "common.missing", &Value::Null);
        assert_eq!(result, "common.missing");
    }
}