es_fluent_cli/commands/check/
mod.rs1mod 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#[derive(Debug, Parser)]
22pub struct CheckArgs {
23 #[command(flatten)]
24 pub workspace: WorkspaceArgs,
25
26 #[arg(long)]
28 pub all: bool,
29
30 #[arg(long, value_delimiter = ',')]
33 pub ignore: Vec<String>,
34
35 #[arg(long)]
37 pub force_run: bool,
38}
39
40pub 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 let ignore_crates: HashSet<String> = args.ignore.into_iter().collect();
51 let force_run = args.force_run;
52
53 let crates_to_check: Vec<_> = workspace
55 .valid
56 .iter()
57 .filter(|k| !ignore_crates.contains(&k.name))
58 .collect();
59
60 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 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_runner_crate(&workspace.workspace_info)
92 .map_err(|e| CliError::Other(e.to_string()))?;
93
94 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 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 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 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 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}