fluent-zero 0.2.0

A zero-allocation, high-performance Fluent localization loader optimized for GUIs and games via compile-time caching to return &'static str for static messages.
Documentation
use std::{
    borrow::Cow,
    collections::HashMap,
    sync::{Mutex, MutexGuard},
};

// Imports
use fluent_zero::{
    __private::{
        CacheStore, ConcurrentFluentBundle, FluentResource, lookup_dynamic, lookup_dynamic_in,
        lookup_static, lookup_static_in,
    },
    CacheEntry, FluentArgs, LanguageIdentifier, LocaleState, set_lang,
};

// =========================================================================
// TEST SUITE: STRICT TYPES
// =========================================================================
// These tests verify that `fluent-zero` behaves exactly as promised:
// 1. Static messages return Cow::Borrowed referencing static memory.
// 2. Dynamic messages return Cow::Owned strings.
// 3. Fallbacks occur in the correct order.
// =========================================================================

fn create_bundle(lang: &str, source: &str) -> ConcurrentFluentBundle<FluentResource> {
    let lang_id: LanguageIdentifier = lang.parse().unwrap();
    let mut bundle = ConcurrentFluentBundle::new_concurrent(vec![lang_id]);
    bundle.set_use_isolating(false);
    let res = FluentResource::try_new(source.to_string()).unwrap();
    bundle.add_resource(res).unwrap();
    bundle
}

/// Mock implementation of the combined cache used by `fluent-zero`.
/// This mocks the `phf` maps generated by the build script.
struct MockCache {
    // Map<LangString, Map<Key, CacheEntry>>
    data: HashMap<String, HashMap<&'static str, CacheEntry>>,
}

impl MockCache {
    fn new() -> Self {
        Self {
            data: HashMap::new(),
        }
    }

    fn insert_static(&mut self, lang: &str, key: &'static str, value: &'static str) {
        self.data
            .entry(lang.to_string())
            .or_default()
            .insert(key, CacheEntry::Static(value));
    }

    fn insert_dynamic(&mut self, lang: &str, key: &'static str) {
        self.data
            .entry(lang.to_string())
            .or_default()
            .insert(key, CacheEntry::Dynamic);
    }
}

// Implement the library trait for our mock
impl CacheStore for MockCache {
    fn get_entry(&self, lang: &str, key: &str) -> Option<CacheEntry> {
        self.data.get(lang).and_then(|c| c.get(key)).copied()
    }
}

struct MockLocales {
    bundles: HashMap<String, ConcurrentFluentBundle<FluentResource>>,
    cache: MockCache,
}

impl MockLocales {
    fn new() -> Self {
        let en_us: LanguageIdentifier = "en-US".parse().unwrap();
        let fr_fr: LanguageIdentifier = "fr-FR".parse().unwrap();

        let mut bundles = HashMap::new();
        bundles.insert(
            en_us.to_string(),
            create_bundle(
                "en-US",
                r#"
hello = Hello World
welcome = Welcome { $name }
emoji = { "😀" }
        "#,
            ),
        );
        bundles.insert(
            fr_fr.to_string(),
            create_bundle("fr-FR", "hello = Bonjour le monde"),
        );

        let mut cache = MockCache::new();
        // Setup static entries
        cache.insert_static("en-US", "hello", "Hello World");
        cache.insert_static("fr-FR", "hello", "Bonjour le monde");

        // Setup dynamic entries
        cache.insert_dynamic("en-US", "welcome");
        cache.insert_dynamic("en-US", "emoji");

        Self { bundles, cache }
    }
}

// The current language is process-global and tests run in parallel, so every
// test that sets it holds this lock for its whole body.
static LANG_LOCK: Mutex<()> = Mutex::new(());

fn lang_lock() -> MutexGuard<'static, ()> {
    LANG_LOCK
        .lock()
        .unwrap_or_else(std::sync::PoisonError::into_inner)
}

// --- TEST CASES ---

#[test]
fn t01_static_hit_returns_borrowed() {
    let _lang = lang_lock();
    let mocks = MockLocales::new();
    set_lang("en-US".parse().unwrap());

    let result = lookup_static(&mocks.bundles, &mocks.cache, "hello");

    assert_eq!(result, "Hello World");

    // CRITICAL: Ensure we did not allocate a new string
    assert!(matches!(result, Cow::Borrowed(_)));
}

