Skip to main content

es_fluent_cli/commands/clean/
mod.rs

1//! Clean command implementation.
2
3mod orphaned;
4
5use crate::commands::{
6    GenerationVerb, WorkspaceArgs, WorkspaceCrates, parallel_generate,
7    render_generation_results_with_dry_run,
8};
9use crate::core::{CliError, GenerationAction};
10use crate::utils::ui;
11use clap::Parser;
12
13use orphaned::clean_orphaned_files;
14
15/// Arguments for the clean command.
16#[derive(Parser)]
17pub struct CleanArgs {
18    #[command(flatten)]
19    pub workspace: WorkspaceArgs,
20
21    /// Clean all locales, not just the fallback language.
22    #[arg(long)]
23    pub all: bool,
24
25    /// Dry run - show what would be cleaned without making changes.
26    #[arg(long)]
27    pub dry_run: bool,
28
29    /// Force rebuild of the runner, ignoring the staleness cache.
30    #[arg(long)]
31    pub force_run: bool,
32
33    /// Remove orphaned FTL files that are no longer tied to any types.
34    /// This removes files that don't correspond to any registered types
35    /// (e.g., when all items are now namespaced or the crate was deleted).
36    #[arg(long)]
37    pub orphaned: bool,
38}
39
40/// Run the clean command.
41pub fn run_clean(args: CleanArgs) -> Result<(), CliError> {
42    let workspace = WorkspaceCrates::discover(args.workspace)?;
43
44    if !workspace.print_discovery(ui::print_header) {
45        return Ok(());
46    }
47
48    // Handle orphaned file removal first if requested
49    if args.orphaned {
50        return clean_orphaned_files(&workspace, args.all, args.dry_run);
51    }
52
53    let action = GenerationAction::Clean {
54        all_locales: args.all,
55        dry_run: args.dry_run,
56    };
57
58    let results = parallel_generate(
59        &workspace.workspace_info,
60        &workspace.valid,
61        &action,
62        args.force_run,
63    );
64    let has_errors =
65        render_generation_results_with_dry_run(&results, args.dry_run, GenerationVerb::Clean);
66
67    if has_errors {
68        std::process::exit(1);
69    }
70
71    Ok(())
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77    use crate::generation::cache::{RunnerCache, compute_content_hash};
78    use std::fs;
79    use std::time::SystemTime;
80    use tempfile::tempdir;
81
82    fn create_test_crate_workspace() -> tempfile::TempDir {
83        let temp = tempdir().unwrap();
84
85        fs::create_dir_all(temp.path().join("src")).unwrap();
86        fs::create_dir_all(temp.path().join("i18n/en")).unwrap();
87        fs::write(
88            temp.path().join("Cargo.toml"),
89            r#"[package]
90name = "test-app"
91version = "0.1.0"
92edition = "2024"
93"#,
94        )
95        .unwrap();
96        fs::write(temp.path().join("src/lib.rs"), "pub struct Demo;\n").unwrap();
97        fs::write(
98            temp.path().join("i18n.toml"),
99            "fallback_language = \"en\"\nassets_dir = \"i18n\"\n",
100        )
101        .unwrap();
102        fs::write(temp.path().join("i18n/en/test-app.ftl"), "hello = Hello\n").unwrap();
103
104        temp
105    }
106
107    #[cfg(unix)]
108    fn set_executable(path: &std::path::Path) {
109        use std::os::unix::fs::PermissionsExt;
110        let mut perms = fs::metadata(path).expect("metadata").permissions();
111        perms.set_mode(0o755);
112        fs::set_permissions(path, perms).expect("set permissions");
113    }
114
115    #[cfg(not(unix))]
116    fn set_executable(_path: &std::path::Path) {}
117
118    fn setup_fake_runner_and_cache(temp: &tempfile::TempDir) {
119        let binary_path = temp.path().join("target/debug/es-fluent-runner");
120        fs::create_dir_all(binary_path.parent().unwrap()).expect("create target/debug");
121        fs::write(&binary_path, "#!/bin/sh\necho cleaned\n").expect("write runner");
122        set_executable(&binary_path);
123
124        let src_dir = temp.path().join("src");
125        let i18n_toml = temp.path().join("i18n.toml");
126        let hash = compute_content_hash(&src_dir, Some(&i18n_toml));
127        let mtime = fs::metadata(&binary_path)
128            .and_then(|m| m.modified())
129            .expect("runner mtime")
130            .duration_since(SystemTime::UNIX_EPOCH)
131            .expect("mtime duration")
132            .as_secs();
133
134        let temp_dir = es_fluent_derive_core::get_es_fluent_temp_dir(temp.path());
135        fs::create_dir_all(&temp_dir).expect("create temp dir");
136        let mut crate_hashes = indexmap::IndexMap::new();
137        crate_hashes.insert("test-app".to_string(), hash);
138        RunnerCache {
139            crate_hashes,
140            runner_mtime: mtime,
141            cli_version: env!("CARGO_PKG_VERSION").to_string(),
142        }
143        .save(&temp_dir)
144        .expect("save runner cache");
145    }
146
147    #[test]
148    fn run_clean_returns_ok_when_package_filter_matches_nothing() {
149        let temp = create_test_crate_workspace();
150
151        let result = run_clean(CleanArgs {
152            workspace: WorkspaceArgs {
153                path: Some(temp.path().to_path_buf()),
154                package: Some("missing-crate".to_string()),
155            },
156            all: false,
157            dry_run: false,
158            force_run: false,
159            orphaned: false,
160        });
161
162        assert!(result.is_ok());
163    }
164
165    #[test]
166    fn run_clean_executes_with_fake_runner() {
167        let temp = create_test_crate_workspace();
168        setup_fake_runner_and_cache(&temp);
169
170        let result = run_clean(CleanArgs {
171            workspace: WorkspaceArgs {
172                path: Some(temp.path().to_path_buf()),
173                package: None,
174            },
175            all: false,
176            dry_run: false,
177            force_run: false,
178            orphaned: false,
179        });
180
181        assert!(result.is_ok());
182    }
183}