rustrails-support 0.1.1

Core utilities (ActiveSupport equivalent)
Documentation
use parking_lot::RwLock;
use serde_json::{Map, Value};
use std::cell::RefCell;
use std::collections::HashMap;

thread_local! {
    static CURRENT_LOCALE: RefCell<Option<String>> = const { RefCell::new(None) };
}

/// Errors returned while loading translations.
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
pub enum I18nError {
    /// The translation source could not be parsed.
    #[error("translation parse error: {0}")]
    Parse(String),
}

/// A locale-aware translation store with thread-local locale overrides.
#[derive(Debug)]
pub struct I18n {
    default_locale: RwLock<String>,
    translations: RwLock<HashMap<String, Value>>,
}

impl I18n {
    /// Creates an empty translation store with `locale` as the default locale.
    #[must_use]
    pub fn new(locale: impl Into<String>) -> Self {
        Self {
            default_locale: RwLock::new(locale.into()),
            translations: RwLock::new(HashMap::new()),
        }
    }

    /// Loads translations from TOML content and deep-merges them into the store.
    pub fn load_translations(&self, toml_str: &str) -> Result<(), I18nError> {
        let parsed: toml::Value =
            toml::from_str(toml_str).map_err(|error| I18nError::Parse(error.to_string()))?;
        let json =
            serde_json::to_value(parsed).map_err(|error| I18nError::Parse(error.to_string()))?;
        let Value::Object(locales) = json else {
            return Err(I18nError::Parse(String::from(
                "top-level translation value must be a table",
            )));
        };

        let mut translations = self.translations.write();
        for (locale, value) in locales {
            match translations.get_mut(&locale) {
                Some(existing) => merge_value(existing, &value),
                None => {
                    translations.insert(locale, value);
                }
            }
        }
        Ok(())
    }

    /// Sets the current thread-local locale.
    pub fn set_locale(&self, locale: impl Into<String>) {
        let locale = locale.into();
        CURRENT_LOCALE.with(|current| *current.borrow_mut() = Some(locale));
    }

    /// Returns the active locale for the current thread.
    #[must_use]
    pub fn locale(&self) -> String {
        CURRENT_LOCALE.with(|current| {
            current
                .borrow()
                .clone()
                .unwrap_or_else(|| self.default_locale.read().clone())
        })
    }

    /// Translates `key` for the active locale, returning `key` when missing.
    #[must_use]
    pub fn t(&self, key: &str) -> String {
        self.translate(key, None, None)
    }

    /// Translates `key`, applying pluralization for `count`.
    #[must_use]
    pub fn t_count(&self, key: &str, count: usize) -> String {
        self.translate(key, Some(count), None)
    }

    /// Translates `key`, returning `default` when missing.
    #[must_use]
    pub fn t_default(&self, key: &str, default: &str) -> String {
        self.translate(key, None, Some(default))
    }

    /// Translates `key` with optional pluralization and fallback handling.
    #[must_use]
    pub fn translate(&self, key: &str, count: Option<usize>, default: Option<&str>) -> String {
        let locale = self.locale();
        let translations = self.translations.read();
        let value = translations
            .get(&locale)
            .and_then(|root| lookup_key(root, key))
            .and_then(|value| resolve_pluralization(value, count));

        match value {
            Some(value) => render_value(value, count),
            None => default.unwrap_or(key).to_owned(),
        }
    }
}

#[cfg(test)]
pub(crate) fn reset_locale() {
    CURRENT_LOCALE.with(|current| *current.borrow_mut() = None);
}

fn lookup_key<'a>(value: &'a Value, key: &str) -> Option<&'a Value> {
    let mut current = value;
    for segment in key.split('.') {
        current = current.as_object()?.get(segment)?;
    }
    Some(current)
}

fn resolve_pluralization(value: &Value, count: Option<usize>) -> Option<&Value> {
    if let Some(count) = count
        && let Some(map) = value.as_object()
    {
        let key = if count == 1 { "one" } else { "other" };
        return map.get(key).or_else(|| map.get("other"));
    }
    Some(value)
}

fn render_value(value: &Value, count: Option<usize>) -> String {
    match value {
        Value::String(text) => count
            .map(|count| text.replace("%{count}", &count.to_string()))
            .unwrap_or_else(|| text.clone()),
        _ => value.to_string(),
    }
}

fn merge_value(existing: &mut Value, incoming: &Value) {
    match (existing, incoming) {
        (Value::Object(existing), Value::Object(incoming)) => {
            for (key, value) in incoming {
                match existing.get_mut(key) {
                    Some(existing_value) => merge_value(existing_value, value),
                    None => {
                        existing.insert(key.clone(), value.clone());
                    }
                }
            }
        }
        (existing, incoming) => *existing = incoming.clone(),
    }
}

#[cfg(test)]
mod tests {
    use super::{I18n, I18nError, reset_locale};
    use std::thread;

