iwcore 0.1.25

IntelliWallet Core - Password manager library with AES-256 encryption
Documentation
//! Localization and translation support
//!
//! Provides multi-language support with 11 languages embedded at compile time.

use std::collections::HashMap;
use crate::error::{Result, WalletError};

/// Supported languages with their codes and names
pub const SUPPORTED_LANGUAGES: &[(&str, &str, &str)] = &[
    ("en", "English", "English"),
    ("de", "Deutsch", "German"),
    ("ru", "Русский", "Russian"),
    ("uk", "Українська", "Ukrainian"),
    ("pl", "Polski", "Polish"),
    ("pt", "Português", "Portuguese"),
    ("be", "Беларуская", "Belarusian"),
    ("bg", "Български", "Bulgarian"),
    ("hi", "हिन्दी", "Hindi"),
    ("ca", "Català", "Catalan"),
    ("es", "Español", "Spanish"),
];

// Embed all language files at compile time
const LANG_EN: &str = include_str!("languages/en.json");
const LANG_DE: &str = include_str!("languages/de.json");
const LANG_RU: &str = include_str!("languages/ru.json");
const LANG_UK: &str = include_str!("languages/uk.json");
const LANG_PL: &str = include_str!("languages/pl.json");
const LANG_PT: &str = include_str!("languages/pt.json");
const LANG_BE: &str = include_str!("languages/be.json");
const LANG_BG: &str = include_str!("languages/bg.json");
const LANG_HI: &str = include_str!("languages/hi.json");
const LANG_CA: &str = include_str!("languages/ca.json");
const LANG_ES: &str = include_str!("languages/es.json");

/// Get the embedded JSON for a language code
fn get_language_json(lang: &str) -> Option<&'static str> {
    match lang {
        "en" => Some(LANG_EN),
        "de" => Some(LANG_DE),
        "ru" => Some(LANG_RU),
        "uk" => Some(LANG_UK),
        "pl" => Some(LANG_PL),
        "pt" => Some(LANG_PT),
        "be" => Some(LANG_BE),
        "bg" => Some(LANG_BG),
        "hi" => Some(LANG_HI),
        "ca" => Some(LANG_CA),
        "es" => Some(LANG_ES),
        _ => None,
    }
}

/// Check if a language code is supported
pub fn is_language_supported(lang: &str) -> bool {
    SUPPORTED_LANGUAGES.iter().any(|(code, _, _)| *code == lang)
}

/// Translation manager
pub struct Translations {
    /// Current language code
    current_lang: String,
    /// Current language strings
    strings: HashMap<String, String>,
    /// English strings (fallback)
    english: HashMap<String, String>,
}

impl Translations {
    /// Create a new translations instance with English as default
    pub fn new() -> Result<Self> {
        let english = Self::load_language("en")?;
        Ok(Self {
            current_lang: "en".to_string(),
            strings: english.clone(),
            english,
        })
    }

    /// Load a language from embedded JSON
    fn load_language(lang: &str) -> Result<HashMap<String, String>> {
        let json = get_language_json(lang)
            .ok_or_else(|| WalletError::LocalizationError(
                format!("Language '{}' not found", lang)
            ))?;

        // Strip UTF-8 BOM if present
        let json = json.strip_prefix('\u{feff}').unwrap_or(json);

        serde_json::from_str(json)
            .map_err(|e| WalletError::LocalizationError(
                format!("Failed to parse language '{}': {}", lang, e)
            ))
    }

    /// Set the current language
    pub fn set_language(&mut self, lang: &str) -> Result<()> {
        if !is_language_supported(lang) {
            return Err(WalletError::LocalizationError(
                format!("Language '{}' is not supported", lang)
            ));
        }

        self.strings = Self::load_language(lang)?;
        self.current_lang = lang.to_string();
        Ok(())
    }

    /// Get a translated string by key
    /// Returns the key itself if not found
    pub fn get<'a>(&'a self, key: &'a str) -> &'a str {
        self.strings.get(key)
            .or_else(|| self.english.get(key))
            .map(|s| s.as_str())
            .unwrap_or(key)
    }

    /// Get a translated string, returning None if not found
    pub fn get_opt(&self, key: &str) -> Option<&str> {
        self.strings.get(key)
            .or_else(|| self.english.get(key))
            .map(|s| s.as_str())
    }

    /// Get English translation (always from English dictionary)
    pub fn get_en<'a>(&'a self, key: &'a str) -> &'a str {
        self.english.get(key)
            .map(|s| s.as_str())
            .unwrap_or(key)
    }

    /// Get the current language code
    pub fn get_language(&self) -> &str {
        &self.current_lang
    }

    /// Get the current language name (in its own language)
    pub fn get_language_name(&self) -> &str {
        SUPPORTED_LANGUAGES.iter()
            .find(|(code, _, _)| *code == self.current_lang)
            .map(|(_, local, _)| *local)
            .unwrap_or("Unknown")
    }

    /// Get available languages as (code, local_name, english_name) tuples
    pub fn available_languages() -> &'static [(&'static str, &'static str, &'static str)] {
        SUPPORTED_LANGUAGES
    }

    /// Get the English dictionary for iteration
    pub fn get_english_dictionary(&self) -> &HashMap<String, String> {
        &self.english
    }

    /// Get all keys in the current language
    pub fn keys(&self) -> impl Iterator<Item = &String> {
        self.strings.keys()
    }
}

