foukoapi 0.1.2-alpha.2

Cross-platform bot framework in Rust: one codebase, many platforms. Shared accounts, embeds, keyboards, economy, i18n and pluggable storage; Telegram and Discord adapters included.
Documentation
//! Tiny translation catalogue.
//!
//! No macros, no build step, no external files: you register strings by
//! key and language, then look them up with a language code. Missing
//! translations fall back to English, and a missing key falls back to the
//! key itself so nothing ever renders blank.
//!
//! ```
//! use foukoapi::I18n;
//!
//! let i18n = I18n::new()
//!     .add("hello", &[("en", "Hello"), ("ru", "Привет")]);
//!
//! assert_eq!(i18n.t("ru", "hello"), "Привет");
//! assert_eq!(i18n.t("de", "hello"), "Hello"); // falls back to English
//! ```
//!
//! For strings with runtime values, keep `{}`-style placeholders in the
//! template and fill them in with [`I18n::tf`]:
//!
//! ```
//! use foukoapi::I18n;
//!
//! let i18n = I18n::new().add("greet", &[("en", "Hi, {}!")]);
//! assert_eq!(i18n.tf("en", "greet", &["Sam"]), "Hi, Sam!");
//! ```

use std::collections::HashMap;

/// The language a catalogue falls back to when a translation is missing.
pub const FALLBACK_LANG: &str = "en";

/// A translation catalogue: `key -> (lang -> text)`.
#[derive(Debug, Clone, Default)]
pub struct I18n {
    entries: HashMap<String, HashMap<String, String>>,
}

impl I18n {
    /// An empty catalogue.
    pub fn new() -> Self {
        Self::default()
    }

    /// Register every translation of a single key.
    ///
    /// Chainable, so a whole catalogue reads as one expression. Language
    /// codes are lower-cased on the way in so lookups are case-insensitive.
    pub fn add(mut self, key: &str, translations: &[(&str, &str)]) -> Self {
        let map = self.entries.entry(key.to_owned()).or_default();
        for (lang, text) in translations {
            map.insert(lang.to_ascii_lowercase(), (*text).to_owned());
        }
        self
    }

    /// Merge another catalogue into this one. Entries in `other` win on
    /// conflicts, which makes it easy to layer bot-specific overrides on
    /// top of a shared base.
    pub fn merge(mut self, other: I18n) -> Self {
        for (key, langs) in other.entries {
            let slot = self.entries.entry(key).or_default();
            slot.extend(langs);
        }
        self
    }

    /// Look up `key` in `lang`, falling back to English and then to the
    /// key itself.
    pub fn t(&self, lang: &str, key: &str) -> String {
        let lang = lang.to_ascii_lowercase();
        match self.entries.get(key) {
            Some(langs) => langs
                .get(&lang)
                .or_else(|| langs.get(FALLBACK_LANG))
                .cloned()
                .unwrap_or_else(|| key.to_owned()),
            None => key.to_owned(),
        }
    }

    /// Like [`I18n::t`], but replaces `{}` placeholders left-to-right with
    /// `args`. Extra placeholders keep their `{}`; extra args are ignored.
    pub fn tf(&self, lang: &str, key: &str, args: &[&str]) -> String {
        let template = self.t(lang, key);
        let mut out = String::with_capacity(template.len());
        let mut args = args.iter();
        let mut chars = template.chars().peekable();
        while let Some(c) = chars.next() {
            if c == '{' && chars.peek() == Some(&'}') {
                chars.next();
                match args.next() {
                    Some(a) => out.push_str(a),
                    None => out.push_str("{}"),
                }
            } else {
                out.push(c);
            }
        }
        out
    }

    /// Every language code that appears anywhere in the catalogue, sorted.
    pub fn languages(&self) -> Vec<String> {
        let mut set: Vec<String> = self
            .entries
            .values()
            .flat_map(|langs| langs.keys().cloned())
            .collect();
        set.sort();
        set.dedup();
        set
    }

    /// Find gaps in coverage: every `(key, lang)` pair from `langs` that
    /// has no translation. Useful in a test to guarantee every string is
    /// localised into every language you ship. The result is sorted for a
    /// stable, readable assertion message.
    pub fn missing(&self, langs: &[&str]) -> Vec<(String, String)> {
        let mut gaps = Vec::new();
        for (key, translations) in &self.entries {
            for lang in langs {
                let lc = lang.to_ascii_lowercase();
                if !translations.contains_key(&lc) {
                    gaps.push((key.clone(), lc));
                }
            }
        }
        gaps.sort();
        gaps
    }
}

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

    fn cat() -> I18n {
        I18n::new()
            .add("hi", &[("en", "Hi"), ("ru", "Привет"), ("de", "Hallo")])
            .add("bye", &[("en", "Bye {}"), ("ru", "Пока {}")])
    }

    #[test]
    fn lookup_and_fallback() {
        let c = cat();
        assert_eq!(c.t("ru", "hi"), "Привет");
        assert_eq!(c.t("de", "hi"), "Hallo");
        assert_eq!(c.t("fr", "hi"), "Hi"); // english fallback
        assert_eq!(c.t("en", "missing"), "missing"); // key fallback
    }

    #[test]
    fn case_insensitive_lang() {
        assert_eq!(cat().t("RU", "hi"), "Привет");
    }

    #[test]
    fn formatting_fills_placeholders() {
        let c = cat();
        assert_eq!(c.tf("en", "bye", &["Sam"]), "Bye Sam");
        assert_eq!(c.tf("ru", "bye", &["Сэм"]), "Пока Сэм");
        // Missing arg leaves the placeholder in place.
        assert_eq!(c.tf("en", "bye", &[]), "Bye {}");
    }

    #[test]
    fn languages_are_unique_and_sorted() {
        assert_eq!(cat().languages(), vec!["de", "en", "ru"]);
    }

    #[test]
    fn missing_reports_gaps() {
        let c = cat();
        // "bye" has en+ru but not de; "hi" has all three.
        let gaps = c.missing(&["en", "ru", "de"]);
        assert_eq!(gaps, vec![("bye".to_owned(), "de".to_owned())]);
        assert!(c.missing(&["en"]).is_empty());
    }
}