Skip to main content

es_fluent_cli/commands/
clean.rs

1//! Clean command implementation.
2
3use crate::commands::{
4    WorkspaceArgs, WorkspaceCrates, parallel_generate, render_generation_results,
5};
6use crate::core::{CliError, GenerationAction};
7use crate::utils::{ftl::main_ftl_path, ui};
8use clap::Parser;
9use colored::Colorize as _;
10use std::collections::HashSet;
11
12/// Arguments for the clean command.
13#[derive(Parser)]
14pub struct CleanArgs {
15    #[command(flatten)]
16    pub workspace: WorkspaceArgs,
17
18    /// Clean all locales, not just the fallback language.
19    #[arg(long)]
20    pub all: bool,
21
22    /// Dry run - show what would be cleaned without making changes.
23    #[arg(long)]
24    pub dry_run: bool,
25
26    /// Force rebuild of the runner, ignoring the staleness cache.
27    #[arg(long)]
28    pub force_run: bool,
29
30    /// Remove orphaned FTL files that are no longer tied to any types.
31    /// This removes files that don't correspond to any registered types
32    /// (e.g., when all items are now namespaced or the crate was deleted).
33    #[arg(long)]
34    pub orphaned: bool,
35}
36
37/// Run the clean command.
38pub fn run_clean(args: CleanArgs) -> Result<(), CliError> {
39    let workspace = WorkspaceCrates::discover(args.workspace)?;
40
41    if !workspace.print_discovery(ui::print_header) {
42        return Ok(());
43    }
44
45    // Handle orphaned file removal first if requested
46    if args.orphaned {
47        return clean_orphaned_files(&workspace, args.all, args.dry_run);
48    }
49
50    let action = GenerationAction::Clean {
51        all_locales: args.all,
52        dry_run: args.dry_run,
53    };
54
55    let results = parallel_generate(
56        &workspace.workspace_info,
57        &workspace.valid,
58        &action,
59        args.force_run,
60    );
61    let has_errors = render_generation_results(
62        &results,
63        |result| {
64            if args.dry_run {
65                if let Some(output) = &result.output {
66                    print!("{}", output);
67                } else if result.changed {
68                    println!(
69                        "{} {} ({} resources)",
70                        format!("{} would be cleaned in", result.name).yellow(),
71                        humantime::format_duration(result.duration)
72                            .to_string()
73                            .green(),
74                        result.resource_count.to_string().cyan()
75                    );
76                } else {
77                    println!("{} {}", "Unchanged:".dimmed(), result.name.bold());
78                }
79            } else if result.changed {
80                ui::print_cleaned(&result.name, result.duration, result.resource_count);
81            } else {
82                println!("{} {}", "Unchanged:".dimmed(), result.name.bold());
83            }
84        },
85        |result| ui::print_generation_error(&result.name, result.error.as_ref().unwrap()),
86    );
87
88    if has_errors {
89        std::process::exit(1);
90    }
91
92    Ok(())
93}
94
95/// Clean orphaned FTL files that are no longer tied to any registered types.
96fn clean_orphaned_files(
97    workspace: &WorkspaceCrates,
98    all_locales: bool,
99    dry_run: bool,
100) -> Result<(), CliError> {
101    use crate::utils::get_all_locales;
102    use es_fluent_toml::I18nConfig;
103    use std::collections::HashSet;
104
105    ui::print_header();
106    println!("{} Looking for orphaned FTL files...", "→".cyan());
107
108    let mut total_removed = 0;
109    let mut total_files_checked = 0;
110
111    // Collect all valid crate names for quick lookup
112    let valid_crate_names: HashSet<&str> =
113        workspace.crates.iter().map(|c| c.name.as_str()).collect();
114
115    // Track which files we've already processed to avoid duplicates
116    // Use canonical paths to handle different ways of referring to the same file
117    let mut processed_files: HashSet<std::path::PathBuf> = HashSet::new();
118
119    // Also track which (locale_dir, relative_path) pairs we've seen
120    let mut seen_paths: HashSet<(std::path::PathBuf, std::path::PathBuf)> = HashSet::new();
121
122    for krate in &workspace.crates {
123        let config = I18nConfig::read_from_path(&krate.i18n_config_path)
124            .map_err(|e| CliError::from(std::io::Error::other(e)))?;
125
126        let assets_dir = krate.manifest_dir.join(&config.assets_dir);
127
128        // Determine which locale directories to check
129        let locale_dirs: Vec<std::path::PathBuf> = if all_locales {
130            get_all_locales(&assets_dir)
131                .map_err(|e| CliError::from(std::io::Error::other(e)))?
132                .into_iter()
133                .map(|locale| assets_dir.join(locale))
134                .collect()
135        } else {
136            vec![assets_dir.join(&config.fallback_language)]
137        };
138
139        // Get the fallback locale directory for this crate
140        let fallback_locale_dir = assets_dir.join(&config.fallback_language);
141
142        for locale_dir in locale_dirs {
143            let locale = locale_dir
144                .file_name()
145                .and_then(|n| n.to_str())
146                .unwrap_or("unknown");
147
148            if !locale_dir.exists() {
149                continue;
150            }
151
152            // Skip the fallback locale - we only clean non-fallback locales
153            if locale == config.fallback_language {
154                continue;
155            }
156
157            // Get the expected FTL files for this crate (based on what's in fallback)
158            let expected_files = get_expected_ftl_files(
159                &krate.name,
160                &locale_dir,
161                &valid_crate_names,
162                &fallback_locale_dir,
163            );
164
165            // Find all actual FTL files in the locale directory
166            let actual_files = find_all_ftl_files(&locale_dir)?;
167
168            // Find orphaned files (actual files that are not in expected files)
169            for file_path in actual_files {
170                // Get canonical path for deduplication
171                let canonical_path = file_path
172                    .canonicalize()
173                    .unwrap_or_else(|_| file_path.clone());
174
175                // Skip if we've already processed this file
176                if processed_files.contains(&canonical_path) {
177                    continue;
178                }
179                processed_files.insert(canonical_path);
180
181                total_files_checked += 1;
182                let relative_path = file_path.strip_prefix(&locale_dir).unwrap_or(&file_path);
183
184                // Create a unique key for this (locale_dir, relative_path) pair
185                let path_key = (locale_dir.clone(), relative_path.to_path_buf());
186                if seen_paths.contains(&path_key) {
187                    continue;
188                }
189                seen_paths.insert(path_key);
190
191                if !expected_files.contains(&file_path) {
192                    total_removed += 1;
193
194                    if dry_run {
195                        println!(
196                            "{} Would remove orphaned file: {}",
197                            "•".yellow(),
198                            relative_path.display().to_string().cyan()
199                        );
200                    } else {
201                        println!(
202                            "{} Removing orphaned file: {}",
203                            "✓".green(),
204                            relative_path.display().to_string().cyan()
205                        );
206                        std::fs::remove_file(&file_path)?;
207
208                        // Try to remove empty parent directories
209                        if let Some(parent) = file_path.parent()
210                            && parent != locale_dir
211                        {
212                            let _ = std::fs::remove_dir(parent);
213                        }
214                    }
215                }
216            }
217        }
218    }
219
220    if total_removed == 0 {
221        println!("\n{} No orphaned FTL files found.", "✓".green());
222    } else if dry_run {
223        println!(
224            "\n{} Would remove {} orphaned file(s) (checked {} files)",
225            "→".cyan(),
226            total_removed.to_string().yellow(),
227            total_files_checked
228        );
229    } else {
230        println!(
231            "\n{} Removed {} orphaned file(s) (checked {} files)",
232            "✓".green(),
233            total_removed.to_string().cyan(),
234            total_files_checked
235        );
236    }
237
238    Ok(())
239}
240
241/// Get the expected FTL file paths for a crate based on registered types.
242/// This looks at what files the generate command would create.
243///
244/// The logic:
245/// - A main FTL file (crate_name.ftl) is expected ONLY if it exists in the fallback locale
246/// - Namespaced files are expected if they exist in the fallback locale's crate subdirectory
247fn get_expected_ftl_files(
248    crate_name: &str,
249    locale_dir: &std::path::Path,
250    valid_crate_names: &HashSet<&str>,
251    fallback_locale_dir: &std::path::Path,
252) -> HashSet<std::path::PathBuf> {
253    let mut expected = HashSet::new();
254
255    // Extract locales from paths
256    let fallback_locale = fallback_locale_dir
257        .file_name()
258        .and_then(|n| n.to_str())
259        .unwrap_or("unknown");
260    let locale = locale_dir
261        .file_name()
262        .and_then(|n| n.to_str())
263        .unwrap_or("unknown");
264
265    // Check if main FTL file exists in fallback locale - if so, it's expected here too
266    let fallback_main_ftl = main_ftl_path(
267        fallback_locale_dir.parent().unwrap(),
268        fallback_locale,
269        crate_name,
270    );
271    if fallback_main_ftl.exists() {
272        expected.insert(main_ftl_path(
273            locale_dir.parent().unwrap(),
274            locale,
275            crate_name,
276        ));
277    }
278
279    // Check for namespaced files in the crate_name subdirectory
280    // Namespaced files are expected if they exist in the fallback locale
281    let fallback_crate_subdir = fallback_locale_dir.join(crate_name);
282    if fallback_crate_subdir.exists()
283        && fallback_crate_subdir.is_dir()
284        && let Ok(entries) = std::fs::read_dir(&fallback_crate_subdir)
285    {
286        for entry in entries.filter_map(|e| e.ok()) {
287            let fallback_path = entry.path();
288            if fallback_path.extension().is_some_and(|e| e == "ftl") {
289                // Get just the filename and construct path in current locale
290                if let Some(filename) = fallback_path.file_name() {
291                    let namespaced_path = locale_dir.join(crate_name).join(filename);
292                    expected.insert(namespaced_path);
293                }
294            }
295        }
296    }
297
298    // Also add expected files for other crates (they're valid, not orphaned)
299    for &other_crate in valid_crate_names.iter().filter(|&&c| c != crate_name) {
300        // Extract fallback locale from fallback_locale_dir
301        let fallback_locale = fallback_locale_dir
302            .file_name()
303            .and_then(|n| n.to_str())
304            .unwrap_or("unknown");
305
306        // Extract current locale from locale_dir
307        let locale = locale_dir
308            .file_name()
309            .and_then(|n| n.to_str())
310            .unwrap_or("unknown");
311
312        // Only expect main FTL file if it exists in fallback
313        let other_fallback_main = main_ftl_path(
314            fallback_locale_dir.parent().unwrap(),
315            fallback_locale,
316            other_crate,
317        );
318        if other_fallback_main.exists() {
319            expected.insert(main_ftl_path(
320                locale_dir.parent().unwrap(),
321                locale,
322                other_crate,
323            ));
324        }
325
326        // Only expect namespaced files if they exist in fallback
327        let other_fallback_subdir = fallback_locale_dir.join(other_crate);
328        if other_fallback_subdir.exists()
329            && other_fallback_subdir.is_dir()
330            && let Ok(entries) = std::fs::read_dir(&other_fallback_subdir)
331        {
332            for entry in entries.filter_map(|e| e.ok()) {
333                let fallback_path = entry.path();
334                if fallback_path.extension().is_some_and(|e| e == "ftl")
335                    && let Some(filename) = fallback_path.file_name()
336                {
337                    let namespaced_path = locale_dir.join(other_crate).join(filename);
338                    expected.insert(namespaced_path);
339                }
340            }
341        }
342    }
343
344    expected
345}
346
347/// Recursively find all FTL files in a directory.
348fn find_all_ftl_files(dir: &std::path::Path) -> Result<Vec<std::path::PathBuf>, CliError> {
349    let mut files = Vec::new();
350
351    if !dir.exists() {
352        return Ok(files);
353    }
354
355    for entry in std::fs::read_dir(dir)? {
356        let entry = entry?;
357        let path = entry.path();
358
359        if path.is_dir() {
360            // Recurse into subdirectories
361            files.extend(find_all_ftl_files(&path)?);
362        } else if path.extension().is_some_and(|e| e == "ftl") {
363            files.push(path);
364        }
365    }
366
367    Ok(files)
368}