1use std::collections::HashMap;
2use std::path::Path;
3use std::sync::Arc;
4
5use intl_pluralrules::{PluralCategory, PluralRuleType, PluralRules};
6use unic_langid::LanguageIdentifier;
7
8#[derive(Debug, Clone)]
9pub(super) enum Entry {
10 Plain(String),
11 Plural {
12 zero: Option<String>,
13 one: Option<String>,
14 two: Option<String>,
15 few: Option<String>,
16 many: Option<String>,
17 other: String,
18 },
19}
20
21struct Inner {
22 translations: HashMap<String, HashMap<String, Entry>>,
23 default_locale: String,
24 plural_rules: HashMap<String, PluralRules>,
25 fallback_plural_rules: PluralRules,
30}
31
32#[derive(Clone)]
39pub struct TranslationStore {
40 inner: Arc<Inner>,
41}
42
43impl std::fmt::Debug for TranslationStore {
44 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45 f.debug_struct("TranslationStore")
46 .field("translations", &self.inner.translations)
47 .field("default_locale", &self.inner.default_locale)
48 .field(
49 "plural_rules",
50 &self.inner.plural_rules.keys().collect::<Vec<_>>(),
51 )
52 .finish()
53 }
54}
55
56impl TranslationStore {
57 pub(super) fn empty(default_locale: &str) -> Self {
63 let en: LanguageIdentifier = "en".parse().expect("en is a valid language tag");
64 let fallback_plural_rules = PluralRules::create(en, PluralRuleType::CARDINAL)
65 .expect("en plural rules creation cannot fail");
66 Self {
67 inner: Arc::new(Inner {
68 translations: HashMap::new(),
69 default_locale: default_locale.to_string(),
70 plural_rules: HashMap::new(),
71 fallback_plural_rules,
72 }),
73 }
74 }
75
76 pub fn load(path: &Path, default_locale: &str) -> crate::Result<Self> {
86 let mut translations: HashMap<String, HashMap<String, Entry>> = HashMap::new();
87
88 let entries = std::fs::read_dir(path).map_err(|e| {
89 crate::Error::internal(format!(
90 "Failed to read locales directory {}: {e}",
91 path.display()
92 ))
93 })?;
94
95 for entry in entries {
96 let entry = entry.map_err(|e| {
97 crate::Error::internal(format!("Failed to read directory entry: {e}"))
98 })?;
99 let locale_path = entry.path();
100 if !locale_path.is_dir() {
101 continue;
102 }
103
104 let Some(locale) = locale_path.file_name().and_then(|n| n.to_str()) else {
105 tracing::warn!(
106 path = %locale_path.display(),
107 "skipping non-UTF-8 locale directory name"
108 );
109 continue;
110 };
111
112 let locale_entries = load_locale_dir(&locale_path)?;
113 translations.insert(locale.to_string(), locale_entries);
114 }
115
116 let en: LanguageIdentifier = "en".parse().expect("en is a valid language tag");
117 let en_rules = PluralRules::create(en, PluralRuleType::CARDINAL)
118 .expect("en plural rules creation cannot fail");
119 let mut plural_rules = HashMap::new();
120 for locale_str in translations.keys() {
121 let Ok(lang_id) = locale_str.parse::<LanguageIdentifier>() else {
122 tracing::warn!(
123 locale = %locale_str,
124 "failed to parse locale as language identifier — plural rules will use English fallback"
125 );
126 continue;
127 };
128 let Ok(rules) = PluralRules::create(lang_id, PluralRuleType::CARDINAL) else {
129 tracing::warn!(
130 locale = %locale_str,
131 "failed to create plural rules for locale — plural rules will use English fallback"
132 );
133 continue;
134 };
135 plural_rules.insert(locale_str.clone(), rules);
136 }
137
138 Ok(Self {
139 inner: Arc::new(Inner {
140 translations,
141 default_locale: default_locale.to_string(),
142 plural_rules,
143 fallback_plural_rules: en_rules,
144 }),
145 })
146 }
147
148 pub fn translate(
154 &self,
155 locale: &str,
156 key: &str,
157 kwargs: &[(&str, &str)],
158 ) -> crate::Result<String> {
159 if let Some(entry) = self.lookup(locale, key) {
161 return Ok(interpolate(entry_to_string(entry), kwargs));
162 }
163
164 if locale != self.inner.default_locale
166 && let Some(entry) = self.lookup(&self.inner.default_locale, key)
167 {
168 return Ok(interpolate(entry_to_string(entry), kwargs));
169 }
170
171 Ok(key.to_string())
173 }
174
175 pub fn translate_plural(
188 &self,
189 locale: &str,
190 key: &str,
191 count: i64,
192 kwargs: &[(&str, &str)],
193 ) -> crate::Result<String> {
194 let entry = self.lookup(locale, key).or_else(|| {
195 if locale != self.inner.default_locale {
196 self.lookup(&self.inner.default_locale, key)
197 } else {
198 None
199 }
200 });
201
202 let Some(entry) = entry else {
203 return Ok(key.to_string());
204 };
205
206 let template = match entry {
207 Entry::Plural {
208 zero,
209 one,
210 two,
211 few,
212 many,
213 other,
214 } => {
215 let category = self.plural_category(locale, count);
216 match category {
217 PluralCategory::ZERO => zero.as_deref().unwrap_or(other),
218 PluralCategory::ONE => one.as_deref().unwrap_or(other),
219 PluralCategory::TWO => two.as_deref().unwrap_or(other),
220 PluralCategory::FEW => few.as_deref().unwrap_or(other),
221 PluralCategory::MANY => many.as_deref().unwrap_or(other),
222 PluralCategory::OTHER => other,
223 }
224 }
225 Entry::Plain(s) => s,
226 };
227
228 let count_str = count.to_string();
230 let mut all_kwargs: Vec<(&str, &str)> = kwargs.to_vec();
231 all_kwargs.push(("count", &count_str));
232
233 Ok(interpolate(template, &all_kwargs))
234 }
235
236 pub fn available_locales(&self) -> Vec<String> {
238 self.inner.translations.keys().cloned().collect()
239 }
240
241 pub fn default_locale(&self) -> &str {
243 &self.inner.default_locale
244 }
245
246 fn lookup(&self, locale: &str, key: &str) -> Option<&Entry> {
247 self.inner.translations.get(locale)?.get(key)
248 }
249
250 fn plural_category(&self, locale: &str, count: i64) -> PluralCategory {
251 let abs_count = count.unsigned_abs() as usize;
252 if let Some(rules) = self.inner.plural_rules.get(locale) {
253 rules.select(abs_count).unwrap_or(PluralCategory::OTHER)
254 } else {
255 self.inner
257 .fallback_plural_rules
258 .select(abs_count)
259 .unwrap_or(PluralCategory::OTHER)
260 }
261 }
262}
263
264pub(super) fn entry_to_string(entry: &Entry) -> &str {
265 match entry {
266 Entry::Plain(s) => s,
267 Entry::Plural { other, .. } => other,
268 }
269}
270
271pub(super) fn interpolate(template: &str, kwargs: &[(&str, &str)]) -> String {
272 let mut result = String::with_capacity(template.len());
273 let mut chars = template.chars().peekable();
274
275 while let Some(ch) = chars.next() {
276 if ch == '{' {
277 let mut key = String::new();
279 let mut found_close = false;
280 for next_ch in chars.by_ref() {
281 if next_ch == '}' {
282 found_close = true;
283 break;
284 }
285 key.push(next_ch);
286 }
287
288 if found_close && !key.is_empty() {
289 if let Some((_, val)) = kwargs.iter().find(|(k, _)| *k == key) {
291 result.push_str(val);
292 } else {
293 result.push('{');
295 result.push_str(&key);
296 result.push('}');
297 }
298 } else {
299 result.push('{');
300 result.push_str(&key);
301 }
302 } else {
303 result.push(ch);
304 }
305 }
306
307 result
308}
309
310pub(super) fn load_locale_dir(locale_path: &Path) -> crate::Result<HashMap<String, Entry>> {
311 let mut entries = HashMap::new();
312
313 let dir_entries = std::fs::read_dir(locale_path).map_err(|e| {
314 crate::Error::internal(format!(
315 "Failed to read locale directory {}: {e}",
316 locale_path.display()
317 ))
318 })?;
319
320 for entry in dir_entries {
321 let entry = entry
322 .map_err(|e| crate::Error::internal(format!("Failed to read directory entry: {e}")))?;
323 let path = entry.path();
324
325 let ext = path.extension().and_then(|e| e.to_str());
326 if ext != Some("yaml") && ext != Some("yml") {
327 continue;
328 }
329
330 let Some(namespace) = path.file_stem().and_then(|n| n.to_str()) else {
331 tracing::warn!(
332 path = %path.display(),
333 "skipping non-UTF-8 translation file name"
334 );
335 continue;
336 };
337 let namespace = namespace.to_string();
338
339 let content = std::fs::read_to_string(&path).map_err(|e| {
340 crate::Error::internal(format!("Failed to read {}: {e}", path.display()))
341 })?;
342
343 let value: serde_yaml_ng::Value = serde_yaml_ng::from_str(&content).map_err(|e| {
344 crate::Error::internal(format!("Failed to parse {}: {e}", path.display()))
345 })?;
346
347 flatten_yaml(&namespace, &value, &mut entries);
348 }
349
350 Ok(entries)
351}
352
353pub(super) fn flatten_yaml(
354 prefix: &str,
355 value: &serde_yaml_ng::Value,
356 entries: &mut HashMap<String, Entry>,
357) {
358 match value {
359 serde_yaml_ng::Value::Mapping(map) => {
360 if is_plural_entry(map) {
362 let other = map
363 .get(serde_yaml_ng::Value::String("other".into()))
364 .and_then(|v| v.as_str())
365 .unwrap_or("")
366 .to_string();
367
368 let entry = Entry::Plural {
369 zero: get_str(map, "zero"),
370 one: get_str(map, "one"),
371 two: get_str(map, "two"),
372 few: get_str(map, "few"),
373 many: get_str(map, "many"),
374 other,
375 };
376
377 entries.insert(prefix.to_string(), entry);
378 return;
379 }
380
381 for (k, v) in map {
383 if let Some(key_str) = k.as_str() {
384 let full_key = format!("{prefix}.{key_str}");
385 flatten_yaml(&full_key, v, entries);
386 }
387 }
388 }
389 serde_yaml_ng::Value::String(s) => {
390 entries.insert(prefix.to_string(), Entry::Plain(s.clone()));
391 }
392 other => {
393 tracing::warn!(
394 key = %prefix,
395 ?other,
396 "translation value is not a string or mapping — ignored"
397 );
398 }
399 }
400}
401
402fn is_plural_entry(map: &serde_yaml_ng::Mapping) -> bool {
403 let has_other = map.contains_key(serde_yaml_ng::Value::String("other".into()));
404 if !has_other {
405 return false;
406 }
407
408 let plural_keys = ["zero", "one", "two", "few", "many", "other"];
410 map.keys()
411 .all(|k| k.as_str().is_some_and(|s| plural_keys.contains(&s)))
412}
413
414fn get_str(map: &serde_yaml_ng::Mapping, key: &str) -> Option<String> {
415 map.get(serde_yaml_ng::Value::String(key.into()))
416 .and_then(|v| v.as_str())
417 .map(|s| s.to_string())
418}
419
420pub fn make_t_function(
423 store: TranslationStore,
424) -> impl Fn(
425 &minijinja::State,
426 &[minijinja::Value],
427 minijinja::value::Kwargs,
428) -> Result<String, minijinja::Error>
429+ Send
430+ Sync
431+ 'static {
432 move |state: &minijinja::State, args: &[minijinja::Value], kwargs: minijinja::value::Kwargs| {
433 let key = args.first().ok_or_else(|| {
434 minijinja::Error::new(
435 minijinja::ErrorKind::MissingArgument,
436 "t() requires a translation key",
437 )
438 })?;
439 let key = key.to_string();
440
441 let locale = state
443 .lookup("locale")
444 .and_then(|v| {
445 let s = v.to_string();
446 if s.is_empty() { None } else { Some(s) }
447 })
448 .unwrap_or_else(|| store.default_locale().to_string());
449
450 let count: Option<i64> = kwargs.get("count").ok();
452
453 let mut kw_pairs: Vec<(String, String)> = Vec::new();
455 for k in kwargs.args() {
456 if let Ok(v) = kwargs.get::<minijinja::Value>(k) {
457 kw_pairs.push((k.to_string(), v.to_string()));
458 }
459 }
460
461 let kw_refs: Vec<(&str, &str)> = kw_pairs
462 .iter()
463 .map(|(k, v)| (k.as_str(), v.as_str()))
464 .collect();
465
466 let result = if let Some(count) = count {
467 store
468 .translate_plural(&locale, &key, count, &kw_refs)
469 .map_err(|e| {
470 minijinja::Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())
471 })?
472 } else {
473 store.translate(&locale, &key, &kw_refs).map_err(|e| {
474 minijinja::Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())
475 })?
476 };
477
478 if let Err(e) = kwargs.assert_all_used() {
482 tracing::warn!(key = %key, error = %e, "unused template kwargs in t() call");
483 }
484
485 Ok(result)
486 }
487}
488
489#[cfg(test)]
490mod tests {
491 use super::*;
492 use std::path::Path;
493
494 fn write_locale_file(dir: &Path, locale: &str, filename: &str, content: &str) {
495 let locale_dir = dir.join(locale);
496 std::fs::create_dir_all(&locale_dir).unwrap();
497 std::fs::write(locale_dir.join(filename), content).unwrap();
498 }
499
500 fn test_store(dir: &Path) -> TranslationStore {
501 TranslationStore::load(dir, "en").unwrap()
502 }
503
504 #[test]
505 fn load_plain_translations() {
506 let dir = tempfile::tempdir().unwrap();
507 write_locale_file(
508 dir.path(),
509 "en",
510 "common.yaml",
511 "greeting: Hello\nbye: Goodbye",
512 );
513 let store = test_store(dir.path());
514 assert_eq!(
515 store.translate("en", "common.greeting", &[]).unwrap(),
516 "Hello"
517 );
518 assert_eq!(store.translate("en", "common.bye", &[]).unwrap(), "Goodbye");
519 }
520
521 #[test]
522 fn load_nested_keys() {
523 let dir = tempfile::tempdir().unwrap();
524 write_locale_file(
525 dir.path(),
526 "en",
527 "auth.yaml",
528 "login:\n title: \"Log In\"\n submit: Submit",
529 );
530 let store = test_store(dir.path());
531 assert_eq!(
532 store.translate("en", "auth.login.title", &[]).unwrap(),
533 "Log In"
534 );
535 assert_eq!(
536 store.translate("en", "auth.login.submit", &[]).unwrap(),
537 "Submit"
538 );
539 }
540
541 #[test]
542 fn interpolation_replaces_placeholders() {
543 let dir = tempfile::tempdir().unwrap();
544 write_locale_file(
545 dir.path(),
546 "en",
547 "greet.yaml",
548 "welcome: \"Hello, {name}! Age: {age}\"",
549 );
550 let store = test_store(dir.path());
551 let result = store
552 .translate("en", "greet.welcome", &[("name", "Dmytro"), ("age", "30")])
553 .unwrap();
554 assert_eq!(result, "Hello, Dmytro! Age: 30");
555 }
556
557 #[test]
558 fn interpolation_leaves_unmatched_placeholders() {
559 let dir = tempfile::tempdir().unwrap();
560 write_locale_file(
561 dir.path(),
562 "en",
563 "test.yaml",
564 "msg: \"Hello {name}, {missing}\"",
565 );
566 let store = test_store(dir.path());
567 let result = store
568 .translate("en", "test.msg", &[("name", "Dmytro")])
569 .unwrap();
570 assert_eq!(result, "Hello Dmytro, {missing}");
571 }
572
573 #[test]
574 fn plural_english_one_other() {
575 let dir = tempfile::tempdir().unwrap();
576 write_locale_file(
577 dir.path(),
578 "en",
579 "items.yaml",
580 "count:\n one: \"{count} item\"\n other: \"{count} items\"",
581 );
582 let store = test_store(dir.path());
583 assert_eq!(
584 store.translate_plural("en", "items.count", 1, &[]).unwrap(),
585 "1 item"
586 );
587 assert_eq!(
588 store.translate_plural("en", "items.count", 0, &[]).unwrap(),
589 "0 items"
590 );
591 assert_eq!(
592 store.translate_plural("en", "items.count", 5, &[]).unwrap(),
593 "5 items"
594 );
595 }
596
597 #[test]
598 fn plural_falls_back_to_other() {
599 let dir = tempfile::tempdir().unwrap();
600 write_locale_file(
601 dir.path(),
602 "en",
603 "items.yaml",
604 "count:\n other: \"{count} things\"",
605 );
606 let store = test_store(dir.path());
607 assert_eq!(
608 store.translate_plural("en", "items.count", 1, &[]).unwrap(),
609 "1 things"
610 );
611 }
612
613 #[test]
614 fn falls_back_to_default_locale() {
615 let dir = tempfile::tempdir().unwrap();
616 write_locale_file(dir.path(), "en", "common.yaml", "greeting: Hello");
617 write_locale_file(dir.path(), "uk", "common.yaml", "bye: Бувай");
618 let store = test_store(dir.path());
619 assert_eq!(
621 store.translate("uk", "common.greeting", &[]).unwrap(),
622 "Hello"
623 );
624 }
625
626 #[test]
627 fn missing_key_returns_key_itself() {
628 let dir = tempfile::tempdir().unwrap();
629 write_locale_file(dir.path(), "en", "common.yaml", "greeting: Hello");
630 let store = test_store(dir.path());
631 assert_eq!(
632 store.translate("en", "nonexistent.key", &[]).unwrap(),
633 "nonexistent.key"
634 );
635 }
636
637 #[test]
638 fn missing_locale_falls_back_to_default() {
639 let dir = tempfile::tempdir().unwrap();
640 write_locale_file(dir.path(), "en", "common.yaml", "greeting: Hello");
641 let store = test_store(dir.path());
642 assert_eq!(
643 store.translate("fr", "common.greeting", &[]).unwrap(),
644 "Hello"
645 );
646 }
647
648 #[test]
649 fn load_returns_error_on_missing_directory() {
650 let result = TranslationStore::load(Path::new("/nonexistent/path"), "en");
651 assert!(result.is_err());
652 }
653
654 #[test]
655 fn plural_slavic_rules_ukrainian() {
656 let dir = tempfile::tempdir().unwrap();
657 let uk_dir = dir.path().join("uk");
658 std::fs::create_dir_all(&uk_dir).unwrap();
659 std::fs::write(
660 uk_dir.join("items.yaml"),
661 "count:\n one: \"{count} елемент\"\n few: \"{count} елементи\"\n many: \"{count} елементів\"\n other: \"{count} елементів\"",
662 )
663 .unwrap();
664 let en_dir = dir.path().join("en");
665 std::fs::create_dir_all(&en_dir).unwrap();
666 std::fs::write(
667 en_dir.join("items.yaml"),
668 "count:\n one: \"{count} item\"\n other: \"{count} items\"",
669 )
670 .unwrap();
671
672 let store = TranslationStore::load(dir.path(), "en").unwrap();
673 assert_eq!(
674 store.translate_plural("uk", "items.count", 1, &[]).unwrap(),
675 "1 елемент"
676 );
677 assert_eq!(
678 store.translate_plural("uk", "items.count", 3, &[]).unwrap(),
679 "3 елементи"
680 );
681 assert_eq!(
682 store.translate_plural("uk", "items.count", 5, &[]).unwrap(),
683 "5 елементів"
684 );
685 assert_eq!(
686 store
687 .translate_plural("uk", "items.count", 21, &[])
688 .unwrap(),
689 "21 елемент"
690 );
691 assert_eq!(
692 store
693 .translate_plural("uk", "items.count", 22, &[])
694 .unwrap(),
695 "22 елементи"
696 );
697 }
698
699 #[test]
700 fn translate_plural_negative_count() {
701 let dir = tempfile::tempdir().unwrap();
702 write_locale_file(
703 dir.path(),
704 "en",
705 "items.yaml",
706 "count:\n one: \"{count} item\"\n other: \"{count} items\"",
707 );
708 let store = TranslationStore::load(dir.path(), "en").unwrap();
709 assert_eq!(
712 store
713 .translate_plural("en", "items.count", -1, &[])
714 .unwrap(),
715 "-1 item"
716 );
717 assert_eq!(
718 store
719 .translate_plural("en", "items.count", -5, &[])
720 .unwrap(),
721 "-5 items"
722 );
723 }
724
725 #[test]
726 fn yml_extension_support() {
727 let dir = tempfile::tempdir().unwrap();
728 let en_dir = dir.path().join("en");
729 std::fs::create_dir_all(&en_dir).unwrap();
730 std::fs::write(en_dir.join("messages.yml"), "hello: Hi there").unwrap();
731 let store = TranslationStore::load(dir.path(), "en").unwrap();
732 assert_eq!(
733 store.translate("en", "messages.hello", &[]).unwrap(),
734 "Hi there"
735 );
736 }
737
738 #[test]
739 fn t_function_with_count_kwarg() {
740 let dir = tempfile::tempdir().unwrap();
741 write_locale_file(
742 dir.path(),
743 "en",
744 "items.yaml",
745 "count:\n one: \"{count} item\"\n other: \"{count} items\"",
746 );
747 let store = TranslationStore::load(dir.path(), "en").unwrap();
748
749 let mut env = minijinja::Environment::new();
750 let t_fn = make_t_function(store);
751 env.add_function("t", t_fn);
752 env.add_template("test", "{{ t('items.count', count=5) }}")
753 .unwrap();
754
755 let tmpl = env.get_template("test").unwrap();
756 let result = tmpl.render(minijinja::context! { locale => "en" }).unwrap();
757 assert_eq!(result, "5 items");
758 }
759}