Skip to main content

es_fluent_cli/commands/
common.rs

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    /// Path to the crate or workspace root (defaults to current directory).
11    #[arg(short, long)]
12    pub path: Option<PathBuf>,
13    /// Package name to filter (if in a workspace, only process this package).
14    #[arg(short = 'P', long)]
15    pub package: Option<String>,
16}
17
18/// Common arguments for locale-based processing commands.
19///
20/// Used by format, check, and sync commands.
21#[derive(Args, Clone, Debug)]
22pub struct LocaleProcessingArgs {
23    /// Process all locales, not just the fallback language.
24    #[arg(long)]
25    pub all: bool,
26
27    /// Dry run - show what would change without making changes.
28    #[arg(long)]
29    pub dry_run: bool,
30}
31
32/// Represents a resolved set of crates for a command to operate on.
33#[derive(Clone, Debug)]
34pub struct WorkspaceCrates {
35    /// The user-supplied (or default) root path.
36    pub path: PathBuf,
37    /// Workspace information (root dir, target dir, all crates).
38    pub workspace_info: WorkspaceInfo,
39    /// All crates discovered (after optional package filtering).
40    pub crates: Vec<CrateInfo>,
41    /// Crates that are eligible for operations (contain `lib.rs`).
42    pub valid: Vec<CrateInfo>,
43    /// Crates that were skipped (missing `lib.rs`).
44    pub skipped: Vec<CrateInfo>,
45}
46
47impl WorkspaceCrates {
48    /// Discover crates for a command, applying the common filtering and partitioning logic.
49    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    /// Print a standardized discovery summary, including skipped crates.
69    ///
70    /// Returns `false` when no crates were discovered to allow early-exit flows.
71    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
89/// Read the changed status from the runner crate's result.json file.
90///
91/// Returns `true` if the file indicates changes were made, `false` otherwise.
92fn 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
108/// Run generation-like work using the monolithic temp crate approach.
109///
110/// This prepares a single temp crate at workspace root that links all workspace crates,
111/// then runs the binary for each crate. Much faster on subsequent runs.
112///
113/// If `force_run` is true, the staleness check is skipped and the runner is always rebuilt.
114pub 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    // Prepare the monolithic temp crate once upfront
123    if let Err(e) = prepare_monolithic_runner_crate(workspace) {
124        // If preparation fails, return error results for all crates
125        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    // Process sequentially since they share the same binary
136    // (parallel could cause contention on first build)
137    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                    // For monolithic, result.json is at workspace root
155                    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
179/// Render a list of `GenerateResult`s with custom success/error handlers.
180///
181/// Returns `true` when any errors were encountered.
182pub 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
227/// Render generation-like results with the standard dry-run output.
228///
229/// Returns `true` when any errors were encountered.
230pub 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}