#[test]
fn t02_static_miss_hits_bundle_via_dynamic_entry() {
    let _lang = lang_lock();
    let mocks = MockLocales::new();
    set_lang("en-US".parse().unwrap());

    // 'emoji' is in bundle and marked Dynamic in cache.
    // This tests the path where CacheEntry::Dynamic forces a bundle lookup.
    let result = lookup_static(&mocks.bundles, &mocks.cache, "emoji");

    assert_eq!(result, "😀");
}

#[test]
fn t03_dynamic_lookup_always_returns_owned_if_args_used() {
    let _lang = lang_lock();
    let mocks = MockLocales::new();
    set_lang("en-US".parse().unwrap());

    let mut args = FluentArgs::new();
    args.set("name", "Alice");

    let result = lookup_dynamic(&mocks.bundles, &mocks.cache, "welcome", &args);

    assert_eq!(result, "Welcome Alice");
}

#[test]
fn t04_fallback_static_returns_borrowed() {
    let _lang = lang_lock();
    let mocks = MockLocales::new();
    set_lang("de-DE".parse().unwrap()); // Unknown language

    // Fallback to en-US
    let result = lookup_static(&mocks.bundles, &mocks.cache, "hello");

    assert_eq!(result, "Hello World");
    assert!(matches!(result, Cow::Borrowed(_)));
}

#[test]
fn t06_language_switch_updates_static_return() {
    let _lang = lang_lock();
    let mocks = MockLocales::new();

    set_lang("en-US".parse().unwrap());
    assert_eq!(
        lookup_static(&mocks.bundles, &mocks.cache, "hello"),
        "Hello World"
    );

    set_lang("fr-FR".parse().unwrap());
    assert_eq!(
        lookup_static(&mocks.bundles, &mocks.cache, "hello"),
        "Bonjour le monde"
    );
}

#[test]
fn t08_verify_zero_copy_pointer_address() {
    let _lang = lang_lock();
    // This test performs pointer arithmetic to guarantee that the string returned
    // by lookup_static is the exact same memory address as the string stored
    // in the mock cache (simulating the .rodata segment).
    let mocks = MockLocales::new();
    set_lang("en-US".parse().unwrap());

    // 1. Get exact reference from cache
    let entry = mocks.cache.data.get("en-US").unwrap().get("hello").unwrap();
    let cached_str_ref = match entry {
        CacheEntry::Static(s) => s,
        CacheEntry::Dynamic => panic!("Expected static"),
    };

    let cached_ptr = std::ptr::from_ref::<str>(*cached_str_ref);

    // 2. Get result from lookup
    let result = lookup_static(&mocks.bundles, &mocks.cache, "hello");

    if let Cow::Borrowed(res_str) = result {
        let res_ptr = std::ptr::from_ref::<str>(res_str);

        // 3. Verify pointers are identical
        assert_eq!(
            cached_ptr, res_ptr,
            "Returned string should point to the exact same memory address."
        );
    } else {
        panic!("Expected Borrowed result");
    }
}

#[test]
fn t10_per_call_locale_ignores_the_global_language() {
    let mocks = MockLocales::new();
    let fr = LocaleState::new("fr-FR".parse().unwrap());

    let result = lookup_static_in(&fr, &mocks.bundles, &mocks.cache, "hello");
    assert_eq!(result, "Bonjour le monde");
    assert!(matches!(result, Cow::Borrowed(_)));

    // Missing in fr-FR: falls back to en-US.
    let mut args = FluentArgs::new();
    args.set("name", "Alice");
    let result = lookup_dynamic_in(&fr, &mocks.bundles, &mocks.cache, "welcome", &args);
    assert_eq!(result, "Welcome Alice");
}

#[test]
fn t11_fallback_chain_drops_variants_and_subtags() {
    let mocks = MockLocales::new();

    // `fr-FR-1996` has no table of its own; its chain reaches `fr-FR`.
    let variant = LocaleState::new("fr-FR-1996".parse().unwrap());
    assert_eq!(
        variant.chain().collect::<Vec<_>>(),
        ["fr-FR-1996", "fr-FR", "fr"]
    );
    assert_eq!(
        lookup_static_in(&variant, &mocks.bundles, &mocks.cache, "hello"),
        "Bonjour le monde"
    );
}

#[test]
fn t09_verify_charset_merging() {
    let app_charset = "abc😀";
    let dep_charset = "cde";
    let ui_charset = "zxa";

    let merged = fluent_zero::join_charsets(&[app_charset, dep_charset, ui_charset]);

    // Ensure it deduplicated successfully, included emoji, and deterministically sorted via BTreeSet.
    assert_eq!(merged, "abcdexz😀");
}