es_fluent_cli/ftl/
locale.rs

1//! Locale context for iterating over locale directories.
2//!
3//! Provides a unified abstraction for the common pattern of iterating
4//! over locale directories with `--all` flag support.
5
6use crate::core::CrateInfo;
7use crate::utils::get_all_locales;
8use anyhow::{Context as _, Result};
9use es_fluent_toml::I18nConfig;
10use std::path::PathBuf;
11
12/// Context for locale-based FTL file operations.
13///
14/// Encapsulates the common pattern of loading i18n config and iterating
15/// over locale directories used by format, check, and sync commands.
16#[derive(Clone, Debug)]
17pub struct LocaleContext {
18    /// The assets directory (e.g., `<crate>/i18n/`).
19    pub assets_dir: PathBuf,
20    /// The fallback language (e.g., "en").
21    pub fallback: String,
22    /// The locales to process.
23    pub locales: Vec<String>,
24    /// The crate name (for constructing FTL file paths).
25    pub crate_name: String,
26}
27
28impl LocaleContext {
29    /// Create a locale context from crate info.
30    ///
31    /// If `all` is true, includes all locale directories.
32    /// Otherwise, includes only the fallback language.
33    pub fn from_crate(krate: &CrateInfo, all: bool) -> Result<Self> {
34        let config = I18nConfig::read_from_path(&krate.i18n_config_path)
35            .with_context(|| format!("Failed to read {}", krate.i18n_config_path.display()))?;
36
37        let assets_dir = krate.manifest_dir.join(&config.assets_dir);
38
39        let locales = if all {
40            get_all_locales(&assets_dir)?
41        } else {
42            vec![config.fallback_language.clone()]
43        };
44
45        Ok(Self {
46            assets_dir,
47            fallback: config.fallback_language,
48            locales,
49            crate_name: krate.name.clone(),
50        })
51    }
52
53    /// Get the FTL file path for a specific locale.
54    pub fn ftl_path(&self, locale: &str) -> PathBuf {
55        self.assets_dir
56            .join(locale)
57            .join(format!("{}.ftl", self.crate_name))
58    }
59
60    /// Get the locale directory path.
61    pub fn locale_dir(&self, locale: &str) -> PathBuf {
62        self.assets_dir.join(locale)
63    }
64
65    /// Iterate over locales, yielding (locale, ftl_path) pairs.
66    ///
67    /// Only yields locales where the directory exists.
68    pub fn iter(&self) -> impl Iterator<Item = (&str, PathBuf)> {
69        self.locales.iter().filter_map(|locale| {
70            let locale_dir = self.locale_dir(locale);
71            if locale_dir.exists() {
72                Some((locale.as_str(), self.ftl_path(locale)))
73            } else {
74                None
75            }
76        })
77    }
78
79    /// Iterate over non-fallback locales.
80    ///
81    /// Useful for sync command which needs to skip the fallback.
82    pub fn iter_non_fallback(&self) -> impl Iterator<Item = (&str, PathBuf)> {
83        self.locales.iter().filter_map(|locale| {
84            if locale == &self.fallback {
85                return None;
86            }
87            let locale_dir = self.locale_dir(locale);
88            if locale_dir.exists() {
89                Some((locale.as_str(), self.ftl_path(locale)))
90            } else {
91                None
92            }
93        })
94    }
95
96    /// Check if a locale is the fallback language.
97    pub fn is_fallback(&self, locale: &str) -> bool {
98        locale == self.fallback
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use std::fs;
106    use tempfile::tempdir;
107
108    fn create_test_crate() -> (tempfile::TempDir, CrateInfo) {
109        let temp_dir = tempdir().unwrap();
110        let assets = temp_dir.path().join("i18n");
111        fs::create_dir(&assets).unwrap();
112        fs::create_dir(assets.join("en")).unwrap();
113        fs::create_dir(assets.join("fr")).unwrap();
114        fs::create_dir(assets.join("de")).unwrap();
115
116        let config_path = temp_dir.path().join("i18n.toml");
117        fs::write(
118            &config_path,
119            "fallback_language = \"en\"\nassets_dir = \"i18n\"\n",
120        )
121        .unwrap();
122
123        let krate = CrateInfo {
124            name: "test-crate".to_string(),
125            manifest_dir: temp_dir.path().to_path_buf(),
126            src_dir: temp_dir.path().join("src"),
127            i18n_config_path: config_path,
128            ftl_output_dir: assets.join("en"),
129            has_lib_rs: true,
130            fluent_features: Vec::new(),
131        };
132
133        (temp_dir, krate)
134    }
135
136    #[test]
137    fn test_locale_context_fallback_only() {
138        let (_temp, krate) = create_test_crate();
139        let ctx = LocaleContext::from_crate(&krate, false).unwrap();
140
141        assert_eq!(ctx.locales.len(), 1);
142        assert_eq!(ctx.locales[0], "en");
143        assert_eq!(ctx.fallback, "en");
144    }
145
146    #[test]
147    fn test_locale_context_all_locales() {
148        let (_temp, krate) = create_test_crate();
149        let ctx = LocaleContext::from_crate(&krate, true).unwrap();
150
151        assert_eq!(ctx.locales.len(), 3);
152        assert!(ctx.locales.contains(&"en".to_string()));
153        assert!(ctx.locales.contains(&"fr".to_string()));
154        assert!(ctx.locales.contains(&"de".to_string()));
155    }
156
157    #[test]
158    fn test_ftl_path() {
159        let (_temp, krate) = create_test_crate();
160        let ctx = LocaleContext::from_crate(&krate, false).unwrap();
161
162        let path = ctx.ftl_path("en");
163        assert!(path.ends_with("i18n/en/test-crate.ftl"));
164    }
165
166    #[test]
167    fn test_iter_non_fallback() {
168        let (_temp, krate) = create_test_crate();
169        let ctx = LocaleContext::from_crate(&krate, true).unwrap();
170
171        let non_fallback: Vec<_> = ctx.iter_non_fallback().map(|(l, _)| l).collect();
172        assert!(!non_fallback.contains(&"en"));
173        assert!(non_fallback.contains(&"fr"));
174        assert!(non_fallback.contains(&"de"));
175    }
176}