rok-core 0.6.0

Core primitives for the rok ecosystem — errors, crypto, i18n, config, DI, and more
Documentation
pub mod context;
pub mod loader;
pub mod translate;

pub use context::{I18nContext, I18nLayer, CURRENT_I18N};
pub use loader::{load_dir, load_from_pairs, LocaleStore};

pub fn translate(key: &str, args: &[(&str, String)]) -> String {
    CURRENT_I18N
        .try_with(|ctx| {
            translate::do_translate(&ctx.store, &ctx.locale, &ctx.default_locale, key, args)
        })
        .unwrap_or_else(|_| key.to_string())
}

pub fn translate_plural(key: &str, count: u64, args: &[(&str, String)]) -> String {
    CURRENT_I18N
        .try_with(|ctx| {
            translate::do_translate_plural(
                &ctx.store,
                &ctx.locale,
                &ctx.default_locale,
                key,
                count,
                args,
            )
        })
        .unwrap_or_else(|_| key.to_string())
}

pub fn try_translate(key: &str, args: &[(&str, String)]) -> Option<String> {
    CURRENT_I18N
        .try_with(|ctx| {
            translate::do_translate(&ctx.store, &ctx.locale, &ctx.default_locale, key, args)
        })
        .ok()
}

pub fn current_locale() -> String {
    CURRENT_I18N
        .try_with(|ctx| ctx.locale.clone())
        .unwrap_or_else(|_| "en".to_string())
}

