1use crate::error::UError;
8
9use fluent::{FluentArgs, FluentBundle, FluentResource};
10use fluent_syntax::parser::ParserError;
11
12use std::cell::Cell;
13use std::fs;
14use std::path::{Path, PathBuf};
15use std::str::FromStr;
16use std::sync::OnceLock;
17
18use os_display::Quotable;
19use thiserror::Error;
20use unic_langid::LanguageIdentifier;
21
22#[derive(Error, Debug)]
23pub enum LocalizationError {
24 #[error("I/O error loading '{path}': {source}")]
25 Io {
26 source: std::io::Error,
27 path: PathBuf,
28 },
29 #[error("Parse-locale error: {0}")]
30 ParseLocale(String),
31 #[error("Resource parse error at '{snippet}': {error:?}")]
32 ParseResource {
33 #[source]
34 error: ParserError,
35 snippet: String,
36 },
37 #[error("Bundle error: {0}")]
38 Bundle(String),
39 #[error("Locales directory not found: {0}")]
40 LocalesDirNotFound(String),
41 #[error("Path resolution error: {0}")]
42 PathResolution(String),
43}
44
45impl From<std::io::Error> for LocalizationError {
46 fn from(error: std::io::Error) -> Self {
47 Self::Io {
48 source: error,
49 path: PathBuf::from("<unknown>"),
50 }
51 }
52}
53
54impl UError for LocalizationError {
56 fn code(&self) -> i32 {
57 1
58 }
59}
60
61pub const DEFAULT_LOCALE: &str = "en-US";
62
63include!(concat!(env!("OUT_DIR"), "/embedded_locales.rs"));
65
66struct Localizer {
68 primary_bundle: FluentBundle<&'static FluentResource>,
69 fallback_bundle: Option<FluentBundle<&'static FluentResource>>,
70}
71
72impl Localizer {
73 fn new(primary_bundle: FluentBundle<&'static FluentResource>) -> Self {
74 Self {
75 primary_bundle,
76 fallback_bundle: None,
77 }
78 }
79
80 fn with_fallback(mut self, fallback_bundle: FluentBundle<&'static FluentResource>) -> Self {
81 self.fallback_bundle = Some(fallback_bundle);
82 self
83 }
84
85 fn format(&self, id: &str, args: Option<&FluentArgs>) -> String {
86 if let Some(message) = self.primary_bundle.get_message(id).and_then(|m| m.value()) {
88 let mut errs = Vec::new();
89 return self
90 .primary_bundle
91 .format_pattern(message, args, &mut errs)
92 .to_string();
93 }
94
95 if let Some(ref fallback) = self.fallback_bundle {
97 if let Some(message) = fallback.get_message(id).and_then(|m| m.value()) {
98 let mut errs = Vec::new();
99 return fallback
100 .format_pattern(message, args, &mut errs)
101 .to_string();
102 }
103 }
104
105 id.to_string()
107 }
108}
109
110static UUCORE_FLUENT: OnceLock<FluentResource> = OnceLock::new();
112static CHECKSUM_FLUENT: OnceLock<FluentResource> = OnceLock::new();
113static UTIL_FLUENT: OnceLock<FluentResource> = OnceLock::new();
114thread_local! {
115 static LOCALIZER: OnceLock<Localizer> = const { OnceLock::new() };
116}
117
118fn find_uucore_locales_dir(utility_locales_dir: &Path) -> Option<PathBuf> {
120 let normalized_dir = utility_locales_dir
122 .canonicalize()
123 .unwrap_or_else(|_| utility_locales_dir.to_path_buf());
124
125 let uucore_locales = normalized_dir
127 .parent()? .parent()? .parent()? .join("uucore")
131 .join("locales");
132
133 uucore_locales.exists().then_some(uucore_locales)
135}
136
137fn create_bundle(
139 locale: &LanguageIdentifier,
140 locales_dir: &Path,
141 util_name: &str,
142) -> Result<FluentBundle<&'static FluentResource>, LocalizationError> {
143 let mut bundle: FluentBundle<&'static FluentResource> = FluentBundle::new(vec![locale.clone()]);
144
145 bundle.set_use_isolating(false);
147
148 let mut try_add_resource_from = |dir_opt: Option<PathBuf>| {
149 if let Some(resource) = dir_opt
150 .map(|dir| dir.join(format!("{locale}.ftl")))
151 .and_then(|locale_path| fs::read_to_string(locale_path).ok())
152 .and_then(|ftl| FluentResource::try_new(ftl).ok())
153 {
154 bundle.add_resource_overriding(Box::leak(Box::new(resource)));
156 }
157 };
158
159 try_add_resource_from(find_uucore_locales_dir(locales_dir));
161 try_add_resource_from(get_locales_dir(util_name).ok());
163
164 if [
166 "cksum",
167 "b2sum",
168 "md5sum",
169 "sha1sum",
170 "sha224sum",
171 "sha256sum",
172 "sha384sum",
173 "sha512sum",
174 ]
175 .contains(&util_name)
176 {
177 try_add_resource_from(get_locales_dir("checksum_common").ok());
178 }
179
180 if bundle.has_message("common-error") || bundle.has_message(&format!("{util_name}-about")) {
182 Ok(bundle)
183 } else {
184 Err(LocalizationError::LocalesDirNotFound(format!(
185 "No localization strings found for {locale} and utility {util_name}"
186 )))
187 }
188}
189
190fn init_localization(
192 locale: &LanguageIdentifier,
193 locales_dir: &Path,
194 util_name: &str,
195) -> Result<(), LocalizationError> {
196 let default_locale = LanguageIdentifier::from_str(DEFAULT_LOCALE)
197 .expect("Default locale should always be valid");
198
199 let english_bundle: FluentBundle<&'static FluentResource> =
201 create_bundle(&default_locale, locales_dir, util_name).or_else(|_| {
202 create_english_bundle_from_embedded(&default_locale, util_name)
204 })?;
205
206 let loc = if locale == &default_locale {
207 Localizer::new(english_bundle)
209 } else {
210 if let Ok(primary_bundle) = create_bundle(locale, locales_dir, util_name) {
212 Localizer::new(primary_bundle).with_fallback(english_bundle)
214 } else {
215 Localizer::new(english_bundle)
217 }
218 };
219
220 LOCALIZER.with(|lock| {
221 lock.set(loc)
222 .map_err(|_| LocalizationError::Bundle("Localizer already initialized".into()))
223 })?;
224 Ok(())
225}
226
227fn parse_fluent_resource(
229 content: &str,
230 cache: &'static OnceLock<FluentResource>,
231) -> Result<&'static FluentResource, LocalizationError> {
232 #[cfg(not(test))]
234 if let Some(res) = cache.get() {
235 return Ok(res);
236 }
237
238 let resource = FluentResource::try_new(content.to_string()).map_err(
239 |(_partial_resource, errs): (FluentResource, Vec<ParserError>)| {
240 if let Some(first_err) = errs.into_iter().next() {
241 let snippet = first_err
242 .slice
243 .clone()
244 .and_then(|range| content.get(range))
245 .unwrap_or("")
246 .to_string();
247 LocalizationError::ParseResource {
248 error: first_err,
249 snippet,
250 }
251 } else {
252 LocalizationError::LocalesDirNotFound("Parse error without details".to_string())
253 }
254 },
255 )?;
256 if cfg!(not(test)) {
258 Ok(cache.get_or_init(|| resource))
259 } else {
260 Ok(Box::leak(Box::new(resource)))
261 }
262}
263
264fn create_english_bundle_from_embedded(
266 locale: &LanguageIdentifier,
267 util_name: &str,
268) -> Result<FluentBundle<&'static FluentResource>, LocalizationError> {
269 if *locale != "en-US" {
271 return Err(LocalizationError::LocalesDirNotFound(
272 "Embedded locales only support en-US".to_string(),
273 ));
274 }
275
276 let mut bundle: FluentBundle<&'static FluentResource> = FluentBundle::new(vec![locale.clone()]);
277 bundle.set_use_isolating(false);
278
279 if let Some(uucore_content) = get_embedded_locale("uucore/en-US.ftl") {
281 let uucore_resource = parse_fluent_resource(uucore_content, &UUCORE_FLUENT)?;
282 bundle.add_resource_overriding(uucore_resource);
283 }
284
285 if util_name.ends_with("sum") {
287 if let Some(uucore_content) = get_embedded_locale("checksum_common/en-US.ftl") {
288 let uucore_resource = parse_fluent_resource(uucore_content, &CHECKSUM_FLUENT)?;
289 bundle.add_resource_overriding(uucore_resource);
290 }
291 }
292
293 let locale_key = format!("{util_name}/en-US.ftl");
295 if let Some(ftl_content) = get_embedded_locale(&locale_key) {
296 let resource = parse_fluent_resource(ftl_content, &UTIL_FLUENT)?;
297 bundle.add_resource_overriding(resource);
298 }
299
300 if bundle.has_message("common-error") || bundle.has_message(&format!("{util_name}-about")) {
302 Ok(bundle)
303 } else {
304 Err(LocalizationError::LocalesDirNotFound(format!(
305 "No embedded locale found for {util_name} and no common strings found"
306 )))
307 }
308}
309
310#[cfg(target_os = "wasi")]
314fn create_wasi_bundle_from_embedded(
315 locale: &LanguageIdentifier,
316 util_name: &str,
317) -> Result<FluentBundle<&'static FluentResource>, LocalizationError> {
318 let locale_str = locale.to_string();
319 let mut bundle: FluentBundle<&'static FluentResource> = FluentBundle::new(vec![locale.clone()]);
320 bundle.set_use_isolating(false);
321
322 let mut try_add = |key: &str| {
323 if let Some(content) = get_embedded_locale(key) {
324 if let Ok(resource) = FluentResource::try_new(content.to_string()) {
325 bundle.add_resource_overriding(Box::leak(Box::new(resource)));
326 }
327 }
328 };
329
330 try_add(&format!("uucore/{locale_str}.ftl"));
331 if util_name.ends_with("sum") {
332 try_add(&format!("checksum_common/{locale_str}.ftl"));
333 }
334 try_add(&format!("{util_name}/{locale_str}.ftl"));
335
336 if bundle.has_message("common-error") || bundle.has_message(&format!("{util_name}-about")) {
337 Ok(bundle)
338 } else {
339 Err(LocalizationError::LocalesDirNotFound(format!(
340 "No embedded locale found for {util_name}/{locale_str}"
341 )))
342 }
343}
344
345fn get_message_internal(id: &str, args: Option<FluentArgs>) -> String {
346 LOCALIZER.with(|lock| {
347 lock.get()
348 .map_or_else(|| id.to_string(), |loc| loc.format(id, args.as_ref())) })
350}
351
352pub fn get_message(id: &str) -> String {
377 get_message_internal(id, None)
378}
379
380pub fn get_message_with_args(id: &str, ftl_args: FluentArgs) -> String {
412 get_message_internal(id, Some(ftl_args))
413}
414
415fn detect_system_locale() -> Result<LanguageIdentifier, LocalizationError> {
417 let locale_str = std::env::var("LANG")
418 .unwrap_or_else(|_| DEFAULT_LOCALE.to_string())
419 .split('.')
420 .next()
421 .unwrap_or(DEFAULT_LOCALE)
422 .to_string();
423 LanguageIdentifier::from_str(&locale_str).map_err(|_| {
424 LocalizationError::ParseLocale(format!("Failed to parse locale: {locale_str}"))
425 })
426}
427
428pub fn setup_localization(p: &str) -> Result<(), LocalizationError> {
466 thread_local! {
468 static LOCALIZER_IS_SET: Cell<bool> = const { Cell::new(false) };
469 }
470 if LOCALIZER_IS_SET.with(Cell::get) {
471 return Ok(());
472 }
473
474 let locale = detect_system_locale().unwrap_or_else(|_| {
475 LanguageIdentifier::from_str(DEFAULT_LOCALE).expect("Default locale should always be valid")
476 });
477
478 if let Ok(locales_dir) = get_locales_dir(p) {
480 init_localization(&locale, &locales_dir, p)?;
482 } else {
483 let default_locale = LanguageIdentifier::from_str(DEFAULT_LOCALE)
485 .expect("Default locale should always be valid");
486
487 #[cfg(target_os = "wasi")]
488 let localizer = {
489 let english_bundle = create_wasi_bundle_from_embedded(&default_locale, p)?;
490 if locale == default_locale {
491 Localizer::new(english_bundle)
492 } else if let Ok(localized) = create_wasi_bundle_from_embedded(&locale, p) {
493 Localizer::new(localized).with_fallback(english_bundle)
494 } else {
495 Localizer::new(english_bundle)
496 }
497 };
498
499 #[cfg(not(target_os = "wasi"))]
500 let localizer = {
501 let english_bundle = create_english_bundle_from_embedded(&default_locale, p)?;
502 Localizer::new(english_bundle)
503 };
504
505 LOCALIZER.with(|lock| {
506 lock.set(localizer)
507 .map_err(|_| LocalizationError::Bundle("Localizer already initialized".into()))
508 })?;
509 }
510 LOCALIZER_IS_SET.with(|f| f.set(true));
511 Ok(())
512}
513
514#[cfg(not(debug_assertions))]
515fn resolve_locales_dir_from_exe_dir(exe_dir: &Path, p: &str) -> Option<PathBuf> {
516 let coreutils = exe_dir.join("locales").join(p);
518 if coreutils.is_dir() {
519 return Some(coreutils);
520 }
521
522 if let Some(prefix) = exe_dir.parent() {
524 let fhs = prefix.join("share").join("locales").join(p);
525 if fhs.is_dir() {
526 return Some(fhs);
527 }
528 }
529
530 let fallback = exe_dir.join(p);
532 if fallback.is_dir() {
533 return Some(fallback);
534 }
535
536 None
537}
538
539fn get_locales_dir(p: &str) -> Result<PathBuf, LocalizationError> {
541 #[cfg(debug_assertions)]
542 {
543 let manifest_dir = env!("CARGO_MANIFEST_DIR");
545 let dev_path = PathBuf::from(manifest_dir)
547 .join("../uu")
548 .join(p)
549 .join("locales");
550
551 if dev_path.exists() {
552 return Ok(dev_path);
553 }
554
555 let fallback_dev_path = PathBuf::from(manifest_dir).join(p);
557 if fallback_dev_path.exists() {
558 return Ok(fallback_dev_path);
559 }
560
561 Err(LocalizationError::LocalesDirNotFound(format!(
562 "Development locales directory not found at {} or {}",
563 dev_path.quote(),
564 fallback_dev_path.quote()
565 )))
566 }
567
568 #[cfg(not(debug_assertions))]
569 {
570 use std::env;
571 let exe_path = env::current_exe().map_err(|e| {
573 LocalizationError::PathResolution(format!("Failed to get executable path: {e}"))
574 })?;
575
576 let exe_dir = exe_path.parent().ok_or_else(|| {
577 LocalizationError::PathResolution("Failed to get executable directory".to_string())
578 })?;
579
580 if let Some(dir) = resolve_locales_dir_from_exe_dir(exe_dir, p) {
581 return Ok(dir);
582 }
583
584 Err(LocalizationError::LocalesDirNotFound(format!(
585 "Release locales directory not found starting from {}",
586 exe_dir.quote()
587 )))
588 }
589}
590
591#[macro_export]
625macro_rules! translate {
626 ($id:expr) => {
628 $crate::locale::get_message($id)
629 };
630
631 ($id:expr, $($key:expr => $value:expr),+ $(,)?) => {
633 {
634 let mut args = fluent::FluentArgs::new();
635 $(
636 let value_str = $value.to_string();
637 if let Ok(num_val) = value_str.parse::<i64>() {
638 args.set($key, num_val);
639 } else if let Ok(float_val) = value_str.parse::<f64>() {
640 args.set($key, float_val);
641 } else {
642 args.set($key, value_str);
644 }
645 )+
646 $crate::locale::get_message_with_args($id, args)
647 }
648 };
649}
650
651pub use translate;
653
654#[cfg(test)]
655mod tests {
656 use super::*;
657 use std::env;
658 use std::fs;
659 use std::path::PathBuf;
660 use tempfile::TempDir;
661
662 #[cfg(test)]
664 fn create_test_bundle(
665 locale: &LanguageIdentifier,
666 test_locales_dir: &Path,
667 ) -> Result<FluentBundle<&'static FluentResource>, LocalizationError> {
668 let mut bundle: FluentBundle<&'static FluentResource> =
669 FluentBundle::new(vec![locale.clone()]);
670 bundle.set_use_isolating(false);
671
672 let locale_path = test_locales_dir.join(format!("{locale}.ftl"));
674 if let Ok(ftl_content) = fs::read_to_string(&locale_path) {
675 let resource = parse_fluent_resource(&ftl_content, &UUCORE_FLUENT)?;
676 bundle.add_resource_overriding(resource);
677 return Ok(bundle);
678 }
679
680 Err(LocalizationError::LocalesDirNotFound(format!(
681 "No localization strings found for {locale} in {}",
682 test_locales_dir.quote()
683 )))
684 }
685
686 #[cfg(test)]
688 fn init_test_localization(
689 locale: &LanguageIdentifier,
690 test_locales_dir: &Path,
691 ) -> Result<(), LocalizationError> {
692 let default_locale = LanguageIdentifier::from_str(DEFAULT_LOCALE)
693 .expect("Default locale should always be valid");
694
695 let english_bundle = create_test_bundle(&default_locale, test_locales_dir)?;
697
698 let loc = if locale == &default_locale {
699 Localizer::new(english_bundle)
701 } else {
702 if let Ok(primary_bundle) = create_test_bundle(locale, test_locales_dir) {
704 Localizer::new(primary_bundle).with_fallback(english_bundle)
706 } else {
707 Localizer::new(english_bundle)
709 }
710 };
711
712 LOCALIZER.with(|lock| {
713 lock.set(loc)
714 .map_err(|_| LocalizationError::Bundle("Localizer already initialized".into()))
715 })?;
716 Ok(())
717 }
718
719 fn create_test_locales_dir() -> TempDir {
721 let temp_dir = TempDir::new().expect("Failed to create temp directory");
722
723 let en_content = r"
725greeting = Hello, world!
726welcome = Welcome, { $name }!
727count-items = You have { $count ->
728 [one] { $count } item
729 *[other] { $count } items
730}
731missing-in-other = This message only exists in English
732";
733
734 let fr_content = r"
736greeting = Bonjour, le monde!
737welcome = Bienvenue, { $name }!
738count-items = Vous avez { $count ->
739 [one] { $count } élément
740 *[other] { $count } éléments
741}
742";
743
744 let ja_content = r"
746greeting = こんにちは、世界!
747welcome = ようこそ、{ $name }さん!
748count-items = { $count }個のアイテムがあります
749";
750
751 let ar_content = r"
753greeting = أهلاً بالعالم!
754welcome = أهلاً وسهلاً، { $name }!
755count-items = لديك { $count ->
756 [zero] لا عناصر
757 [one] عنصر واحد
758 [two] عنصران
759 [few] { $count } عناصر
760 *[other] { $count } عنصر
761}
762";
763
764 let es_invalid_content = r"
766greeting = Hola, mundo!
767invalid-syntax = This is { $missing
768";
769
770 fs::write(temp_dir.path().join("en-US.ftl"), en_content)
771 .expect("Failed to write en-US.ftl");
772 fs::write(temp_dir.path().join("fr-FR.ftl"), fr_content)
773 .expect("Failed to write fr-FR.ftl");
774 fs::write(temp_dir.path().join("ja-JP.ftl"), ja_content)
775 .expect("Failed to write ja-JP.ftl");
776 fs::write(temp_dir.path().join("ar-SA.ftl"), ar_content)
777 .expect("Failed to write ar-SA.ftl");
778 fs::write(temp_dir.path().join("es-ES.ftl"), es_invalid_content)
779 .expect("Failed to write es-ES.ftl");
780
781 temp_dir
782 }
783
784 #[test]
785 fn test_create_bundle_success() {
786 let temp_dir = create_test_locales_dir();
787 let locale = LanguageIdentifier::from_str("en-US").unwrap();
788
789 let result = create_test_bundle(&locale, temp_dir.path());
790 assert!(result.is_ok());
791
792 let bundle = result.unwrap();
793 assert!(bundle.get_message("greeting").is_some());
794 }
795
796 #[test]
797 fn test_create_bundle_file_not_found() {
798 let temp_dir = TempDir::new().unwrap();
799 let locale = LanguageIdentifier::from_str("de-DE").unwrap();
800
801 let result = create_test_bundle(&locale, temp_dir.path());
802 assert!(result.is_err());
803
804 if let Err(LocalizationError::LocalesDirNotFound(_)) = result {
805 } else {
807 panic!("Expected LocalesDirNotFound error");
808 }
809 }
810
811 #[test]
812 fn test_create_bundle_invalid_syntax() {
813 let temp_dir = create_test_locales_dir();
814 let locale = LanguageIdentifier::from_str("es-ES").unwrap();
815
816 let result = create_test_bundle(&locale, temp_dir.path());
817
818 match result {
820 Err(LocalizationError::ParseResource {
821 error: _parser_err,
822 snippet: _,
823 }) => {
824 }
826 Ok(_) => {
827 panic!("Expected ParseResource error, but bundle was created successfully");
828 }
829 Err(other) => {
830 panic!("Expected ParseResource error, but got: {other:?}");
831 }
832 }
833 }
834
835 #[test]
836 fn test_localizer_format_primary_bundle() {
837 let temp_dir = create_test_locales_dir();
838 let en_bundle: FluentBundle<&'static FluentResource> = create_test_bundle(
839 &LanguageIdentifier::from_str("en-US").unwrap(),
840 temp_dir.path(),
841 )
842 .unwrap();
843
844 let localizer = Localizer::new(en_bundle);
845 let result = localizer.format("greeting", None);
846 assert_eq!(result, "Hello, world!");
847 }
848
849 #[test]
850 fn test_localizer_format_with_args() {
851 use fluent::FluentArgs;
852 let temp_dir = create_test_locales_dir();
853 let en_bundle = create_test_bundle(
854 &LanguageIdentifier::from_str("en-US").unwrap(),
855 temp_dir.path(),
856 )
857 .unwrap();
858
859 let localizer = Localizer::new(en_bundle);
860 let mut args = FluentArgs::new();
861 args.set("name", "Alice");
862
863 let result = localizer.format("welcome", Some(&args));
864 assert_eq!(result, "Welcome, Alice!");
865 }
866
867 #[test]
868 fn test_localizer_fallback_to_english() {
869 let temp_dir = create_test_locales_dir();
870 let fr_bundle = create_test_bundle(
871 &LanguageIdentifier::from_str("fr-FR").unwrap(),
872 temp_dir.path(),
873 )
874 .unwrap();
875 let en_bundle = create_test_bundle(
876 &LanguageIdentifier::from_str("en-US").unwrap(),
877 temp_dir.path(),
878 )
879 .unwrap();
880
881 let localizer = Localizer::new(fr_bundle).with_fallback(en_bundle);
882
883 let result1 = localizer.format("greeting", None);
885 assert_eq!(result1, "Bonjour, le monde!");
886
887 let result2 = localizer.format("missing-in-other", None);
889 assert_eq!(result2, "This message only exists in English");
890 }
891
892 #[test]
893 fn test_localizer_format_message_not_found() {
894 let temp_dir = create_test_locales_dir();
895 let en_bundle = create_test_bundle(
896 &LanguageIdentifier::from_str("en-US").unwrap(),
897 temp_dir.path(),
898 )
899 .unwrap();
900
901 let localizer = Localizer::new(en_bundle);
902 let result = localizer.format("nonexistent-message", None);
903 assert_eq!(result, "nonexistent-message");
904 }
905
906 #[test]
907 fn test_init_localization_english_only() {
908 std::thread::spawn(|| {
910 let temp_dir = create_test_locales_dir();
911 let locale = LanguageIdentifier::from_str("en-US").unwrap();
912
913 let result = init_test_localization(&locale, temp_dir.path());
914 assert!(result.is_ok());
915
916 let message = get_message("greeting");
918 assert_eq!(message, "Hello, world!");
919 })
920 .join()
921 .unwrap();
922 }
923
924 #[test]
925 fn test_init_localization_with_fallback() {
926 std::thread::spawn(|| {
927 let temp_dir = create_test_locales_dir();
928 let locale = LanguageIdentifier::from_str("fr-FR").unwrap();
929
930 let result = init_test_localization(&locale, temp_dir.path());
931 assert!(result.is_ok());
932
933 let message1 = get_message("greeting");
935 assert_eq!(message1, "Bonjour, le monde!");
936
937 let message2 = get_message("missing-in-other");
939 assert_eq!(message2, "This message only exists in English");
940 })
941 .join()
942 .unwrap();
943 }
944
945 #[test]
946 fn test_init_localization_invalid_locale_falls_back_to_english() {
947 std::thread::spawn(|| {
948 let temp_dir = create_test_locales_dir();
949 let locale = LanguageIdentifier::from_str("de-DE").unwrap(); let result = init_test_localization(&locale, temp_dir.path());
952 assert!(result.is_ok());
953
954 let message = get_message("greeting");
956 assert_eq!(message, "Hello, world!");
957 })
958 .join()
959 .unwrap();
960 }
961
962 #[test]
963 fn test_init_localization_already_initialized() {
964 std::thread::spawn(|| {
965 let temp_dir = create_test_locales_dir();
966 let locale = LanguageIdentifier::from_str("en-US").unwrap();
967
968 let result1 = init_test_localization(&locale, temp_dir.path());
970 assert!(result1.is_ok());
971
972 let result2 = init_test_localization(&locale, temp_dir.path());
974 assert!(result2.is_err());
975
976 match result2 {
977 Err(LocalizationError::Bundle(msg)) => {
978 assert!(msg.contains("already initialized"));
979 }
980 _ => panic!("Expected Bundle error"),
981 }
982 })
983 .join()
984 .unwrap();
985 }
986
987 #[test]
988 fn test_get_message() {
989 std::thread::spawn(|| {
990 let temp_dir = create_test_locales_dir();
991 let locale = LanguageIdentifier::from_str("fr-FR").unwrap();
992
993 init_test_localization(&locale, temp_dir.path()).unwrap();
994
995 let message = get_message("greeting");
996 assert_eq!(message, "Bonjour, le monde!");
997 })
998 .join()
999 .unwrap();
1000 }
1001
1002 #[test]
1003 fn test_get_message_with_args() {
1004 use fluent::FluentArgs;
1005 std::thread::spawn(|| {
1006 let temp_dir = create_test_locales_dir();
1007 let locale = LanguageIdentifier::from_str("en-US").unwrap();
1008
1009 init_test_localization(&locale, temp_dir.path()).unwrap();
1010
1011 let mut args = FluentArgs::new();
1012 args.set("name".to_string(), "Bob".to_string());
1013
1014 let message = get_message_with_args("welcome", args);
1015 assert_eq!(message, "Welcome, Bob!");
1016 })
1017 .join()
1018 .unwrap();
1019 }
1020
1021 #[test]
1022 fn test_get_message_with_args_pluralization() {
1023 use fluent::FluentArgs;
1024 std::thread::spawn(|| {
1025 let temp_dir = create_test_locales_dir();
1026 let locale = LanguageIdentifier::from_str("en-US").unwrap();
1027
1028 init_test_localization(&locale, temp_dir.path()).unwrap();
1029
1030 let mut args1 = FluentArgs::new();
1032 args1.set("count", 1);
1033 let message1 = get_message_with_args("count-items", args1);
1034 assert_eq!(message1, "You have 1 item");
1035
1036 let mut args2 = FluentArgs::new();
1038 args2.set("count", 5);
1039 let message2 = get_message_with_args("count-items", args2);
1040 assert_eq!(message2, "You have 5 items");
1041 })
1042 .join()
1043 .unwrap();
1044 }
1045
1046 #[test]
1047 fn test_thread_local_isolation() {
1048 use std::thread;
1049
1050 let temp_dir = create_test_locales_dir();
1051
1052 let temp_path_main = temp_dir.path().to_path_buf();
1054 let main_handle = thread::spawn(move || {
1055 let locale = LanguageIdentifier::from_str("fr-FR").unwrap();
1056 init_test_localization(&locale, &temp_path_main).unwrap();
1057 let main_message = get_message("greeting");
1058 assert_eq!(main_message, "Bonjour, le monde!");
1059 });
1060 main_handle.join().unwrap();
1061
1062 let temp_path = temp_dir.path().to_path_buf();
1064 let handle = thread::spawn(move || {
1065 let thread_message = get_message("greeting");
1067 assert_eq!(thread_message, "greeting"); let en_locale = LanguageIdentifier::from_str("en-US").unwrap();
1071 init_test_localization(&en_locale, &temp_path).unwrap();
1072 let thread_message_after_init = get_message("greeting");
1073 assert_eq!(thread_message_after_init, "Hello, world!");
1074 });
1075
1076 handle.join().unwrap();
1077
1078 let final_handle = thread::spawn(move || {
1080 let final_message = get_message("greeting");
1082 assert_eq!(final_message, "greeting");
1083 });
1084 final_handle.join().unwrap();
1085 }
1086
1087 #[test]
1088 fn test_japanese_localization() {
1089 use fluent::FluentArgs;
1090 std::thread::spawn(|| {
1091 let temp_dir = create_test_locales_dir();
1092 let locale = LanguageIdentifier::from_str("ja-JP").unwrap();
1093
1094 let result = init_test_localization(&locale, temp_dir.path());
1095 assert!(result.is_ok());
1096
1097 let message = get_message("greeting");
1099 assert_eq!(message, "こんにちは、世界!");
1100
1101 let mut args = FluentArgs::new();
1103 args.set("name".to_string(), "田中".to_string());
1104 let welcome = get_message_with_args("welcome", args);
1105 assert_eq!(welcome, "ようこそ、田中さん!");
1106
1107 let mut count_args = FluentArgs::new();
1109 count_args.set("count".to_string(), "5".to_string());
1110 let count_message = get_message_with_args("count-items", count_args);
1111 assert_eq!(count_message, "5個のアイテムがあります");
1112 })
1113 .join()
1114 .unwrap();
1115 }
1116
1117 #[test]
1118 fn test_arabic_localization() {
1119 use fluent::FluentArgs;
1120 std::thread::spawn(|| {
1121 let temp_dir = create_test_locales_dir();
1122 let locale = LanguageIdentifier::from_str("ar-SA").unwrap();
1123
1124 let result = init_test_localization(&locale, temp_dir.path());
1125 assert!(result.is_ok());
1126
1127 let message = get_message("greeting");
1129 assert_eq!(message, "أهلاً بالعالم!");
1130
1131 let mut args = FluentArgs::new();
1133 args.set("name", "أحمد".to_string());
1134 let welcome = get_message_with_args("welcome", args);
1135 assert_eq!(welcome, "أهلاً وسهلاً، أحمد!");
1136
1137 let mut args_zero = FluentArgs::new();
1139 args_zero.set("count", 0);
1140 let message_zero = get_message_with_args("count-items", args_zero);
1141 assert_eq!(message_zero, "لديك لا عناصر");
1142
1143 let mut args_one = FluentArgs::new();
1145 args_one.set("count", 1);
1146 let message_one = get_message_with_args("count-items", args_one);
1147 assert_eq!(message_one, "لديك عنصر واحد");
1148
1149 let mut args_two = FluentArgs::new();
1151 args_two.set("count", 2);
1152 let message_two = get_message_with_args("count-items", args_two);
1153 assert_eq!(message_two, "لديك عنصران");
1154
1155 let mut args_few = FluentArgs::new();
1157 args_few.set("count", 5);
1158 let message_few = get_message_with_args("count-items", args_few);
1159 assert_eq!(message_few, "لديك 5 عناصر");
1160
1161 let mut args_many = FluentArgs::new();
1163 args_many.set("count", 15);
1164 let message_many = get_message_with_args("count-items", args_many);
1165 assert_eq!(message_many, "لديك 15 عنصر");
1166 })
1167 .join()
1168 .unwrap();
1169 }
1170
1171 #[test]
1172 fn test_arabic_localization_with_macro() {
1173 std::thread::spawn(|| {
1174 let temp_dir = create_test_locales_dir();
1175 let locale = LanguageIdentifier::from_str("ar-SA").unwrap();
1176
1177 let result = init_test_localization(&locale, temp_dir.path());
1178 assert!(result.is_ok());
1179
1180 let message = translate!("greeting");
1182 assert_eq!(message, "أهلاً بالعالم!");
1183
1184 let welcome = translate!("welcome", "name" => "أحمد");
1186 assert_eq!(welcome, "أهلاً وسهلاً، أحمد!");
1187
1188 let message_zero = translate!("count-items", "count" => 0);
1190 assert_eq!(message_zero, "لديك لا عناصر");
1191
1192 let message_one = translate!("count-items", "count" => 1);
1194 assert_eq!(message_one, "لديك عنصر واحد");
1195
1196 let message_two = translate!("count-items", "count" => 2);
1198 assert_eq!(message_two, "لديك عنصران");
1199
1200 let message_few = translate!("count-items", "count" => 5);
1202 assert_eq!(message_few, "لديك 5 عناصر");
1203
1204 let message_many = translate!("count-items", "count" => 15);
1206 assert_eq!(message_many, "لديك 15 عنصر");
1207 })
1208 .join()
1209 .unwrap();
1210 }
1211
1212 #[test]
1213 fn test_mixed_script_fallback() {
1214 std::thread::spawn(|| {
1215 let temp_dir = create_test_locales_dir();
1216 let locale = LanguageIdentifier::from_str("ar-SA").unwrap();
1217
1218 let result = init_test_localization(&locale, temp_dir.path());
1219 assert!(result.is_ok());
1220
1221 let arabic_message = get_message("greeting");
1223 assert_eq!(arabic_message, "أهلاً بالعالم!");
1224
1225 let fallback_message = get_message("missing-in-other");
1227 assert_eq!(fallback_message, "This message only exists in English");
1228 })
1229 .join()
1230 .unwrap();
1231 }
1232
1233 #[test]
1234 fn test_unicode_directional_isolation_disabled() {
1235 use fluent::FluentArgs;
1236 std::thread::spawn(|| {
1237 let temp_dir = create_test_locales_dir();
1238 let locale = LanguageIdentifier::from_str("ar-SA").unwrap();
1239
1240 init_test_localization(&locale, temp_dir.path()).unwrap();
1241
1242 let mut args = FluentArgs::new();
1245 args.set("name".to_string(), "John Smith".to_string());
1246 let message = get_message_with_args("welcome", args);
1247
1248 assert!(!message.contains("\u{2068}John Smith\u{2069}"));
1250 assert_eq!(message, "أهلاً وسهلاً، John Smith!");
1251 })
1252 .join()
1253 .unwrap();
1254 }
1255
1256 #[test]
1257 fn test_parse_resource_error_includes_snippet() {
1258 let temp_dir = create_test_locales_dir();
1259 let locale = LanguageIdentifier::from_str("es-ES").unwrap();
1260
1261 let result = create_test_bundle(&locale, temp_dir.path());
1262 assert!(result.is_err());
1263
1264 if let Err(LocalizationError::ParseResource {
1265 error: _err,
1266 snippet,
1267 }) = result
1268 {
1269 assert!(
1271 snippet.contains("This is { $missing"),
1272 "snippet was `{snippet}` but did not include the invalid text"
1273 );
1274 } else {
1275 panic!("Expected LocalizationError::ParseResource with snippet");
1276 }
1277 }
1278
1279 #[test]
1280 fn test_localization_error_from_io_error() {
1281 let io_error = std::io::Error::new(std::io::ErrorKind::NotFound, "File not found");
1282 let loc_error = LocalizationError::from(io_error);
1283
1284 match loc_error {
1285 LocalizationError::Io { source: _, path } => {
1286 assert_eq!(path, PathBuf::from("<unknown>"));
1287 }
1288 _ => panic!("Expected IO error variant"),
1289 }
1290 }
1291
1292 #[test]
1293 fn test_localization_error_uerror_impl() {
1294 let error = LocalizationError::Bundle("some error".to_string());
1295 assert_eq!(error.code(), 1);
1296 }
1297
1298 #[test]
1299 fn test_get_message_not_initialized() {
1300 std::thread::spawn(|| {
1301 let message = get_message("greeting");
1302 assert_eq!(message, "greeting"); })
1304 .join()
1305 .unwrap();
1306 }
1307
1308 #[test]
1309 fn test_detect_system_locale_from_lang_env() {
1310 let locale_with_encoding = "fr-FR.UTF-8";
1315 let parsed = locale_with_encoding.split('.').next().unwrap();
1316 let lang_id = LanguageIdentifier::from_str(parsed).unwrap();
1317 assert_eq!(lang_id.to_string(), "fr-FR");
1318
1319 let locale_without_encoding = "es-ES";
1321 let lang_id = LanguageIdentifier::from_str(locale_without_encoding).unwrap();
1322 assert_eq!(lang_id.to_string(), "es-ES");
1323
1324 let default_lang_id = LanguageIdentifier::from_str(DEFAULT_LOCALE).unwrap();
1326 assert_eq!(default_lang_id.to_string(), "en-US");
1327 }
1328
1329 #[test]
1330 fn test_detect_system_locale_no_lang_env() {
1331 let original_lang = env::var("LANG").ok();
1333
1334 unsafe {
1336 env::remove_var("LANG");
1337 }
1338
1339 let result = detect_system_locale();
1340 assert!(result.is_ok());
1341 assert_eq!(result.unwrap().to_string(), "en-US");
1342
1343 if let Some(val) = original_lang {
1345 unsafe {
1346 env::set_var("LANG", val);
1347 }
1348 } else {
1349 {} }
1351 }
1352
1353 #[test]
1354 fn test_setup_localization_success() {
1355 std::thread::spawn(|| {
1356 let original_lang = env::var("LANG").ok();
1358 unsafe {
1359 env::set_var("LANG", "en-US.UTF-8"); }
1361
1362 let result = setup_localization("test");
1363 assert!(result.is_ok());
1364
1365 let message = get_message("test-about");
1367 assert!(!message.is_empty());
1369
1370 if let Some(val) = original_lang {
1372 unsafe {
1373 env::set_var("LANG", val);
1374 }
1375 } else {
1376 unsafe {
1377 env::remove_var("LANG");
1378 }
1379 }
1380 })
1381 .join()
1382 .unwrap();
1383 }
1384
1385 #[test]
1386 fn test_setup_localization_falls_back_to_english() {
1387 std::thread::spawn(|| {
1388 let original_lang = env::var("LANG").ok();
1390 unsafe {
1391 env::set_var("LANG", "de-DE.UTF-8"); }
1393
1394 let result = setup_localization("test");
1395 assert!(result.is_ok());
1396
1397 let message = get_message("test-about");
1399 assert!(!message.is_empty()); if let Some(val) = original_lang {
1403 unsafe {
1404 env::set_var("LANG", val);
1405 }
1406 } else {
1407 unsafe {
1408 env::remove_var("LANG");
1409 }
1410 }
1411 })
1412 .join()
1413 .unwrap();
1414 }
1415
1416 #[test]
1417 fn test_setup_localization_fallback_to_embedded() {
1418 std::thread::spawn(|| {
1419 unsafe {
1421 env::set_var("LANG", "en-US");
1422 }
1423
1424 let result = setup_localization("test");
1427 if let Err(e) = &result {
1428 eprintln!("Setup localization failed: {e}");
1429 }
1430 assert!(result.is_ok());
1431
1432 let message = get_message("test-about");
1434 assert_eq!(message, "Check file types and compare values."); })
1436 .join()
1437 .unwrap();
1438 }
1439
1440 #[test]
1441 fn test_error_display() {
1442 let io_error = LocalizationError::Io {
1443 source: std::io::Error::new(std::io::ErrorKind::NotFound, "File not found"),
1444 path: PathBuf::from("/test/path.ftl"),
1445 };
1446 let error_string = format!("{io_error}");
1447 assert!(error_string.contains("I/O error loading"));
1448 assert!(error_string.contains("/test/path.ftl"));
1449
1450 let bundle_error = LocalizationError::Bundle("Bundle creation failed".to_string());
1451 let bundle_string = format!("{bundle_error}");
1452 assert!(bundle_string.contains("Bundle error: Bundle creation failed"));
1453 }
1454
1455 #[test]
1456 fn test_clap_localization_fallbacks() {
1457 std::thread::spawn(|| {
1458 let error_msg = get_message("common-error");
1463 assert_eq!(error_msg, "common-error"); let tip_msg = get_message("common-tip");
1466 assert_eq!(tip_msg, "common-tip"); let result = setup_localization("comm");
1470 if result.is_err() {
1471 let _ = setup_localization("test");
1473 }
1474
1475 let error_after_init = get_message("common-error");
1477 assert!(!error_after_init.is_empty());
1479
1480 let tip_after_init = get_message("common-tip");
1481 assert!(!tip_after_init.is_empty());
1482
1483 let unknown_arg_key = get_message("clap-error-unexpected-argument");
1485 assert!(!unknown_arg_key.is_empty());
1486
1487 let usage_key = get_message("common-usage");
1489 assert!(!usage_key.is_empty());
1490 })
1491 .join()
1492 .unwrap();
1493 }
1494}
1495
1496#[cfg(all(test, not(debug_assertions)))]
1497mod fhs_tests {
1498 use super::*;
1499 use tempfile::TempDir;
1500
1501 #[test]
1502 fn resolves_fhs_share_locales_layout() {
1503 let prefix = TempDir::new().unwrap(); let bin_dir = prefix.path().join("bin"); let share_dir = prefix.path().join("share").join("locales").join("cut"); std::fs::create_dir_all(&share_dir).unwrap();
1508 std::fs::create_dir_all(&bin_dir).unwrap();
1509
1510 let exe_dir = bin_dir.as_path();
1512
1513 let result = resolve_locales_dir_from_exe_dir(exe_dir, "cut")
1515 .expect("should find locales via FHS path");
1516
1517 assert_eq!(result, share_dir);
1518 }
1519}