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}