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}