use std::collections::HashMap;
use crate::error::LangError;
use crate::interpolation::interpolate;
use crate::loader::{load_translations, normalize_locale};
use crate::pluralization::select_plural_form;
pub struct Translator {
translations: HashMap<String, HashMap<String, String>>,
fallback: String,
}
impl Translator {
pub fn load(path: impl AsRef<str>, fallback: impl Into<String>) -> Result<Self, LangError> {
let fallback = fallback.into();
let translations = load_translations(path.as_ref(), &fallback)?;
Ok(Self {
translations,
fallback: normalize_locale(&fallback),
})
}
pub fn get(&self, locale: &str, key: &str, params: &[(&str, &str)]) -> String {
let locale = normalize_locale(locale);
let value = self
.translations
.get(&locale)
.and_then(|m| m.get(key))
.or_else(|| {
self.translations
.get(&self.fallback)
.and_then(|m| m.get(key))
});
match value {
Some(template) => interpolate(template, params),
None => {
tracing::warn!(locale = %locale, key, "translation key not found");
key.to_string()
}
}
}
pub fn choice(&self, locale: &str, key: &str, count: i64, params: &[(&str, &str)]) -> String {
let locale = normalize_locale(locale);
let value = self
.translations
.get(&locale)
.and_then(|m| m.get(key))
.or_else(|| {
self.translations
.get(&self.fallback)
.and_then(|m| m.get(key))
});
match value {
Some(template) => {
let form = select_plural_form(template, count);
let count_str = count.to_string();
let mut all_params: Vec<(&str, &str)> = params.to_vec();
all_params.push(("count", &count_str));
interpolate(&form, &all_params)
}
None => {
tracing::warn!(locale = %locale, key, "translation key not found");
key.to_string()
}
}
}
pub fn has(&self, locale: &str, key: &str) -> bool {
let locale = normalize_locale(locale);
self.translations
.get(&locale)
.is_some_and(|m| m.contains_key(key))
}
pub fn locales(&self) -> Vec<&str> {
self.translations.keys().map(|s| s.as_str()).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
fn unique_dir(label: &str) -> PathBuf {
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir =
std::env::temp_dir().join(format!("ferro_lang_{}_{}_{}", label, std::process::id(), n));
let _ = fs::remove_dir_all(&dir);
dir
}
fn write_fixtures(dir: &Path) {
let en_dir = dir.join("en");
fs::create_dir_all(&en_dir).unwrap();
fs::write(
en_dir.join("messages.json"),
serde_json::json!({
"welcome": "Welcome, :name!",
"items.count": "One item|:count items",
"cart.summary": "{0} Your cart is empty|{1} :count item in your cart|[2,*] :count items in your cart",
"only_en": "English only"
})
.to_string(),
)
.unwrap();
let es_dir = dir.join("es");
fs::create_dir_all(&es_dir).unwrap();
fs::write(
es_dir.join("messages.json"),
serde_json::json!({
"welcome": "Bienvenido, :name!",
"items.count": "Un elemento|:count elementos"
})
.to_string(),
)
.unwrap();
}
fn cleanup(dir: &PathBuf) {
let _ = fs::remove_dir_all(dir);
}
#[test]
fn load_succeeds() {
let dir = unique_dir("load");
write_fixtures(&dir);
let t = Translator::load(dir.to_str().unwrap(), "en").unwrap();
assert!(t.locales().contains(&"en"));
assert!(t.locales().contains(&"es"));
cleanup(&dir);
}
#[test]
fn get_with_interpolation() {
let dir = unique_dir("get_interp");
write_fixtures(&dir);
let t = Translator::load(dir.to_str().unwrap(), "en").unwrap();
assert_eq!(
t.get("en", "welcome", &[("name", "Alice")]),
"Welcome, Alice!"
);
cleanup(&dir);
}
#[test]
fn get_returns_key_when_missing() {
let dir = unique_dir("get_missing");
write_fixtures(&dir);
let t = Translator::load(dir.to_str().unwrap(), "en").unwrap();
assert_eq!(t.get("en", "nonexistent.key", &[]), "nonexistent.key");
cleanup(&dir);
}
#[test]
fn choice_returns_plural_form() {
let dir = unique_dir("choice_plural");
write_fixtures(&dir);
let t = Translator::load(dir.to_str().unwrap(), "en").unwrap();
assert_eq!(t.choice("en", "items.count", 1, &[]), "One item");
assert_eq!(t.choice("en", "items.count", 5, &[]), "5 items");
cleanup(&dir);
}
#[test]
fn choice_auto_adds_count_param() {
let dir = unique_dir("choice_count");
write_fixtures(&dir);
let t = Translator::load(dir.to_str().unwrap(), "en").unwrap();
assert_eq!(t.choice("en", "items.count", 42, &[]), "42 items");
cleanup(&dir);
}
#[test]
fn choice_explicit_ranges() {
let dir = unique_dir("choice_ranges");
write_fixtures(&dir);
let t = Translator::load(dir.to_str().unwrap(), "en").unwrap();
assert_eq!(t.choice("en", "cart.summary", 0, &[]), "Your cart is empty");
assert_eq!(
t.choice("en", "cart.summary", 1, &[]),
"1 item in your cart"
);
assert_eq!(
t.choice("en", "cart.summary", 3, &[]),
"3 items in your cart"
);
cleanup(&dir);
}
#[test]
fn has_returns_correct() {
let dir = unique_dir("has");
write_fixtures(&dir);
let t = Translator::load(dir.to_str().unwrap(), "en").unwrap();
assert!(t.has("en", "welcome"));
assert!(!t.has("en", "nonexistent"));
cleanup(&dir);
}
#[test]
fn locales_returns_all() {
let dir = unique_dir("locales");
write_fixtures(&dir);
let t = Translator::load(dir.to_str().unwrap(), "en").unwrap();
let mut locales = t.locales();
locales.sort();
assert_eq!(locales, vec!["en", "es"]);
cleanup(&dir);
}
#[test]
fn fallback_locale_works() {
let dir = unique_dir("fallback");
write_fixtures(&dir);
let t = Translator::load(dir.to_str().unwrap(), "en").unwrap();
assert_eq!(t.get("es", "only_en", &[]), "English only");
cleanup(&dir);
}
#[test]
fn locale_normalization() {
let dir = unique_dir("normalization");
write_fixtures(&dir);
let en_us_dir = dir.join("en_US");
fs::create_dir_all(&en_us_dir).unwrap();
fs::write(
en_us_dir.join("messages.json"),
serde_json::json!({
"greeting": "Hey!"
})
.to_string(),
)
.unwrap();
let t = Translator::load(dir.to_str().unwrap(), "en").unwrap();
assert!(t.has("en-us", "greeting"));
assert!(t.has("en_US", "greeting"));
assert!(t.has("EN_US", "greeting"));
cleanup(&dir);
}
#[test]
fn nested_json_flattened() {
let dir = unique_dir("nested");
write_fixtures(&dir);
let fr_dir = dir.join("fr");
fs::create_dir_all(&fr_dir).unwrap();
fs::write(
fr_dir.join("auth.json"),
serde_json::json!({
"auth": {
"login": "Connexion",
"register": "Inscription"
}
})
.to_string(),
)
.unwrap();
let t = Translator::load(dir.to_str().unwrap(), "en").unwrap();
assert_eq!(t.get("fr", "auth.login", &[]), "Connexion");
assert_eq!(t.get("fr", "auth.register", &[]), "Inscription");
cleanup(&dir);
}
#[test]
fn empty_dir_errors() {
let dir = unique_dir("empty");
fs::create_dir_all(&dir).unwrap();
let result = Translator::load(dir.to_str().unwrap(), "en");
assert!(result.is_err());
cleanup(&dir);
}
}