Skip to main content

es_fluent_toml/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use serde::{Deserialize, Serialize};
4use std::path::{Path, PathBuf};
5use std::{env, fs, io};
6use thiserror::Error;
7use unic_langid::{LanguageIdentifier, LanguageIdentifierError};
8
9#[derive(Debug, Error)]
10pub enum I18nConfigError {
11    /// Configuration file not found.
12    #[error("i18n.toml configuration file not found")]
13    NotFound,
14    /// Failed to read configuration file.
15    #[error("Failed to read configuration file: {0}")]
16    ReadError(#[from] std::io::Error),
17    /// Failed to parse configuration file.
18    #[error("Failed to parse configuration file: {0}")]
19    ParseError(#[from] toml::de::Error),
20    /// Encountered an invalid language identifier while reading assets directory.
21    #[error("Invalid language identifier '{name}' found in assets directory")]
22    InvalidLanguageIdentifier {
23        /// The invalid identifier.
24        name: String,
25        /// The parsing error produced by `unic-langid`.
26        #[source]
27        source: LanguageIdentifierError,
28    },
29    /// Encountered a language identifier that uses an unsupported subtag combination.
30    #[error("Language identifier '{name}' is not supported: {reason}")]
31    UnsupportedLanguageIdentifier {
32        /// The invalid identifier.
33        name: String,
34        /// Explanation of why it is not supported.
35        reason: String,
36    },
37    /// Encountered an invalid fallback language identifier.
38    #[error("Invalid fallback language identifier '{name}'")]
39    InvalidFallbackLanguageIdentifier {
40        /// The invalid identifier.
41        name: String,
42        /// The parsing error produced by `unic-langid`.
43        #[source]
44        source: LanguageIdentifierError,
45    },
46}
47
48/// Represents the `fluent_feature` field in `i18n.toml`.
49/// Supports both a single string and an array of strings.
50///
51/// # Examples
52///
53/// Single feature:
54/// ```toml
55/// fluent_feature = "fluent"
56/// ```
57///
58/// Multiple features:
59/// ```toml
60/// fluent_feature = ["fluent", "i18n"]
61/// ```
62#[derive(Clone, Debug, Deserialize, Serialize)]
63#[serde(untagged)]
64pub enum FluentFeature {
65    /// A single feature name.
66    Single(String),
67    /// Multiple feature names.
68    Multiple(Vec<String>),
69}
70
71impl FluentFeature {
72    /// Returns the features as a vector of strings.
73    pub fn as_vec(&self) -> Vec<String> {
74        match self {
75            FluentFeature::Single(s) => vec![s.clone()],
76            FluentFeature::Multiple(v) => v.clone(),
77        }
78    }
79
80    /// Returns true if there are no features.
81    pub fn is_empty(&self) -> bool {
82        match self {
83            FluentFeature::Single(s) => s.is_empty(),
84            FluentFeature::Multiple(v) => v.is_empty(),
85        }
86    }
87}
88
89/// The configuration for `es-fluent`.
90#[derive(Clone, Debug, Deserialize, Serialize)]
91pub struct I18nConfig {
92    /// The fallback language identifier (e.g., "en-US").
93    pub fallback_language: String,
94    /// Path to the assets directory containing translation files.
95    /// Expected structure: {assets_dir}/{language}/{domain}.ftl
96    pub assets_dir: PathBuf,
97    /// Optional feature flag(s) that enable es-fluent derives in the crate.
98    /// If specified, the CLI will enable these features when generating FTL files.
99    ///
100    /// # Examples
101    ///
102    /// Single feature:
103    /// ```toml
104    /// fluent_feature = "fluent"
105    /// ```
106    ///
107    /// Multiple features:
108    /// ```toml
109    /// fluent_feature = ["fluent", "i18n"]
110    /// ```
111    #[serde(default)]
112    pub fluent_feature: Option<FluentFeature>,
113    /// Optional list of allowed namespaces for FTL file generation.
114    /// If specified, only these namespace values can be used in `#[fluent(namespace = "...")]`.
115    /// If not specified, any namespace is allowed.
116    ///
117    /// # Examples
118    ///
119    /// ```toml
120    /// namespaces = ["ui", "errors", "messages"]
121    /// ```
122    #[serde(default)]
123    pub namespaces: Option<Vec<String>>,
124}
125
126impl I18nConfig {
127    /// Reads the configuration from a path.
128    pub fn read_from_path<P: AsRef<Path>>(path: P) -> Result<Self, I18nConfigError> {
129        let path = path.as_ref();
130
131        if !path.exists() {
132            return Err(I18nConfigError::NotFound);
133        }
134
135        let content = fs::read_to_string(path)?;
136
137        let config: I18nConfig = toml::from_str(&content)?;
138
139        Ok(config)
140    }
141
142    /// Reads the configuration from the manifest directory.
143    pub fn read_from_manifest_dir() -> Result<Self, I18nConfigError> {
144        let manifest_dir = env::var("CARGO_MANIFEST_DIR").map_err(|_| I18nConfigError::NotFound)?;
145
146        let config_path = Path::new(&manifest_dir).join("i18n.toml");
147        Self::read_from_path(config_path)
148    }
149
150    /// Returns the path to the assets directory.
151    pub fn assets_dir_path(&self) -> PathBuf {
152        PathBuf::from(&self.assets_dir)
153    }
154
155    /// Returns the path to the assets directory from the manifest directory.
156    pub fn assets_dir_from_manifest(&self) -> Result<PathBuf, I18nConfigError> {
157        self.assets_dir_from_base(None)
158    }
159
160    /// Returns the path to the assets directory from a base directory.
161    /// If `base_dir` is `None`, uses `CARGO_MANIFEST_DIR` environment variable.
162    pub fn assets_dir_from_base(
163        &self,
164        base_dir: Option<&Path>,
165    ) -> Result<PathBuf, I18nConfigError> {
166        let base = match base_dir {
167            Some(dir) => dir.to_path_buf(),
168            None => {
169                let manifest_dir =
170                    env::var("CARGO_MANIFEST_DIR").map_err(|_| I18nConfigError::NotFound)?;
171                PathBuf::from(manifest_dir)
172            },
173        };
174
175        Ok(base.join(&self.assets_dir))
176    }
177
178    /// Returns the configured fallback language as a `LanguageIdentifier`.
179    pub fn fallback_language_identifier(&self) -> Result<LanguageIdentifier, I18nConfigError> {
180        let lang = self
181            .fallback_language
182            .parse::<LanguageIdentifier>()
183            .map_err(
184                |source| I18nConfigError::InvalidFallbackLanguageIdentifier {
185                    name: self.fallback_language.clone(),
186                    source,
187                },
188            )?;
189
190        ensure_supported_language_identifier(&lang, &self.fallback_language)?;
191
192        Ok(lang)
193    }
194
195    /// Returns the languages available under the assets directory.
196    pub fn available_languages(&self) -> Result<Vec<LanguageIdentifier>, I18nConfigError> {
197        self.available_languages_from_base(None)
198    }
199
200    /// Returns the languages available under the assets directory from a base directory.
201    /// If `base_dir` is `None`, uses `CARGO_MANIFEST_DIR` environment variable.
202    pub fn available_languages_from_base(
203        &self,
204        base_dir: Option<&Path>,
205    ) -> Result<Vec<LanguageIdentifier>, I18nConfigError> {
206        let assets_path = self.assets_dir_from_base(base_dir)?;
207        let entries = fs::read_dir(&assets_path).map_err(I18nConfigError::ReadError)?;
208
209        let mut languages: Vec<(String, LanguageIdentifier)> = entries
210            .filter_map(|entry| entry.ok())
211            .filter_map(|entry| parse_language_entry(entry).transpose())
212            .collect::<Result<Vec<_>, _>>()?
213            .into_iter()
214            .map(|lang| (lang.to_string(), lang))
215            .collect();
216
217        languages.sort_by(|a, b| a.0.cmp(&b.0));
218        languages.dedup_by(|a, b| a.0 == b.0);
219
220        Ok(languages.into_iter().map(|(_, lang)| lang).collect())
221    }
222
223    /// Validates the assets directory.
224    pub fn validate_assets_dir(&self) -> Result<(), I18nConfigError> {
225        let assets_path = self.assets_dir_from_manifest()?;
226
227        if !assets_path.exists() {
228            return Err(I18nConfigError::ReadError(std::io::Error::new(
229                std::io::ErrorKind::NotFound,
230                format!(
231                    "Assets directory '{}' does not exist",
232                    assets_path.display()
233                ),
234            )));
235        }
236
237        if !assets_path.is_dir() {
238            return Err(I18nConfigError::ReadError(std::io::Error::new(
239                std::io::ErrorKind::InvalidInput,
240                format!("Assets path '{}' is not a directory", assets_path.display()),
241            )));
242        }
243
244        Ok(())
245    }
246
247    /// Returns the fallback language identifier.
248    pub fn fallback_language_id(&self) -> &str {
249        &self.fallback_language
250    }
251
252    /// Read configuration and resolve paths for a given manifest directory.
253    ///
254    /// This is a common pattern used across CLI tools and helpers.
255    pub fn from_manifest_dir(manifest_dir: &Path) -> Result<Self, I18nConfigError> {
256        let config_path = manifest_dir.join("i18n.toml");
257        Self::read_from_path(config_path)
258    }
259
260    /// Get assets directory resolved from a manifest directory.
261    pub fn assets_dir_from_manifest_dir(manifest_dir: &Path) -> Result<PathBuf, I18nConfigError> {
262        let config = Self::from_manifest_dir(manifest_dir)?;
263        config.assets_dir_from_base(Some(manifest_dir))
264    }
265
266    /// Get output directory (fallback language directory) from manifest directory.
267    pub fn output_dir_from_manifest_dir(manifest_dir: &Path) -> Result<PathBuf, I18nConfigError> {
268        let config = Self::from_manifest_dir(manifest_dir)?;
269        let assets_dir = config.assets_dir_from_base(Some(manifest_dir))?;
270        Ok(assets_dir.join(&config.fallback_language))
271    }
272}
273
274/// Parse a directory entry as a language identifier.
275///
276/// Returns `Ok(None)` if the entry is not a directory.
277fn parse_language_entry(
278    entry: fs::DirEntry,
279) -> Result<Option<LanguageIdentifier>, I18nConfigError> {
280    if !entry
281        .file_type()
282        .map_err(I18nConfigError::ReadError)?
283        .is_dir()
284    {
285        return Ok(None);
286    }
287
288    let raw_name = entry.file_name();
289    let name = raw_name.into_string().map_err(|raw| {
290        I18nConfigError::ReadError(io::Error::new(
291            io::ErrorKind::InvalidData,
292            format!("Assets directory contains a non UTF-8 entry: {:?}", raw),
293        ))
294    })?;
295
296    let lang = name.parse::<LanguageIdentifier>().map_err(|source| {
297        I18nConfigError::InvalidLanguageIdentifier {
298            name: name.clone(),
299            source,
300        }
301    })?;
302
303    ensure_supported_language_identifier(&lang, &name)?;
304    Ok(Some(lang))
305}
306
307fn ensure_supported_language_identifier(
308    lang: &LanguageIdentifier,
309    original: &str,
310) -> Result<(), I18nConfigError> {
311    if lang.variants().next().is_some() {
312        return Err(I18nConfigError::UnsupportedLanguageIdentifier {
313            name: original.to_string(),
314            reason: "variants are not supported".to_string(),
315        });
316    }
317
318    Ok(())
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use std::fs;
325    use tempfile::TempDir;
326
327    #[test]
328    fn test_read_from_path_success() {
329        let temp_dir = TempDir::new().unwrap();
330        let config_path = temp_dir.path().join("i18n.toml");
331
332        let config_content = r#"
333fallback_language = "en"
334assets_dir = "i18n"
335"#;
336
337        fs::write(&config_path, config_content).unwrap();
338
339        let result = I18nConfig::read_from_path(&config_path);
340        assert!(result.is_ok());
341
342        let config = result.unwrap();
343        assert_eq!(config.fallback_language, "en");
344        assert_eq!(config.assets_dir, PathBuf::from("i18n"));
345    }
346
347    #[test]
348    fn test_read_from_path_file_not_found() {
349        let non_existent_path = Path::new("/non/existent/path/i18n.toml");
350        let result = I18nConfig::read_from_path(non_existent_path);
351        assert!(matches!(result, Err(I18nConfigError::NotFound)));
352    }
353
354    #[test]
355    fn test_read_from_path_invalid_toml() {
356        let temp_dir = TempDir::new().unwrap();
357        let config_path = temp_dir.path().join("i18n.toml");
358
359        let invalid_config = r#"
360fallback_language = "en"
361[invalid_section]
362assets_dir = "i18n"
363"#;
364
365        fs::write(&config_path, invalid_config).unwrap();
366
367        let result = I18nConfig::read_from_path(&config_path);
368        assert!(matches!(result, Err(I18nConfigError::ParseError(_))));
369    }
370
371    #[test]
372    fn test_assets_dir_path() {
373        let config = I18nConfig {
374            fallback_language: "en-US".to_string(),
375            assets_dir: PathBuf::from("locales"),
376            fluent_feature: None,
377            namespaces: None,
378        };
379
380        assert_eq!(config.assets_dir_path(), PathBuf::from("locales"));
381    }
382
383    #[test]
384    fn test_fallback_language_id() {
385        let config = I18nConfig {
386            fallback_language: "en-US".to_string(),
387            assets_dir: PathBuf::from("i18n"),
388            fluent_feature: None,
389            namespaces: None,
390        };
391
392        assert_eq!(config.fallback_language_id(), "en-US");
393    }
394
395    #[test]
396    fn test_fallback_language_identifier_success() {
397        let config = I18nConfig {
398            fallback_language: "en-US".to_string(),
399            assets_dir: PathBuf::from("i18n"),
400            fluent_feature: None,
401            namespaces: None,
402        };
403
404        let lang = config.fallback_language_identifier().unwrap();
405
406        assert_eq!(lang.to_string(), "en-US");
407    }
408
409    #[test]
410    fn test_fallback_language_identifier_invalid() {
411        let config = I18nConfig {
412            fallback_language: "invalid-lang!".to_string(),
413            assets_dir: PathBuf::from("i18n"),
414            fluent_feature: None,
415            namespaces: None,
416        };
417
418        let result = config.fallback_language_identifier();
419
420        assert!(matches!(
421            result,
422            Err(I18nConfigError::InvalidFallbackLanguageIdentifier { name, .. })
423                if name == "invalid-lang!"
424        ));
425    }
426
427    #[test]
428    fn test_available_languages_collects_directories() {
429        let temp_dir = TempDir::new().unwrap();
430        let manifest_dir = temp_dir.path();
431        let assets = manifest_dir.join("i18n");
432        fs::create_dir(&assets).unwrap();
433        fs::create_dir(assets.join("en")).unwrap();
434        fs::create_dir(assets.join("en-US")).unwrap();
435        fs::create_dir(assets.join("fr")).unwrap();
436        fs::create_dir(assets.join("zh-Hans")).unwrap();
437        fs::write(assets.join("README.txt"), "ignored file").unwrap();
438
439        let config = I18nConfig {
440            fallback_language: "en".to_string(),
441            assets_dir: PathBuf::from("i18n"),
442            fluent_feature: None,
443            namespaces: None,
444        };
445
446        let languages = config
447            .available_languages_from_base(Some(manifest_dir))
448            .unwrap();
449
450        let mut codes: Vec<String> = languages.into_iter().map(|lang| lang.to_string()).collect();
451        codes.sort();
452
453        assert_eq!(codes, vec!["en", "en-US", "fr", "zh-Hans"]);
454    }
455
456    #[test]
457    fn test_available_languages_allows_language_only() {
458        let temp_dir = TempDir::new().unwrap();
459        let manifest_dir = temp_dir.path();
460        let assets = manifest_dir.join("i18n");
461        fs::create_dir(&assets).unwrap();
462        fs::create_dir(assets.join("en")).unwrap();
463
464        let config = I18nConfig {
465            fallback_language: "en".to_string(),
466            assets_dir: PathBuf::from("i18n"),
467            fluent_feature: None,
468            namespaces: None,
469        };
470
471        let languages = config
472            .available_languages_from_base(Some(manifest_dir))
473            .unwrap();
474        let codes: Vec<String> = languages.into_iter().map(|lang| lang.to_string()).collect();
475
476        assert_eq!(codes, vec!["en"]);
477    }
478
479    #[test]
480    fn test_fluent_feature_single_string() {
481        let temp_dir = TempDir::new().unwrap();
482        let config_path = temp_dir.path().join("i18n.toml");
483
484        let config_content = r#"
485fallback_language = "en"
486assets_dir = "i18n"
487fluent_feature = "fluent"
488"#;
489
490        fs::write(&config_path, config_content).unwrap();
491
492        let config = I18nConfig::read_from_path(&config_path).unwrap();
493        let features = config.fluent_feature.unwrap().as_vec();
494        assert_eq!(features, vec!["fluent"]);
495    }
496
497    #[test]
498    fn test_fluent_feature_array() {
499        let temp_dir = TempDir::new().unwrap();
500        let config_path = temp_dir.path().join("i18n.toml");
501
502        let config_content = r#"
503fallback_language = "en"
504assets_dir = "i18n"
505fluent_feature = ["fluent", "i18n"]
506"#;
507
508        fs::write(&config_path, config_content).unwrap();
509
510        let config = I18nConfig::read_from_path(&config_path).unwrap();
511        let features = config.fluent_feature.unwrap().as_vec();
512        assert_eq!(features, vec!["fluent", "i18n"]);
513    }
514
515    #[test]
516    fn test_fluent_feature_none() {
517        let temp_dir = TempDir::new().unwrap();
518        let config_path = temp_dir.path().join("i18n.toml");
519
520        let config_content = r#"
521fallback_language = "en"
522assets_dir = "i18n"
523"#;
524
525        fs::write(&config_path, config_content).unwrap();
526
527        let config = I18nConfig::read_from_path(&config_path).unwrap();
528        assert!(config.fluent_feature.is_none());
529    }
530}