1#![warn(missing_docs)]
2
3use std::{collections::HashMap, num::NonZeroUsize, path::Path};
8use tracing::info;
9
10#[derive(Debug)]
12pub enum I18nError {
13 FileRead(std::io::Error),
15 ParseError(String),
17 UnsupportedFormat(String),
19 FeatureNotEnabled(String),
21 TranslationNotFound(String),
23}
24
25impl std::error::Error for I18nError {
26 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
27 match self {
28 I18nError::FileRead(err) => Some(err),
29 _ => None,
30 }
31 }
32}
33
34impl std::fmt::Display for I18nError {
35 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36 match self {
37 I18nError::FileRead(err) => write!(f, "Failed to read file: {}", err),
38 I18nError::ParseError(err) => write!(f, "Failed to parse content: {}", err),
39 I18nError::UnsupportedFormat(format) => write!(f, "Unsupported file format: {}", format),
40 I18nError::FeatureNotEnabled(feature) => write!(f, "Feature not enabled: {}", feature),
41 I18nError::TranslationNotFound(key) => write!(f, "Translation not found: {}", key),
42 }
43 }
44}
45
46impl From<std::io::Error> for I18nError {
47 fn from(err: std::io::Error) -> Self {
48 I18nError::FileRead(err)
49 }
50}
51
52use oak_fluent::{Translator as OakFluentTranslatorCore, parse};
54
55pub trait TranslationProvider {
57 fn translate(&self, key: &str, args: Option<&HashMap<String, String>>, locale: &str) -> Result<String, I18nError>;
59
60 fn get_locale(&self) -> &str;
62
63 fn set_locale(&mut self, locale: &str);
65
66 fn clone_box(&self) -> Box<dyn TranslationProvider + Send + Sync>;
68}
69
70#[derive(Debug, Clone)]
72pub struct SimpleTranslator {
73 translations: HashMap<String, HashMap<String, String>>,
74 current_locale: String,
75 default_locale: String,
76}
77
78impl SimpleTranslator {
79 pub fn new(current_locale: &str, default_locale: &str) -> Self {
81 Self {
82 translations: HashMap::new(),
83 current_locale: current_locale.to_string(),
84 default_locale: default_locale.to_string(),
85 }
86 }
87
88 pub fn add_translations(&mut self, locale: &str, translations: HashMap<String, String>) {
90 self.translations.insert(locale.to_string(), translations);
91 }
92}
93
94impl TranslationProvider for SimpleTranslator {
95 fn translate(&self, key: &str, args: Option<&HashMap<String, String>>, locale: &str) -> Result<String, I18nError> {
96 let translation = self
98 .translations
99 .get(locale)
100 .and_then(|lang| lang.get(key))
101 .or_else(|| {
102 if locale != self.default_locale {
103 self.translations.get(&self.default_locale).and_then(|lang| lang.get(key))
104 }
105 else {
106 None
107 }
108 })
109 .unwrap_or(&key.to_string())
110 .to_string();
111
112 if let Some(args) = args {
114 let mut result = translation;
115 for (name, value) in args {
116 result = result.replace(&format!("{{{}}}", name), value);
117 }
118 Ok(result)
119 }
120 else {
121 Ok(translation)
122 }
123 }
124
125 fn get_locale(&self) -> &str {
126 &self.current_locale
127 }
128
129 fn set_locale(&mut self, locale: &str) {
130 self.current_locale = locale.to_string();
131 }
132
133 fn clone_box(&self) -> Box<dyn TranslationProvider + Send + Sync> {
134 Box::new(self.clone())
135 }
136}
137
138pub struct OakFluentTranslator {
140 translators: HashMap<String, OakFluentTranslatorCore>,
141 current_locale: String,
142 default_locale: String,
143}
144
145impl OakFluentTranslator {
146 pub fn new(current_locale: &str, default_locale: &str) -> Self {
148 Self {
149 translators: HashMap::new(),
150 current_locale: current_locale.to_string(),
151 default_locale: default_locale.to_string(),
152 }
153 }
154
155 pub fn add_translations_from_file(&mut self, locale: &str, file_path: &Path) -> Result<(), I18nError> {
157 let content = std::fs::read_to_string(file_path)?;
158
159 match file_path.extension().and_then(|ext| ext.to_str()) {
161 Some("ftl") => self.add_translations_from_string(locale, &content),
162 Some("json") => {
163 #[cfg(feature = "json")]
164 {
165 let translations: HashMap<String, String> =
166 oak_json::from_str(&content).map_err(|e| I18nError::ParseError(e.to_string()))?;
167 self.add_simple_translations(locale, translations)
168 }
169 #[cfg(not(feature = "json"))]
170 {
171 Err(I18nError::FeatureNotEnabled("json".to_string()))
172 }
173 }
174 Some("toml") => {
175 #[cfg(feature = "toml")]
176 {
177 let translations: HashMap<String, String> =
178 oak_toml::from_str(&content).map_err(|e| I18nError::ParseError(e.to_string()))?;
179 self.add_simple_translations(locale, translations)
180 }
181 #[cfg(not(feature = "toml"))]
182 {
183 Err(I18nError::FeatureNotEnabled("toml".to_string()))
184 }
185 }
186 Some("yaml") | Some("yml") => {
187 #[cfg(feature = "yaml")]
188 {
189 let translations: HashMap<String, String> =
190 oak_yaml::from_str(&content).map_err(|e| I18nError::ParseError(e.to_string()))?;
191 self.add_simple_translations(locale, translations)
192 }
193 #[cfg(not(feature = "yaml"))]
194 {
195 Err(I18nError::FeatureNotEnabled("yaml".to_string()))
196 }
197 }
198 Some(ext) => Err(I18nError::UnsupportedFormat(ext.to_string())),
199 None => Err(I18nError::UnsupportedFormat("unknown".to_string())),
200 }
201 }
202
203 pub fn add_simple_translations(&mut self, locale: &str, translations: HashMap<String, String>) -> Result<(), I18nError> {
205 let mut ftl_content = String::new();
215 for (key, value) in translations {
216 ftl_content.push_str(&format!("{} = {}", key, value));
217 ftl_content.push('\n');
218 }
219
220 self.add_translations_from_string(locale, &ftl_content)
221 }
222
223 pub fn add_translations_from_string(&mut self, locale: &str, ftl_content: &str) -> Result<(), I18nError> {
225 match parse(ftl_content) {
226 Ok(root) => {
227 let translator = OakFluentTranslatorCore::new(root);
228 self.translators.insert(locale.to_string(), translator);
229 Ok(())
230 }
231 Err(err) => Err(I18nError::ParseError(format!("{:?}", err))),
232 }
233 }
234}
235
236impl TranslationProvider for OakFluentTranslator {
237 fn translate(&self, key: &str, args: Option<&HashMap<String, String>>, locale: &str) -> Result<String, I18nError> {
238 if let Some(translator) = self.translators.get(locale)
240 && let Some(translation) = translator.translate(key, args.unwrap_or(&HashMap::new())) {
241 return Ok(translation);
242 }
243
244 if locale != self.default_locale
246 && let Some(translator) = self.translators.get(&self.default_locale)
247 && let Some(translation) = translator.translate(key, args.unwrap_or(&HashMap::new())) {
248 return Ok(translation);
249 }
250
251 Ok(key.to_string())
253 }
254
255 fn get_locale(&self) -> &str {
256 &self.current_locale
257 }
258
259 fn set_locale(&mut self, locale: &str) {
260 self.current_locale = locale.to_string();
261 }
262
263 fn clone_box(&self) -> Box<dyn TranslationProvider + Send + Sync> {
264 let mut new_translator = OakFluentTranslator::new(&self.current_locale, &self.default_locale);
266
267 for (locale, translator) in &self.translators {
269 new_translator.translators.insert(locale.clone(), translator.clone());
271 }
272
273 Box::new(new_translator)
274 }
275}
276
277unsafe impl Send for OakFluentTranslator {}
279
280unsafe impl Sync for OakFluentTranslator {}
281
282#[derive(Debug, Hash, PartialEq, Eq)]
284struct TranslationCacheKey {
285 key: String,
286 locale: String,
287 args: Option<Vec<(String, String)>>,
288}
289
290impl TranslationCacheKey {
291 fn new(key: &str, locale: &str, args: Option<&HashMap<String, String>>) -> Self {
293 let args_vec = args.map(|a| {
294 let mut vec: Vec<(String, String)> = a.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
295 vec.sort_by(|(a, _), (b, _)| a.cmp(b));
296 vec
297 });
298
299 Self { key: key.to_string(), locale: locale.to_string(), args: args_vec }
300 }
301}
302
303pub struct I18nContext {
305 translator: Box<dyn TranslationProvider + Send + Sync>,
306 translation_cache: lru::LruCache<TranslationCacheKey, String>,
307 cache_capacity: usize,
308}
309
310impl Clone for I18nContext {
311 fn clone(&self) -> Self {
312 Self {
313 translator: self.translator.clone_box(),
314 translation_cache: lru::LruCache::new(NonZeroUsize::new(self.cache_capacity).unwrap()),
315 cache_capacity: self.cache_capacity,
316 }
317 }
318}
319
320impl I18nContext {
321 pub fn new(translator: Box<dyn TranslationProvider + Send + Sync>) -> Self {
323 Self { translator, translation_cache: lru::LruCache::new(NonZeroUsize::new(1000).unwrap()), cache_capacity: 1000 }
324 }
325
326 pub fn with_cache_capacity(translator: Box<dyn TranslationProvider + Send + Sync>, capacity: usize) -> Self {
328 Self {
329 translator,
330 translation_cache: lru::LruCache::new(NonZeroUsize::new(capacity).unwrap()),
331 cache_capacity: capacity,
332 }
333 }
334
335 pub fn t(&mut self, key: &str) -> String {
337 self.t_with_args(key, None)
338 }
339
340 pub fn t_with_args(&mut self, key: &str, args: Option<&HashMap<String, String>>) -> String {
342 let locale = self.translator.get_locale();
343
344 let cache_key = TranslationCacheKey::new(key, locale, args);
346
347 if let Some(cached) = self.translation_cache.get(&cache_key) {
349 return cached.clone();
350 }
351
352 let result = match self.translator.translate(key, args, locale) {
354 Ok(translation) => translation,
355 Err(e) => {
356 tracing::debug!("Translation error: {}, falling back to key", e);
357 key.to_string()
358 }
359 };
360
361 self.translation_cache.put(cache_key, result.clone());
363
364 result
365 }
366
367 pub fn t_batch(&mut self, keys: &[(String, Option<HashMap<String, String>>)]) -> Vec<String> {
369 keys.iter().map(|(key, args)| self.t_with_args(key, args.as_ref())).collect()
370 }
371
372 pub fn get_locale(&self) -> &str {
374 self.translator.get_locale()
375 }
376
377 pub fn set_locale(&mut self, locale: &str) {
379 self.translator.set_locale(locale);
380 self.clear_cache(); }
382
383 pub fn clear_cache(&mut self) {
385 self.translation_cache.clear();
386 }
387
388 pub fn cache_size(&self) -> usize {
390 self.translation_cache.len()
391 }
392}
393
394lazy_static::lazy_static! {
395 pub static ref I18N: std::sync::Mutex<I18nContext> = {
397 let mut translator = OakFluentTranslator::new("en", "en");
399
400 let default_locale = std::env::var("LANG").unwrap_or_else(|_| "en".to_string());
402
403 if let Ok(english_path) = std::env::var("ITOOLS_LOCALE_EN") {
405 let path = Path::new(&english_path);
406 if path.exists() && let Err(e) = translator.add_translations_from_file("en", path) {
407 info!("Failed to load English translations: {}", e);
408 }
409 }
410
411 if default_locale != "en" && let Ok(locale_path) = std::env::var(format!("ITOOLS_LOCALE_{}", default_locale.to_uppercase().replace('-', "_"))) {
413 let path = Path::new(&locale_path);
414 if path.exists() && let Err(e) = translator.add_translations_from_file(&default_locale, path) {
415 info!("Failed to load {} translations: {}", default_locale, e);
416 }
417 }
418
419 std::sync::Mutex::new(I18nContext::new(Box::new(translator)))
420 };
421}
422
423pub fn t(key: &str) -> String {
425 let mut i18n = I18N.lock().unwrap();
426 i18n.t(key)
427}
428
429pub fn t_with_args(key: &str, args: &[(&str, &str)]) -> String {
431 let mut i18n = I18N.lock().unwrap();
432 let args_map: HashMap<String, String> = args.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
433 i18n.t_with_args(key, Some(&args_map))
434}
435
436pub fn set_locale(locale: &str) {
438 let mut i18n = I18N.lock().unwrap();
439 i18n.set_locale(locale);
440}
441
442pub fn get_locale() -> String {
444 let i18n = I18N.lock().unwrap();
445 i18n.get_locale().to_string()
446}