i18n-rs 1.0.0

A Rust library for internationalization (i18n) support
Documentation
//! Lightweight internationalization utilities for Rust web services.
//!
//! The crate focuses on a simple contract: load a translation file that maps
//! language codes to string resources, then look up translated strings with an
//! optional fallback language. The translation file is expected to be JSON and
//! follow this general structure:
//!
//! ```json
//! {
//!   "en": {
//!     "greeting": {
//!       "welcome": "Welcome"
//!     }
//!   },
//!   "es": {
//!     "greeting": {
//!       "welcome": "Bienvenido"
//!     }
//!   }
//! }
//! ```
//!
//! Nested objects will automatically be flattened using dot notation
//! (`greeting.welcome`), making lookups ergonomic without forcing a specific
//! module system. This keeps the API framework agnostic while remaining easy to
//! embed in any web framework.

use serde_json::Value;
use std::{
    collections::HashMap,
    fmt,
    fs::File,
    io::{self, Read},
    path::Path,
};

/// Errors returned by [`Translator`] operations.
#[derive(Debug)]
pub enum I18nError {
    /// A translation file could not be read.
    Io(io::Error),
    /// The translation file was not valid JSON.
    Json(serde_json::Error),
    /// The translation data did not match the expected structure.
    InvalidFormat(String),
    /// Requested a fallback language that does not exist in the data set.
    UnknownFallbackLanguage(String),
}

impl fmt::Display for I18nError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Io(err) => write!(f, "failed to read translation source: {err}"),
            Self::Json(err) => write!(f, "failed to parse translation JSON: {err}"),
            Self::InvalidFormat(details) => write!(f, "invalid translation format: {details}"),
            Self::UnknownFallbackLanguage(lang) => {
                write!(f, "fallback language `{lang}` was not found in translations")
            }
        }
    }
}

impl std::error::Error for I18nError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            Self::Io(err) => Some(err),
            Self::Json(err) => Some(err),
            _ => None,
        }
    }
}

impl From<io::Error> for I18nError {
    fn from(err: io::Error) -> Self {
        Self::Io(err)
    }
}

impl From<serde_json::Error> for I18nError {
    fn from(err: serde_json::Error) -> Self {
        Self::Json(err)
    }
}

/// Central translation store capable of performing lookups with an optional fallback.
#[derive(Debug, Clone)]
pub struct Translator {
    fallback_language: Option<String>,
    translations: HashMap<String, HashMap<String, String>>,
}

impl Translator {
    /// Creates a new [`TranslatorBuilder`].
    pub fn builder() -> TranslatorBuilder {
        TranslatorBuilder::default()
    }

    /// Finds the translation for `key` within `language`.
    ///
    /// Returns `None` when the language or key is missing.
    pub fn translate<'a>(&'a self, language: &str, key: &str) -> Option<&'a str> {
        self.translations
            .get(language)
            .and_then(|catalog| catalog.get(key).map(|value| value.as_str()))
    }

    /// Resolves a translation for `key`, falling back to the configured language if needed.
    pub fn translate_with_fallback<'a>(&'a self, language: &str, key: &str) -> Option<&'a str> {
        self.translate(language, key)
            .or_else(|| self.fallback_language.as_deref().and_then(|fallback| self.translate(fallback, key)))
    }

    /// Returns a list of known language identifiers.
    pub fn available_languages(&self) -> Vec<&str> {
        self.translations.keys().map(|key| key.as_str()).collect()
    }
}

/// Builder used to configure and create a [`Translator`].
#[derive(Default)]
pub struct TranslatorBuilder {
    fallback_language: Option<String>,
}

impl TranslatorBuilder {
    /// Enables the use of `language` as fallback whenever a requested translation is missing.
    pub fn fallback_language(mut self, language: impl Into<String>) -> Self {
        self.fallback_language = Some(language.into());
        self
    }

