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 if cfg!(not(test)) {
234 if let Some(res) = cache.get() {
235 return Ok(res);
236 }
237 }
238
239 let resource = FluentResource::try_new(content.to_string()).map_err(
240 |(_partial_resource, errs): (FluentResource, Vec<ParserError>)| {
241 if let Some(first_err) = errs.into_iter().next() {
242 let snippet = first_err
243 .slice
244 .clone()
245 .and_then(|range| content.get(range))
246 .unwrap_or("")
247 .to_string();
248 LocalizationError::ParseResource {
249 error: first_err,
250 snippet,
251 }
252 } else {
253 LocalizationError::LocalesDirNotFound("Parse error without details".to_string())
254 }
255 },
256 )?;
257 if cfg!(not(test)) {
259 Ok(cache.get_or_init(|| resource))
260 } else {
261 Ok(Box::leak(Box::new(resource)))
262 }
263}
264
265fn create_english_bundle_from_embedded(
267 locale: &LanguageIdentifier,
268 util_name: &str,
269) -> Result<FluentBundle<&'static FluentResource>, LocalizationError> {
270 if *locale != "en-US" {
272 return Err(LocalizationError::LocalesDirNotFound(
273 "Embedded locales only support en-US".to_string(),
274 ));
275 }
276
277 let mut bundle: FluentBundle<&'static FluentResource> = FluentBundle::new(vec![locale.clone()]);
278 bundle.set_use_isolating(false);
279
280 if let Some(uucore_content) = get_embedded_locale("uucore/en-US.ftl") {
282 let uucore_resource = parse_fluent_resource(uucore_content, &UUCORE_FLUENT)?;
283 bundle.add_resource_overriding(uucore_resource);
284 }
285
286 if util_name.ends_with("sum") {
288 if let Some(uucore_content) = get_embedded_locale("checksum_common/en-US.ftl") {
289 let uucore_resource = parse_fluent_resource(uucore_content, &CHECKSUM_FLUENT)?;
290 bundle.add_resource_overriding(uucore_resource);
291 }
292 }
293
294 let locale_key = format!("{util_name}/en-US.ftl");
296 if let Some(ftl_content) = get_embedded_locale(&locale_key) {
297 let resource = parse_fluent_resource(ftl_content, &UTIL_FLUENT)?;
298 bundle.add_resource_overriding(resource);
299 }
300
301 if bundle.has_message("common-error") || bundle.has_message(&format!("{util_name}-about")) {
303 Ok(bundle)
304 } else {
305 Err(LocalizationError::LocalesDirNotFound(format!(
306 "No embedded locale found for {util_name} and no common strings found"
307 )))
308 }
309}
310
311#[cfg(target_os = "wasi")]
315fn create_wasi_bundle_from_embedded(
316 locale: &LanguageIdentifier,
317 util_name: &str,
318) -> Result<FluentBundle<&'static FluentResource>, LocalizationError> {
319 let locale_str = locale.to_string();
320 let mut bundle: FluentBundle<&'static FluentResource> = FluentBundle::new(vec![locale.clone()]);
321 bundle.set_use_isolating(false);
322
323 let mut try_add = |key: &str| {
324 if let Some(content) = get_embedded_locale(key) {
325 if let Ok(resource) = FluentResource::try_new(content.to_string()) {
326 bundle.add_resource_overriding(Box::leak(Box::new(resource)));
327 }
328 }
329 };
330
331 try_add(&format!("uucore/{locale_str}.ftl"));
332 if util_name.ends_with("sum") {
333 try_add(&format!("checksum_common/{locale_str}.ftl"));
334 }
335 try_add(&format!("{util_name}/{locale_str}.ftl"));
336
337 if bundle.has_message("common-error") || bundle.has_message(&format!("{util_name}-about")) {
338 Ok(bundle)
339 } else {
340 Err(LocalizationError::LocalesDirNotFound(format!(
341 "No embedded locale found for {util_name}/{locale_str}"
342 )))
343 }
344}
345
346fn get_message_internal(id: &str, args: Option<FluentArgs>) -> String {
347 LOCALIZER.with(|lock| {
348 lock.get()
349 .map_or_else(|| id.to_string(), |loc| loc.format(id, args.as_ref())) })
351}
352
353pub fn get_message(id: &str) -> String {
378 get_message_internal(id, None)
379}
380
381pub fn get_message_with_args(id: &str, ftl_args: FluentArgs) -> String {
413 get_message_internal(id, Some(ftl_args))
414}
415
416fn detect_system_locale() -> Result<LanguageIdentifier, LocalizationError> {
418 let locale_str = std::env::var("LANG")
419 .unwrap_or_else(|_| DEFAULT_LOCALE.to_string())
420 .split('.')
421 .next()
422 .unwrap_or(DEFAULT_LOCALE)
423 .to_string();
424 LanguageIdentifier::from_str(&locale_str).map_err(|_| {
425 LocalizationError::ParseLocale(format!("Failed to parse locale: {locale_str}"))
426 })
427}
428
429pub fn setup_localization(p: &str) -> Result<(), LocalizationError> {
467 thread_local! {
469 static LOCALIZER_IS_SET: Cell<bool> = const { Cell::new(false) };
470 }
471 if LOCALIZER_IS_SET.with(Cell::get) {
472 return Ok(());
473 }
474
475 let locale = detect_system_locale().unwrap_or_else(|_| {
476 LanguageIdentifier::from_str(DEFAULT_LOCALE).expect("Default locale should always be valid")
477 });
478
479 if let Ok(locales_dir) = get_locales_dir(p) {
481 init_localization(&locale, &locales_dir, p)?;
483 } else {
484 let default_locale = LanguageIdentifier::from_str(DEFAULT_LOCALE)
486 .expect("Default locale should always be valid");
487
488 #[cfg(target_os = "wasi")]
489 let localizer = {
490 let english_bundle = create_wasi_bundle_from_embedded(&default_locale, p)?;
491 if locale == default_locale {
492 Localizer::new(english_bundle)
493 } else if let Ok(localized) = create_wasi_bundle_from_embedded(&locale, p) {
494 Localizer::new(localized).with_fallback(english_bundle)
495 } else {
496 Localizer::new(english_bundle)
497 }
498 };
499
500 #[cfg(not(target_os = "wasi"))]
501 let localizer = {
502 let english_bundle = create_english_bundle_from_embedded(&default_locale, p)?;
503 Localizer::new(english_bundle)
504 };
505
506 LOCALIZER.with(|lock| {
507 lock.set(localizer)
508 .map_err(|_| LocalizationError::Bundle("Localizer already initialized".into()))
509 })?;
510 }
511 LOCALIZER_IS_SET.with(|f| f.set(true));
512 Ok(())
513}
514
515#[cfg(not(debug_assertions))]
516fn resolve_locales_dir_from_exe_dir(exe_dir: &Path, p: &str) -> Option<PathBuf> {
517 let coreutils = exe_dir.join("locales").join(p);
519 if coreutils.exists() {
520 return Some(coreutils);
521 }
522
523 if let Some(prefix) = exe_dir.parent() {
525 let fhs = prefix.join("share").join("locales").join(p);
526 if fhs.exists() {
527 return Some(fhs);
528 }
529 }
530
531 let fallback = exe_dir.join(p);
533 if fallback.exists() {
534 return Some(fallback);
535 }
536
537 None
538}
539
540fn get_locales_dir(p: &str) -> Result<PathBuf, LocalizationError> {
542 #[cfg(debug_assertions)]
543 {
544 let manifest_dir = env!("CARGO_MANIFEST_DIR");
546 let dev_path = PathBuf::from(manifest_dir)
548 .join("../uu")
549 .join(p)
550 .join("locales");
551
552 if dev_path.exists() {
553 return Ok(dev_path);
554 }
555
556 let fallback_dev_path = PathBuf::from(manifest_dir).join(p);
558 if fallback_dev_path.exists() {
559 return Ok(fallback_dev_path);
560 }
561
562 Err(LocalizationError::LocalesDirNotFound(format!(
563 "Development locales directory not found at {} or {}",
564 dev_path.quote(),
565 fallback_dev_path.quote()
566 )))
567 }
568
569 #[cfg(not(debug_assertions))]
570 {
571 use std::env;
572 let exe_path = env::current_exe().map_err(|e| {
574 LocalizationError::PathResolution(format!("Failed to get executable path: {e}"))
575 })?;
576
577 let exe_dir = exe_path.parent().ok_or_else(|| {
578 LocalizationError::PathResolution("Failed to get executable directory".to_string())
579 })?;
580
581 if let Some(dir) = resolve_locales_dir_from_exe_dir(exe_dir, p) {
582 return Ok(dir);
583 }
584
585 Err(LocalizationError::LocalesDirNotFound(format!(
586 "Release locales directory not found starting from {}",
587 exe_dir.quote()
588 )))
589 }
590}
591
592#[macro_export]
626macro_rules! translate {
627 ($id:expr) => {
629 $crate::locale::get_message($id)
630 };
631
632 ($id:expr, $($key:expr => $value:expr),+ $(,)?) => {
634 {
635 let mut args = fluent::FluentArgs::new();
636 $(
637 let value_str = $value.to_string();
638 if let Ok(num_val) = value_str.parse::<i64>() {
639 args.set($key, num_val);
640 } else if let Ok(float_val) = value_str.parse::<f64>() {
641 args.set($key, float_val);
642 } else {
643 args.set($key, value_str);
645 }
646 )+
647 $crate::locale::get_message_with_args($id, args)
648 }
649 };
650}
651
652pub use translate;
654
655#[cfg(test)]
656mod tests {
657 use super::*;
658 use std::env;
659 use std::fs;
660 use std::path::PathBuf;
661 use tempfile::TempDir;
662
663 #[cfg(test)]
665 fn create_test_bundle(
666 locale: &LanguageIdentifier,
667 test_locales_dir: &Path,
668 ) -> Result<FluentBundle<&'static FluentResource>, LocalizationError> {
669 let mut bundle: FluentBundle<&'static FluentResource> =
670 FluentBundle::new(vec![locale.clone()]);
671 bundle.set_use_isolating(false);
672
673 let locale_path = test_locales_dir.join(format!("{locale}.ftl"));
675 if let Ok(ftl_content) = fs::read_to_string(&locale_path) {
676 let resource = parse_fluent_resource(&ftl_content, &UUCORE_FLUENT)?;
677 bundle.add_resource_overriding(resource);
678 return Ok(bundle);
679 }
680
681 Err(LocalizationError::LocalesDirNotFound(format!(
682 "No localization strings found for {locale} in {}",
683 test_locales_dir.quote()
684 )))
685 }
686
687 #[cfg(test)]
689 fn init_test_localization(
690 locale: &LanguageIdentifier,
691 test_locales_dir: &Path,
692 ) -> Result<(), LocalizationError> {
693 let default_locale = LanguageIdentifier::from_str(DEFAULT_LOCALE)
694 .expect("Default locale should always be valid");
695
696 let english_bundle = create_test_bundle(&default_locale, test_locales_dir)?;
698
699 let loc = if locale == &default_locale {
700 Localizer::new(english_bundle)
702 } else {
703 if let Ok(primary_bundle) = create_test_bundle(locale, test_locales_dir) {
705 Localizer::new(primary_bundle).with_fallback(english_bundle)
707 } else {
708 Localizer::new(english_bundle)
710 }
711 };
712
713 LOCALIZER.with(|lock| {
714 lock.set(loc)
715 .map_err(|_| LocalizationError::Bundle("Localizer already initialized".into()))
716 })?;
717 Ok(())
718 }
719
720 fn create_test_locales_dir() -> TempDir {
722 let temp_dir = TempDir::new().expect("Failed to create temp directory");
723
724 let en_content = r"
726greeting = Hello, world!
727welcome = Welcome, { $name }!
728count-items = You have { $count ->
729 [one] { $count } item
730 *[other] { $count } items
731}
732missing-in-other = This message only exists in English
733";
734
735 let fr_content = r"
737greeting = Bonjour, le monde!
738welcome = Bienvenue, { $name }!
739count-items = Vous avez { $count ->
740 [one] { $count } élément
741 *[other] { $count } éléments
742}
743";
744
745 let ja_content = r"
747greeting = こんにちは、世界!
748welcome = ようこそ、{ $name }さん!
749count-items = { $count }個のアイテムがあります
750";
751
752 let ar_content = r"
754greeting = أهلاً بالعالم!
755welcome = أهلاً وسهلاً، { $name }!
756count-items = لديك { $count ->
757 [zero] لا عناصر
758 [one] عنصر واحد
759 [two] عنصران
760 [few] { $count } عناصر
761 *[other] { $count } عنصر
762}
763";
764
765 let es_invalid_content = r"
767greeting = Hola, mundo!
768invalid-syntax = This is { $missing
769";
770
771 fs::write(temp_dir.path().join("en-US.ftl"), en_content)
772 .expect("Failed to write en-US.ftl");
773 fs::write(temp_dir.path().join("fr-FR.ftl"), fr_content)
774 .expect("Failed to write fr-FR.ftl");
775 fs::write(temp_dir.path().join("ja-JP.ftl"), ja_content)
776 .expect("Failed to write ja-JP.ftl");
777 fs::write(temp_dir.path().join("ar-SA.ftl"), ar_content)
778 .expect("Failed to write ar-SA.ftl");
779 fs::write(temp_dir.path().join("es-ES.ftl"), es_invalid_content)
780 .expect("Failed to write es-ES.ftl");
781
782 temp_dir
783 }
784
785 #[test]
786 fn test_create_bundle_success() {
787 let temp_dir = create_test_locales_dir();
788 let locale = LanguageIdentifier::from_str("en-US").unwrap();
789
790 let result = create_test_bundle(&locale, temp_dir.path());
791 assert!(result.is_ok());
792
793 let bundle = result.unwrap();
794 assert!(bundle.get_message("greeting").is_some());
795 }
796
797 #[test]
798 fn test_create_bundle_file_not_found() {
799 let temp_dir = TempDir::new().unwrap();
800 let locale = LanguageIdentifier::from_str("de-DE").unwrap();
801
802 let result = create_test_bundle(&locale, temp_dir.path());
803 assert!(result.is_err());
804
805 if let Err(LocalizationError::LocalesDirNotFound(_)) = result {
806 } else {
808 panic!("Expected LocalesDirNotFound error");
809 }
810 }
811
812 #[test]
813 fn test_create_bundle_invalid_syntax() {
814 let temp_dir = create_test_locales_dir();
815 let locale = LanguageIdentifier::from_str("es-ES").unwrap();
816
817 let result = create_test_bundle(&locale, temp_dir.path());
818
819 match result {
821 Err(LocalizationError::ParseResource {
822 error: _parser_err,
823 snippet: _,
824 }) => {
825 }
827 Ok(_) => {
828 panic!("Expected ParseResource error, but bundle was created successfully");
829 }
830 Err(other) => {
831 panic!("Expected ParseResource error, but got: {other:?}");
832 }
833 }
834 }
835
836 #[test]
837 fn test_localizer_format_primary_bundle() {
838 let temp_dir = create_test_locales_dir();
839 let en_bundle: FluentBundle<&'static FluentResource> = create_test_bundle(
840 &LanguageIdentifier::from_str("en-US").unwrap(),
841 temp_dir.path(),
842 )
843 .unwrap();
844
845 let localizer = Localizer::new(en_bundle);
846 let result = localizer.format("greeting", None);
847 assert_eq!(result, "Hello, world!");
848 }
849
850 #[test]
851 fn test_localizer_format_with_args() {
852 use fluent::FluentArgs;
853 let temp_dir = create_test_locales_dir();
854 let en_bundle = create_test_bundle(
855 &LanguageIdentifier::from_str("en-US").unwrap(),
856 temp_dir.path(),
857 )
858 .unwrap();
859
860 let localizer = Localizer::new(en_bundle);
861 let mut args = FluentArgs::new();
862 args.set("name", "Alice");
863
864 let result = localizer.format("welcome", Some(&args));
865 assert_eq!(result, "Welcome, Alice!");
866 }
867
868 #[test]
869 fn test_localizer_fallback_to_english() {
870 let temp_dir = create_test_locales_dir();
871 let fr_bundle = create_test_bundle(
872 &LanguageIdentifier::from_str("fr-FR").unwrap(),
873 temp_dir.path(),
874 )
875 .unwrap();
876 let en_bundle = create_test_bundle(
877 &LanguageIdentifier::from_str("en-US").unwrap(),
878 temp_dir.path(),
879 )
880 .unwrap();
881
882 let localizer = Localizer::new(fr_bundle).with_fallback(en_bundle);
883
884 let result1 = localizer.format("greeting", None);
886 assert_eq!(result1, "Bonjour, le monde!");
887
888 let result2 = localizer.format("missing-in-other", None);
890 assert_eq!(result2, "This message only exists in English");
891 }
892
893 #[test]
894 fn test_localizer_format_message_not_found() {
895 let temp_dir = create_test_locales_dir();
896 let en_bundle = create_test_bundle(
897 &LanguageIdentifier::from_str("en-US").unwrap(),
898 temp_dir.path(),
899 )
900 .unwrap();
901
902 let localizer = Localizer::new(en_bundle);
903 let result = localizer.format("nonexistent-message", None);
904 assert_eq!(result, "nonexistent-message");
905 }
906
907 #[test]
908 fn test_init_localization_english_only() {
909 std::thread::spawn(|| {
911 let temp_dir = create_test_locales_dir();
912 let locale = LanguageIdentifier::from_str("en-US").unwrap();
913
914 let result = init_test_localization(&locale, temp_dir.path());
915 assert!(result.is_ok());
916
917 let message = get_message("greeting");
919 assert_eq!(message, "Hello, world!");
920 })
921 .join()
922 .unwrap();
923 }
924
925 #[test]
926 fn test_init_localization_with_fallback() {
927 std::thread::spawn(|| {
928 let temp_dir = create_test_locales_dir();
929 let locale = LanguageIdentifier::from_str("fr-FR").unwrap();
930
931 let result = init_test_localization(&locale, temp_dir.path());
932 assert!(result.is_ok());
933
934 let message1 = get_message("greeting");
936 assert_eq!(message1, "Bonjour, le monde!");
937
938 let message2 = get_message("missing-in-other");
940 assert_eq!(message2, "This message only exists in English");
941 })
942 .join()
943 .unwrap();
944 }
945
946 #[test]
947 fn test_init_localization_invalid_locale_falls_back_to_english() {
948 std::thread::spawn(|| {
949 let temp_dir = create_test_locales_dir();
950 let locale = LanguageIdentifier::from_str("de-DE").unwrap(); let result = init_test_localization(&locale, temp_dir.path());
953 assert!(result.is_ok());
954
955 let message = get_message("greeting");
957 assert_eq!(message, "Hello, world!");
958 })
959 .join()
960 .unwrap();
961 }
962
963 #[test]
964 fn test_init_localization_already_initialized() {
965 std::thread::spawn(|| {
966 let temp_dir = create_test_locales_dir();
967 let locale = LanguageIdentifier::from_str("en-US").unwrap();
968
969 let result1 = init_test_localization(&locale, temp_dir.path());
971 assert!(result1.is_ok());
972
973 let result2 = init_test_localization(&locale, temp_dir.path());
975 assert!(result2.is_err());
976
977 match result2 {
978 Err(LocalizationError::Bundle(msg)) => {
979 assert!(msg.contains("already initialized"));
980 }
981 _ => panic!("Expected Bundle error"),
982 }
983 })
984 .join()
985 .unwrap();
986 }
987
988 #[test]
989 fn test_get_message() {
990 std::thread::spawn(|| {
991 let temp_dir = create_test_locales_dir();
992 let locale = LanguageIdentifier::from_str("fr-FR").unwrap();
993
994 init_test_localization(&locale, temp_dir.path()).unwrap();
995
996 let message = get_message("greeting");
997 assert_eq!(message, "Bonjour, le monde!");
998 })
999 .join()
1000 .unwrap();
1001 }
1002
1003 #[test]
1004 fn test_get_message_with_args() {
1005 use fluent::FluentArgs;
1006 std::thread::spawn(|| {
1007 let temp_dir = create_test_locales_dir();
1008 let locale = LanguageIdentifier::from_str("en-US").unwrap();
1009
1010 init_test_localization(&locale, temp_dir.path()).unwrap();
1011
1012 let mut args = FluentArgs::new();
1013 args.set("name".to_string(), "Bob".to_string());
1014
1015 let message = get_message_with_args("welcome", args);
1016 assert_eq!(message, "Welcome, Bob!");
1017 })
1018 .join()
1019 .unwrap();
1020 }
1021
1022 #[test]
1023 fn test_get_message_with_args_pluralization() {
1024 use fluent::FluentArgs;
1025 std::thread::spawn(|| {
1026 let temp_dir = create_test_locales_dir();
1027 let locale = LanguageIdentifier::from_str("en-US").unwrap();
1028
1029 init_test_localization(&locale, temp_dir.path()).unwrap();
1030
1031 let mut args1 = FluentArgs::new();
1033 args1.set("count", 1);
1034 let message1 = get_message_with_args("count-items", args1);
1035 assert_eq!(message1, "You have 1 item");
1036
1037 let mut args2 = FluentArgs::new();
1039 args2.set("count", 5);
1040 let message2 = get_message_with_args("count-items", args2);
1041 assert_eq!(message2, "You have 5 items");
1042 })
1043 .join()
1044 .unwrap();
1045 }
1046
1047 #[test]
1048 fn test_thread_local_isolation() {
1049 use std::thread;
1050
1051 let temp_dir = create_test_locales_dir();
1052
1053 let temp_path_main = temp_dir.path().to_path_buf();
1055 let main_handle = thread::spawn(move || {
1056 let locale = LanguageIdentifier::from_str("fr-FR").unwrap();
1057 init_test_localization(&locale, &temp_path_main).unwrap();
1058 let main_message = get_message("greeting");
1059 assert_eq!(main_message, "Bonjour, le monde!");
1060 });
1061 main_handle.join().unwrap();
1062
1063 let temp_path = temp_dir.path().to_path_buf();
1065 let handle = thread::spawn(move || {
1066 let thread_message = get_message("greeting");
1068 assert_eq!(thread_message, "greeting"); let en_locale = LanguageIdentifier::from_str("en-US").unwrap();
1072 init_test_localization(&en_locale, &temp_path).unwrap();
1073 let thread_message_after_init = get_message("greeting");
1074 assert_eq!(thread_message_after_init, "Hello, world!");
1075 });
1076
1077 handle.join().unwrap();
1078
1079 let final_handle = thread::spawn(move || {
1081 let final_message = get_message("greeting");
1083 assert_eq!(final_message, "greeting");
1084 });
1085 final_handle.join().unwrap();
1086 }
1087
1088 #[test]
1089 fn test_japanese_localization() {
1090 use fluent::FluentArgs;
1091 std::thread::spawn(|| {
1092 let temp_dir = create_test_locales_dir();
1093 let locale = LanguageIdentifier::from_str("ja-JP").unwrap();
1094
1095 let result = init_test_localization(&locale, temp_dir.path());
1096 assert!(result.is_ok());
1097
1098 let message = get_message("greeting");
1100 assert_eq!(message, "こんにちは、世界!");
1101
1102 let mut args = FluentArgs::new();
1104 args.set("name".to_string(), "田中".to_string());
1105 let welcome = get_message_with_args("welcome", args);
1106 assert_eq!(welcome, "ようこそ、田中さん!");
1107
1108 let mut count_args = FluentArgs::new();
1110 count_args.set("count".to_string(), "5".to_string());
1111 let count_message = get_message_with_args("count-items", count_args);
1112 assert_eq!(count_message, "5個のアイテムがあります");
1113 })
1114 .join()
1115 .unwrap();
1116 }
1117
1118 #[test]
1119 fn test_arabic_localization() {
1120 use fluent::FluentArgs;
1121 std::thread::spawn(|| {
1122 let temp_dir = create_test_locales_dir();
1123 let locale = LanguageIdentifier::from_str("ar-SA").unwrap();
1124
1125 let result = init_test_localization(&locale, temp_dir.path());
1126 assert!(result.is_ok());
1127
1128 let message = get_message("greeting");
1130 assert_eq!(message, "أهلاً بالعالم!");
1131
1132 let mut args = FluentArgs::new();
1134 args.set("name", "أحمد".to_string());
1135 let welcome = get_message_with_args("welcome", args);
1136 assert_eq!(welcome, "أهلاً وسهلاً، أحمد!");
1137
1138 let mut args_zero = FluentArgs::new();
1140 args_zero.set("count", 0);
1141 let message_zero = get_message_with_args("count-items", args_zero);
1142 assert_eq!(message_zero, "لديك لا عناصر");
1143
1144 let mut args_one = FluentArgs::new();
1146 args_one.set("count", 1);
1147 let message_one = get_message_with_args("count-items", args_one);
1148 assert_eq!(message_one, "لديك عنصر واحد");
1149
1150 let mut args_two = FluentArgs::new();
1152 args_two.set("count", 2);
1153 let message_two = get_message_with_args("count-items", args_two);
1154 assert_eq!(message_two, "لديك عنصران");
1155
1156 let mut args_few = FluentArgs::new();
1158 args_few.set("count", 5);
1159 let message_few = get_message_with_args("count-items", args_few);
1160 assert_eq!(message_few, "لديك 5 عناصر");
1161
1162 let mut args_many = FluentArgs::new();
1164 args_many.set("count", 15);
1165 let message_many = get_message_with_args("count-items", args_many);
1166 assert_eq!(message_many, "لديك 15 عنصر");
1167 })
1168 .join()
1169 .unwrap();
1170 }
1171
1172 #[test]
1173 fn test_arabic_localization_with_macro() {
1174 std::thread::spawn(|| {
1175 let temp_dir = create_test_locales_dir();
1176 let locale = LanguageIdentifier::from_str("ar-SA").unwrap();
1177
1178 let result = init_test_localization(&locale, temp_dir.path());
1179 assert!(result.is_ok());
1180
1181 let message = translate!("greeting");
1183 assert_eq!(message, "أهلاً بالعالم!");
1184
1185 let welcome = translate!("welcome", "name" => "أحمد");
1187 assert_eq!(welcome, "أهلاً وسهلاً، أحمد!");
1188
1189 let message_zero = translate!("count-items", "count" => 0);
1191 assert_eq!(message_zero, "لديك لا عناصر");
1192
1193 let message_one = translate!("count-items", "count" => 1);
1195 assert_eq!(message_one, "لديك عنصر واحد");
1196
1197 let message_two = translate!("count-items", "count" => 2);
1199 assert_eq!(message_two, "لديك عنصران");
1200
1201 let message_few = translate!("count-items", "count" => 5);
1203 assert_eq!(message_few, "لديك 5 عناصر");
1204
1205 let message_many = translate!("count-items", "count" => 15);
1207 assert_eq!(message_many, "لديك 15 عنصر");
1208 })
1209 .join()
1210 .unwrap();
1211 }
1212
1213 #[test]
1214 fn test_mixed_script_fallback() {
1215 std::thread::spawn(|| {
1216 let temp_dir = create_test_locales_dir();
1217 let locale = LanguageIdentifier::from_str("ar-SA").unwrap();
1218
1219 let result = init_test_localization(&locale, temp_dir.path());
1220 assert!(result.is_ok());
1221
1222 let arabic_message = get_message("greeting");
1224 assert_eq!(arabic_message, "أهلاً بالعالم!");
1225
1226 let fallback_message = get_message("missing-in-other");
1228 assert_eq!(fallback_message, "This message only exists in English");
1229 })
1230 .join()
1231 .unwrap();
1232 }
1233
1234 #[test]
1235 fn test_unicode_directional_isolation_disabled() {
1236 use fluent::FluentArgs;
1237 std::thread::spawn(|| {
1238 let temp_dir = create_test_locales_dir();
1239 let locale = LanguageIdentifier::from_str("ar-SA").unwrap();
1240
1241 init_test_localization(&locale, temp_dir.path()).unwrap();
1242
1243 let mut args = FluentArgs::new();
1246 args.set("name".to_string(), "John Smith".to_string());
1247 let message = get_message_with_args("welcome", args);
1248
1249 assert!(!message.contains("\u{2068}John Smith\u{2069}"));
1251 assert_eq!(message, "أهلاً وسهلاً، John Smith!");
1252 })
1253 .join()
1254 .unwrap();
1255 }
1256
1257 #[test]
1258 fn test_parse_resource_error_includes_snippet() {
1259 let temp_dir = create_test_locales_dir();
1260 let locale = LanguageIdentifier::from_str("es-ES").unwrap();
1261
1262 let result = create_test_bundle(&locale, temp_dir.path());
1263 assert!(result.is_err());
1264
1265 if let Err(LocalizationError::ParseResource {
1266 error: _err,
1267 snippet,
1268 }) = result
1269 {
1270 assert!(
1272 snippet.contains("This is { $missing"),
1273 "snippet was `{snippet}` but did not include the invalid text"
1274 );
1275 } else {
1276 panic!("Expected LocalizationError::ParseResource with snippet");
1277 }
1278 }
1279
1280 #[test]
1281 fn test_localization_error_from_io_error() {
1282 let io_error = std::io::Error::new(std::io::ErrorKind::NotFound, "File not found");
1283 let loc_error = LocalizationError::from(io_error);
1284
1285 match loc_error {
1286 LocalizationError::Io { source: _, path } => {
1287 assert_eq!(path, PathBuf::from("<unknown>"));
1288 }
1289 _ => panic!("Expected IO error variant"),
1290 }
1291 }
1292
1293 #[test]
1294 fn test_localization_error_uerror_impl() {
1295 let error = LocalizationError::Bundle("some error".to_string());
1296 assert_eq!(error.code(), 1);
1297 }
1298
1299 #[test]
1300 fn test_get_message_not_initialized() {
1301 std::thread::spawn(|| {
1302 let message = get_message("greeting");
1303 assert_eq!(message, "greeting"); })
1305 .join()
1306 .unwrap();
1307 }
1308
1309 #[test]
1310 fn test_detect_system_locale_from_lang_env() {
1311 let locale_with_encoding = "fr-FR.UTF-8";
1316 let parsed = locale_with_encoding.split('.').next().unwrap();
1317 let lang_id = LanguageIdentifier::from_str(parsed).unwrap();
1318 assert_eq!(lang_id.to_string(), "fr-FR");
1319
1320 let locale_without_encoding = "es-ES";
1322 let lang_id = LanguageIdentifier::from_str(locale_without_encoding).unwrap();
1323 assert_eq!(lang_id.to_string(), "es-ES");
1324
1325 let default_lang_id = LanguageIdentifier::from_str(DEFAULT_LOCALE).unwrap();
1327 assert_eq!(default_lang_id.to_string(), "en-US");
1328 }
1329
1330 #[test]
1331 fn test_detect_system_locale_no_lang_env() {
1332 let original_lang = env::var("LANG").ok();
1334
1335 unsafe {
1337 env::remove_var("LANG");
1338 }
1339
1340 let result = detect_system_locale();
1341 assert!(result.is_ok());
1342 assert_eq!(result.unwrap().to_string(), "en-US");
1343
1344 if let Some(val) = original_lang {
1346 unsafe {
1347 env::set_var("LANG", val);
1348 }
1349 } else {
1350 {} }
1352 }
1353
1354 #[test]
1355 fn test_setup_localization_success() {
1356 std::thread::spawn(|| {
1357 let original_lang = env::var("LANG").ok();
1359 unsafe {
1360 env::set_var("LANG", "en-US.UTF-8"); }
1362
1363 let result = setup_localization("test");
1364 assert!(result.is_ok());
1365
1366 let message = get_message("test-about");
1368 assert!(!message.is_empty());
1370
1371 if let Some(val) = original_lang {
1373 unsafe {
1374 env::set_var("LANG", val);
1375 }
1376 } else {
1377 unsafe {
1378 env::remove_var("LANG");
1379 }
1380 }
1381 })
1382 .join()
1383 .unwrap();
1384 }
1385
1386 #[test]
1387 fn test_setup_localization_falls_back_to_english() {
1388 std::thread::spawn(|| {
1389 let original_lang = env::var("LANG").ok();
1391 unsafe {
1392 env::set_var("LANG", "de-DE.UTF-8"); }
1394
1395 let result = setup_localization("test");
1396 assert!(result.is_ok());
1397
1398 let message = get_message("test-about");
1400 assert!(!message.is_empty()); if let Some(val) = original_lang {
1404 unsafe {
1405 env::set_var("LANG", val);
1406 }
1407 } else {
1408 unsafe {
1409 env::remove_var("LANG");
1410 }
1411 }
1412 })
1413 .join()
1414 .unwrap();
1415 }
1416
1417 #[test]
1418 fn test_setup_localization_fallback_to_embedded() {
1419 std::thread::spawn(|| {
1420 unsafe {
1422 env::set_var("LANG", "en-US");
1423 }
1424
1425 let result = setup_localization("test");
1428 if let Err(e) = &result {
1429 eprintln!("Setup localization failed: {e}");
1430 }
1431 assert!(result.is_ok());
1432
1433 let message = get_message("test-about");
1435 assert_eq!(message, "Check file types and compare values."); })
1437 .join()
1438 .unwrap();
1439 }
1440
1441 #[test]
1442 fn test_error_display() {
1443 let io_error = LocalizationError::Io {
1444 source: std::io::Error::new(std::io::ErrorKind::NotFound, "File not found"),
1445 path: PathBuf::from("/test/path.ftl"),
1446 };
1447 let error_string = format!("{io_error}");
1448 assert!(error_string.contains("I/O error loading"));
1449 assert!(error_string.contains("/test/path.ftl"));
1450
1451 let bundle_error = LocalizationError::Bundle("Bundle creation failed".to_string());
1452 let bundle_string = format!("{bundle_error}");
1453 assert!(bundle_string.contains("Bundle error: Bundle creation failed"));
1454 }
1455
1456 #[test]
1457 fn test_clap_localization_fallbacks() {
1458 std::thread::spawn(|| {
1459 let error_msg = get_message("common-error");
1464 assert_eq!(error_msg, "common-error"); let tip_msg = get_message("common-tip");
1467 assert_eq!(tip_msg, "common-tip"); let result = setup_localization("comm");
1471 if result.is_err() {
1472 let _ = setup_localization("test");
1474 }
1475
1476 let error_after_init = get_message("common-error");
1478 assert!(!error_after_init.is_empty());
1480
1481 let tip_after_init = get_message("common-tip");
1482 assert!(!tip_after_init.is_empty());
1483
1484 let unknown_arg_key = get_message("clap-error-unexpected-argument");
1486 assert!(!unknown_arg_key.is_empty());
1487
1488 let usage_key = get_message("common-usage");
1490 assert!(!usage_key.is_empty());
1491 })
1492 .join()
1493 .unwrap();
1494 }
1495}
1496
1497#[cfg(all(test, not(debug_assertions)))]
1498mod fhs_tests {
1499 use super::*;
1500 use tempfile::TempDir;
1501
1502 #[test]
1503 fn resolves_fhs_share_locales_layout() {
1504 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();
1509 std::fs::create_dir_all(&bin_dir).unwrap();
1510
1511 let exe_dir = bin_dir.as_path();
1513
1514 let result = resolve_locales_dir_from_exe_dir(exe_dir, "cut")
1516 .expect("should find locales via FHS path");
1517
1518 assert_eq!(result, share_dir);
1519 }
1520}