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");
}
}