1use crate::core::{CliError, CrateInfo, GenerateResult, GenerationAction, WorkspaceInfo};
2use crate::utils::{count_ftl_resources, filter_crates_by_package, partition_by_lib_rs, ui};
3use clap::Args;
4use colored::Colorize as _;
5use std::path::PathBuf;
6use std::time::Instant;
7
8#[derive(Args, Clone, Debug)]
9pub struct WorkspaceArgs {
10 #[arg(short, long)]
12 pub path: Option<PathBuf>,
13 #[arg(short = 'P', long)]
15 pub package: Option<String>,
16}
17
18#[derive(Args, Clone, Debug)]
22pub struct LocaleProcessingArgs {
23 #[arg(long)]
25 pub all: bool,
26
27 #[arg(long)]
29 pub dry_run: bool,
30}
31
32#[derive(Clone, Debug)]
34pub struct WorkspaceCrates {
35 pub path: PathBuf,
37 pub workspace_info: WorkspaceInfo,
39 pub crates: Vec<CrateInfo>,
41 pub valid: Vec<CrateInfo>,
43 pub skipped: Vec<CrateInfo>,
45}
46
47impl WorkspaceCrates {
48 pub fn discover(args: WorkspaceArgs) -> Result<Self, CliError> {
50 use crate::utils::discover_workspace;
51
52 let path = args.path.unwrap_or_else(|| PathBuf::from("."));
53 let workspace_info = discover_workspace(&path)?;
54 let crates = filter_crates_by_package(workspace_info.crates.clone(), args.package.as_ref());
55 let (valid_refs, skipped_refs) = partition_by_lib_rs(&crates);
56 let valid = valid_refs.into_iter().cloned().collect();
57 let skipped = skipped_refs.into_iter().cloned().collect();
58
59 Ok(Self {
60 path,
61 workspace_info,
62 crates,
63 valid,
64 skipped,
65 })
66 }
67
68 pub fn print_discovery(&self, header: impl Fn()) -> bool {
72 header();
73
74 if self.crates.is_empty() {
75 ui::print_discovered(&[]);
76 return false;
77 }
78
79 ui::print_discovered(&self.crates);
80
81 for krate in &self.skipped {
82 ui::print_missing_lib_rs(&krate.name);
83 }
84
85 true
86 }
87}
88
89fn read_changed_status(temp_dir: &std::path::Path, crate_name: &str) -> bool {
93 let result_json_path = es_fluent_derive_core::get_metadata_result_path(temp_dir, crate_name);
94
95 if !result_json_path.exists() {
96 return false;
97 }
98
99 match std::fs::read_to_string(&result_json_path) {
100 Ok(json_str) => match serde_json::from_str::<serde_json::Value>(&json_str) {
101 Ok(json) => json["changed"].as_bool().unwrap_or(false),
102 Err(_) => false,
103 },
104 Err(_) => false,
105 }
106}
107
108pub fn parallel_generate(
115 workspace: &WorkspaceInfo,
116 crates: &[CrateInfo],
117 action: &GenerationAction,
118 force_run: bool,
119) -> Vec<GenerateResult> {
120 use crate::generation::{generate_for_crate_monolithic, prepare_monolithic_runner_crate};
121
122 if let Err(e) = prepare_monolithic_runner_crate(workspace) {
124 return crates
126 .iter()
127 .map(|k| {
128 GenerateResult::failure(k.name.clone(), std::time::Duration::ZERO, e.to_string())
129 })
130 .collect();
131 }
132
133 let pb = ui::create_progress_bar(crates.len() as u64, "Processing crates...");
134
135 crates
138 .iter()
139 .map(|krate| {
140 let start = Instant::now();
141 let result = generate_for_crate_monolithic(krate, workspace, action, force_run);
142 let duration = start.elapsed();
143
144 pb.inc(1);
145
146 let resource_count = result
147 .as_ref()
148 .ok()
149 .map(|_| count_ftl_resources(&krate.ftl_output_dir, &krate.name))
150 .unwrap_or(0);
151
152 match result {
153 Ok(output) => {
154 let temp_dir =
156 es_fluent_derive_core::get_es_fluent_temp_dir(&workspace.root_dir);
157 let changed = read_changed_status(&temp_dir, &krate.name);
158
159 let output_opt = if output.is_empty() {
160 None
161 } else {
162 Some(output.to_string())
163 };
164
165 GenerateResult::success(
166 krate.name.clone(),
167 duration,
168 resource_count,
169 output_opt,
170 changed,
171 )
172 },
173 Err(e) => GenerateResult::failure(krate.name.clone(), duration, e.to_string()),
174 }
175 })
176 .collect()
177}
178
179pub fn render_generation_results(
183 results: &[GenerateResult],
184 on_success: impl Fn(&GenerateResult),
185 on_error: impl Fn(&GenerateResult),
186) -> bool {
187 let mut has_errors = false;
188
189 for result in results {
190 if result.error.is_some() {
191 has_errors = true;
192 on_error(result);
193 } else {
194 on_success(result);
195 }
196 }
197
198 has_errors
199}
200
201#[derive(Clone, Copy, Debug)]
202pub enum GenerationVerb {
203 Generate,
204 Clean,
205}
206
207impl GenerationVerb {
208 fn dry_run_label(self) -> &'static str {
209 match self {
210 GenerationVerb::Generate => "would be generated in",
211 GenerationVerb::Clean => "would be cleaned in",
212 }
213 }
214
215 fn print_changed(self, result: &GenerateResult) {
216 match self {
217 GenerationVerb::Generate => {
218 ui::print_generated(&result.name, result.duration, result.resource_count);
219 },
220 GenerationVerb::Clean => {
221 ui::print_cleaned(&result.name, result.duration, result.resource_count);
222 },
223 }
224 }
225}
226
227pub fn render_generation_results_with_dry_run(
231 results: &[GenerateResult],
232 dry_run: bool,
233 verb: GenerationVerb,
234) -> bool {
235 render_generation_results(
236 results,
237 |result| {
238 if dry_run {
239 if let Some(output) = &result.output {
240 print!("{}", output);
241 } else if result.changed {
242 println!(
243 "{} {} ({} resources)",
244 format!("{} {}", result.name, verb.dry_run_label()).yellow(),
245 ui::format_duration(result.duration).green(),
246 result.resource_count.to_string().cyan()
247 );
248 } else {
249 println!("{} {}", "Unchanged:".dimmed(), result.name.bold());
250 }
251 } else if result.changed {
252 verb.print_changed(result);
253 } else {
254 println!("{} {}", "Unchanged:".dimmed(), result.name.bold());
255 }
256 },
257 |result| ui::print_generation_error(&result.name, result.error.as_ref().unwrap()),
258 )
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264 use crate::core::{CrateInfo, FluentParseMode, GenerationAction, WorkspaceInfo};
265 use crate::generation::cache::{RunnerCache, compute_content_hash};
266 use std::cell::Cell;
267 use std::fs;
268 use std::path::PathBuf;
269 use std::time::Duration;
270 use tempfile::tempdir;
271
272 fn create_test_crate_workspace() -> tempfile::TempDir {
273 let temp = tempdir().unwrap();
274
275 fs::create_dir_all(temp.path().join("src")).unwrap();
276 fs::create_dir_all(temp.path().join("i18n/en")).unwrap();
277
278 fs::write(
279 temp.path().join("Cargo.toml"),
280 r#"[package]
281name = "test-app"
282version = "0.1.0"
283edition = "2024"
284"#,
285 )
286 .unwrap();
287
288 fs::write(temp.path().join("src/lib.rs"), "pub struct Hello;\n").unwrap();
289 fs::write(
290 temp.path().join("i18n.toml"),
291 "fallback_language = \"en\"\nassets_dir = \"i18n\"\n",
292 )
293 .unwrap();
294
295 temp
296 }
297
298 fn create_workspace_info(temp: &tempfile::TempDir) -> WorkspaceInfo {
299 let manifest_dir = temp.path().to_path_buf();
300 let src_dir = manifest_dir.join("src");
301 let i18n_toml = manifest_dir.join("i18n.toml");
302 let krate = CrateInfo {
303 name: "test-app".to_string(),
304 manifest_dir: manifest_dir.clone(),
305 src_dir,
306 i18n_config_path: i18n_toml,
307 ftl_output_dir: manifest_dir.join("i18n/en"),
308 has_lib_rs: true,
309 fluent_features: Vec::new(),
310 };
311
312 WorkspaceInfo {
313 root_dir: manifest_dir.clone(),
314 target_dir: manifest_dir.join("target"),
315 crates: vec![krate],
316 }
317 }
318
319 #[cfg(unix)]
320 fn set_executable(path: &std::path::Path) {
321 use std::os::unix::fs::PermissionsExt;
322 let mut perms = fs::metadata(path).expect("metadata").permissions();
323 perms.set_mode(0o755);
324 fs::set_permissions(path, perms).expect("set permissions");
325 }
326
327 #[cfg(not(unix))]
328 fn set_executable(_path: &std::path::Path) {}
329
330 #[test]
331 fn read_changed_status_handles_missing_invalid_and_valid_json() {
332 let temp = tempdir().unwrap();
333 let crate_name = "demo";
334 let result_path = es_fluent_derive_core::get_metadata_result_path(temp.path(), crate_name);
335 fs::create_dir_all(result_path.parent().unwrap()).unwrap();
336
337 assert!(!read_changed_status(temp.path(), crate_name));
338
339 fs::write(&result_path, "{not-json").unwrap();
340 assert!(!read_changed_status(temp.path(), crate_name));
341
342 fs::write(&result_path, r#"{"changed":true}"#).unwrap();
343 assert!(read_changed_status(temp.path(), crate_name));
344 }
345
346 #[test]
347 fn render_generation_results_reports_error_presence() {
348 let success = GenerateResult::success(
349 "ok-crate".to_string(),
350 Duration::from_millis(10),
351 1,
352 None,
353 false,
354 );
355 let failure = GenerateResult::failure(
356 "bad-crate".to_string(),
357 Duration::from_millis(5),
358 "boom".to_string(),
359 );
360
361 let success_calls = Cell::new(0usize);
362 let error_calls = Cell::new(0usize);
363
364 let has_errors = render_generation_results(
365 &[success, failure],
366 |_| success_calls.set(success_calls.get() + 1),
367 |_| error_calls.set(error_calls.get() + 1),
368 );
369
370 assert!(has_errors);
371 assert_eq!(success_calls.get(), 1);
372 assert_eq!(error_calls.get(), 1);
373 }
374
375 #[test]
376 fn generation_verb_labels_match_expected_text() {
377 assert_eq!(
378 GenerationVerb::Generate.dry_run_label(),
379 "would be generated in"
380 );
381 assert_eq!(GenerationVerb::Clean.dry_run_label(), "would be cleaned in");
382 }
383
384 #[test]
385 fn workspace_discover_supports_package_filtering() {
386 let temp = create_test_crate_workspace();
387
388 let all = WorkspaceCrates::discover(WorkspaceArgs {
389 path: Some(temp.path().to_path_buf()),
390 package: None,
391 })
392 .unwrap();
393 assert_eq!(all.crates.len(), 1);
394 assert_eq!(all.valid.len(), 1);
395
396 let filtered = WorkspaceCrates::discover(WorkspaceArgs {
397 path: Some(temp.path().to_path_buf()),
398 package: Some("missing-crate".to_string()),
399 })
400 .unwrap();
401 assert!(filtered.crates.is_empty());
402 assert!(filtered.valid.is_empty());
403 }
404
405 #[test]
406 fn parallel_generate_uses_cached_runner_and_reads_changed_status() {
407 let temp = create_test_crate_workspace();
408 let workspace = create_workspace_info(&temp);
409 let krate = workspace.crates[0].clone();
410
411 let runner_binary = workspace.target_dir.join("debug/es-fluent-runner");
412 fs::create_dir_all(runner_binary.parent().unwrap()).expect("create target/debug");
413 fs::write(
414 &runner_binary,
415 "#!/bin/sh\necho generated-from-fake-runner\n",
416 )
417 .expect("write fake runner");
418 set_executable(&runner_binary);
419
420 let mtime = fs::metadata(&runner_binary)
421 .and_then(|m| m.modified())
422 .expect("runner mtime")
423 .duration_since(std::time::SystemTime::UNIX_EPOCH)
424 .expect("mtime duration")
425 .as_secs();
426 let hash = compute_content_hash(&krate.src_dir, Some(&krate.i18n_config_path));
427 let mut crate_hashes = indexmap::IndexMap::new();
428 crate_hashes.insert(krate.name.clone(), hash);
429 let temp_dir = es_fluent_derive_core::get_es_fluent_temp_dir(&workspace.root_dir);
430 fs::create_dir_all(&temp_dir).expect("create .es-fluent");
431 RunnerCache {
432 crate_hashes,
433 runner_mtime: mtime,
434 cli_version: env!("CARGO_PKG_VERSION").to_string(),
435 }
436 .save(&temp_dir)
437 .expect("save runner cache");
438
439 let result_json = es_fluent_derive_core::get_metadata_result_path(&temp_dir, &krate.name);
440 fs::create_dir_all(result_json.parent().unwrap()).expect("create metadata dir");
441 fs::write(&result_json, r#"{"changed":true}"#).expect("write result json");
442
443 let results = parallel_generate(
444 &workspace,
445 std::slice::from_ref(&krate),
446 &GenerationAction::Generate {
447 mode: FluentParseMode::default(),
448 dry_run: false,
449 },
450 false,
451 );
452
453 assert_eq!(results.len(), 1);
454 assert!(results[0].error.is_none());
455 assert!(results[0].changed);
456 assert!(
457 results[0]
458 .output
459 .as_ref()
460 .expect("captured output")
461 .contains("generated-from-fake-runner")
462 );
463 }
464
465 #[test]
466 fn workspace_print_discovery_handles_empty_and_skipped_crates() {
467 let empty = WorkspaceCrates {
468 path: PathBuf::from("."),
469 workspace_info: WorkspaceInfo {
470 root_dir: PathBuf::from("."),
471 target_dir: PathBuf::from("./target"),
472 crates: Vec::new(),
473 },
474 crates: Vec::new(),
475 valid: Vec::new(),
476 skipped: Vec::new(),
477 };
478 assert!(!empty.print_discovery(|| {}));
479
480 let skipped_crate = CrateInfo {
481 name: "missing-lib".to_string(),
482 manifest_dir: PathBuf::from("/tmp/test"),
483 src_dir: PathBuf::from("/tmp/test/src"),
484 i18n_config_path: PathBuf::from("/tmp/test/i18n.toml"),
485 ftl_output_dir: PathBuf::from("/tmp/test/i18n/en"),
486 has_lib_rs: false,
487 fluent_features: Vec::new(),
488 };
489 let non_empty = WorkspaceCrates {
490 path: PathBuf::from("."),
491 workspace_info: WorkspaceInfo {
492 root_dir: PathBuf::from("."),
493 target_dir: PathBuf::from("./target"),
494 crates: vec![skipped_crate.clone()],
495 },
496 crates: vec![skipped_crate.clone()],
497 valid: Vec::new(),
498 skipped: vec![skipped_crate],
499 };
500 assert!(non_empty.print_discovery(|| {}));
501 }
502
503 #[test]
504 fn parallel_generate_returns_failures_when_runner_preparation_fails() {
505 let krate = CrateInfo {
506 name: "broken".to_string(),
507 manifest_dir: PathBuf::from("/dev/null"),
508 src_dir: PathBuf::from("/dev/null/src"),
509 i18n_config_path: PathBuf::from("/dev/null/i18n.toml"),
510 ftl_output_dir: PathBuf::from("/dev/null/i18n/en"),
511 has_lib_rs: true,
512 fluent_features: Vec::new(),
513 };
514 let workspace = WorkspaceInfo {
515 root_dir: PathBuf::from("/dev/null"),
516 target_dir: PathBuf::from("/dev/null/target"),
517 crates: vec![krate.clone()],
518 };
519
520 let results = parallel_generate(
521 &workspace,
522 std::slice::from_ref(&krate),
523 &GenerationAction::Generate {
524 mode: FluentParseMode::default(),
525 dry_run: false,
526 },
527 false,
528 );
529
530 assert_eq!(results.len(), 1);
531 assert!(results[0].error.is_some());
532 }
533
534 #[test]
535 fn parallel_generate_handles_empty_output_and_dry_run_render_paths() {
536 let temp = create_test_crate_workspace();
537 let workspace = create_workspace_info(&temp);
538 let krate = workspace.crates[0].clone();
539
540 let runner_binary = workspace.target_dir.join("debug/es-fluent-runner");
541 fs::create_dir_all(runner_binary.parent().unwrap()).expect("create target/debug");
542 fs::write(&runner_binary, "#!/bin/sh\n:\n").expect("write fake runner");
543 set_executable(&runner_binary);
544
545 let mtime = fs::metadata(&runner_binary)
546 .and_then(|m| m.modified())
547 .expect("runner mtime")
548 .duration_since(std::time::SystemTime::UNIX_EPOCH)
549 .expect("mtime duration")
550 .as_secs();
551 let hash = compute_content_hash(&krate.src_dir, Some(&krate.i18n_config_path));
552 let mut crate_hashes = indexmap::IndexMap::new();
553 crate_hashes.insert(krate.name.clone(), hash);
554 let temp_dir = es_fluent_derive_core::get_es_fluent_temp_dir(&workspace.root_dir);
555 fs::create_dir_all(&temp_dir).expect("create .es-fluent");
556 RunnerCache {
557 crate_hashes,
558 runner_mtime: mtime,
559 cli_version: env!("CARGO_PKG_VERSION").to_string(),
560 }
561 .save(&temp_dir)
562 .expect("save runner cache");
563
564 let results = parallel_generate(
565 &workspace,
566 std::slice::from_ref(&krate),
567 &GenerationAction::Generate {
568 mode: FluentParseMode::default(),
569 dry_run: true,
570 },
571 false,
572 );
573 assert_eq!(results.len(), 1);
574 assert!(results[0].error.is_none());
575 assert!(
576 results[0].output.is_none(),
577 "empty runner output should map to None"
578 );
579
580 let dry_run_has_errors =
581 render_generation_results_with_dry_run(&results, true, GenerationVerb::Generate);
582 assert!(!dry_run_has_errors);
583
584 let clean_result = GenerateResult::success(
585 "crate-clean".to_string(),
586 Duration::from_millis(1),
587 1,
588 None,
589 true,
590 );
591 let clean_has_errors =
592 render_generation_results_with_dry_run(&[clean_result], false, GenerationVerb::Clean);
593 assert!(!clean_has_errors);
594 }
595}