Skip to main content

oxihuman_core/
localization.rs

1//! Internationalization string tables.
2
3#[allow(dead_code)]
4pub struct LocaleString {
5    pub key: String,
6    pub value: String,
7    pub context: Option<String>,
8}
9
10#[allow(dead_code)]
11pub struct LocaleTable {
12    pub locale_id: String,
13    pub name: String,
14    pub entries: Vec<LocaleString>,
15}
16
17#[allow(dead_code)]
18pub struct LocalizationSystem {
19    pub tables: Vec<LocaleTable>,
20    pub active_locale: String,
21    pub fallback_locale: String,
22}
23
24#[allow(dead_code)]
25pub fn new_localization(fallback: &str) -> LocalizationSystem {
26    LocalizationSystem {
27        tables: Vec::new(),
28        active_locale: fallback.to_string(),
29        fallback_locale: fallback.to_string(),
30    }
31}
32
33#[allow(dead_code)]
34pub fn add_locale_table(sys: &mut LocalizationSystem, table: LocaleTable) {
35    sys.tables.push(table);
36}
37
38#[allow(dead_code)]
39pub fn set_active_locale(sys: &mut LocalizationSystem, locale_id: &str) {
40    sys.active_locale = locale_id.to_string();
41}
42
43/// Look up a key in a given locale table, returning the value if found.
44fn find_in_table<'a>(tables: &'a [LocaleTable], locale_id: &str, key: &str) -> Option<&'a str> {
45    tables
46        .iter()
47        .find(|t| t.locale_id == locale_id)
48        .and_then(|t| t.entries.iter().find(|e| e.key == key))
49        .map(|e| e.value.as_str())
50}
51
52#[allow(dead_code)]
53pub fn translate<'a>(sys: &'a LocalizationSystem, key: &'a str) -> &'a str {
54    if let Some(v) = find_in_table(&sys.tables, &sys.active_locale, key) {
55        return v;
56    }
57    if let Some(v) = find_in_table(&sys.tables, &sys.fallback_locale, key) {
58        return v;
59    }
60    key
61}
62
63#[allow(dead_code)]
64pub fn translate_with_context<'a>(
65    sys: &'a LocalizationSystem,
66    key: &'a str,
67    context: &str,
68) -> &'a str {
69    let find_with_ctx = |tables: &'a [LocaleTable], locale: &str| -> Option<&'a str> {
70        tables
71            .iter()
72            .find(|t| t.locale_id == locale)
73            .and_then(|t| {
74                t.entries
75                    .iter()
76                    .find(|e| {
77                        e.key == key && e.context.as_deref().map(|c| c == context).unwrap_or(false)
78                    })
79                    .or_else(|| t.entries.iter().find(|e| e.key == key))
80            })
81            .map(|e| e.value.as_str())
82    };
83
84    if let Some(v) = find_with_ctx(&sys.tables, &sys.active_locale) {
85        return v;
86    }
87    if let Some(v) = find_with_ctx(&sys.tables, &sys.fallback_locale) {
88        return v;
89    }
90    key
91}
92
93#[allow(dead_code)]
94pub fn has_key(sys: &LocalizationSystem, locale_id: &str, key: &str) -> bool {
95    sys.tables
96        .iter()
97        .find(|t| t.locale_id == locale_id)
98        .map(|t| t.entries.iter().any(|e| e.key == key))
99        .unwrap_or(false)
100}
101
102#[allow(dead_code)]
103pub fn locale_count(sys: &LocalizationSystem) -> usize {
104    sys.tables.len()
105}
106
107#[allow(dead_code)]
108pub fn key_count(table: &LocaleTable) -> usize {
109    table.entries.len()
110}
111
112#[allow(dead_code)]
113pub fn new_locale_table(locale_id: &str, name: &str) -> LocaleTable {
114    LocaleTable {
115        locale_id: locale_id.to_string(),
116        name: name.to_string(),
117        entries: Vec::new(),
118    }
119}
120
121#[allow(dead_code)]
122pub fn add_locale_string(table: &mut LocaleTable, key: &str, value: &str) {
123    table.entries.push(LocaleString {
124        key: key.to_string(),
125        value: value.to_string(),
126        context: None,
127    });
128}
129
130#[allow(dead_code)]
131pub fn export_locale_json(table: &LocaleTable) -> String {
132    let mut json = String::from("{");
133    for (i, entry) in table.entries.iter().enumerate() {
134        if i > 0 {
135            json.push(',');
136        }
137        json.push('"');
138        json.push_str(&entry.key);
139        json.push_str("\":\"");
140        json.push_str(&entry.value);
141        json.push('"');
142    }
143    json.push('}');
144    json
145}
146
147#[allow(dead_code)]
148pub fn import_locale_strings(table: &mut LocaleTable, data: &[(String, String)]) {
149    for (key, value) in data {
150        table.entries.push(LocaleString {
151            key: key.clone(),
152            value: value.clone(),
153            context: None,
154        });
155    }
156}
157
158#[allow(dead_code)]
159pub fn missing_keys(
160    sys: &LocalizationSystem,
161    reference_locale: &str,
162    target_locale: &str,
163) -> Vec<String> {
164    let ref_keys: Vec<&str> = sys
165        .tables
166        .iter()
167        .find(|t| t.locale_id == reference_locale)
168        .map(|t| t.entries.iter().map(|e| e.key.as_str()).collect())
169        .unwrap_or_default();
170
171    ref_keys
172        .into_iter()
173        .filter(|k| !has_key(sys, target_locale, k))
174        .map(|k| k.to_string())
175        .collect()
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    fn make_en_table() -> LocaleTable {
183        let mut t = new_locale_table("en-US", "English");
184        add_locale_string(&mut t, "greeting", "Hello");
185        add_locale_string(&mut t, "farewell", "Goodbye");
186        t
187    }
188
189    fn make_ja_table() -> LocaleTable {
190        let mut t = new_locale_table("ja-JP", "Japanese");
191        add_locale_string(&mut t, "greeting", "こんにちは");
192        t
193    }
194
195    #[test]
196    fn test_new_localization() {
197        let sys = new_localization("en-US");
198        assert_eq!(sys.fallback_locale, "en-US");
199        assert_eq!(sys.active_locale, "en-US");
200        assert!(sys.tables.is_empty());
201    }
202
203    #[test]
204    fn test_add_locale_table() {
205        let mut sys = new_localization("en-US");
206        add_locale_table(&mut sys, make_en_table());
207        assert_eq!(locale_count(&sys), 1);
208    }
209
210    #[test]
211    fn test_locale_count() {
212        let mut sys = new_localization("en-US");
213        add_locale_table(&mut sys, make_en_table());
214        add_locale_table(&mut sys, make_ja_table());
215        assert_eq!(locale_count(&sys), 2);
216    }
217
218    #[test]
219    fn test_key_count() {
220        let table = make_en_table();
221        assert_eq!(key_count(&table), 2);
222    }
223
224    #[test]
225    fn test_translate_known_key() {
226        let mut sys = new_localization("en-US");
227        add_locale_table(&mut sys, make_en_table());
228        assert_eq!(translate(&sys, "greeting"), "Hello");
229    }
230
231    #[test]
232    fn test_translate_unknown_falls_back_to_key() {
233        let mut sys = new_localization("en-US");
234        add_locale_table(&mut sys, make_en_table());
235        assert_eq!(translate(&sys, "unknown_key"), "unknown_key");
236    }
237
238    #[test]
239    fn test_translate_active_locale_priority() {
240        let mut sys = new_localization("en-US");
241        add_locale_table(&mut sys, make_en_table());
242        add_locale_table(&mut sys, make_ja_table());
243        set_active_locale(&mut sys, "ja-JP");
244        assert_eq!(translate(&sys, "greeting"), "こんにちは");
245    }
246
247    #[test]
248    fn test_translate_fallback_when_key_missing_in_active() {
249        let mut sys = new_localization("en-US");
250        add_locale_table(&mut sys, make_en_table());
251        add_locale_table(&mut sys, make_ja_table());
252        set_active_locale(&mut sys, "ja-JP");
253        // "farewell" is not in ja-JP, should fall back to en-US
254        assert_eq!(translate(&sys, "farewell"), "Goodbye");
255    }
256
257    #[test]
258    fn test_has_key_true() {
259        let mut sys = new_localization("en-US");
260        add_locale_table(&mut sys, make_en_table());
261        assert!(has_key(&sys, "en-US", "greeting"));
262    }
263
264    #[test]
265    fn test_has_key_false() {
266        let mut sys = new_localization("en-US");
267        add_locale_table(&mut sys, make_en_table());
268        assert!(!has_key(&sys, "en-US", "nonexistent"));
269    }
270
271    #[test]
272    fn test_export_locale_json_non_empty() {
273        let table = make_en_table();
274        let json = export_locale_json(&table);
275        assert!(!json.is_empty());
276        assert!(json.contains("greeting"));
277        assert!(json.contains("Hello"));
278    }
279
280    #[test]
281    fn test_import_locale_strings() {
282        let mut table = new_locale_table("en-US", "English");
283        let data = vec![
284            ("key1".to_string(), "val1".to_string()),
285            ("key2".to_string(), "val2".to_string()),
286        ];
287        import_locale_strings(&mut table, &data);
288        assert_eq!(key_count(&table), 2);
289    }
290
291    #[test]
292    fn test_missing_keys_detects_gap() {
293        let mut sys = new_localization("en-US");
294        add_locale_table(&mut sys, make_en_table());
295        add_locale_table(&mut sys, make_ja_table());
296        let missing = missing_keys(&sys, "en-US", "ja-JP");
297        assert!(missing.contains(&"farewell".to_string()));
298        assert!(!missing.contains(&"greeting".to_string()));
299    }
300
301    #[test]
302    fn test_missing_keys_empty_when_complete() {
303        let mut sys = new_localization("en-US");
304        add_locale_table(&mut sys, make_en_table());
305        let mut full = make_en_table();
306        full.locale_id = "fr-FR".to_string();
307        add_locale_table(&mut sys, full);
308        let missing = missing_keys(&sys, "en-US", "fr-FR");
309        assert!(missing.is_empty());
310    }
311
312    #[test]
313    fn test_translate_with_context() {
314        let mut sys = new_localization("en-US");
315        let mut table = new_locale_table("en-US", "English");
316        table.entries.push(LocaleString {
317            key: "action".to_string(),
318            value: "File (verb)".to_string(),
319            context: Some("verb".to_string()),
320        });
321        table.entries.push(LocaleString {
322            key: "action".to_string(),
323            value: "File (noun)".to_string(),
324            context: Some("noun".to_string()),
325        });
326        add_locale_table(&mut sys, table);
327        let v = translate_with_context(&sys, "action", "verb");
328        assert_eq!(v, "File (verb)");
329    }
330
331    #[test]
332    fn test_new_locale_table() {
333        let table = new_locale_table("de-DE", "German");
334        assert_eq!(table.locale_id, "de-DE");
335        assert_eq!(table.name, "German");
336        assert!(table.entries.is_empty());
337    }
338}