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}