Skip to main content

es_fluent_cli/commands/check/
mod.rs

1//! Check command for validating FTL files against inventory-registered types.
2//!
3//! This module provides functionality to check FTL files by:
4//! - Running a temp crate that collects inventory registrations (expected keys/variables)
5//! - Parsing FTL files directly using fluent-syntax (for proper ParserError handling)
6//! - Comparing FTL files against the expected keys and variables from Rust code
7//! - Reporting missing keys as errors
8//! - Reporting missing variables as warnings
9
10mod inventory;
11mod validation;
12
13use crate::commands::{WorkspaceArgs, WorkspaceCrates};
14use crate::core::{CliError, ValidationIssue, ValidationReport};
15use crate::generation::{prepare_monolithic_runner_crate, run_monolithic};
16use crate::utils::ui;
17use clap::Parser;
18use std::collections::HashSet;
19
20/// Arguments for the check command.
21#[derive(Debug, Parser)]
22pub struct CheckArgs {
23    #[command(flatten)]
24    pub workspace: WorkspaceArgs,
25
26    /// Check all locales, not just the fallback language.
27    #[arg(long)]
28    pub all: bool,
29
30    /// Crates to skip during validation. Can be specified multiple times
31    /// (e.g., --ignore foo --ignore bar) or comma-separated (e.g., --ignore foo,bar).
32    #[arg(long, value_delimiter = ',')]
33    pub ignore: Vec<String>,
34
35    /// Force rebuild of the runner, ignoring the staleness cache.
36    #[arg(long)]
37    pub force_run: bool,
38}
39
40/// Run the check command.
41pub fn run_check(args: CheckArgs) -> Result<(), CliError> {
42    let workspace = WorkspaceCrates::discover(args.workspace)?;
43
44    if !workspace.print_discovery(ui::print_check_header) {
45        ui::print_no_crates_found();
46        return Ok(());
47    }
48
49    // Convert ignore list to a HashSet for efficient lookups
50    let ignore_crates: HashSet<String> = args.ignore.into_iter().collect();
51    let force_run = args.force_run;
52
53    // Filter out ignored crates
54    let crates_to_check: Vec<_> = workspace
55        .valid
56        .iter()
57        .filter(|k| !ignore_crates.contains(&k.name))
58        .collect();
59
60    // Validate that all ignored crates are known
61    if !ignore_crates.is_empty() {
62        let all_crate_names: HashSet<String> =
63            workspace.valid.iter().map(|k| k.name.clone()).collect();
64
65        let mut unknown_crates: Vec<&String> = ignore_crates
66            .iter()
67            .filter(|c| !all_crate_names.contains(*c))
68            .collect();
69
70        if !unknown_crates.is_empty() {
71            // Sort for deterministic error messages
72            unknown_crates.sort();
73
74            return Err(CliError::Other(format!(
75                "Unknown crates passed to --ignore: {}",
76                unknown_crates
77                    .iter()
78                    .map(|c| format!("'{}'", c))
79                    .collect::<Vec<_>>()
80                    .join(", ")
81            )));
82        }
83    }
84
85    if crates_to_check.is_empty() {
86        ui::print_no_crates_found();
87        return Ok(());
88    }
89
90    // Prepare monolithic temp crate once for all checks
91    prepare_monolithic_runner_crate(&workspace.workspace_info)
92        .map_err(|e| CliError::Other(e.to_string()))?;
93
94    // First pass: collect all expected keys from crates
95    let temp_dir =
96        es_fluent_derive_core::get_es_fluent_temp_dir(&workspace.workspace_info.root_dir);
97
98    let pb = ui::create_progress_bar(crates_to_check.len() as u64, "Collecting keys...");
99
100    for krate in &crates_to_check {
101        pb.set_message(format!("Scanning {}", krate.name));
102        run_monolithic(
103            &workspace.workspace_info,
104            "check",
105            &krate.name,
106            &[],
107            force_run,
108        )
109        .map_err(|e| CliError::Other(e.to_string()))?;
110        pb.inc(1);
111    }
112
113    pb.finish_and_clear();
114
115    // Second pass: validate FTL files
116    let mut all_issues: Vec<ValidationIssue> = Vec::new();
117
118    let pb = ui::create_progress_bar(crates_to_check.len() as u64, "Checking crates...");
119
120    for krate in &crates_to_check {
121        pb.set_message(format!("Checking {}", krate.name));
122
123        match validation::validate_crate(
124            krate,
125            &workspace.workspace_info.root_dir,
126            &temp_dir,
127            args.all,
128        ) {
129            Ok(issues) => {
130                all_issues.extend(issues);
131            },
132            Err(e) => {
133                // If error, print above progress bar
134                pb.suspend(|| {
135                    ui::print_check_error(&krate.name, &e.to_string());
136                });
137            },
138        }
139        pb.inc(1);
140    }
141
142    pb.finish_and_clear();
143
144    // Sort issues for deterministic output
145    all_issues.sort_by_cached_key(|issue| issue.sort_key());
146
147    let error_count = all_issues
148        .iter()
149        .filter(|i| {
150            matches!(
151                i,
152                ValidationIssue::MissingKey(_) | ValidationIssue::SyntaxError(_)
153            )
154        })
155        .count();
156    let warning_count = all_issues
157        .iter()
158        .filter(|i| matches!(i, ValidationIssue::MissingVariable(_)))
159        .count();
160
161    if all_issues.is_empty() {
162        ui::print_check_success();
163        Ok(())
164    } else {
165        Err(CliError::Validation(ValidationReport {
166            error_count,
167            warning_count,
168            issues: all_issues,
169        }))
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use crate::generation::cache::{RunnerCache, compute_content_hash};
177    use std::fs;
178    use std::time::SystemTime;
179    use tempfile::tempdir;
180
181    use crate::test_fixtures::{
182        CARGO_TOML, HELLO_FTL, I18N_TOML, INVENTORY_WITH_HELLO, INVENTORY_WITH_MISSING_KEY, LIB_RS,
183        RUNNER_FAILING_SCRIPT, RUNNER_SCRIPT,
184    };
185
186    fn create_test_crate_workspace() -> tempfile::TempDir {
187        let temp = tempdir().unwrap();
188
189        fs::create_dir_all(temp.path().join("src")).unwrap();
190        fs::create_dir_all(temp.path().join("i18n/en")).unwrap();
191        fs::write(temp.path().join("Cargo.toml"), CARGO_TOML).unwrap();
192        fs::write(temp.path().join("src/lib.rs"), LIB_RS).unwrap();
193        fs::write(temp.path().join("i18n.toml"), I18N_TOML).unwrap();
194        fs::write(temp.path().join("i18n/en/test-app.ftl"), HELLO_FTL).unwrap();
195
196        temp
197    }
198
199    #[cfg(unix)]
200    fn set_executable(path: &std::path::Path) {
201        use std::os::unix::fs::PermissionsExt;
202        let mut perms = fs::metadata(path).expect("metadata").permissions();
203        perms.set_mode(0o755);
204        fs::set_permissions(path, perms).expect("set permissions");
205    }
206
207    #[cfg(not(unix))]
208    fn set_executable(_path: &std::path::Path) {}
209
210    fn setup_fake_runner_and_cache_with_script(temp: &tempfile::TempDir, script: &str) {
211        let binary_path = temp.path().join("target/debug/es-fluent-runner");
212        fs::create_dir_all(binary_path.parent().unwrap()).expect("create target/debug");
213        fs::write(&binary_path, script).expect("write runner");
214        set_executable(&binary_path);
215
216        let src_dir = temp.path().join("src");
217        let i18n_toml = temp.path().join("i18n.toml");
218        let hash = compute_content_hash(&src_dir, Some(&i18n_toml));
219        let mtime = fs::metadata(&binary_path)
220            .and_then(|m| m.modified())
221            .expect("runner mtime")
222            .duration_since(SystemTime::UNIX_EPOCH)
223            .expect("mtime duration")
224            .as_secs();
225
226        let temp_dir = es_fluent_derive_core::get_es_fluent_temp_dir(temp.path());
227        fs::create_dir_all(&temp_dir).expect("create temp dir");
228        let mut crate_hashes = indexmap::IndexMap::new();
229        crate_hashes.insert("test-app".to_string(), hash);
230        RunnerCache {
231            crate_hashes,
232            runner_mtime: mtime,
233            cli_version: env!("CARGO_PKG_VERSION").to_string(),
234        }
235        .save(&temp_dir)
236        .expect("save runner cache");
237    }
238
239    fn setup_fake_runner_and_cache(temp: &tempfile::TempDir) {
240        setup_fake_runner_and_cache_with_script(temp, RUNNER_SCRIPT);
241    }
242
243    #[test]
244    fn run_check_returns_error_for_unknown_ignored_crate() {
245        let temp = create_test_crate_workspace();
246
247        let result = run_check(CheckArgs {
248            workspace: WorkspaceArgs {
249                path: Some(temp.path().to_path_buf()),
250                package: None,
251            },
252            all: false,
253            ignore: vec!["missing-crate".to_string()],
254            force_run: false,
255        });
256
257        assert!(
258            matches!(result, Err(CliError::Other(msg)) if msg.contains("Unknown crates passed to --ignore"))
259        );
260    }
261
262    #[test]
263    fn run_check_returns_ok_when_package_filter_matches_nothing() {
264        let temp = create_test_crate_workspace();
265
266        let result = run_check(CheckArgs {
267            workspace: WorkspaceArgs {
268                path: Some(temp.path().to_path_buf()),
269                package: Some("missing-crate".to_string()),
270            },
271            all: false,
272            ignore: Vec::new(),
273            force_run: false,
274        });
275
276        assert!(result.is_ok());
277    }
278
279    #[test]
280    fn run_check_succeeds_with_fake_runner_and_matching_inventory() {
281        let temp = create_test_crate_workspace();
282        setup_fake_runner_and_cache(&temp);
283
284        let inventory_path = es_fluent_derive_core::get_metadata_inventory_path(
285            &temp.path().join(".es-fluent"),
286            "test-app",
287        );
288        fs::create_dir_all(inventory_path.parent().unwrap()).expect("create inventory dir");
289        fs::write(&inventory_path, INVENTORY_WITH_HELLO).expect("write inventory");
290
291        let result = run_check(CheckArgs {
292            workspace: WorkspaceArgs {
293                path: Some(temp.path().to_path_buf()),
294                package: None,
295            },
296            all: false,
297            ignore: Vec::new(),
298            force_run: false,
299        });
300
301        assert!(result.is_ok());
302    }
303
304    #[test]
305    fn run_check_returns_validation_error_for_missing_key() {
306        let temp = create_test_crate_workspace();
307        setup_fake_runner_and_cache(&temp);
308
309        let inventory_path = es_fluent_derive_core::get_metadata_inventory_path(
310            &temp.path().join(".es-fluent"),
311            "test-app",
312        );
313        fs::create_dir_all(inventory_path.parent().unwrap()).expect("create inventory dir");
314        fs::write(&inventory_path, INVENTORY_WITH_MISSING_KEY).expect("write inventory");
315
316        let result = run_check(CheckArgs {
317            workspace: WorkspaceArgs {
318                path: Some(temp.path().to_path_buf()),
319                package: None,
320            },
321            all: false,
322            ignore: Vec::new(),
323            force_run: false,
324        });
325
326        assert!(matches!(result, Err(CliError::Validation(_))));
327    }
328
329    #[test]
330    fn run_check_returns_ok_when_all_crates_are_ignored() {
331        let temp = create_test_crate_workspace();
332        let result = run_check(CheckArgs {
333            workspace: WorkspaceArgs {
334                path: Some(temp.path().to_path_buf()),
335                package: None,
336            },
337            all: false,
338            ignore: vec!["test-app".to_string()],
339            force_run: false,
340        });
341
342        assert!(result.is_ok());
343    }
344
345    #[test]
346    fn run_check_returns_other_error_when_runner_execution_fails() {
347        let temp = create_test_crate_workspace();
348        setup_fake_runner_and_cache_with_script(&temp, RUNNER_FAILING_SCRIPT);
349
350        let result = run_check(CheckArgs {
351            workspace: WorkspaceArgs {
352                path: Some(temp.path().to_path_buf()),
353                package: None,
354            },
355            all: false,
356            ignore: Vec::new(),
357            force_run: false,
358        });
359
360        assert!(matches!(result, Err(CliError::Other(_))));
361    }
362
363    #[test]
364    fn run_check_handles_validation_errors_per_crate_and_completes() {
365        let temp = create_test_crate_workspace();
366        setup_fake_runner_and_cache(&temp);
367        // Intentionally do not create inventory file so validation::validate_crate fails.
368
369        let result = run_check(CheckArgs {
370            workspace: WorkspaceArgs {
371                path: Some(temp.path().to_path_buf()),
372                package: None,
373            },
374            all: false,
375            ignore: Vec::new(),
376            force_run: false,
377        });
378
379        assert!(
380            result.is_ok(),
381            "per-crate validation errors should be reported and command should complete"
382        );
383    }
384}