Skip to main content

lang_lib/
store.rs

1use std::collections::{HashMap, HashSet};
2use std::sync::{OnceLock, RwLock, RwLockReadGuard, RwLockWriteGuard};
3
4use crate::error::LangError;
5use crate::loader;
6
7// ---------------------------------------------------------------------------
8// Global state
9// ---------------------------------------------------------------------------
10
11struct LangState {
12    /// Base directory where language files are stored.
13    path: String,
14    /// Active locale used by `t!("key")` with no explicit locale argument.
15    active: String,
16    /// Fallback locale chain. Checked in order when a key is missing.
17    fallbacks: Vec<String>,
18    /// All loaded locales. Each entry maps translation keys to their strings.
19    locales: HashMap<String, HashMap<String, String>>,
20}
21
22impl LangState {
23    fn new() -> Self {
24        Self {
25            path: "locales".to_string(),
26            active: "en".to_string(),
27            fallbacks: vec!["en".to_string()],
28            locales: HashMap::new(),
29        }
30    }
31}
32
33static STATE: OnceLock<RwLock<LangState>> = OnceLock::new();
34
35fn state() -> &'static RwLock<LangState> {
36    STATE.get_or_init(|| RwLock::new(LangState::new()))
37}
38
39fn read_state() -> RwLockReadGuard<'static, LangState> {
40    state()
41        .read()
42        .unwrap_or_else(|poisoned| poisoned.into_inner())
43}
44
45fn write_state() -> RwLockWriteGuard<'static, LangState> {
46    state()
47        .write()
48        .unwrap_or_else(|poisoned| poisoned.into_inner())
49}
50
51// ---------------------------------------------------------------------------
52// Public API
53// ---------------------------------------------------------------------------
54
55/// The main entry point for configuring and querying the localization system.
56///
57/// `Lang` manages process-global state behind a read/write lock. Configure it
58/// once during startup, load the locales your application needs, and then use
59/// [`t!`](crate::t) or [`Lang::translate`] wherever translated text is needed.
60pub struct Lang;
61
62/// A lightweight, request-scoped translation helper.
63///
64/// `Translator` stores a locale identifier and forwards lookups to the global
65/// [`Lang`] store without mutating the process-wide active locale. This makes
66/// it a good fit for web handlers, jobs, and other code paths where locale is
67/// part of the input rather than part of global application state.
68///
69/// # Examples
70///
71/// ```rust,no_run
72/// use lang_lib::{Lang, Translator};
73///
74/// Lang::load_from("en", "tests/fixtures/locales").unwrap();
75/// let translator = Translator::new("en");
76///
77/// let title = translator.translate_with_fallback("welcome", "Welcome");
78/// assert_eq!(title, "Welcome");
79/// ```
80#[derive(Clone, Debug, Eq, PartialEq)]
81pub struct Translator {
82    locale: String,
83}
84
85impl Translator {
86    /// Creates a translator bound to a specific locale.
87    pub fn new(locale: impl Into<String>) -> Self {
88        Self {
89            locale: locale.into(),
90        }
91    }
92
93    /// Returns the locale used by this translator.
94    pub fn locale(&self) -> &str {
95        &self.locale
96    }
97
98    /// Translates a key using this translator's locale.
99    pub fn translate(&self, key: &str) -> String {
100        Lang::translate(key, Some(self.locale.as_str()), None)
101    }
102
103    /// Translates a key using this translator's locale and an inline fallback.
104    pub fn translate_with_fallback(&self, key: &str, fallback: &str) -> String {
105        Lang::translate(key, Some(self.locale.as_str()), Some(fallback))
106    }
107}
108
109impl Lang {
110    /// Sets the directory where language files are looked up.
111    ///
112    /// Defaults to `"locales"`. Call this before the first [`Lang::load`] if
113    /// your project stores files elsewhere.
114    ///
115    /// # Examples
116    ///
117    /// ```rust,no_run
118    /// use lang_lib::Lang;
119    /// Lang::set_path("assets/lang");
120    /// ```
121    pub fn set_path(path: impl Into<String>) {
122        write_state().path = path.into();
123    }
124
125    /// Returns the current language file path.
126    ///
127    /// # Examples
128    ///
129    /// ```rust,no_run
130    /// use lang_lib::Lang;
131    ///
132    /// Lang::set_path("assets/locales");
133    /// assert_eq!(Lang::path(), "assets/locales");
134    /// ```
135    pub fn path() -> String {
136        read_state().path.clone()
137    }
138
139    /// Sets the active locale used when no locale is specified in `t!`.
140    ///
141    /// The locale does not need to be loaded before calling this, but
142    /// translations will be empty until it is.
143    ///
144    /// This method is a good fit for single-user applications, CLIs, and
145    /// startup-time configuration. In request-driven servers, prefer passing
146    /// an explicit locale to [`Lang::translate`] or [`t!`](crate::t) so one
147    /// request does not change another request's active locale.
148    ///
149    /// # Examples
150    ///
151    /// ```rust,no_run
152    /// use lang_lib::Lang;
153    /// Lang::set_locale("es");
154    /// ```
155    pub fn set_locale(locale: impl Into<String>) {
156        write_state().active = locale.into();
157    }
158
159    /// Returns the currently active locale.
160    ///
161    /// # Examples
162    ///
163    /// ```rust,no_run
164    /// use lang_lib::Lang;
165    ///
166    /// Lang::set_locale("fr");
167    /// assert_eq!(Lang::locale(), "fr");
168    /// ```
169    pub fn locale() -> String {
170        read_state().active.clone()
171    }
172
173    /// Sets the fallback locale chain.
174    ///
175    /// When a key is not found in the requested locale, each fallback is
176    /// checked in order. The last resort is the inline `fallback:` argument
177    /// in `t!`, and if that is absent, the key itself is returned.
178    ///
179    /// # Examples
180    ///
181    /// ```rust,no_run
182    /// use lang_lib::Lang;
183    /// Lang::set_fallbacks(vec!["en".to_string()]);
184    /// ```
185    pub fn set_fallbacks(chain: Vec<String>) {
186        write_state().fallbacks = chain;
187    }
188
189    /// Loads a locale from disk.
190    ///
191    /// Reads `{path}/{locale}.toml` and stores all translations in memory.
192    /// Calling this a second time for the same locale replaces the existing
193    /// translations with a fresh load from disk.
194    ///
195    /// Locale names must be single file stems such as `en`, `en-US`, or
196    /// `pt_BR`. Path separators and relative path components are rejected.
197    ///
198    /// # Errors
199    ///
200    /// Returns [`LangError::Io`] if the file cannot be read, or
201    /// [`LangError::Parse`] if the TOML is invalid.
202    ///
203    /// # Examples
204    ///
205    /// ```rust,no_run
206    /// use lang_lib::Lang;
207    /// Lang::set_path("locales");
208    /// Lang::load("en").unwrap();
209    /// Lang::load("es").unwrap();
210    /// ```
211    pub fn load(locale: impl Into<String>) -> Result<(), LangError> {
212        let locale = locale.into();
213        let path = read_state().path.clone();
214        let map = loader::load_file(&path, &locale)?;
215        write_state().locales.insert(locale, map);
216        Ok(())
217    }
218
219    /// Loads a locale from a specific path, ignoring the global path setting.
220    ///
221    /// Useful when a project stores one locale separately from the others.
222    /// Locale names follow the same validation rules as [`Lang::load`].
223    ///
224    /// # Errors
225    ///
226    /// Returns [`LangError::Io`] or [`LangError::Parse`] on failure.
227    ///
228    /// # Examples
229    ///
230    /// ```rust,no_run
231    /// use lang_lib::Lang;
232    ///
233    /// Lang::load_from("en", "tests/fixtures/locales").unwrap();
234    /// ```
235    pub fn load_from(locale: impl Into<String>, path: &str) -> Result<(), LangError> {
236        let locale = locale.into();
237        let map = loader::load_file(path, &locale)?;
238        write_state().locales.insert(locale, map);
239        Ok(())
240    }
241
242    /// Returns `true` if the locale has been loaded.
243    ///
244    /// # Examples
245    ///
246    /// ```rust,no_run
247    /// use lang_lib::Lang;
248    ///
249    /// Lang::load_from("en", "tests/fixtures/locales").unwrap();
250    /// assert!(Lang::is_loaded("en"));
251    /// ```
252    pub fn is_loaded(locale: &str) -> bool {
253        read_state().locales.contains_key(locale)
254    }
255
256    /// Returns a sorted list of all loaded locale identifiers.
257    ///
258    /// Sorting keeps diagnostics and tests deterministic.
259    ///
260    /// # Examples
261    ///
262    /// ```rust,no_run
263    /// use lang_lib::Lang;
264    ///
265    /// Lang::load_from("es", "tests/fixtures/locales").unwrap();
266    /// Lang::load_from("en", "tests/fixtures/locales").unwrap();
267    /// assert_eq!(Lang::loaded(), vec!["en".to_string(), "es".to_string()]);
268    /// ```
269    pub fn loaded() -> Vec<String> {
270        let mut locales: Vec<_> = read_state().locales.keys().cloned().collect();
271        locales.sort_unstable();
272        locales
273    }
274
275    /// Unloads a locale and frees its memory.
276    ///
277    /// Unloading a locale does not change the active locale or fallback chain.
278    /// If either of those still references the removed locale, translation will
279    /// simply skip it.
280    ///
281    /// # Examples
282    ///
283    /// ```rust,no_run
284    /// use lang_lib::Lang;
285    ///
286    /// Lang::load_from("en", "tests/fixtures/locales").unwrap();
287    /// Lang::unload("en");
288    /// assert!(!Lang::is_loaded("en"));
289    /// ```
290    pub fn unload(locale: &str) {
291        write_state().locales.remove(locale);
292    }
293
294    /// Creates a request-scoped [`Translator`] for the provided locale.
295    ///
296    /// This is a convenience wrapper around [`Translator::new`]. It is most
297    /// useful in server code where locale is resolved per request and passed
298    /// through the handler stack.
299    ///
300    /// # Examples
301    ///
302    /// ```rust,no_run
303    /// use lang_lib::Lang;
304    ///
305    /// let translator = Lang::translator("es");
306    /// assert_eq!(translator.locale(), "es");
307    /// ```
308    pub fn translator(locale: impl Into<String>) -> Translator {
309        Translator::new(locale)
310    }
311
312    /// Translates a key.
313    ///
314    /// Lookup order:
315    /// 1. The requested locale (or active locale if `None`)
316    /// 2. Each locale in the fallback chain, in order
317    /// 3. The inline `fallback` string if provided
318    /// 4. The key itself (never returns an empty string)
319    ///
320    /// This is the function called by the [`t!`](crate::t) macro. Prefer
321    /// using the macro directly in application code.
322    ///
323    /// In concurrent server code, passing `Some(locale)` is usually the safest
324    /// policy because it avoids mutating the process-wide active locale.
325    ///
326    /// # Examples
327    ///
328    /// ```rust,no_run
329    /// use lang_lib::Lang;
330    ///
331    /// Lang::load_from("en", "tests/fixtures/locales").unwrap();
332    /// let text = Lang::translate("welcome", Some("en"), Some("Welcome"));
333    /// assert_eq!(text, "Welcome");
334    /// ```
335    pub fn translate(key: &str, locale: Option<&str>, fallback: Option<&str>) -> String {
336        let state = read_state();
337
338        let target = locale.unwrap_or(&state.active);
339
340        // Check requested locale first
341        if let Some(map) = state.locales.get(target) {
342            if let Some(val) = map.get(key) {
343                return val.clone();
344            }
345        }
346
347        // Walk the fallback chain
348        let mut seen = HashSet::with_capacity(state.fallbacks.len());
349        for fb_locale in &state.fallbacks {
350            if fb_locale == target || !seen.insert(fb_locale.as_str()) {
351                continue;
352            }
353            if let Some(map) = state.locales.get(fb_locale.as_str()) {
354                if let Some(val) = map.get(key) {
355                    return val.clone();
356                }
357            }
358        }
359
360        // Inline fallback
361        if let Some(fb) = fallback {
362            return fb.to_string();
363        }
364
365        // Last resort: return the key itself
366        key.to_string()
367    }
368}