es_fluent_cli/commands/
clean.rs1use 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#[derive(Parser)]
14pub struct CleanArgs {
15 #[command(flatten)]
16 pub workspace: WorkspaceArgs,
17
18 #[arg(long)]
20 pub all: bool,
21
22 #[arg(long)]
24 pub dry_run: bool,
25
26 #[arg(long)]
28 pub force_run: bool,
29
30 #[arg(long)]
34 pub orphaned: bool,
35}
36
37pub 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 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
95fn 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 let valid_crate_names: HashSet<&str> =
113 workspace.crates.iter().map(|c| c.name.as_str()).collect();
114
115 let mut processed_files: HashSet<std::path::PathBuf> = HashSet::new();
118
119 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 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 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 if locale == config.fallback_language {
154 continue;
155 }
156
157 let expected_files = get_expected_ftl_files(
159 &krate.name,
160 &locale_dir,
161 &valid_crate_names,
162 &fallback_locale_dir,
163 );
164
165 let actual_files = find_all_ftl_files(&locale_dir)?;
167
168 for file_path in actual_files {
170 let canonical_path = file_path
172 .canonicalize()
173 .unwrap_or_else(|_| file_path.clone());
174
175 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 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 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
241fn 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 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 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 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 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 for &other_crate in valid_crate_names.iter().filter(|&&c| c != crate_name) {
300 let fallback_locale = fallback_locale_dir
302 .file_name()
303 .and_then(|n| n.to_str())
304 .unwrap_or("unknown");
305
306 let locale = locale_dir
308 .file_name()
309 .and_then(|n| n.to_str())
310 .unwrap_or("unknown");
311
312 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 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
347fn 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 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}