Skip to main content

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::collections::HashSet;
11use std::path::PathBuf;
12
13/// Context for locale-based FTL file operations.
14///
15/// Encapsulates the common pattern of loading i18n config and iterating
16/// over locale directories used by format, check, and sync commands.
17#[derive(Clone, Debug)]
18pub struct LocaleContext {
19    /// The assets directory (e.g., `<crate>/i18n/`).
20    pub assets_dir: PathBuf,
21    /// The fallback language (e.g., "en").
22    pub fallback: String,
23    /// The locales to process.
24    pub locales: Vec<String>,
25    /// The crate name (for constructing FTL file paths).
26    pub crate_name: String,
27}
28
29impl LocaleContext {
30    /// Create a locale context from crate info.
31    ///
32    /// If `all` is true, includes all locale directories.
33    /// Otherwise, includes only the fallback language.
34    pub fn from_crate(krate: &CrateInfo, all: bool) -> Result<Self> {
35        let config = I18nConfig::read_from_path(&krate.i18n_config_path)
36            .with_context(|| format!("Failed to read {}", krate.i18n_config_path.display()))?;
37
38        let assets_dir = krate.manifest_dir.join(&config.assets_dir);
39
40        let locales = if all {
41            get_all_locales(&assets_dir)?
42        } else {
43            vec![config.fallback_language.clone()]
44        };
45
46        Ok(Self {
47            assets_dir,
48            fallback: config.fallback_language,
49            locales,
50            crate_name: krate.name.clone(),
51        })
52    }
53
54    /// Get the FTL file path for a specific locale.
55    pub fn ftl_path(&self, locale: &str) -> PathBuf {
56        self.assets_dir
57            .join(locale)
58            .join(format!("{}.ftl", self.crate_name))
59    }
60
61    /// Get the locale directory path.
62    pub fn locale_dir(&self, locale: &str) -> PathBuf {
63        self.assets_dir.join(locale)
64    }
65
66    /// Iterate over locales, yielding (locale, ftl_path) pairs.
67    ///
68    /// Only yields locales where the directory exists.
69    pub fn iter(&self) -> impl Iterator<Item = (&str, PathBuf)> {
70        self.locales.iter().filter_map(|locale| {
71            let locale_dir = self.locale_dir(locale);
72            if locale_dir.exists() {
73                Some((locale.as_str(), self.ftl_path(locale)))
74            } else {
75                None
76            }
77        })
78    }
79
80    /// Iterate over non-fallback locales.
81    ///
82    /// Useful for sync command which needs to skip the fallback.
83    pub fn iter_non_fallback(&self) -> impl Iterator<Item = (&str, PathBuf)> {
84        self.locales.iter().filter_map(|locale| {
85            if locale == &self.fallback {
86                return None;
87            }
88            let locale_dir = self.locale_dir(locale);
89            if locale_dir.exists() {
90                Some((locale.as_str(), self.ftl_path(locale)))
91            } else {
92                None
93            }
94        })
95    }
96
97    /// Check if a locale is the fallback language.
98    pub fn is_fallback(&self, locale: &str) -> bool {
99        locale == self.fallback
100    }
101}
102
103/// Collect all available locales across all crates.
104pub fn collect_all_available_locales(crates: &[CrateInfo]) -> Result<HashSet<String>> {
105    let mut all_locales = HashSet::new();
106
107    for krate in crates {
108        let ctx = LocaleContext::from_crate(krate, true)?;
109        for locale in ctx.locales {
110            all_locales.insert(locale);
111        }
112    }
113
114    Ok(all_locales)
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use std::fs;
121    use std::path::PathBuf;
122    use tempfile::tempdir;
123
124    fn create_test_crate() -> (tempfile::TempDir, CrateInfo) {
125        let temp_dir = tempdir().unwrap();
126        let assets = temp_dir.path().join("i18n");
127        fs::create_dir(&assets).unwrap();
128        fs::create_dir(assets.join("en")).unwrap();
129        fs::create_dir(assets.join("fr")).unwrap();
130        fs::create_dir(assets.join("de")).unwrap();
131
132        let config_path = temp_dir.path().join("i18n.toml");
133        fs::write(
134            &config_path,
135            "fallback_language = \"en\"\nassets_dir = \"i18n\"\n",
136        )
137        .unwrap();
138
139        let krate = CrateInfo {
140            name: "test-crate".to_string(),
141            manifest_dir: temp_dir.path().to_path_buf(),
142            src_dir: temp_dir.path().join("src"),
143            i18n_config_path: config_path,
144            ftl_output_dir: assets.join("en"),
145            has_lib_rs: true,
146            fluent_features: Vec::new(),
147        };
148
149        (temp_dir, krate)
150    }
151
152    #[test]
153    fn test_collect_all_available_locales() {
154        let temp_dir = tempdir().unwrap();
155        let assets = temp_dir.path().join("i18n");
156        fs::create_dir(&assets).unwrap();
157        fs::create_dir(assets.join("en")).unwrap();
158        fs::create_dir(assets.join("fr")).unwrap();
159        fs::create_dir(assets.join("de")).unwrap();
160
161        // Create a minimal i18n.toml
162        let config_path = temp_dir.path().join("i18n.toml");
163        fs::write(
164            &config_path,
165            "fallback_language = \"en\"\nassets_dir = \"i18n\"\n",
166        )
167        .unwrap();
168
169        let crates = vec![CrateInfo {
170            name: "test-crate".to_string(),
171            manifest_dir: temp_dir.path().to_path_buf(),
172            src_dir: PathBuf::new(),
173            i18n_config_path: config_path,
174            ftl_output_dir: PathBuf::new(),
175            has_lib_rs: true,
176            fluent_features: Vec::new(),
177        }];
178
179        let locales = collect_all_available_locales(&crates).unwrap();
180
181        assert!(locales.contains("en"));
182        assert!(locales.contains("fr"));
183        assert!(locales.contains("de"));
184        assert_eq!(locales.len(), 3);
185        assert!(!locales.contains("awd"));
186    }
187
188    #[test]
189    fn test_locale_context_fallback_only() {
190        let (_temp, krate) = create_test_crate();
191        let ctx = LocaleContext::from_crate(&krate, false).unwrap();
192
193        assert_eq!(ctx.locales.len(), 1);
194        assert_eq!(ctx.locales[0], "en");
195        assert_eq!(ctx.fallback, "en");
196    }
197
198    #[test]
199    fn test_locale_context_all_locales() {
200        let (_temp, krate) = create_test_crate();
201        let ctx = LocaleContext::from_crate(&krate, true).unwrap();
202
203        assert_eq!(ctx.locales.len(), 3);
204        assert!(ctx.locales.contains(&"en".to_string()));
205        assert!(ctx.locales.contains(&"fr".to_string()));
206        assert!(ctx.locales.contains(&"de".to_string()));
207    }
208
209    #[test]
210    fn test_ftl_path() {
211        let (_temp, krate) = create_test_crate();
212        let ctx = LocaleContext::from_crate(&krate, false).unwrap();
213
214        let path = ctx.ftl_path("en");
215        assert!(path.ends_with("i18n/en/test-crate.ftl"));
216    }
217
218    #[test]
219    fn test_iter_non_fallback() {
220        let (_temp, krate) = create_test_crate();
221        let ctx = LocaleContext::from_crate(&krate, true).unwrap();
222
223        let non_fallback: Vec<_> = ctx.iter_non_fallback().map(|(l, _)| l).collect();
224        assert!(!non_fallback.contains(&"en"));
225        assert!(non_fallback.contains(&"fr"));
226        assert!(non_fallback.contains(&"de"));
227    }
228
229    #[test]
230    fn test_iter_and_is_fallback_cover_directory_presence() {
231        let (temp, krate) = create_test_crate();
232        // Remove one locale directory to ensure iter() skips missing dirs.
233        std::fs::remove_dir_all(temp.path().join("i18n/fr")).unwrap();
234
235        let ctx = LocaleContext::from_crate(&krate, true).unwrap();
236        let locales_from_iter: Vec<_> = ctx.iter().map(|(l, _)| l.to_string()).collect();
237
238        assert!(ctx.is_fallback("en"));
239        assert!(!ctx.is_fallback("de"));
240        assert!(locales_from_iter.contains(&"en".to_string()));
241        assert!(!locales_from_iter.contains(&"fr".to_string()));
242    }
243}