Skip to main content

ftui_runtime/
locale.rs

1#![forbid(unsafe_code)]
2
3//! Locale context provider for runtime-wide internationalization.
4//!
5//! The [`LocaleContext`] owns the current locale and exposes scoped overrides
6//! for widget subtrees. Locale changes are versioned so the runtime can
7//! trigger re-renders when the active locale changes.
8
9use crate::reactive::{Observable, Subscription};
10pub use ftui_i18n::catalog::Locale;
11use std::cell::RefCell;
12use std::env;
13use std::rc::Rc;
14
15thread_local! {
16    static GLOBAL_CONTEXT: LocaleContext = LocaleContext::system();
17}
18
19/// Runtime locale context with scoped overrides.
20#[derive(Clone, Debug)]
21pub struct LocaleContext {
22    current: Observable<Locale>,
23    overrides: Rc<RefCell<Vec<Locale>>>,
24}
25
26impl LocaleContext {
27    /// Create a new locale context with the provided locale.
28    #[must_use]
29    pub fn new(locale: impl Into<Locale>) -> Self {
30        let locale = normalize_locale(locale.into());
31        Self {
32            current: Observable::new(locale),
33            overrides: Rc::new(RefCell::new(Vec::new())),
34        }
35    }
36
37    /// Create a locale context initialized from system locale detection.
38    #[must_use]
39    pub fn system() -> Self {
40        Self::new(detect_system_locale())
41    }
42
43    /// Access the global locale context (thread-local).
44    #[must_use]
45    pub fn global() -> Self {
46        GLOBAL_CONTEXT.with(Clone::clone)
47    }
48
49    /// Get the active locale, honoring any scoped override.
50    #[must_use]
51    pub fn current_locale(&self) -> Locale {
52        if let Some(locale) = self.overrides.borrow().last() {
53            locale.clone()
54        } else {
55            self.current.get()
56        }
57    }
58
59    /// Get the base locale without considering overrides.
60    #[must_use]
61    pub fn base_locale(&self) -> Locale {
62        self.current.get()
63    }
64
65    /// Set the base locale.
66    pub fn set_locale(&self, locale: impl Into<Locale>) {
67        let locale = normalize_locale(locale.into());
68        self.current.set(locale);
69    }
70
71    /// Subscribe to base locale changes.
72    pub fn subscribe(&self, callback: impl Fn(&Locale) + 'static) -> Subscription {
73        self.current.subscribe(callback)
74    }
75
76    /// Push a scoped locale override. Dropping the guard restores the prior locale.
77    #[must_use = "dropping this guard clears the locale override"]
78    pub fn push_override(&self, locale: impl Into<Locale>) -> LocaleOverride {
79        let locale = normalize_locale(locale.into());
80        self.overrides.borrow_mut().push(locale.clone());
81        LocaleOverride {
82            stack: Rc::clone(&self.overrides),
83            locale,
84        }
85    }
86
87    /// Current version counter for the base locale.
88    #[must_use]
89    pub fn version(&self) -> u64 {
90        self.current.version()
91    }
92}
93
94/// RAII guard for scoped locale overrides.
95#[must_use = "dropping this guard clears the locale override"]
96pub struct LocaleOverride {
97    stack: Rc<RefCell<Vec<Locale>>>,
98    locale: Locale,
99}
100
101impl Drop for LocaleOverride {
102    fn drop(&mut self) {
103        let popped = self.stack.borrow_mut().pop();
104        if let Some(popped) = popped {
105            debug_assert_eq!(popped, self.locale);
106        }
107    }
108}
109
110/// Detect the system locale from environment variables.
111///
112/// Preference order: `LC_ALL`, then `LANG`. Falls back to `"en"` when unknown.
113#[must_use]
114pub fn detect_system_locale() -> Locale {
115    let lc_all = env::var("LC_ALL").ok();
116    let lang = env::var("LANG").ok();
117    detect_system_locale_from(lc_all.as_deref(), lang.as_deref())
118}
119
120/// Convenience: set the global locale.
121pub fn set_locale(locale: impl Into<Locale>) {
122    LocaleContext::global().set_locale(locale);
123}
124
125/// Convenience: get the global locale.
126#[must_use]
127pub fn current_locale() -> Locale {
128    LocaleContext::global().current_locale()
129}
130
131fn normalize_locale(mut locale: Locale) -> Locale {
132    normalize_locale_raw(&locale).unwrap_or_else(|| {
133        locale.clear();
134        locale.push_str("en");
135        locale
136    })
137}
138
139fn detect_system_locale_from(lc_all: Option<&str>, lang: Option<&str>) -> Locale {
140    lc_all
141        .and_then(normalize_locale_raw)
142        .or_else(|| lang.and_then(normalize_locale_raw))
143        .unwrap_or_else(|| "en".to_string())
144}
145
146fn normalize_locale_raw(raw: &str) -> Option<Locale> {
147    let raw = raw.trim();
148    if raw.is_empty() {
149        return None;
150    }
151    let raw = raw.split('@').next().unwrap_or(raw);
152    let raw = raw.split('.').next().unwrap_or(raw);
153    let raw = raw.trim();
154    if raw.is_empty() {
155        return None;
156    }
157    let mut normalized = raw.replace('_', "-");
158    if normalized.eq_ignore_ascii_case("c") || normalized.eq_ignore_ascii_case("posix") {
159        normalized.clear();
160        normalized.push_str("en");
161    }
162    Some(normalized)
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use proptest::prelude::*;
169
170    // ---------------------------------------------------------------------
171    // Invariants (Alien Artifact)
172    // ---------------------------------------------------------------------
173    // 1. Normalized locales contain no '_' '.' or '@' suffixes.
174    // 2. Locale overrides are LIFO and never mutate the base locale.
175    // 3. Locale versions only advance on base locale changes.
176    //
177    // Failure Modes:
178    // | Scenario                     | Expected Behavior                 |
179    // |-----------------------------|-----------------------------------|
180    // | Empty / whitespace locale   | Falls back to "en"                |
181    // | "C"/"POSIX" locale          | Normalized to "en"                |
182    // | Override drop out of order  | Debug assert (in dev builds)      |
183
184    #[test]
185    fn detect_system_locale_prefers_lc_all() {
186        let locale = detect_system_locale_from(Some("fr_FR.UTF-8"), Some("en_US.UTF-8"));
187        assert_eq!(locale, "fr-FR");
188    }
189
190    #[test]
191    fn detect_system_locale_uses_lang_when_lc_all_missing() {
192        let locale = detect_system_locale_from(None, Some("en_US.UTF-8"));
193        assert_eq!(locale, "en-US");
194    }
195
196    #[test]
197    fn detect_system_locale_defaults_to_en() {
198        let locale = detect_system_locale_from(None, None);
199        assert_eq!(locale, "en");
200    }
201
202    #[test]
203    fn locale_context_switching_updates_version() {
204        let ctx = LocaleContext::new("en");
205        let v0 = ctx.version();
206        ctx.set_locale("en");
207        assert_eq!(ctx.version(), v0);
208        ctx.set_locale("es");
209        assert!(ctx.version() > v0);
210        assert_eq!(ctx.current_locale(), "es");
211    }
212
213    #[test]
214    fn locale_override_is_scoped() {
215        let ctx = LocaleContext::new("en");
216        assert_eq!(ctx.current_locale(), "en");
217        let guard = ctx.push_override("fr");
218        assert_eq!(ctx.current_locale(), "fr");
219        drop(guard);
220        assert_eq!(ctx.current_locale(), "en");
221    }
222
223    #[test]
224    fn locale_override_is_lifo() {
225        let ctx = LocaleContext::new("en");
226        let _outer = ctx.push_override("fr");
227        assert_eq!(ctx.current_locale(), "fr");
228        {
229            let _inner = ctx.push_override("es");
230            assert_eq!(ctx.current_locale(), "es");
231        }
232        assert_eq!(ctx.current_locale(), "fr");
233    }
234
235    #[test]
236    fn normalize_locale_handles_c_and_posix() {
237        let c_locale = normalize_locale_raw("C");
238        let posix_locale = normalize_locale_raw("POSIX");
239        assert_eq!(c_locale.as_deref(), Some("en"));
240        assert_eq!(posix_locale.as_deref(), Some("en"));
241    }
242
243    #[test]
244    fn normalize_locale_strips_codeset_and_modifier() {
245        let locale = normalize_locale_raw("en_US.UTF-8@latin");
246        assert_eq!(locale.as_deref(), Some("en-US"));
247    }
248
249    #[test]
250    fn locale_override_does_not_mutate_base_locale() {
251        let ctx = LocaleContext::new("en");
252        let v0 = ctx.version();
253        let _guard = ctx.push_override("fr");
254        assert_eq!(ctx.base_locale(), "en");
255        assert_eq!(ctx.version(), v0);
256    }
257
258    proptest! {
259        #[test]
260        fn normalize_locale_raw_sanitizes_segments(raw in "[A-Za-z0-9_@.\\-]{1,32}") {
261            let normalized = normalize_locale_raw(&raw);
262            if let Some(locale) = normalized {
263                prop_assert!(!locale.trim().is_empty());
264                prop_assert!(!locale.contains('@'));
265                prop_assert!(!locale.contains('.'));
266                prop_assert!(!locale.contains('_'));
267            }
268        }
269
270        #[test]
271        fn overrides_are_lifo(locales in proptest::collection::vec("[a-z]{2}(-[A-Z]{2})?", 1..6)) {
272            let ctx = LocaleContext::new("en");
273            let mut guards = Vec::new();
274            for locale in &locales {
275                guards.push(ctx.push_override(locale));
276            }
277            prop_assert_eq!(ctx.current_locale(), locales.last().unwrap().as_str());
278            guards.pop();
279            if locales.len() >= 2 {
280                let prev = &locales[locales.len() - 2];
281                prop_assert_eq!(ctx.current_locale(), prev.as_str());
282            } else {
283                prop_assert_eq!(ctx.current_locale(), "en");
284            }
285            // Drop remaining guards in LIFO order (Vec drops front-to-back,
286            // but the override stack expects last-pushed-first-dropped).
287            while guards.pop().is_some() {}
288        }
289    }
290}