es_fluent_cli_helpers/
generate.rs

1//! FTL file generation functionality.
2
3use es_fluent_core::registry::FtlTypeInfo;
4use std::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
25/// Builder for generating FTL files from registered types.
26///
27/// Uses the `inventory` crate to collect all types registered via
28/// `#[derive(EsFluent)]`, `#[derive(EsFluentKv)]`, or `#[derive(EsFluentThis)]`.
29#[derive(bon::Builder)]
30pub struct EsFluentGenerator {
31    /// The parse mode (Conservative preserves existing translations, Aggressive overwrites).
32    /// Defaults to Conservative.
33    #[builder(default)]
34    mode: FluentParseMode,
35
36    /// Override the crate name (defaults to auto-detect from Cargo.toml).
37    #[builder(into)]
38    crate_name: Option<String>,
39
40    /// Override the output path (defaults to reading from i18n.toml).
41    #[builder(into)]
42    output_path: Option<PathBuf>,
43
44    /// Override the assets directory (defaults to reading from i18n.toml).
45    #[builder(into)]
46    assets_dir: Option<PathBuf>,
47
48    /// Dry run (don't write changes).
49    #[builder(default)]
50    dry_run: bool,
51}
52
53/// Command line arguments for the generator.
54#[derive(clap::Parser)]
55pub struct GeneratorArgs {
56    #[command(subcommand)]
57    action: Action,
58}
59
60#[derive(clap::Subcommand)]
61enum Action {
62    /// Generate FTL files
63    Generate {
64        /// Parse mode
65        #[arg(long, default_value_t = FluentParseMode::default())]
66        mode: FluentParseMode,
67        /// Dry run (don't write changes)
68        #[arg(long)]
69        dry_run: bool,
70    },
71    /// Clean FTL files (remove orphans)
72    Clean {
73        /// Clean all locales
74        #[arg(long)]
75        all: bool,
76        /// Dry run (don't write changes)
77        #[arg(long)]
78        dry_run: bool,
79    },
80}
81
82impl EsFluentGenerator {
83    /// Runs the generator based on command line arguments.
84    pub fn run_cli(self) -> Result<bool, GeneratorError> {
85        use clap::Parser;
86        let args = GeneratorArgs::parse();
87
88        match args.action {
89            Action::Generate { mode, dry_run } => {
90                let mut generator = self;
91                generator.mode = mode;
92                generator.dry_run = dry_run;
93                generator.generate()
94            },
95            Action::Clean { all, dry_run } => self.clean(all, dry_run),
96        }
97    }
98
99    // --- Resolution helpers (DRY) ---
100
101    /// Resolve the crate name, using override or auto-detection.
102    fn resolve_crate_name(&self) -> Result<String, GeneratorError> {
103        self.crate_name
104            .clone()
105            .map_or_else(Self::detect_crate_name, Ok)
106    }
107
108    /// Resolve the output path for the fallback locale.
109    fn resolve_output_path(&self) -> Result<PathBuf, GeneratorError> {
110        if let Some(path) = &self.output_path {
111            return Ok(path.clone());
112        }
113        let config = es_fluent_toml::I18nConfig::read_from_manifest_dir()?;
114        Ok(config.assets_dir.join(&config.fallback_language))
115    }
116
117    /// Resolve the assets directory.
118    fn resolve_assets_dir(&self) -> Result<PathBuf, GeneratorError> {
119        if let Some(path) = &self.assets_dir {
120            return Ok(path.clone());
121        }
122        let config = es_fluent_toml::I18nConfig::read_from_manifest_dir()?;
123        Ok(config.assets_dir)
124    }
125
126    /// Resolve the paths to clean based on configuration.
127    fn resolve_clean_paths(&self, all_locales: bool) -> Result<Vec<PathBuf>, GeneratorError> {
128        if !all_locales {
129            return Ok(vec![self.resolve_output_path()?]);
130        }
131
132        let assets_dir = self.resolve_assets_dir()?;
133        let paths = std::fs::read_dir(&assets_dir)
134            .ok()
135            .map(|entries| {
136                entries
137                    .filter_map(|e| e.ok())
138                    .filter(|e| e.path().is_dir())
139                    .map(|e| e.path())
140                    .collect()
141            })
142            .unwrap_or_else(|| self.output_path.clone().into_iter().collect());
143
144        Ok(paths)
145    }
146
147    /// Generates FTL files from all registered types.
148    pub fn generate(&self) -> Result<bool, GeneratorError> {
149        let crate_name = self.resolve_crate_name()?;
150        let output_path = self.resolve_output_path()?;
151        let type_infos = collect_type_infos(&crate_name);
152
153        tracing::info!(
154            "Generating FTL files for {} types in crate '{}'",
155            type_infos.len(),
156            crate_name
157        );
158
159        let changed = es_fluent_generate::generate(
160            &crate_name,
161            output_path,
162            type_infos,
163            self.mode.clone(),
164            self.dry_run,
165        )?;
166
167        Ok(changed)
168    }
169
170    /// Cleans FTL files by removing orphan keys while preserving existing translations.
171    pub fn clean(&self, all_locales: bool, dry_run: bool) -> Result<bool, GeneratorError> {
172        let crate_name = self.resolve_crate_name()?;
173        let paths = self.resolve_clean_paths(all_locales)?;
174        let type_infos = collect_type_infos(&crate_name);
175
176        let mut any_changed = false;
177        for output_path in paths {
178            if !dry_run {
179                tracing::info!(
180                    "Cleaning FTL files for {} types in crate '{}' at {}",
181                    type_infos.len(),
182                    crate_name,
183                    output_path.display()
184                );
185            }
186
187            if es_fluent_generate::clean::clean(
188                &crate_name,
189                output_path,
190                type_infos.clone(),
191                dry_run,
192            )? {
193                any_changed = true;
194            }
195        }
196
197        Ok(any_changed)
198    }
199
200    /// Auto-detects the crate name from Cargo.toml using cargo_metadata.
201    fn detect_crate_name() -> Result<String, GeneratorError> {
202        let manifest_dir = std::env::var("CARGO_MANIFEST_DIR")
203            .map_err(|_| GeneratorError::CrateName("CARGO_MANIFEST_DIR not set".to_string()))?;
204        let manifest_path = PathBuf::from(&manifest_dir).join("Cargo.toml");
205
206        cargo_metadata::MetadataCommand::new()
207            .exec()
208            .ok()
209            .and_then(|metadata| {
210                metadata
211                    .packages
212                    .iter()
213                    .find(|pkg| pkg.manifest_path == manifest_path)
214                    .map(|pkg| pkg.name.to_string())
215            })
216            .or_else(|| std::env::var("CARGO_PKG_NAME").ok())
217            .ok_or_else(|| GeneratorError::CrateName("Could not determine crate name".to_string()))
218    }
219}
220
221/// Collect all registered type infos for a given crate.
222fn collect_type_infos(crate_name: &str) -> Vec<FtlTypeInfo> {
223    let crate_ident = crate_name.replace('-', "_");
224    es_fluent_core::registry::get_all_ftl_type_infos()
225        .into_iter()
226        .filter(|info| {
227            info.module_path == crate_ident
228                || info.module_path.starts_with(&format!("{}::", crate_ident))
229        })
230        .collect()
231}