Skip to main content

uucore/mods/
locale.rs

1// This file is part of the uutils coreutils package.
2//
3// For the full copyright and license information, please view the LICENSE
4// file that was distributed with this source code.
5// spell-checker:disable
6
7use 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
54// Add a generic way to convert LocalizationError to UError
55impl UError for LocalizationError {
56    fn code(&self) -> i32 {
57        1
58    }
59}
60
61pub const DEFAULT_LOCALE: &str = "en-US";
62
63// Include embedded locale files as fallback
64include!(concat!(env!("OUT_DIR"), "/embedded_locales.rs"));
65
66// A struct to handle localization with optional English fallback
67struct 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        // Try primary bundle first
87        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        // Fall back to English bundle if available
96        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        // Return the key ID if not found anywhere
106        id.to_string()
107    }
108}
109
110// Cache localizer. FluentResource cannot be shared between threads while FluentBundle can be shared
111static 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
118/// Helper function to find the uucore locales directory from a utility's locales directory
119fn find_uucore_locales_dir(utility_locales_dir: &Path) -> Option<PathBuf> {
120    // Normalize the path to get absolute path
121    let normalized_dir = utility_locales_dir
122        .canonicalize()
123        .unwrap_or_else(|_| utility_locales_dir.to_path_buf());
124
125    // Walk up: locales -> printenv -> uu -> src
126    let uucore_locales = normalized_dir
127        .parent()? // printenv
128        .parent()? // uu
129        .parent()? // src
130        .join("uucore")
131        .join("locales");
132
133    // Only return if the directory actually exists
134    uucore_locales.exists().then_some(uucore_locales)
135}
136
137/// Create a bundle that combines common and utility-specific strings
138fn 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    // Disable Unicode directional isolate characters
146    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            // use Box::leak to provide 'static lifetime for shared FluentBundle between threads
155            bundle.add_resource_overriding(Box::leak(Box::new(resource)));
156        }
157    };
158
159    // Load common strings from uucore locales directory
160    try_add_resource_from(find_uucore_locales_dir(locales_dir));
161    // Then, try to load utility-specific strings from the utility's locale directory
162    try_add_resource_from(get_locales_dir(util_name).ok());
163
164    // checksum binaries also require fluent files from the checksum_common crate
165    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 we have at least one resource, return the bundle
181    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
190/// Initialize localization with common strings in addition to utility-specific strings
191fn 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    // Try to create a bundle that combines common and utility-specific strings
200    let english_bundle: FluentBundle<&'static FluentResource> =
201        create_bundle(&default_locale, locales_dir, util_name).or_else(|_| {
202            // Fallback to embedded utility-specific and common strings
203            create_english_bundle_from_embedded(&default_locale, util_name)
204        })?;
205
206    let loc = if locale == &default_locale {
207        // If requesting English, just use English as primary (no fallback needed)
208        Localizer::new(english_bundle)
209    } else {
210        // Try to load the requested locale with common strings
211        if let Ok(primary_bundle) = create_bundle(locale, locales_dir, util_name) {
212            // Successfully loaded requested locale, load English as fallback
213            Localizer::new(primary_bundle).with_fallback(english_bundle)
214        } else {
215            // Failed to load requested locale, just use English as primary
216            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
227/// Helper function to parse FluentResource from content string
228fn parse_fluent_resource(
229    content: &str,
230    cache: &'static OnceLock<FluentResource>,
231) -> Result<&'static FluentResource, LocalizationError> {
232    // global cache breaks unit tests
233    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    // global cache breaks unit tests
258    if cfg!(not(test)) {
259        Ok(cache.get_or_init(|| resource))
260    } else {
261        Ok(Box::leak(Box::new(resource)))
262    }
263}
264
265/// Create a bundle from embedded English locale files with common uucore strings
266fn create_english_bundle_from_embedded(
267    locale: &LanguageIdentifier,
268    util_name: &str,
269) -> Result<FluentBundle<&'static FluentResource>, LocalizationError> {
270    // Only support English from embedded files
271    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    // First, try to load common uucore strings
281    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    // Checksum algorithms need locale messages from checksum_common
287    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    // Then, try to load utility-specific strings
295    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    // Return the bundle if we have either common strings or utility-specific strings
302    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/// Create a bundle from embedded locale files for any locale on WASI.
312/// Bypasses the global OnceLock cache (uses Box::leak) so it can be
313/// called for multiple locales in the same process.
314#[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())) // Return the key ID if localizer not initialized
350    })
351}
352
353/// Retrieves a localized message by its identifier.
354///
355/// Looks up a message with the given ID in the current locale bundle and returns
356/// the localized text. If the message ID is not found in the current locale,
357/// it will fall back to English. If the message is not found in English either,
358/// returns the message ID itself.
359///
360/// # Arguments
361///
362/// * `id` - The message identifier in the Fluent resources
363///
364/// # Returns
365///
366/// A `String` containing the localized message, or the message ID if not found
367///
368/// # Examples
369///
370/// ```
371/// use uucore::locale::get_message;
372///
373/// // Get a localized greeting (from .ftl files)
374/// let greeting = get_message("greeting");
375/// println!("{greeting}");
376/// ```
377pub fn get_message(id: &str) -> String {
378    get_message_internal(id, None)
379}
380
381/// Retrieves a localized message with variable substitution.
382///
383/// Looks up a message with the given ID in the current locale bundle,
384/// substitutes variables from the provided arguments map, and returns the
385/// localized text. If the message ID is not found in the current locale,
386/// it will fall back to English. If the message is not found in English either,
387/// returns the message ID itself.
388///
389/// # Arguments
390///
391/// * `id` - The message identifier in the Fluent resources
392/// * `ftl_args` - Key-value pairs for variable substitution in the message
393///
394/// # Returns
395///
396/// A `String` containing the localized message with variable substitution, or the message ID if not found
397///
398/// # Examples
399///
400/// ```
401/// use uucore::locale::get_message_with_args;
402/// use fluent::FluentArgs;
403///
404/// // For a Fluent message like: "Hello, { $name }! You have { $count } notifications."
405/// let mut args = FluentArgs::new();
406/// args.set("name".to_string(), "Alice".to_string());
407/// args.set("count".to_string(), 3);
408///
409/// let message = get_message_with_args("notification", args);
410/// println!("{message}");
411/// ```
412pub fn get_message_with_args(id: &str, ftl_args: FluentArgs) -> String {
413    get_message_internal(id, Some(ftl_args))
414}
415
416/// Function to detect system locale from environment variables
417fn 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
429/// Sets up localization using the system locale with English fallback.
430/// Always loads common strings in addition to utility-specific strings.
431///
432/// This function initializes the localization system based on the system's locale
433/// preferences (via the LANG environment variable) or falls back to English
434/// if the system locale cannot be determined or the locale file doesn't exist.
435/// English is always loaded as a fallback.
436///
437/// # Arguments
438///
439/// * `p` - Path to the directory containing localization (.ftl) files
440///
441/// # Returns
442///
443/// * `Ok(())` if initialization succeeds
444/// * `Err(LocalizationError)` if initialization fails
445///
446/// # Errors
447///
448/// Returns a `LocalizationError` if:
449/// * The en-US.ftl file cannot be read (English is required)
450/// * The files contain invalid Fluent syntax
451/// * The bundle cannot be initialized properly
452///
453/// # Examples
454///
455/// ```
456/// use uucore::locale::setup_localization;
457///
458/// // Initialize localization using files in the "locales" directory
459/// // Make sure you have at least an "en-US.ftl" file in this directory
460/// // Other locale files like "fr-FR.ftl" are optional
461/// match setup_localization("./locales") {
462///     Ok(_) => println!("Localization initialized successfully"),
463///     Err(e) => eprintln!("Failed to initialize localization: {e}"),
464/// }
465/// ```
466pub fn setup_localization(p: &str) -> Result<(), LocalizationError> {
467    // Avoid duplicated and high-cost localizer setup
468    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    // Load common strings along with utility-specific strings
480    if let Ok(locales_dir) = get_locales_dir(p) {
481        // Load both utility-specific and common strings
482        init_localization(&locale, &locales_dir, p)?;
483    } else {
484        // No locales directory found, use embedded locales
485        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    // 1. <bindir>/locales/<prog>
518    let coreutils = exe_dir.join("locales").join(p);
519    if coreutils.exists() {
520        return Some(coreutils);
521    }
522
523    // 2. <prefix>/share/locales/<prog>
524    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    // 3. <bindir>/<prog>   (legacy fall-back)
532    let fallback = exe_dir.join(p);
533    if fallback.exists() {
534        return Some(fallback);
535    }
536
537    None
538}
539
540/// Helper function to get the locales directory based on the build configuration
541fn get_locales_dir(p: &str) -> Result<PathBuf, LocalizationError> {
542    #[cfg(debug_assertions)]
543    {
544        // During development, use the project's locales directory
545        let manifest_dir = env!("CARGO_MANIFEST_DIR");
546        // from uucore path, load the locales directory from the program directory
547        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        // Fallback for development if the expected path doesn't exist
557        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        // In release builds, look relative to executable
573        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 for retrieving localized messages with optional arguments.
593///
594/// This macro provides a unified interface for both simple message retrieval
595/// and message retrieval with variable substitution. It accepts a message ID
596/// and optionally key-value pairs using the `"key" => value` syntax.
597///
598/// # Arguments
599///
600/// * `$id` - The message identifier string
601/// * Optional key-value pairs in the format `"key" => value`
602///
603/// # Examples
604///
605/// ```
606/// use uucore::translate;
607/// use fluent::FluentArgs;
608///
609/// // Simple message without arguments
610/// let greeting = translate!("greeting");
611///
612/// // Message with one argument
613/// let welcome = translate!("welcome", "name" => "Alice");
614///
615/// // Message with multiple arguments
616/// let username = "user name";
617/// let item_count = 2;
618/// let notification = translate!(
619///     "user-stats",
620///     "name" => username,
621///     "count" => item_count,
622///     "status" => "active"
623/// );
624/// ```
625#[macro_export]
626macro_rules! translate {
627    // Case 1: Message ID only (no arguments)
628    ($id:expr) => {
629        $crate::locale::get_message($id)
630    };
631
632    // Case 2: Message ID with key-value arguments
633    ($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                    // Keep as string if not a number
644                    args.set($key, value_str);
645                }
646            )+
647            $crate::locale::get_message_with_args($id, args)
648        }
649    };
650}
651
652// Re-export the macro for easier access
653pub 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    /// Test-specific helper function to create a bundle from test directory only
664    #[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        // Only load from the test directory - no common strings or utility-specific paths
674        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    /// Test-specific initialization function for test directories
688    #[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        // Create English bundle from test directory
697        let english_bundle = create_test_bundle(&default_locale, test_locales_dir)?;
698
699        let loc = if locale == &default_locale {
700            // If requesting English, just use English as primary
701            Localizer::new(english_bundle)
702        } else {
703            // Try to load the requested locale from test directory
704            if let Ok(primary_bundle) = create_test_bundle(locale, test_locales_dir) {
705                // Successfully loaded requested locale, load English as fallback
706                Localizer::new(primary_bundle).with_fallback(english_bundle)
707            } else {
708                // Failed to load requested locale, just use English as primary
709                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    /// Helper function to create a temporary directory with test locale files
721    fn create_test_locales_dir() -> TempDir {
722        let temp_dir = TempDir::new().expect("Failed to create temp directory");
723
724        // Create en-US.ftl
725        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        // Create fr-FR.ftl
736        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        // Create ja-JP.ftl (Japanese)
746        let ja_content = r"
747greeting = こんにちは、世界!
748welcome = ようこそ、{ $name }さん!
749count-items = { $count }個のアイテムがあります
750";
751
752        // Create ar-SA.ftl (Arabic - Right-to-Left)
753        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        // Create es-ES.ftl with invalid syntax
766        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            // Expected - no localization strings found
807        } 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        // The result should be an error due to invalid syntax
820        match result {
821            Err(LocalizationError::ParseResource {
822                error: _parser_err,
823                snippet: _,
824            }) => {
825                // Expected ParseResource variant - test passes
826            }
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        // This message exists in French
885        let result1 = localizer.format("greeting", None);
886        assert_eq!(result1, "Bonjour, le monde!");
887
888        // This message only exists in English, should fallback
889        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        // Run in a separate thread to avoid conflicts with other tests
910        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            // Test that we can get messages
918            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            // Test French message
935            let message1 = get_message("greeting");
936            assert_eq!(message1, "Bonjour, le monde!");
937
938            // Test fallback to English
939            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(); // No German file
951
952            let result = init_test_localization(&locale, temp_dir.path());
953            assert!(result.is_ok());
954
955            // Should use English as primary since German failed to load
956            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            // Initialize once
970            let result1 = init_test_localization(&locale, temp_dir.path());
971            assert!(result1.is_ok());
972
973            // Try to initialize again - should fail
974            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            // Test singular
1032            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            // Test plural
1038            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        // Initialize in main thread with French
1054        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        // Test in a different thread - should not be initialized
1064        let temp_path = temp_dir.path().to_path_buf();
1065        let handle = thread::spawn(move || {
1066            // This thread should have its own uninitialized LOCALIZER
1067            let thread_message = get_message("greeting");
1068            assert_eq!(thread_message, "greeting"); // Returns ID since not initialized
1069
1070            // Initialize in this thread with English
1071            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        // Test another thread to verify French doesn't persist across threads
1080        let final_handle = thread::spawn(move || {
1081            // Should be uninitialized again
1082            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            // Test Japanese greeting
1099            let message = get_message("greeting");
1100            assert_eq!(message, "こんにちは、世界!");
1101
1102            // Test Japanese with arguments
1103            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            // Test Japanese count (no pluralization)
1109            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            // Test Arabic greeting (RTL text)
1129            let message = get_message("greeting");
1130            assert_eq!(message, "أهلاً بالعالم!");
1131
1132            // Test Arabic with arguments
1133            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            // Test Arabic pluralization (zero case)
1139            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            // Test Arabic pluralization (one case)
1145            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            // Test Arabic pluralization (two case)
1151            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            // Test Arabic pluralization (few case - 3-10)
1157            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            // Test Arabic pluralization (other case - 11+)
1163            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            // Test Arabic greeting (RTL text)
1182            let message = translate!("greeting");
1183            assert_eq!(message, "أهلاً بالعالم!");
1184
1185            // Test Arabic with arguments
1186            let welcome = translate!("welcome", "name" => "أحمد");
1187            assert_eq!(welcome, "أهلاً وسهلاً، أحمد!");
1188
1189            // Test Arabic pluralization (zero case)
1190            let message_zero = translate!("count-items", "count" => 0);
1191            assert_eq!(message_zero, "لديك لا عناصر");
1192
1193            // Test Arabic pluralization (one case)
1194            let message_one = translate!("count-items", "count" => 1);
1195            assert_eq!(message_one, "لديك عنصر واحد");
1196
1197            // Test Arabic pluralization (two case)
1198            let message_two = translate!("count-items", "count" => 2);
1199            assert_eq!(message_two, "لديك عنصران");
1200
1201            // Test Arabic pluralization (few case - 3-10)
1202            let message_few = translate!("count-items", "count" => 5);
1203            assert_eq!(message_few, "لديك 5 عناصر");
1204
1205            // Test Arabic pluralization (other case - 11+)
1206            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            // Test Arabic message exists
1223            let arabic_message = get_message("greeting");
1224            assert_eq!(arabic_message, "أهلاً بالعالم!");
1225
1226            // Test fallback to English for missing message
1227            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            // Test that Latin script names are NOT isolated in RTL context
1244            // since we disabled Unicode directional isolation
1245            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            // The Latin name should NOT be wrapped in directional isolate characters
1250            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            // The snippet should contain exactly the invalid text from es-ES.ftl
1271            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"); // Should return the ID itself
1304        })
1305        .join()
1306        .unwrap();
1307    }
1308
1309    #[test]
1310    fn test_detect_system_locale_from_lang_env() {
1311        // Test locale parsing logic directly instead of relying on environment variables
1312        // which can have race conditions in multi-threaded test environments
1313
1314        // Test parsing logic with UTF-8 encoding
1315        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        // Test parsing logic without encoding
1321        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        // Test that DEFAULT_LOCALE is valid
1326        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        // Save current LANG value
1333        let original_lang = env::var("LANG").ok();
1334
1335        // Remove LANG environment variable
1336        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        // Restore original LANG value
1345        if let Some(val) = original_lang {
1346            unsafe {
1347                env::set_var("LANG", val);
1348            }
1349        } else {
1350            {} // Was already unset
1351        }
1352    }
1353
1354    #[test]
1355    fn test_setup_localization_success() {
1356        std::thread::spawn(|| {
1357            // Save current LANG value
1358            let original_lang = env::var("LANG").ok();
1359            unsafe {
1360                env::set_var("LANG", "en-US.UTF-8"); // Use English since we have embedded resources for "test"
1361            }
1362
1363            let result = setup_localization("test");
1364            assert!(result.is_ok());
1365
1366            // Test that we can get messages (should use embedded English for "test" utility)
1367            let message = get_message("test-about");
1368            // Since we're using embedded resources, we should get the expected message
1369            assert!(!message.is_empty());
1370
1371            // Restore original LANG value
1372            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            // Save current LANG value
1390            let original_lang = env::var("LANG").ok();
1391            unsafe {
1392                env::set_var("LANG", "de-DE.UTF-8"); // German file doesn't exist, should fallback
1393            }
1394
1395            let result = setup_localization("test");
1396            assert!(result.is_ok());
1397
1398            // Should fall back to English embedded resources
1399            let message = get_message("test-about");
1400            assert!(!message.is_empty()); // Should get something, not just the key
1401
1402            // Restore original LANG value
1403            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            // Force English locale for this test
1421            unsafe {
1422                env::set_var("LANG", "en-US");
1423            }
1424
1425            // Test with a utility name that has embedded locales
1426            // This should fall back to embedded English when filesystem files aren't found
1427            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            // Verify we can get messages (using embedded English)
1434            let message = get_message("test-about");
1435            assert_eq!(message, "Check file types and compare values."); // Should use embedded English
1436        })
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            // Test the scenario where localization isn't properly initialized
1460            // and we need fallbacks for clap error handling
1461
1462            // First, test when localizer is not initialized
1463            let error_msg = get_message("common-error");
1464            assert_eq!(error_msg, "common-error"); // Should return key when not initialized
1465
1466            let tip_msg = get_message("common-tip");
1467            assert_eq!(tip_msg, "common-tip"); // Should return key when not initialized
1468
1469            // Now initialize with setup_localization
1470            let result = setup_localization("comm");
1471            if result.is_err() {
1472                // If setup fails (e.g., no embedded locales for comm), try with a known utility
1473                let _ = setup_localization("test");
1474            }
1475
1476            // Test that common strings are available after initialization
1477            let error_after_init = get_message("common-error");
1478            // Should either be translated or return the key (but not panic)
1479            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            // Test that clap error keys work with fallbacks
1485            let unknown_arg_key = get_message("clap-error-unexpected-argument");
1486            assert!(!unknown_arg_key.is_empty());
1487
1488            // Test usage key fallback
1489            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        // 1. Set up a fake installation prefix in a temp directory
1505        let prefix = TempDir::new().unwrap(); // e.g.  /tmp/xyz
1506        let bin_dir = prefix.path().join("bin"); //        /tmp/xyz/bin
1507        let share_dir = prefix.path().join("share").join("locales").join("cut"); // /tmp/xyz/share/locales/cut
1508        std::fs::create_dir_all(&share_dir).unwrap();
1509        std::fs::create_dir_all(&bin_dir).unwrap();
1510
1511        // 2. Pretend the executable lives in <prefix>/bin
1512        let exe_dir = bin_dir.as_path();
1513
1514        // 3. Ask the helper to resolve the locales dir
1515        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}