    const TRANSLATIONS: &str = r#"
[en.errors.messages]
blank = "can't be blank"

[en.items]
one = "%{count} item"
other = "%{count} items"

[en.meta]
version = 1

[es.errors.messages]
blank = "no puede estar en blanco"

[es.greeting]
hello = "hola"
"#;

    fn run_isolated<R>(test: impl FnOnce() -> R + Send + 'static) -> R
    where
        R: Send + 'static,
    {
        match thread::spawn(test).join() {
            Ok(result) => result,
            Err(payload) => std::panic::resume_unwind(payload),
        }
    }

    #[test]
    fn locale_defaults_to_constructor_value() {
        run_isolated(|| {
            reset_locale();
            let i18n = I18n::new("en");
            assert_eq!(i18n.locale(), "en");
        });
    }

    #[test]
    fn load_translations_parses_toml_content() {
        run_isolated(|| {
            reset_locale();
            let i18n = I18n::new("en");
            i18n.load_translations(TRANSLATIONS)
                .expect("translations should load");

            assert_eq!(i18n.t("errors.messages.blank"), "can't be blank");
        });
    }

    #[test]
    fn dot_notation_traverses_nested_translation_keys() {
        run_isolated(|| {
            reset_locale();
            let i18n = I18n::new("en");
            i18n.load_translations(TRANSLATIONS)
                .expect("translations should load");

            assert_eq!(i18n.t("errors.messages.blank"), "can't be blank");
        });
    }

    #[test]
    fn missing_key_returns_the_key() {
        run_isolated(|| {
            reset_locale();
            let i18n = I18n::new("en");
            i18n.load_translations(TRANSLATIONS)
                .expect("translations should load");

            assert_eq!(i18n.t("missing.key"), "missing.key");
        });
    }

    #[test]
    fn default_value_is_used_when_key_is_missing() {
        run_isolated(|| {
            reset_locale();
            let i18n = I18n::new("en");
            assert_eq!(i18n.t_default("missing.key", "fallback"), "fallback");
        });
    }

    #[test]
    fn pluralization_uses_one_for_singular_counts() {
        run_isolated(|| {
            reset_locale();
            let i18n = I18n::new("en");
            i18n.load_translations(TRANSLATIONS)
                .expect("translations should load");

            assert_eq!(i18n.t_count("items", 1), "1 item");
        });
    }

    #[test]
    fn pluralization_uses_other_for_plural_counts() {
        run_isolated(|| {
            reset_locale();
            let i18n = I18n::new("en");
            i18n.load_translations(TRANSLATIONS)
                .expect("translations should load");

            assert_eq!(i18n.t_count("items", 3), "3 items");
        });
    }

    #[test]
    fn set_locale_overrides_the_current_thread_locale() {
        run_isolated(|| {
            reset_locale();
            let i18n = I18n::new("en");
            i18n.load_translations(TRANSLATIONS)
                .expect("translations should load");
            i18n.set_locale("es");

            assert_eq!(i18n.locale(), "es");
            assert_eq!(i18n.t("errors.messages.blank"), "no puede estar en blanco");
        });
    }

    #[test]
    fn locale_override_is_thread_local() {
        reset_locale();
        let i18n = I18n::new("en");
        i18n.load_translations(TRANSLATIONS)
            .expect("translations should load");
        i18n.set_locale("es");

        let child_value = run_isolated(|| {
            reset_locale();
            let i18n = I18n::new("en");
            i18n.load_translations(TRANSLATIONS)
                .expect("translations should load");
            i18n.t("errors.messages.blank")
        });

        assert_eq!(child_value, "can't be blank");
        assert_eq!(i18n.t("errors.messages.blank"), "no puede estar en blanco");
    }

    #[test]
    fn later_translation_loads_are_merged() {
        run_isolated(|| {
            reset_locale();
            let i18n = I18n::new("en");
            i18n.load_translations("[en.errors.messages]\nblank = \"can't be blank\"")
                .expect("translations should load");
            i18n.load_translations("[en.greeting]\nhello = \"hello\"")
                .expect("translations should load");

            assert_eq!(i18n.t("errors.messages.blank"), "can't be blank");
            assert_eq!(i18n.t("greeting.hello"), "hello");
        });
    }

    #[test]
    fn non_string_values_are_rendered() {
        run_isolated(|| {
            reset_locale();
            let i18n = I18n::new("en");
            i18n.load_translations(TRANSLATIONS)
                .expect("translations should load");

            assert_eq!(i18n.t("meta.version"), "1");
        });
    }

    #[test]
    fn missing_pluralization_uses_default_when_provided() {
        run_isolated(|| {
            reset_locale();
            let i18n = I18n::new("en");
            assert_eq!(
                i18n.translate("items", Some(2), Some("fallback")),
                "fallback"
            );
        });
    }

    #[test]
    fn invalid_toml_returns_a_typed_error() {
        run_isolated(|| {
            reset_locale();
            let i18n = I18n::new("en");
            let error = i18n
                .load_translations("[en\nhello = \"world\"")
                .expect_err("invalid toml should fail");

            assert!(matches!(error, I18nError::Parse(_)));
        });
    }
}