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 #[error("i18n.toml configuration file not found")]
13 NotFound,
14 #[error("Failed to read configuration file: {0}")]
16 ReadError(#[from] std::io::Error),
17 #[error("Failed to parse configuration file: {0}")]
19 ParseError(#[from] toml::de::Error),
20 #[error("Invalid language identifier '{name}' found in assets directory")]
22 InvalidLanguageIdentifier {
23 name: String,
25 #[source]
27 source: LanguageIdentifierError,
28 },
29 #[error("Language identifier '{name}' is not supported: {reason}")]
31 UnsupportedLanguageIdentifier {
32 name: String,
34 reason: String,
36 },
37 #[error("Invalid fallback language identifier '{name}'")]
39 InvalidFallbackLanguageIdentifier {
40 name: String,
42 #[source]
44 source: LanguageIdentifierError,
45 },
46}
47
48#[derive(Clone, Debug, Deserialize, Serialize)]
63#[serde(untagged)]
64pub enum FluentFeature {
65 Single(String),
67 Multiple(Vec<String>),
69}
70
71impl FluentFeature {
72 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 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#[derive(Clone, Debug, Deserialize, Serialize)]
91pub struct I18nConfig {
92 pub fallback_language: String,
94 pub assets_dir: PathBuf,
97 #[serde(default)]
112 pub fluent_feature: Option<FluentFeature>,
113 #[serde(default)]
123 pub namespaces: Option<Vec<String>>,
124}
125
126impl I18nConfig {
127 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 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 pub fn assets_dir_path(&self) -> PathBuf {
152 PathBuf::from(&self.assets_dir)
153 }
154
155 pub fn assets_dir_from_manifest(&self) -> Result<PathBuf, I18nConfigError> {
157 self.assets_dir_from_base(None)
158 }
159
160 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 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 pub fn available_languages(&self) -> Result<Vec<LanguageIdentifier>, I18nConfigError> {
197 self.available_languages_from_base(None)
198 }
199
200 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 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 pub fn fallback_language_id(&self) -> &str {
249 &self.fallback_language
250 }
251
252 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 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 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
274fn 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}