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(
159 &self,
160 locale: &str,
161 key: &str,
162 kwargs: &[(&str, &str)],
163 ) -> crate::Result<String> {
164 if let Some(entry) = self.lookup(locale, key) {
166 return Ok(interpolate(entry_to_string(entry), kwargs));
167 }
168
169 if locale != self.inner.default_locale
171 && let Some(entry) = self.lookup(&self.inner.default_locale, key)
172 {
173 return Ok(interpolate(entry_to_string(entry), kwargs));
174 }
175
176 Ok(key.to_string())
178 }
179
180 pub fn translate_plural(
198 &self,
199 locale: &str,
200 key: &str,
201 count: i64,
202 kwargs: &[(&str, &str)],
203 ) -> crate::Result<String> {
204 let entry = self.lookup(locale, key).or_else(|| {
205 if locale != self.inner.default_locale {
206 self.lookup(&self.inner.default_locale, key)
207 } else {
208 None
209 }
210 });
211
212 let Some(entry) = entry else {
213 return Ok(key.to_string());
214 };
215
216 let template = match entry {
217 Entry::Plural {
218 zero,
219 one,
220 two,
221 few,
222 many,
223 other,
224 } => {
225 let category = self.plural_category(locale, count);
226 match category {
227 PluralCategory::ZERO => zero.as_deref().unwrap_or(other),
228 PluralCategory::ONE => one.as_deref().unwrap_or(other),
229 PluralCategory::TWO => two.as_deref().unwrap_or(other),
230 PluralCategory::FEW => few.as_deref().unwrap_or(other),
231 PluralCategory::MANY => many.as_deref().unwrap_or(other),
232 PluralCategory::OTHER => other,
233 }
234 }
235 Entry::Plain(s) => s,
236 };
237
238 let count_str = count.to_string();
240 let mut all_kwargs: Vec<(&str, &str)> = kwargs.to_vec();
241 all_kwargs.push(("count", &count_str));
242
243 Ok(interpolate(template, &all_kwargs))
244 }
245
246 pub fn available_locales(&self) -> Vec<String> {
248 self.inner.translations.keys().cloned().collect()
249 }
250
251 pub fn default_locale(&self) -> &str {
253 &self.inner.default_locale
254 }
255
256 fn lookup(&self, locale: &str, key: &str) -> Option<&Entry> {
257 self.inner.translations.get(locale)?.get(key)
258 }
259
260 fn plural_category(&self, locale: &str, count: i64) -> PluralCategory {
261 let abs_count = count.unsigned_abs() as usize;
262 if let Some(rules) = self.inner.plural_rules.get(locale) {
263 rules.select(abs_count).unwrap_or(PluralCategory::OTHER)
264 } else {
265 self.inner
267 .fallback_plural_rules
268 .select(abs_count)
269 .unwrap_or(PluralCategory::OTHER)
270 }
271 }
272}
273
274pub(super) fn entry_to_string(entry: &Entry) -> &str {
275 match entry {
276 Entry::Plain(s) => s,
277 Entry::Plural { other, .. } => other,
278 }
279}
280
281pub(super) fn interpolate(template: &str, kwargs: &[(&str, &str)]) -> String {
282 let mut result = String::with_capacity(template.len());
283 let mut chars = template.chars().peekable();
284
285 while let Some(ch) = chars.next() {
286 if ch == '{' {
287 let mut key = String::new();
289 let mut found_close = false;
290 for next_ch in chars.by_ref() {
291 if next_ch == '}' {
292 found_close = true;
293 break;
294 }
295 key.push(next_ch);
296 }
297
298 if found_close && !key.is_empty() {
299 if let Some((_, val)) = kwargs.iter().find(|(k, _)| *k == key) {
301 result.push_str(val);
302 } else {
303 result.push('{');
305 result.push_str(&key);
306 result.push('}');
307 }
308 } else {
309 result.push('{');
310 result.push_str(&key);
311 }
312 } else {
313 result.push(ch);
314 }
315 }
316
317 result
318}
319
320pub(super) fn load_locale_dir(locale_path: &Path) -> crate::Result<HashMap<String, Entry>> {
321 let mut entries = HashMap::new();
322
323 let dir_entries = std::fs::read_dir(locale_path).map_err(|e| {
324 crate::Error::internal(format!(
325 "Failed to read locale directory {}: {e}",
326 locale_path.display()
327 ))
328 })?;
329
330 for entry in dir_entries {
331 let entry = entry
332 .map_err(|e| crate::Error::internal(format!("Failed to read directory entry: {e}")))?;
333 let path = entry.path();
334
335 let ext = path.extension().and_then(|e| e.to_str());
336 if ext != Some("yaml") && ext != Some("yml") {
337 continue;
338 }
339
340 let Some(namespace) = path.file_stem().and_then(|n| n.to_str()) else {
341 tracing::warn!(
342 path = %path.display(),
343 "skipping non-UTF-8 translation file name"
344 );
345 continue;
346 };
347 let namespace = namespace.to_string();
348
349 let content = std::fs::read_to_string(&path).map_err(|e| {
350 crate::Error::internal(format!("Failed to read {}: {e}", path.display()))
351 })?;
352
353 let value: serde_yaml_ng::Value = serde_yaml_ng::from_str(&content).map_err(|e| {
354 crate::Error::internal(format!("Failed to parse {}: {e}", path.display()))
355 })?;
356
357 flatten_yaml(&namespace, &value, &mut entries);
358 }
359
360 Ok(entries)
361}
362
363pub(super) fn flatten_yaml(
364 prefix: &str,
365 value: &serde_yaml_ng::Value,
366 entries: &mut HashMap<String, Entry>,
367) {
368 match value {
369 serde_yaml_ng::Value::Mapping(map) => {
370 if is_plural_entry(map) {
372 let other = map
373 .get(serde_yaml_ng::Value::String("other".into()))
374 .and_then(|v| v.as_str())
375 .unwrap_or("")
376 .to_string();
377
378 let entry = Entry::Plural {
379 zero: get_str(map, "zero"),
380 one: get_str(map, "one"),
381 two: get_str(map, "two"),
382 few: get_str(map, "few"),
383 many: get_str(map, "many"),
384 other,
385 };
386
387 entries.insert(prefix.to_string(), entry);
388 return;
389 }
390
391 for (k, v) in map {
393 if let Some(key_str) = k.as_str() {
394 let full_key = format!("{prefix}.{key_str}");
395 flatten_yaml(&full_key, v, entries);
396 }
397 }
398 }
399 serde_yaml_ng::Value::String(s) => {
400 entries.insert(prefix.to_string(), Entry::Plain(s.clone()));
401 }
402 other => {
403 tracing::warn!(
404 key = %prefix,
405 ?other,
406 "translation value is not a string or mapping — ignored"
407 );
408 }
409 }
410}
411
412fn is_plural_entry(map: &serde_yaml_ng::Mapping) -> bool {
413 let has_other = map.contains_key(serde_yaml_ng::Value::String("other".into()));
414 if !has_other {
415 return false;
416 }
417
418 let plural_keys = ["zero", "one", "two", "few", "many", "other"];
420 map.keys()
421 .all(|k| k.as_str().is_some_and(|s| plural_keys.contains(&s)))
422}
423
424fn get_str(map: &serde_yaml_ng::Mapping, key: &str) -> Option<String> {
425 map.get(serde_yaml_ng::Value::String(key.into()))
426 .and_then(|v| v.as_str())
427 .map(|s| s.to_string())
428}
429
430pub fn make_t_function(
433 store: TranslationStore,
434) -> impl Fn(
435 &minijinja::State,
436 &[minijinja::Value],
437 minijinja::value::Kwargs,
438) -> Result<String, minijinja::Error>
439+ Send
440+ Sync
441+ 'static {
442 move |state: &minijinja::State, args: &[minijinja::Value], kwargs: minijinja::value::Kwargs| {
443 let key = args.first().ok_or_else(|| {
444 minijinja::Error::new(
445 minijinja::ErrorKind::MissingArgument,
446 "t() requires a translation key",
447 )
448 })?;
449 let key = key.to_string();
450
451 let locale = state
453 .lookup("locale")
454 .and_then(|v| {
455 let s = v.to_string();
456 if s.is_empty() { None } else { Some(s) }
457 })
458 .unwrap_or_else(|| store.default_locale().to_string());
459
460 let count: Option<i64> = kwargs.get("count").ok();
462
463 let mut kw_pairs: Vec<(String, String)> = Vec::new();
465 for k in kwargs.args() {
466 if let Ok(v) = kwargs.get::<minijinja::Value>(k) {
467 kw_pairs.push((k.to_string(), v.to_string()));
468 }
469 }
470
471 let kw_refs: Vec<(&str, &str)> = kw_pairs
472 .iter()
473 .map(|(k, v)| (k.as_str(), v.as_str()))
474 .collect();
475
476 let result = if let Some(count) = count {
477 store
478 .translate_plural(&locale, &key, count, &kw_refs)
479 .map_err(|e| {
480 minijinja::Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())
481 })?
482 } else {
483 store.translate(&locale, &key, &kw_refs).map_err(|e| {
484 minijinja::Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())
485 })?
486 };
487
488 if let Err(e) = kwargs.assert_all_used() {
492 tracing::warn!(key = %key, error = %e, "unused template kwargs in t() call");
493 }
494
495 Ok(result)
496 }
497}
498
499#[cfg(test)]
500mod tests {
501 use super::*;
502 use std::path::Path;
503
504 fn write_locale_file(dir: &Path, locale: &str, filename: &str, content: &str) {
505 let locale_dir = dir.join(locale);
506 std::fs::create_dir_all(&locale_dir).unwrap();
507 std::fs::write(locale_dir.join(filename), content).unwrap();
508 }
509
510 fn test_store(dir: &Path) -> TranslationStore {
511 TranslationStore::load(dir, "en").unwrap()
512 }
513
514 #[test]
515 fn load_plain_translations() {
516 let dir = tempfile::tempdir().unwrap();
517 write_locale_file(
518 dir.path(),
519 "en",
520 "common.yaml",
521 "greeting: Hello\nbye: Goodbye",
522 );
523 let store = test_store(dir.path());
524 assert_eq!(
525 store.translate("en", "common.greeting", &[]).unwrap(),
526 "Hello"
527 );
528 assert_eq!(store.translate("en", "common.bye", &[]).unwrap(), "Goodbye");
529 }
530
531 #[test]
532 fn load_nested_keys() {
533 let dir = tempfile::tempdir().unwrap();
534 write_locale_file(
535 dir.path(),
536 "en",
537 "auth.yaml",
538 "login:\n title: \"Log In\"\n submit: Submit",
539 );
540 let store = test_store(dir.path());
541 assert_eq!(
542 store.translate("en", "auth.login.title", &[]).unwrap(),
543 "Log In"
544 );
545 assert_eq!(
546 store.translate("en", "auth.login.submit", &[]).unwrap(),
547 "Submit"
548 );
549 }
550
551 #[test]
552 fn interpolation_replaces_placeholders() {
553 let dir = tempfile::tempdir().unwrap();
554 write_locale_file(
555 dir.path(),
556 "en",
557 "greet.yaml",
558 "welcome: \"Hello, {name}! Age: {age}\"",
559 );
560 let store = test_store(dir.path());
561 let result = store
562 .translate("en", "greet.welcome", &[("name", "Dmytro"), ("age", "30")])
563 .unwrap();
564 assert_eq!(result, "Hello, Dmytro! Age: 30");
565 }
566
567 #[test]
568 fn interpolation_leaves_unmatched_placeholders() {
569 let dir = tempfile::tempdir().unwrap();
570 write_locale_file(
571 dir.path(),
572 "en",
573 "test.yaml",
574 "msg: \"Hello {name}, {missing}\"",
575 );
576 let store = test_store(dir.path());
577 let result = store
578 .translate("en", "test.msg", &[("name", "Dmytro")])
579 .unwrap();
580 assert_eq!(result, "Hello Dmytro, {missing}");
581 }
582
583 #[test]
584 fn plural_english_one_other() {
585 let dir = tempfile::tempdir().unwrap();
586 write_locale_file(
587 dir.path(),
588 "en",
589 "items.yaml",
590 "count:\n one: \"{count} item\"\n other: \"{count} items\"",
591 );
592 let store = test_store(dir.path());
593 assert_eq!(
594 store.translate_plural("en", "items.count", 1, &[]).unwrap(),
595 "1 item"
596 );
597 assert_eq!(
598 store.translate_plural("en", "items.count", 0, &[]).unwrap(),
599 "0 items"
600 );
601 assert_eq!(
602 store.translate_plural("en", "items.count", 5, &[]).unwrap(),
603 "5 items"
604 );
605 }
606
607 #[test]
608 fn plural_falls_back_to_other() {
609 let dir = tempfile::tempdir().unwrap();
610 write_locale_file(
611 dir.path(),
612 "en",
613 "items.yaml",
614 "count:\n other: \"{count} things\"",
615 );
616 let store = test_store(dir.path());
617 assert_eq!(
618 store.translate_plural("en", "items.count", 1, &[]).unwrap(),
619 "1 things"
620 );
621 }
622
623 #[test]
624 fn falls_back_to_default_locale() {
625 let dir = tempfile::tempdir().unwrap();
626 write_locale_file(dir.path(), "en", "common.yaml", "greeting: Hello");
627 write_locale_file(dir.path(), "uk", "common.yaml", "bye: Бувай");
628 let store = test_store(dir.path());
629 assert_eq!(
631 store.translate("uk", "common.greeting", &[]).unwrap(),
632 "Hello"
633 );
634 }
635
636 #[test]
637 fn missing_key_returns_key_itself() {
638 let dir = tempfile::tempdir().unwrap();
639 write_locale_file(dir.path(), "en", "common.yaml", "greeting: Hello");
640 let store = test_store(dir.path());
641 assert_eq!(
642 store.translate("en", "nonexistent.key", &[]).unwrap(),
643 "nonexistent.key"
644 );
645 }
646
647 #[test]
648 fn missing_locale_falls_back_to_default() {
649 let dir = tempfile::tempdir().unwrap();
650 write_locale_file(dir.path(), "en", "common.yaml", "greeting: Hello");
651 let store = test_store(dir.path());
652 assert_eq!(
653 store.translate("fr", "common.greeting", &[]).unwrap(),
654 "Hello"
655 );
656 }
657
658 #[test]
659 fn load_returns_error_on_missing_directory() {
660 let result = TranslationStore::load(Path::new("/nonexistent/path"), "en");
661 assert!(result.is_err());
662 }
663
664 #[test]
665 fn plural_slavic_rules_ukrainian() {
666 let dir = tempfile::tempdir().unwrap();
667 let uk_dir = dir.path().join("uk");
668 std::fs::create_dir_all(&uk_dir).unwrap();
669 std::fs::write(
670 uk_dir.join("items.yaml"),
671 "count:\n one: \"{count} елемент\"\n few: \"{count} елементи\"\n many: \"{count} елементів\"\n other: \"{count} елементів\"",
672 )
673 .unwrap();
674 let en_dir = dir.path().join("en");
675 std::fs::create_dir_all(&en_dir).unwrap();
676 std::fs::write(
677 en_dir.join("items.yaml"),
678 "count:\n one: \"{count} item\"\n other: \"{count} items\"",
679 )
680 .unwrap();
681
682 let store = TranslationStore::load(dir.path(), "en").unwrap();
683 assert_eq!(
684 store.translate_plural("uk", "items.count", 1, &[]).unwrap(),
685 "1 елемент"
686 );
687 assert_eq!(
688 store.translate_plural("uk", "items.count", 3, &[]).unwrap(),
689 "3 елементи"
690 );
691 assert_eq!(
692 store.translate_plural("uk", "items.count", 5, &[]).unwrap(),
693 "5 елементів"
694 );
695 assert_eq!(
696 store
697 .translate_plural("uk", "items.count", 21, &[])
698 .unwrap(),
699 "21 елемент"
700 );
701 assert_eq!(
702 store
703 .translate_plural("uk", "items.count", 22, &[])
704 .unwrap(),
705 "22 елементи"
706 );
707 }
708
709 #[test]
710 fn translate_plural_negative_count() {
711 let dir = tempfile::tempdir().unwrap();
712 write_locale_file(
713 dir.path(),
714 "en",
715 "items.yaml",
716 "count:\n one: \"{count} item\"\n other: \"{count} items\"",
717 );
718 let store = TranslationStore::load(dir.path(), "en").unwrap();
719 assert_eq!(
722 store
723 .translate_plural("en", "items.count", -1, &[])
724 .unwrap(),
725 "-1 item"
726 );
727 assert_eq!(
728 store
729 .translate_plural("en", "items.count", -5, &[])
730 .unwrap(),
731 "-5 items"
732 );
733 }
734
735 #[test]
736 fn yml_extension_support() {
737 let dir = tempfile::tempdir().unwrap();
738 let en_dir = dir.path().join("en");
739 std::fs::create_dir_all(&en_dir).unwrap();
740 std::fs::write(en_dir.join("messages.yml"), "hello: Hi there").unwrap();
741 let store = TranslationStore::load(dir.path(), "en").unwrap();
742 assert_eq!(
743 store.translate("en", "messages.hello", &[]).unwrap(),
744 "Hi there"
745 );
746 }
747
748 #[test]
749 fn t_function_with_count_kwarg() {
750 let dir = tempfile::tempdir().unwrap();
751 write_locale_file(
752 dir.path(),
753 "en",
754 "items.yaml",
755 "count:\n one: \"{count} item\"\n other: \"{count} items\"",
756 );
757 let store = TranslationStore::load(dir.path(), "en").unwrap();
758
759 let mut env = minijinja::Environment::new();
760 let t_fn = make_t_function(store);
761 env.add_function("t", t_fn);
762 env.add_template("test", "{{ t('items.count', count=5) }}")
763 .unwrap();
764
765 let tmpl = env.get_template("test").unwrap();
766 let result = tmpl.render(minijinja::context! { locale => "en" }).unwrap();
767 assert_eq!(result, "5 items");
768 }
769}