#[macro_export]
macro_rules! t {
    ($key:expr) => {
        $crate::i18n::translate($key, &[])
    };
    ($key:expr, count = $n:expr $(,)?) => {
        $crate::i18n::translate_plural($key, ($n) as u64, &[])
    };
    ($key:expr, count = $n:expr, $($k:ident = $v:expr),+ $(,)?) => {
        $crate::i18n::translate_plural(
            $key,
            ($n) as u64,
            &[$( (stringify!($k), ($v).to_string()) ),+],
        )
    };
    ($key:expr, $($k:ident = $v:expr),+ $(,)?) => {
        $crate::i18n::translate($key, &[$( (stringify!($k), ($v).to_string()) ),+])
    };
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;
    use std::sync::Arc;

    fn make_ctx(
        locale: &str,
        entries: impl IntoIterator<Item = (&'static str, serde_json::Value)>,
    ) -> Arc<I18nContext> {
        Arc::new(I18nContext {
            locale: locale.to_string(),
            default_locale: "en".to_string(),
            store: load_from_pairs(entries),
        })
    }

    #[tokio::test]
    async fn translate_simple_key() {
        let ctx = make_ctx("en", [("en", json!({ "hello": "Hello!" }))]);
        let result = CURRENT_I18N
            .scope(ctx, async { translate("hello", &[]) })
            .await;
        assert_eq!(result, "Hello!");
    }

    #[tokio::test]
    async fn translate_with_interpolation() {
        let ctx = make_ctx("en", [("en", json!({ "welcome": "Welcome, {name}!" }))]);
        let result = CURRENT_I18N
            .scope(ctx, async {
                translate("welcome", &[("name", "Alice".to_string())])
            })
            .await;
        assert_eq!(result, "Welcome, Alice!");
    }

    #[tokio::test]
    async fn translate_falls_back_to_default_locale() {
        let ctx = Arc::new(I18nContext {
            locale: "fr".to_string(),
            default_locale: "en".to_string(),
            store: load_from_pairs([("en", json!({ "greeting": "Hello" }))]),
        });
        let result = CURRENT_I18N
            .scope(ctx, async { translate("greeting", &[]) })
            .await;
        assert_eq!(result, "Hello");
    }

    #[tokio::test]
    async fn translate_falls_back_to_key_when_missing_everywhere() {
        let ctx = make_ctx("en", [("en", json!({}))]);
        let result = CURRENT_I18N
            .scope(ctx, async { translate("missing.key", &[]) })
            .await;
        assert_eq!(result, "missing.key");
    }

    #[tokio::test]
    async fn translate_outside_scope_returns_key() {
        let result = translate("some.key", &[]);
        assert_eq!(result, "some.key");
    }

    #[tokio::test]
    async fn try_translate_outside_scope_returns_none() {
        assert!(try_translate("k", &[]).is_none());
    }

    #[tokio::test]
    async fn try_translate_inside_scope_returns_some() {
        let ctx = make_ctx("en", [("en", json!({ "k": "v" }))]);
        let result = CURRENT_I18N
            .scope(ctx, async { try_translate("k", &[]) })
            .await;
        assert_eq!(result, Some("v".to_string()));
    }

    #[tokio::test]
    async fn plural_selects_one_form() {
        let ctx = make_ctx(
            "en",
            [(
                "en",
                json!({ "items": { "one": "1 item", "other": "{count} items" } }),
            )],
        );
        let result = CURRENT_I18N
            .scope(ctx, async { translate_plural("items", 1, &[]) })
            .await;
        assert_eq!(result, "1 item");
    }

    #[tokio::test]
    async fn plural_selects_other_form() {
        let ctx = make_ctx(
            "en",
            [(
                "en",
                json!({ "items": { "one": "1 item", "other": "{count} items" } }),
            )],
        );
        let result = CURRENT_I18N
            .scope(ctx, async { translate_plural("items", 5, &[]) })
            .await;
        assert_eq!(result, "5 items");
    }

    #[tokio::test]
    async fn plural_injects_count_automatically() {
        let ctx = make_ctx(
            "en",
            [(
                "en",
                json!({ "n": { "one": "one", "other": "{count} things" } }),
            )],
        );
        let result = CURRENT_I18N
            .scope(ctx, async { translate_plural("n", 42, &[]) })
            .await;
        assert_eq!(result, "42 things");
    }

    #[tokio::test]
    async fn plural_falls_back_to_default_locale() {
        let ctx = Arc::new(I18nContext {
            locale: "de".to_string(),
            default_locale: "en".to_string(),
            store: load_from_pairs([(
                "en",
                json!({ "msgs": { "one": "1 message", "other": "{count} messages" } }),
            )]),
        });
        let result = CURRENT_I18N
            .scope(ctx, async { translate_plural("msgs", 3, &[]) })
            .await;
        assert_eq!(result, "3 messages");
    }

    #[tokio::test]
    async fn current_locale_returns_active_locale() {
        let ctx = make_ctx("fr", []);
        let result = CURRENT_I18N.scope(ctx, async { current_locale() }).await;
        assert_eq!(result, "fr");
    }

    #[tokio::test]
    async fn current_locale_outside_scope_returns_en() {
        assert_eq!(current_locale(), "en");
    }

    #[tokio::test]
    async fn macro_simple() {
        let ctx = make_ctx("en", [("en", json!({ "hi": "Hi!" }))]);
        let result = CURRENT_I18N.scope(ctx, async { t!("hi") }).await;
        assert_eq!(result, "Hi!");
    }

    #[tokio::test]
    async fn macro_with_variable() {
        let ctx = make_ctx("en", [("en", json!({ "greet": "Hello, {name}!" }))]);
        let name = "Bob";
        let result = CURRENT_I18N
            .scope(ctx, async { t!("greet", name = name) })
            .await;
        assert_eq!(result, "Hello, Bob!");
    }

    #[tokio::test]
    async fn macro_plural() {
        let ctx = make_ctx(
            "en",
            [(
                "en",
                json!({ "files": { "one": "1 file", "other": "{count} files" } }),
            )],
        );
        let result = CURRENT_I18N
            .scope(ctx, async { t!("files", count = 7u64) })
            .await;
        assert_eq!(result, "7 files");
    }

    #[tokio::test]
    async fn macro_plural_with_extra_var() {
        let ctx = make_ctx(
            "en",
            [(
                "en",
                json!({ "dl": { "one": "1 {kind}", "other": "{count} {kind}s" } }),
            )],
        );
        let result = CURRENT_I18N
            .scope(ctx, async { t!("dl", count = 3u64, kind = "file") })
            .await;
        assert_eq!(result, "3 files");
    }

    #[test]
    fn detect_locale_from_query_param() {
        let store = load_from_pairs([
            ("en", json!({ "k": "english" })),
            ("fr", json!({ "k": "french" })),
        ]);
        assert!(store.contains_key("en"));
        assert!(store.contains_key("fr"));
    }
}