Skip to main content

es_fluent_cli_helpers/
generate.rs

1//! FTL file generation functionality.
2
3use 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/// Error type for FTL generation.
10#[derive(Debug, thiserror::Error)]
11pub enum GeneratorError {
12    /// Failed to read i18n.toml configuration.
13    #[error("Configuration error: {0}")]
14    Config(#[from] es_fluent_toml::I18nConfigError),
15
16    /// Failed to detect crate name.
17    #[error("Failed to detect crate name: {0}")]
18    CrateName(String),
19
20    /// Failed to generate FTL files.
21    #[error("Generation error: {0}")]
22    Generate(#[from] FluentGenerateError),
23
24    /// Invalid namespace used (not in allowed list).
25    #[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/// Builder for generating FTL files from registered types.
36///
37/// Uses the `inventory` crate to collect all types registered via
38/// `#[derive(EsFluent)]`, `#[derive(EsFluentVariants)]`, or `#[derive(EsFluentThis)]`.
39#[derive(bon::Builder)]
40pub struct EsFluentGenerator {
41    /// The parse mode (Conservative preserves existing translations, Aggressive overwrites).
42    /// Defaults to Conservative.
43    #[builder(default)]
44    mode: FluentParseMode,
45
46    /// Override the crate name (defaults to auto-detect from Cargo.toml).
47    #[builder(into)]
48    crate_name: Option<String>,
49
50    /// Override the output path (defaults to reading from i18n.toml).
51    #[builder(into)]
52    output_path: Option<PathBuf>,
53
54    /// Override the assets directory (defaults to reading from i18n.toml).
55    #[builder(into)]
56    assets_dir: Option<PathBuf>,
57
58    /// Override the manifest directory for namespace resolution.
59    #[builder(into)]
60    manifest_dir: Option<PathBuf>,
61
62    /// Dry run (don't write changes).
63    #[builder(default)]
64    dry_run: bool,
65}
66
67/// Command line arguments for the generator.
68#[derive(clap::Parser)]
69pub struct GeneratorArgs {
70    #[command(subcommand)]
71    action: Action,
72}
73
74#[derive(clap::Subcommand)]
75enum Action {
76    /// Generate FTL files
77    Generate {
78        /// Parse mode
79        #[arg(long, default_value_t = FluentParseMode::default())]
80        mode: FluentParseMode,
81        /// Dry run (don't write changes)
82        #[arg(long)]
83        dry_run: bool,
84    },
85    /// Clean FTL files (remove orphans)
86    Clean {
87        /// Clean all locales
88        #[arg(long)]
89        all: bool,
90        /// Dry run (don't write changes)
91        #[arg(long)]
92        dry_run: bool,
93    },
94}
95
96impl EsFluentGenerator {
97    /// Runs the generator based on command line arguments.
98    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    // --- Resolution helpers (DRY) ---
114
115    /// Resolve the crate name, using override or auto-detection.
116    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    /// Resolve the output path for the fallback locale.
123    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    /// Resolve the assets directory.
134    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    /// Resolve the manifest directory for namespace resolution.
145    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    /// Resolve the paths to clean based on configuration.
156    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        // Sort paths to ensure deterministic ordering across filesystems
174        paths.sort();
175
176        Ok(paths)
177    }
178
179    /// Generates FTL files from all registered types.
180    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        // Validate namespaces against allowed list if configured
187        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    /// Validates that all namespaces in the type infos are allowed by the config.
208    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    /// Cleans FTL files by removing orphan keys while preserving existing translations.
234    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    /// Auto-detects the crate name from Cargo.toml using cargo_metadata.
266    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
286/// Collect all registered type infos for a given crate.
287fn 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                // SAFETY: tests serialize environment updates with a global lock.
350                unsafe { std::env::set_var(key, value) };
351            },
352            None => {
353                // SAFETY: tests serialize environment updates with a global lock.
354                unsafe { std::env::remove_var(key) };
355            },
356        }
357
358        let result = f();
359
360        match previous {
361            Some(previous) => {
362                // SAFETY: tests serialize environment updates with a global lock.
363                unsafe { std::env::set_var(key, previous) };
364            },
365            None => {
366                // SAFETY: tests serialize environment updates with a global lock.
367                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                    // SAFETY: tests serialize environment updates with a global lock.
385                    unsafe { std::env::set_var(key, value) };
386                },
387                None => {
388                    // SAFETY: tests serialize environment updates with a global lock.
389                    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                    // SAFETY: tests serialize environment updates with a global lock.
400                    unsafe { std::env::set_var(&key, value) };
401                },
402                None => {
403                    // SAFETY: tests serialize environment updates with a global lock.
404                    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}