1use es_fluent::registry::FtlTypeInfo;
4use std::path::{Path, PathBuf};
5
6pub use es_fluent_generate::FluentParseMode;
7pub use es_fluent_generate::error::FluentGenerateError;
8
9#[derive(Debug, thiserror::Error)]
11pub enum GeneratorError {
12 #[error("Configuration error: {0}")]
14 Config(#[from] es_fluent_toml::I18nConfigError),
15
16 #[error("Failed to detect crate name: {0}")]
18 CrateName(String),
19
20 #[error("Generation error: {0}")]
22 Generate(#[from] FluentGenerateError),
23
24 #[error(
26 "Invalid namespace '{namespace}' for type '{type_name}'. Allowed namespaces: {allowed:?}"
27 )]
28 InvalidNamespace {
29 namespace: String,
30 type_name: String,
31 allowed: Vec<String>,
32 },
33}
34
35#[derive(bon::Builder)]
40pub struct EsFluentGenerator {
41 #[builder(default)]
44 mode: FluentParseMode,
45
46 #[builder(into)]
48 crate_name: Option<String>,
49
50 #[builder(into)]
52 output_path: Option<PathBuf>,
53
54 #[builder(into)]
56 assets_dir: Option<PathBuf>,
57
58 #[builder(into)]
60 manifest_dir: Option<PathBuf>,
61
62 #[builder(default)]
64 dry_run: bool,
65}
66
67#[derive(clap::Parser)]
69pub struct GeneratorArgs {
70 #[command(subcommand)]
71 action: Action,
72}
73
74#[derive(clap::Subcommand)]
75enum Action {
76 Generate {
78 #[arg(long, default_value_t = FluentParseMode::default())]
80 mode: FluentParseMode,
81 #[arg(long)]
83 dry_run: bool,
84 },
85 Clean {
87 #[arg(long)]
89 all: bool,
90 #[arg(long)]
92 dry_run: bool,
93 },
94}
95
96impl EsFluentGenerator {
97 pub fn run_cli(self) -> Result<bool, GeneratorError> {
99 use clap::Parser as _;
100 let args = GeneratorArgs::parse();
101
102 match args.action {
103 Action::Generate { mode, dry_run } => {
104 let mut generator = self;
105 generator.mode = mode;
106 generator.dry_run = dry_run;
107 generator.generate()
108 },
109 Action::Clean { all, dry_run } => self.clean(all, dry_run),
110 }
111 }
112
113 fn resolve_crate_name(&self) -> Result<String, GeneratorError> {
117 self.crate_name
118 .clone()
119 .map_or_else(Self::detect_crate_name, Ok)
120 }
121
122 fn resolve_output_path(&self) -> Result<PathBuf, GeneratorError> {
124 if let Some(path) = &self.output_path {
125 return Ok(path.clone());
126 }
127 let manifest_dir = self.resolve_manifest_dir()?;
128 Ok(es_fluent_toml::I18nConfig::output_dir_from_manifest_dir(
129 &manifest_dir,
130 )?)
131 }
132
133 fn resolve_assets_dir(&self) -> Result<PathBuf, GeneratorError> {
135 if let Some(path) = &self.assets_dir {
136 return Ok(path.clone());
137 }
138 let manifest_dir = self.resolve_manifest_dir()?;
139 Ok(es_fluent_toml::I18nConfig::assets_dir_from_manifest_dir(
140 &manifest_dir,
141 )?)
142 }
143
144 fn resolve_manifest_dir(&self) -> Result<PathBuf, GeneratorError> {
146 if let Some(path) = &self.manifest_dir {
147 return Ok(path.clone());
148 }
149
150 let manifest_dir = std::env::var("CARGO_MANIFEST_DIR")
151 .map_err(|_| GeneratorError::CrateName("CARGO_MANIFEST_DIR not set".to_string()))?;
152 Ok(PathBuf::from(manifest_dir))
153 }
154
155 fn resolve_clean_paths(&self, all_locales: bool) -> Result<Vec<PathBuf>, GeneratorError> {
157 if !all_locales {
158 return Ok(vec![self.resolve_output_path()?]);
159 }
160
161 let assets_dir = self.resolve_assets_dir()?;
162 let mut paths: Vec<PathBuf> = std::fs::read_dir(&assets_dir)
163 .ok()
164 .map(|entries| {
165 entries
166 .filter_map(|e| e.ok())
167 .filter(|e| e.path().is_dir())
168 .map(|e| e.path())
169 .collect()
170 })
171 .unwrap_or_else(|| self.output_path.clone().into_iter().collect());
172
173 paths.sort();
175
176 Ok(paths)
177 }
178
179 pub fn generate(&self) -> Result<bool, GeneratorError> {
181 let crate_name = self.resolve_crate_name()?;
182 let output_path = self.resolve_output_path()?;
183 let manifest_dir = self.resolve_manifest_dir()?;
184 let type_infos = collect_type_infos(&crate_name);
185
186 self.validate_namespaces(&type_infos, &manifest_dir)?;
188
189 tracing::info!(
190 "Generating FTL files for {} types in crate '{}'",
191 type_infos.len(),
192 crate_name
193 );
194
195 let changed = es_fluent_generate::generate(
196 &crate_name,
197 output_path,
198 &manifest_dir,
199 &type_infos,
200 self.mode.clone(),
201 self.dry_run,
202 )?;
203
204 Ok(changed)
205 }
206
207 fn validate_namespaces(
209 &self,
210 type_infos: &[&'static FtlTypeInfo],
211 manifest_dir: &Path,
212 ) -> Result<(), GeneratorError> {
213 let config = es_fluent_toml::I18nConfig::from_manifest_dir(manifest_dir).ok();
214 let allowed = config.as_ref().and_then(|c| c.namespaces.as_ref());
215
216 if let Some(allowed_namespaces) = allowed {
217 for info in type_infos {
218 if let Some(ns) = info.resolved_namespace(manifest_dir)
219 && !allowed_namespaces.contains(&ns)
220 {
221 return Err(GeneratorError::InvalidNamespace {
222 namespace: ns,
223 type_name: info.type_name.to_string(),
224 allowed: allowed_namespaces.clone(),
225 });
226 }
227 }
228 }
229
230 Ok(())
231 }
232
233 pub fn clean(&self, all_locales: bool, dry_run: bool) -> Result<bool, GeneratorError> {
235 let crate_name = self.resolve_crate_name()?;
236 let paths = self.resolve_clean_paths(all_locales)?;
237 let manifest_dir = self.resolve_manifest_dir()?;
238 let type_infos = collect_type_infos(&crate_name);
239
240 let mut any_changed = false;
241 for output_path in paths {
242 if !dry_run {
243 tracing::info!(
244 "Cleaning FTL files for {} types in crate '{}' at {}",
245 type_infos.len(),
246 crate_name,
247 output_path.display()
248 );
249 }
250
251 if es_fluent_generate::clean::clean(
252 &crate_name,
253 output_path,
254 &manifest_dir,
255 &type_infos,
256 dry_run,
257 )? {
258 any_changed = true;
259 }
260 }
261
262 Ok(any_changed)
263 }
264
265 fn detect_crate_name() -> Result<String, GeneratorError> {
267 let manifest_dir = std::env::var("CARGO_MANIFEST_DIR")
268 .map_err(|_| GeneratorError::CrateName("CARGO_MANIFEST_DIR not set".to_string()))?;
269 let manifest_path = PathBuf::from(&manifest_dir).join("Cargo.toml");
270
271 cargo_metadata::MetadataCommand::new()
272 .exec()
273 .ok()
274 .and_then(|metadata| {
275 metadata
276 .packages
277 .iter()
278 .find(|pkg| pkg.manifest_path == manifest_path)
279 .map(|pkg| pkg.name.to_string())
280 })
281 .or_else(|| std::env::var("CARGO_PKG_NAME").ok())
282 .ok_or_else(|| GeneratorError::CrateName("Could not determine crate name".to_string()))
283 }
284}
285
286fn collect_type_infos(crate_name: &str) -> Vec<&'static FtlTypeInfo> {
288 let crate_ident = crate_name.replace('-', "_");
289 es_fluent::registry::get_all_ftl_type_infos()
290 .filter(|info| {
291 info.module_path == crate_ident
292 || info.module_path.starts_with(&format!("{}::", crate_ident))
293 })
294 .collect()
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300 use es_fluent::registry::{FtlVariant, NamespaceRule};
301 use es_fluent_derive_core::meta::TypeKind;
302 use std::sync::{LazyLock, Mutex};
303 use tempfile::tempdir;
304
305 static EMPTY_VARIANTS: &[FtlVariant] = &[];
306 static ALLOWED_INFO: FtlTypeInfo = FtlTypeInfo {
307 type_kind: TypeKind::Struct,
308 type_name: "AllowedType",
309 variants: EMPTY_VARIANTS,
310 file_path: "src/lib.rs",
311 module_path: "test_crate",
312 namespace: Some(NamespaceRule::Literal("ui")),
313 };
314 static DISALLOWED_INFO: FtlTypeInfo = FtlTypeInfo {
315 type_kind: TypeKind::Struct,
316 type_name: "DisallowedType",
317 variants: EMPTY_VARIANTS,
318 file_path: "src/lib.rs",
319 module_path: "test_crate",
320 namespace: Some(NamespaceRule::Literal("errors")),
321 };
322 static CLEAN_VARIANTS: &[FtlVariant] = &[FtlVariant {
323 name: "Key1",
324 ftl_key: "group_a-Key1",
325 args: &[],
326 module_path: "test",
327 line: 0,
328 }];
329 static CLEAN_INFO: FtlTypeInfo = FtlTypeInfo {
330 type_kind: TypeKind::Enum,
331 type_name: "GroupA",
332 variants: CLEAN_VARIANTS,
333 file_path: "src/lib.rs",
334 module_path: "coverage_test_crate",
335 namespace: None,
336 };
337 static ENV_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
338
339 es_fluent::__inventory::submit! {
340 es_fluent::registry::RegisteredFtlType(&CLEAN_INFO)
341 }
342
343 fn with_env_var<T>(key: &str, value: Option<&str>, f: impl FnOnce() -> T) -> T {
344 let _guard = ENV_LOCK.lock().expect("lock poisoned");
345 let previous = std::env::var_os(key);
346
347 match value {
348 Some(value) => {
349 unsafe { std::env::set_var(key, value) };
351 },
352 None => {
353 unsafe { std::env::remove_var(key) };
355 },
356 }
357
358 let result = f();
359
360 match previous {
361 Some(previous) => {
362 unsafe { std::env::set_var(key, previous) };
364 },
365 None => {
366 unsafe { std::env::remove_var(key) };
368 },
369 }
370
371 result
372 }
373
374 fn with_env_vars<T>(vars: &[(&str, Option<&str>)], f: impl FnOnce() -> T) -> T {
375 let _guard = ENV_LOCK.lock().expect("lock poisoned");
376 let previous: Vec<(String, Option<std::ffi::OsString>)> = vars
377 .iter()
378 .map(|(key, _)| ((*key).to_string(), std::env::var_os(key)))
379 .collect();
380
381 for (key, value) in vars {
382 match value {
383 Some(value) => {
384 unsafe { std::env::set_var(key, value) };
386 },
387 None => {
388 unsafe { std::env::remove_var(key) };
390 },
391 }
392 }
393
394 let result = f();
395
396 for (key, value) in previous {
397 match value {
398 Some(value) => {
399 unsafe { std::env::set_var(&key, value) };
401 },
402 None => {
403 unsafe { std::env::remove_var(&key) };
405 },
406 }
407 }
408
409 result
410 }
411
412 fn write_basic_i18n_config(manifest_dir: &Path) {
413 std::fs::create_dir_all(manifest_dir.join("i18n/en-US")).expect("mkdir en-US");
414 std::fs::create_dir_all(manifest_dir.join("i18n/fr")).expect("mkdir fr");
415 std::fs::write(
416 manifest_dir.join("i18n.toml"),
417 "fallback_language = \"en-US\"\nassets_dir = \"i18n\"\nnamespaces = [\"ui\"]\n",
418 )
419 .expect("write i18n.toml");
420 }
421
422 #[test]
423 fn resolve_helpers_use_overrides_and_config_defaults() {
424 let temp = tempdir().expect("tempdir");
425 write_basic_i18n_config(temp.path());
426
427 let output_override = temp.path().join("custom-output");
428 let assets_override = temp.path().join("custom-assets");
429 let generator = EsFluentGenerator::builder()
430 .crate_name("my-crate")
431 .output_path(&output_override)
432 .assets_dir(&assets_override)
433 .manifest_dir(temp.path())
434 .build();
435
436 assert_eq!(
437 generator.resolve_crate_name().expect("crate name"),
438 "my-crate"
439 );
440 assert_eq!(
441 generator.resolve_output_path().expect("output"),
442 output_override
443 );
444 assert_eq!(
445 generator.resolve_assets_dir().expect("assets"),
446 assets_override
447 );
448 assert_eq!(
449 generator.resolve_manifest_dir().expect("manifest"),
450 temp.path()
451 );
452 }
453
454 #[test]
455 fn resolve_helpers_can_load_defaults_from_manifest_environment() {
456 let temp = tempdir().expect("tempdir");
457 write_basic_i18n_config(temp.path());
458
459 with_env_var("CARGO_MANIFEST_DIR", temp.path().to_str(), || {
460 let generator = EsFluentGenerator::builder()
461 .crate_name("missing-crate")
462 .build();
463 assert_eq!(
464 generator.resolve_output_path().expect("output path"),
465 temp.path().join("i18n/en-US")
466 );
467 assert_eq!(
468 generator.resolve_assets_dir().expect("assets path"),
469 temp.path().join("i18n")
470 );
471 assert_eq!(
472 generator.resolve_manifest_dir().expect("manifest path"),
473 temp.path()
474 );
475 });
476 }
477
478 #[test]
479 fn resolve_manifest_dir_reports_missing_environment() {
480 let generator = EsFluentGenerator::builder()
481 .crate_name("missing-crate")
482 .build();
483
484 with_env_var("CARGO_MANIFEST_DIR", None, || {
485 let err = generator
486 .resolve_manifest_dir()
487 .expect_err("missing env should fail");
488 assert!(
489 matches!(err, GeneratorError::CrateName(message) if message.contains("CARGO_MANIFEST_DIR not set"))
490 );
491 });
492 }
493
494 #[test]
495 fn resolve_helpers_report_config_errors_when_manifest_lacks_i18n_toml() {
496 let temp = tempdir().expect("tempdir");
497 let generator = EsFluentGenerator::builder()
498 .crate_name("missing-crate")
499 .manifest_dir(temp.path())
500 .build();
501
502 let output_err = generator
503 .resolve_output_path()
504 .expect_err("missing config should fail");
505 assert!(matches!(output_err, GeneratorError::Config(_)));
506
507 let assets_err = generator
508 .resolve_assets_dir()
509 .expect_err("missing config should fail");
510 assert!(matches!(assets_err, GeneratorError::Config(_)));
511 }
512
513 #[test]
514 fn resolve_clean_paths_supports_single_or_all_locales() {
515 let temp = tempdir().expect("tempdir");
516 write_basic_i18n_config(temp.path());
517
518 let generator = EsFluentGenerator::builder()
519 .crate_name("missing-crate")
520 .manifest_dir(temp.path())
521 .build();
522
523 let single = generator
524 .resolve_clean_paths(false)
525 .expect("single clean path");
526 assert_eq!(single, vec![temp.path().join("i18n/en-US")]);
527
528 let all = generator
529 .resolve_clean_paths(true)
530 .expect("all clean paths");
531 assert_eq!(
532 all,
533 vec![temp.path().join("i18n/en-US"), temp.path().join("i18n/fr")]
534 );
535 }
536
537 #[test]
538 fn resolve_clean_paths_falls_back_to_output_override_when_assets_dir_missing() {
539 let temp = tempdir().expect("tempdir");
540 let fallback_output = temp.path().join("fallback-output");
541 let generator = EsFluentGenerator::builder()
542 .crate_name("missing-crate")
543 .manifest_dir(temp.path())
544 .output_path(&fallback_output)
545 .assets_dir(temp.path().join("missing-assets"))
546 .build();
547
548 let paths = generator
549 .resolve_clean_paths(true)
550 .expect("resolve clean paths");
551 assert_eq!(paths, vec![fallback_output]);
552 }
553
554 #[test]
555 fn validate_namespaces_allows_configured_namespaces_only() {
556 let temp = tempdir().expect("tempdir");
557 write_basic_i18n_config(temp.path());
558
559 let generator = EsFluentGenerator::builder()
560 .crate_name("missing-crate")
561 .manifest_dir(temp.path())
562 .build();
563
564 generator
565 .validate_namespaces(&[&ALLOWED_INFO], temp.path())
566 .expect("allowed namespace should pass");
567
568 let err = generator
569 .validate_namespaces(&[&DISALLOWED_INFO], temp.path())
570 .expect_err("disallowed namespace should fail");
571 assert!(matches!(
572 err,
573 GeneratorError::InvalidNamespace {
574 namespace,
575 type_name,
576 ..
577 } if namespace == "errors" && type_name == "DisallowedType"
578 ));
579 }
580
581 #[test]
582 fn generate_and_clean_handle_empty_inventory() {
583 let temp = tempdir().expect("tempdir");
584 write_basic_i18n_config(temp.path());
585
586 let generator = EsFluentGenerator::builder()
587 .crate_name("missing-crate")
588 .manifest_dir(temp.path())
589 .build();
590
591 let generate_changed = generator.generate().expect("generate");
592 assert!(!generate_changed);
593
594 let clean_changed = generator.clean(false, false).expect("clean");
595 assert!(!clean_changed);
596
597 let clean_all_changed = generator.clean(true, true).expect("clean all");
598 assert!(!clean_all_changed);
599 }
600
601 #[test]
602 fn clean_marks_changes_when_cleaner_rewrites_files() {
603 let temp = tempdir().expect("tempdir");
604 write_basic_i18n_config(temp.path());
605
606 let target_file = temp.path().join("i18n/en-US/coverage-test-crate.ftl");
607 std::fs::write(
608 &target_file,
609 "## GroupA\n\ngroup_a-Key1 = Keep\norphan-Old = stale value\n",
610 )
611 .expect("write stale ftl");
612
613 let generator = EsFluentGenerator::builder()
614 .crate_name("coverage-test-crate")
615 .manifest_dir(temp.path())
616 .build();
617
618 let changed = generator.clean(false, false).expect("clean");
619 assert!(changed);
620 }
621
622 #[test]
623 fn detect_crate_name_works_in_test_environment() {
624 with_env_vars(
625 &[
626 ("CARGO_MANIFEST_DIR", Some(env!("CARGO_MANIFEST_DIR"))),
627 ("CARGO_PKG_NAME", Some(env!("CARGO_PKG_NAME"))),
628 ],
629 || {
630 let crate_name = EsFluentGenerator::detect_crate_name().expect("crate name");
631 assert_eq!(crate_name, env!("CARGO_PKG_NAME"));
632 },
633 );
634 }
635
636 #[test]
637 fn detect_crate_name_uses_env_fallback_or_errors_when_unavailable() {
638 let temp = tempdir().expect("tempdir");
639
640 with_env_vars(
641 &[
642 ("CARGO_MANIFEST_DIR", temp.path().to_str()),
643 ("CARGO_PKG_NAME", Some("env-fallback-crate")),
644 ],
645 || {
646 let crate_name = EsFluentGenerator::detect_crate_name().expect("crate name");
647 assert_eq!(crate_name, "env-fallback-crate");
648 },
649 );
650
651 with_env_vars(
652 &[
653 ("CARGO_MANIFEST_DIR", temp.path().to_str()),
654 ("CARGO_PKG_NAME", None),
655 ],
656 || {
657 let err = EsFluentGenerator::detect_crate_name().expect_err("should fail");
658 assert!(
659 matches!(err, GeneratorError::CrateName(message) if message.contains("Could not determine crate name"))
660 );
661 },
662 );
663
664 with_env_var("CARGO_MANIFEST_DIR", None, || {
665 let err = EsFluentGenerator::detect_crate_name().expect_err("missing env should fail");
666 assert!(
667 matches!(err, GeneratorError::CrateName(message) if message.contains("CARGO_MANIFEST_DIR not set"))
668 );
669 });
670 }
671
672 #[test]
673 fn env_helpers_restore_unset_variables() {
674 let key = format!("ES_FLUENT_TEST_UNSET_{}_A", std::process::id());
675 with_env_var(&key, Some("value"), || {
676 assert_eq!(std::env::var(&key).expect("set"), "value");
677 });
678 assert!(std::env::var(&key).is_err());
679
680 let key_a = format!("ES_FLUENT_TEST_UNSET_{}_B", std::process::id());
681 let key_b = format!("ES_FLUENT_TEST_UNSET_{}_C", std::process::id());
682 with_env_vars(
683 &[(key_a.as_str(), Some("first")), (key_b.as_str(), None)],
684 || {
685 assert_eq!(std::env::var(&key_a).expect("set"), "first");
686 assert!(std::env::var(&key_b).is_err());
687 },
688 );
689 assert!(std::env::var(&key_a).is_err());
690 assert!(std::env::var(&key_b).is_err());
691 }
692
693 #[test]
694 fn collect_type_infos_returns_empty_for_unknown_crate() {
695 let infos = collect_type_infos("definitely_unknown_crate_name");
696 assert!(infos.is_empty());
697 }
698}