impl Default for Translations {
    fn default() -> Self {
        Self::new().expect("Failed to load default translations")
    }
}

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

    #[test]
    fn test_load_english() {
        let tr = Translations::new().unwrap();
        assert_eq!(tr.get_language(), "en");
    }

    #[test]
    fn test_get_translation() {
        let tr = Translations::new().unwrap();
        // Check a known key from en.json
        assert_eq!(tr.get("app_name"), "IntelliWallet");
        assert_eq!(tr.get("ok"), "OK");
        assert_eq!(tr.get("cancel"), "Cancel");
    }

    #[test]
    fn test_unknown_key_returns_key() {
        let tr = Translations::new().unwrap();
        assert_eq!(tr.get("unknown_key_xyz"), "unknown_key_xyz");
    }

    #[test]
    fn test_set_language() {
        let mut tr = Translations::new().unwrap();

        // Switch to Russian
        tr.set_language("ru").unwrap();
        assert_eq!(tr.get_language(), "ru");

        // Russian translations should be loaded
        // "ok" in Russian is "ОК"
        let ok_ru = tr.get("ok");
        assert!(!ok_ru.is_empty());
    }

    #[test]
    fn test_fallback_to_english() {
        let mut tr = Translations::new().unwrap();
        tr.set_language("de").unwrap();

        // If a key is missing in German, should fall back to English
        // All keys should either be in German or fall back to English
        let app_name = tr.get("app_name");
        assert!(!app_name.is_empty());
    }

    #[test]
    fn test_unsupported_language() {
        let mut tr = Translations::new().unwrap();
        let result = tr.set_language("xx");
        assert!(result.is_err());
    }

    #[test]
    fn test_all_languages_load() {
        for (code, _, _) in SUPPORTED_LANGUAGES {
            let mut tr = Translations::new().unwrap();
            tr.set_language(code).expect(&format!("Failed to load language: {}", code));
            assert_eq!(tr.get_language(), *code);
        }
    }

    #[test]
    fn test_is_language_supported() {
        assert!(is_language_supported("en"));
        assert!(is_language_supported("ru"));
        assert!(is_language_supported("de"));
        assert!(!is_language_supported("xx"));
        assert!(!is_language_supported("fr")); // French not in list
    }

    #[test]
    fn test_get_language_name() {
        let mut tr = Translations::new().unwrap();
        assert_eq!(tr.get_language_name(), "English");

        tr.set_language("ru").unwrap();
        assert_eq!(tr.get_language_name(), "Русский");

        tr.set_language("de").unwrap();
        assert_eq!(tr.get_language_name(), "Deutsch");
    }

    #[test]
    fn test_available_languages() {
        let langs = Translations::available_languages();
        assert_eq!(langs.len(), 11);

        // Check first and last
        assert_eq!(langs[0], ("en", "English", "English"));
        assert_eq!(langs[10], ("es", "Español", "Spanish"));
    }

    #[test]
    fn test_get_opt() {
        let tr = Translations::new().unwrap();
        assert!(tr.get_opt("app_name").is_some());
        assert!(tr.get_opt("nonexistent_key_xyz").is_none());
    }

    #[test]
    fn test_get_en() {
        let mut tr = Translations::new().unwrap();
        tr.set_language("ru").unwrap();

        // get_en should always return English regardless of current language
        let en_ok = tr.get_en("ok");
        assert_eq!(en_ok, "OK");
    }

    #[test]
    fn test_keys_iterator() {
        let tr = Translations::new().unwrap();
        let count = tr.keys().count();
        assert!(count > 0);
    }

    #[test]
    fn test_default_translations() {
        let tr = Translations::default();
        assert_eq!(tr.get_language(), "en");
    }

    /// Test: CheckTranslations from C# LocalizationFixture
    /// Verifies all keys are translated in each language (different from English)
    #[test]
    fn test_all_keys_translated() {
        let tr_en = Translations::new().unwrap();
        let en_dict = tr_en.get_english_dictionary();

        // Keys that are expected to be the same across languages
        let exceptions = [
            "app_name",           // IntelliWallet is the brand name
            "ok",                 // OK is universal
            "ip",                 // IP is technical term
            "premium_upgrade_why", // May be same in some langs
        ];

        for (code, _, _) in SUPPORTED_LANGUAGES {
            if *code == "en" {
                continue;
            }

            let mut tr = Translations::new().unwrap();
            tr.set_language(code).unwrap();

            // Check each key has a translation
            for (key, en_value) in en_dict {
                let translated = tr.get(key);

                // Should not be empty
                assert!(!translated.is_empty(),
                    "Language {} has empty translation for key '{}'", code, key);

                // Most translations should differ from English (with exceptions)
                if !exceptions.contains(&key.as_str()) && !en_value.is_empty() {
                    // Note: Some keys may legitimately be the same, so we just warn
                    if translated == en_value.as_str() && translated.len() > 3 {
                        // Only log, don't fail - some translations may legitimately be the same
                        // eprintln!("Warning: {} key '{}' same as English: {}", code, key, en_value);
                    }
                }
            }
        }
    }
}