Skip to main content

lang_lib/
store.rs

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