Skip to main content

es_fluent_cli_helpers/
generate.rs

1//! FTL file generation functionality.
2
3use es_fluent::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    /// 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    /// Dry run (don't write changes).
59    #[builder(default)]
60    dry_run: bool,
61}
62
63/// Command line arguments for the generator.
64#[derive(clap::Parser)]
65pub struct GeneratorArgs {
66    #[command(subcommand)]
67    action: Action,
68}
69
70#[derive(clap::Subcommand)]
71enum Action {
72    /// Generate FTL files
73    Generate {
74        /// Parse mode
75        #[arg(long, default_value_t = FluentParseMode::default())]
76        mode: FluentParseMode,
77        /// Dry run (don't write changes)
78        #[arg(long)]
79        dry_run: bool,
80    },
81    /// Clean FTL files (remove orphans)
82    Clean {
83        /// Clean all locales
84        #[arg(long)]
85        all: bool,
86        /// Dry run (don't write changes)
87        #[arg(long)]
88        dry_run: bool,
89    },
90}
91
92impl EsFluentGenerator {
93    /// Runs the generator based on command line arguments.
94    pub fn run_cli(self) -> Result<bool, GeneratorError> {
95        use clap::Parser;
96        let args = GeneratorArgs::parse();
97
98        match args.action {
99            Action::Generate { mode, dry_run } => {
100                let mut generator = self;
101                generator.mode = mode;
102                generator.dry_run = dry_run;
103                generator.generate()
104            },
105            Action::Clean { all, dry_run } => self.clean(all, dry_run),
106        }
107    }
108
109    // --- Resolution helpers (DRY) ---
110
111    /// Resolve the crate name, using override or auto-detection.
112    fn resolve_crate_name(&self) -> Result<String, GeneratorError> {
113        self.crate_name
114            .clone()
115            .map_or_else(Self::detect_crate_name, Ok)
116    }
117
118    /// Resolve the output path for the fallback locale.
119    fn resolve_output_path(&self) -> Result<PathBuf, GeneratorError> {
120        if let Some(path) = &self.output_path {
121            return Ok(path.clone());
122        }
123        let config = es_fluent_toml::I18nConfig::read_from_manifest_dir()?;
124        Ok(config.assets_dir.join(&config.fallback_language))
125    }
126
127    /// Resolve the assets directory.
128    fn resolve_assets_dir(&self) -> Result<PathBuf, GeneratorError> {
129        if let Some(path) = &self.assets_dir {
130            return Ok(path.clone());
131        }
132        let config = es_fluent_toml::I18nConfig::read_from_manifest_dir()?;
133        Ok(config.assets_dir)
134    }
135
136    /// Resolve the paths to clean based on configuration.
137    fn resolve_clean_paths(&self, all_locales: bool) -> Result<Vec<PathBuf>, GeneratorError> {
138        if !all_locales {
139            return Ok(vec![self.resolve_output_path()?]);
140        }
141
142        let assets_dir = self.resolve_assets_dir()?;
143        let mut paths: Vec<PathBuf> = std::fs::read_dir(&assets_dir)
144            .ok()
145            .map(|entries| {
146                entries
147                    .filter_map(|e| e.ok())
148                    .filter(|e| e.path().is_dir())
149                    .map(|e| e.path())
150                    .collect()
151            })
152            .unwrap_or_else(|| self.output_path.clone().into_iter().collect());
153
154        // Sort paths to ensure deterministic ordering across filesystems
155        paths.sort();
156
157        Ok(paths)
158    }
159
160    /// Generates FTL files from all registered types.
161    pub fn generate(&self) -> Result<bool, GeneratorError> {
162        let crate_name = self.resolve_crate_name()?;
163        let output_path = self.resolve_output_path()?;
164        let type_infos = collect_type_infos(&crate_name);
165
166        // Validate namespaces against allowed list if configured
167        self.validate_namespaces(&type_infos)?;
168
169        tracing::info!(
170            "Generating FTL files for {} types in crate '{}'",
171            type_infos.len(),
172            crate_name
173        );
174
175        let changed = es_fluent_generate::generate(
176            &crate_name,
177            output_path,
178            &type_infos,
179            self.mode.clone(),
180            self.dry_run,
181        )?;
182
183        Ok(changed)
184    }
185
186    /// Validates that all namespaces in the type infos are allowed by the config.
187    fn validate_namespaces(
188        &self,
189        type_infos: &[&'static FtlTypeInfo],
190    ) -> Result<(), GeneratorError> {
191        let config = es_fluent_toml::I18nConfig::read_from_manifest_dir().ok();
192        let allowed = config.as_ref().and_then(|c| c.namespaces.as_ref());
193
194        if let Some(allowed_namespaces) = allowed {
195            for info in type_infos {
196                if let Some(ns) = info.namespace
197                    && !allowed_namespaces.contains(&ns.to_string())
198                {
199                    return Err(GeneratorError::InvalidNamespace {
200                        namespace: ns.to_string(),
201                        type_name: info.type_name.to_string(),
202                        allowed: allowed_namespaces.clone(),
203                    });
204                }
205            }
206        }
207
208        Ok(())
209    }
210
211    /// Cleans FTL files by removing orphan keys while preserving existing translations.
212    pub fn clean(&self, all_locales: bool, dry_run: bool) -> Result<bool, GeneratorError> {
213        let crate_name = self.resolve_crate_name()?;
214        let paths = self.resolve_clean_paths(all_locales)?;
215        let type_infos = collect_type_infos(&crate_name);
216
217        let mut any_changed = false;
218        for output_path in paths {
219            if !dry_run {
220                tracing::info!(
221                    "Cleaning FTL files for {} types in crate '{}' at {}",
222                    type_infos.len(),
223                    crate_name,
224                    output_path.display()
225                );
226            }
227
228            if es_fluent_generate::clean::clean(&crate_name, output_path, &type_infos, dry_run)? {
229                any_changed = true;
230            }
231        }
232
233        Ok(any_changed)
234    }
235
236    /// Auto-detects the crate name from Cargo.toml using cargo_metadata.
237    fn detect_crate_name() -> Result<String, GeneratorError> {
238        let manifest_dir = std::env::var("CARGO_MANIFEST_DIR")
239            .map_err(|_| GeneratorError::CrateName("CARGO_MANIFEST_DIR not set".to_string()))?;
240        let manifest_path = PathBuf::from(&manifest_dir).join("Cargo.toml");
241
242        cargo_metadata::MetadataCommand::new()
243            .exec()
244            .ok()
245            .and_then(|metadata| {
246                metadata
247                    .packages
248                    .iter()
249                    .find(|pkg| pkg.manifest_path == manifest_path)
250                    .map(|pkg| pkg.name.to_string())
251            })
252            .or_else(|| std::env::var("CARGO_PKG_NAME").ok())
253            .ok_or_else(|| GeneratorError::CrateName("Could not determine crate name".to_string()))
254    }
255}
256
257/// Collect all registered type infos for a given crate.
258fn collect_type_infos(crate_name: &str) -> Vec<&'static FtlTypeInfo> {
259    let crate_ident = crate_name.replace('-', "_");
260    es_fluent::registry::get_all_ftl_type_infos()
261        .filter(|info| {
262            info.module_path == crate_ident
263                || info.module_path.starts_with(&format!("{}::", crate_ident))
264        })
265        .collect()
266}