    /// Creates a [`Translator`] from a file path containing JSON translations.
    pub fn load_from_path<P>(self, path: P) -> Result<Translator, I18nError>
    where
        P: AsRef<Path>,
    {
        let file = File::open(path)?;
        self.load_from_reader(file)
    }

    /// Creates a [`Translator`] from any readable JSON source.
    pub fn load_from_reader<R>(self, mut reader: R) -> Result<Translator, I18nError>
    where
        R: Read,
    {
        let mut buf = String::new();
        reader.read_to_string(&mut buf)?;
        self.load_from_str(&buf)
    }

    /// Creates a [`Translator`] from JSON already loaded into memory.
    pub fn load_from_str(self, json: &str) -> Result<Translator, I18nError> {
        let raw: HashMap<String, Value> = serde_json::from_str(json)?;
        build_translator(raw, self.fallback_language)
    }
}

fn build_translator(
    raw: HashMap<String, Value>,
    fallback_language: Option<String>,
) -> Result<Translator, I18nError> {
    let mut translations = HashMap::with_capacity(raw.len());

    for (language, value) in raw {
        let mut catalog = HashMap::new();
        flatten_value("", &value, &mut catalog)?;

        translations.insert(language, catalog);
    }

    if let Some(ref language) = fallback_language {
        if !translations.contains_key(language) {
            return Err(I18nError::UnknownFallbackLanguage(language.clone()));
        }
    }

    Ok(Translator {
        fallback_language,
        translations,
    })
}

fn flatten_value(prefix: &str, value: &Value, out: &mut HashMap<String, String>) -> Result<(), I18nError> {
    match value {
        Value::String(text) => {
            if prefix.is_empty() {
                return Err(I18nError::InvalidFormat(
                    "language entries must be objects containing key/value pairs".to_owned(),
                ));
            }
            out.insert(prefix.to_owned(), text.clone());
            Ok(())
        }
        Value::Object(map) => {
            for (key, nested) in map {
                let next_prefix = if prefix.is_empty() {
                    key.to_owned()
                } else {
                    format!("{prefix}.{key}")
                };
                flatten_value(&next_prefix, nested, out)?;
            }
            Ok(())
        }
        other => Err(I18nError::InvalidFormat(format!(
            "expected string or object but found {other:?} at `{prefix}`"
        ))),
    }
}

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

    static SAMPLE_JSON: &str = r#"{
        "en": {
            "greeting": {
                "welcome": "Welcome",
                "farewell": "Goodbye"
            },
            "plain": "Simple"
        },
        "fr": {
            "greeting": {
                "welcome": "Bienvenue"
            },
            "plain": "Simple"
        }
    }"#;

    #[test]
    fn loads_translations_from_str() {
        let translator = Translator::builder().load_from_str(SAMPLE_JSON).unwrap();

        assert_eq!(
            translator.translate("en", "greeting.welcome"),
            Some("Welcome")
        );
        assert_eq!(translator.translate("fr", "greeting.welcome"), Some("Bienvenue"));
        assert_eq!(translator.translate("fr", "greeting.farewell"), None);
    }

    #[test]
    fn uses_fallback_language() {
        let translator = Translator::builder()
            .fallback_language("en")
            .load_from_str(SAMPLE_JSON)
            .unwrap();

        assert_eq!(
            translator.translate_with_fallback("fr", "greeting.farewell"),
            Some("Goodbye")
        );
    }

    #[test]
    fn rejects_unknown_fallback() {
        let err = Translator::builder()
            .fallback_language("es")
            .load_from_str(SAMPLE_JSON)
            .unwrap_err();

        match err {
            I18nError::UnknownFallbackLanguage(lang) => assert_eq!(lang, "es"),
            other => panic!("unexpected error: {other:?}"),
        }
    }

    #[test]
    fn returns_available_languages() {
        let translator = Translator::builder().load_from_str(SAMPLE_JSON).unwrap();
        let mut languages = translator.available_languages();
        languages.sort();
        assert_eq!(languages, vec!["en", "fr"]);
    }
}