Menu
Public repository / master
AkurAI-Framework / crates/i18n/src/catalog.rs
Latest change 4310a8373c9b2e01b559c8a42bd21b36b0fb950e - prefer 'is' as the default locale when present in i18n catalogs by Ólafur Búi Ólafsson
//! Locale catalogs: loading, lookup, and `{placeholder}` interpolation.
use crate::error::Error;
use akurai_json::Value;
use std::collections::HashMap;
use std::path::Path;
/// Messages for a single locale: key → translated string.
#[derive(Debug)]
pub struct Catalog {
messages: HashMap<String, String>,
}
impl Catalog {
/// Build a `Catalog` from a parsed JSON `Value` (must be a top-level object).
fn from_json(v: Value) -> Result<Catalog, Error> {
match v {
Value::Object(pairs) => {
let mut messages = HashMap::new();
for (k, v) in pairs {
match v {
Value::Str(s) => {
messages.insert(k, s);
}
_ => {
return Err(Error::new(format!(
"message value for key '{k}' must be a JSON string"
)))
}
}
}
Ok(Catalog { messages })
}
_ => Err(Error::new("catalog JSON root must be an object")),
}
}
/// Look up `key` in this locale; returns `None` if not found.
pub fn get(&self, key: &str) -> Option<&str> {
self.messages.get(key).map(String::as_str)
}
}
/// A set of [`Catalog`]s — one per locale — loaded from a directory of JSON files.
#[derive(Debug)]
///
/// Lookup falls back from the requested locale → default locale → the key itself
/// so templates always produce *something* even when a translation is missing.
pub struct Catalogs {
locales: HashMap<String, Catalog>,
default_locale: String,
}
impl Catalogs {
/// Load every `*.json` file in `dir` as a locale catalog.
///
/// The file stem becomes the locale code, e.g. `en.json` → `"en"`.
/// The default locale is `"en"` when present, otherwise the
/// lexicographically smallest locale found.
///
/// Returns an error if the directory cannot be read, a file cannot be
/// parsed, or no `.json` files are found.
pub fn load_dir(dir: &Path) -> Result<Catalogs, Error> {
let mut locales: HashMap<String, Catalog> = HashMap::new();
let iter = std::fs::read_dir(dir)
.map_err(|e| Error::new(format!("cannot read directory '{}': {e}", dir.display())))?;
for entry in iter {
let entry = entry.map_err(|e| Error::new(format!("directory entry error: {e}")))?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let locale = path
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| Error::new("locale filename is not valid UTF-8"))?
.to_string();
let src = std::fs::read_to_string(&path)
.map_err(|e| Error::new(format!("cannot read '{}': {e}", path.display())))?;
let value = akurai_json::parse(&src)
.map_err(|e| Error::new(format!("JSON error in '{}': {e}", path.display())))?;
locales.insert(locale, Catalog::from_json(value)?);
}
if locales.is_empty() {
return Err(Error::new(format!(
"no *.json locale files found in '{}'",
dir.display()
)));
}
let default_locale = if locales.contains_key("is") {
"is".to_string()
} else if locales.contains_key("en") {
"en".to_string()
} else {
let mut names: Vec<&String> = locales.keys().collect();
names.sort();
names[0].clone()
};
Ok(Catalogs {
locales,
default_locale,
})
}
/// Construct `Catalogs` directly from a locale map and an explicit default.
///
/// Useful for testing or programmatic catalog construction.
pub fn from_map(
locales: HashMap<String, HashMap<String, String>>,
default_locale: impl Into<String>,
) -> Catalogs {
let locales = locales
.into_iter()
.map(|(locale, msgs)| (locale, Catalog { messages: msgs }))
.collect();
Catalogs {
locales,
default_locale: default_locale.into(),
}
}
/// The default locale name (e.g. `"en"`).
pub fn default_locale(&self) -> &str {
&self.default_locale
}
/// Translate `key` for `locale`.
///
/// Fallback chain:
/// 1. `locale` catalog
/// 2. `default_locale` catalog (skipped if `locale == default_locale`)
/// 3. The key itself (never panics, never returns empty for a non-empty key)
pub fn translate<'a>(&'a self, locale: &str, key: &'a str) -> &'a str {
if let Some(msg) = self.locales.get(locale).and_then(|c| c.get(key)) {
return msg;
}
if locale != self.default_locale {
if let Some(msg) = self
.locales
.get(self.default_locale.as_str())
.and_then(|c| c.get(key))
{
return msg;
}
}
key
}
/// Translate `key` for `locale`, then replace `{name}` placeholders.
///
/// `args` is a slice of `("placeholder_name", "substitution_value")` pairs.
/// Only `{exact_name}` patterns are replaced; unknown placeholders are left as-is.
///
/// ```
/// use akurai_i18n::Catalogs;
/// use std::collections::HashMap;
///
/// let mut msgs = HashMap::new();
/// msgs.insert("greeting".into(), "Hello, {name}!".into());
/// let catalogs = Catalogs::from_map(
/// [("en".into(), msgs)].into_iter().collect(),
/// "en",
/// );
/// assert_eq!(
/// catalogs.translate_args("en", "greeting", &[("name", "Óli")]),
/// "Hello, Óli!"
/// );
/// ```
pub fn translate_args(&self, locale: &str, key: &str, args: &[(&str, &str)]) -> String {
let template = self.translate(locale, key);
interpolate(template, args)
}
}
/// Replace `{name}` placeholders in `template` with values from `args`.
///
/// Each `(name, value)` pair replaces every `{name}` occurrence. Unmatched
/// placeholders are left verbatim.
pub(crate) fn interpolate(template: &str, args: &[(&str, &str)]) -> String {
if args.is_empty() || !template.contains('{') {
return template.to_string();
}
let mut result = template.to_string();
for (name, value) in args {
result = result.replace(&format!("{{{name}}}"), value);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
fn en_catalog() -> Catalog {
let mut messages = HashMap::new();
messages.insert("hello".into(), "Hello".into());
messages.insert("greeting".into(), "Hello, {name}!".into());
Catalog { messages }
}
#[test]
fn catalog_get_hit_and_miss() {
let c = en_catalog();
assert_eq!(c.get("hello"), Some("Hello"));
assert_eq!(c.get("missing"), None);
}
#[test]
fn interpolate_replaces_placeholder() {
assert_eq!(
interpolate("Hello, {name}!", &[("name", "Óli")]),
"Hello, Óli!"
);
}
#[test]
fn interpolate_multiple_and_unknown() {
assert_eq!(
interpolate("{a} + {b} = {c}", &[("a", "1"), ("b", "2")]),
"1 + 2 = {c}"
);
}
#[test]
fn interpolate_no_args_fast_path() {
assert_eq!(interpolate("plain text", &[]), "plain text");
}
#[test]
fn catalogs_translate_hit() {
let catalogs = Catalogs::from_map(
[("en".into(), [("hello".into(), "Hello".into())].into())].into(),
"en",
);
assert_eq!(catalogs.translate("en", "hello"), "Hello");
}
#[test]
fn catalogs_translate_fallback_to_default() {
let en: HashMap<String, String> = [("hello".into(), "Hello".into())].into();
let is: HashMap<String, String> = HashMap::new(); // no key
let catalogs = Catalogs::from_map([("en".into(), en), ("is".into(), is)].into(), "en");
assert_eq!(catalogs.translate("is", "hello"), "Hello");
}
#[test]
fn catalogs_translate_fallback_to_key() {
let catalogs = Catalogs::from_map([("en".into(), HashMap::new())].into(), "en");
assert_eq!(catalogs.translate("en", "unknown.key"), "unknown.key");
assert_eq!(catalogs.translate("fr", "unknown.key"), "unknown.key");
}
#[test]
fn translate_args_end_to_end() {
let msgs: HashMap<String, String> = [("greeting".into(), "Hello, {name}!".into())].into();
let catalogs = Catalogs::from_map([("en".into(), msgs)].into(), "en");
assert_eq!(
catalogs.translate_args("en", "greeting", &[("name", "Óli")]),
"Hello, Óli!"
);
}
}