Skip to main content

es_fluent_cli/commands/
format.rs

1//! Format command for sorting FTL entries alphabetically (A-Z).
2//!
3//! This module provides functionality to format FTL files by sorting
4//! message keys alphabetically while preserving group comments.
5
6use crate::commands::{DryRunDiff, DryRunSummary, WorkspaceArgs, WorkspaceCrates};
7use crate::core::{CliError, CrateInfo, FormatError, FormatReport};
8use crate::ftl::LocaleContext;
9use crate::utils::{discover_ftl_files, ui};
10use anyhow::Result;
11use clap::Parser;
12use fluent_syntax::parser;
13use std::fs;
14use std::path::{Path, PathBuf};
15
16/// Arguments for the format command.
17#[derive(Debug, Parser)]
18pub struct FormatArgs {
19    #[command(flatten)]
20    pub workspace: WorkspaceArgs,
21
22    /// Format all locales, not just the fallback language.
23    #[arg(long)]
24    pub all: bool,
25
26    /// Dry run - show what would be formatted without making changes.
27    #[arg(long)]
28    pub dry_run: bool,
29}
30
31/// Result of formatting a single file.
32#[derive(Debug)]
33pub struct FormatResult {
34    /// Path to the file.
35    pub path: PathBuf,
36    /// Whether the file was changed.
37    pub changed: bool,
38    /// Error if formatting failed.
39    pub error: Option<String>,
40    /// Diff info (original, new) if dry run and changed.
41    pub diff_info: Option<DryRunDiff>,
42}
43
44impl FormatResult {
45    /// Create an error result.
46    fn error(path: &Path, msg: impl Into<String>) -> Self {
47        Self {
48            path: path.to_path_buf(),
49            changed: false,
50            error: Some(msg.into()),
51            diff_info: None,
52        }
53    }
54
55    /// Create an unchanged result.
56    fn unchanged(path: &Path) -> Self {
57        Self {
58            path: path.to_path_buf(),
59            changed: false,
60            error: None,
61            diff_info: None,
62        }
63    }
64
65    /// Create a changed result with optional diff info.
66    fn changed(path: &Path, diff: Option<DryRunDiff>) -> Self {
67        Self {
68            path: path.to_path_buf(),
69            changed: true,
70            error: None,
71            diff_info: diff,
72        }
73    }
74}
75
76/// Run the format command.
77pub fn run_format(args: FormatArgs) -> Result<(), CliError> {
78    let workspace = WorkspaceCrates::discover(args.workspace)?;
79
80    if !workspace.print_discovery(ui::print_format_header) {
81        ui::print_no_crates_found();
82        return Ok(());
83    }
84
85    let mut total_formatted = 0;
86    let mut total_unchanged = 0;
87    let mut errors: Vec<FormatError> = Vec::new();
88
89    let pb = ui::create_progress_bar(workspace.crates.len() as u64, "Formatting crates...");
90
91    for krate in &workspace.crates {
92        pb.set_message(format!("Formatting {}", krate.name));
93        let results = format_crate(krate, args.all, args.dry_run)?;
94
95        for result in results {
96            if let Some(error) = result.error {
97                errors.push(FormatError {
98                    path: result.path,
99                    help: error,
100                });
101            } else if result.changed {
102                total_formatted += 1;
103                pb.suspend(|| {
104                    let display_path = std::env::current_dir()
105                        .ok()
106                        .and_then(|cwd| result.path.strip_prefix(&cwd).ok())
107                        .unwrap_or(&result.path);
108
109                    if args.dry_run {
110                        ui::print_would_format(display_path);
111                        if let Some(diff) = &result.diff_info {
112                            diff.print();
113                        }
114                    } else {
115                        ui::print_formatted(display_path);
116                    }
117                });
118            } else {
119                total_unchanged += 1;
120            }
121        }
122        pb.inc(1);
123    }
124    pb.finish_and_clear();
125
126    if errors.is_empty() {
127        if args.dry_run && total_formatted > 0 {
128            DryRunSummary::Format {
129                formatted: total_formatted,
130            }
131            .print();
132        } else {
133            ui::print_format_summary(total_formatted, total_unchanged);
134        }
135        Ok(())
136    } else {
137        Err(CliError::Format(FormatReport {
138            formatted_count: total_formatted,
139            error_count: errors.len(),
140            errors,
141        }))
142    }
143}
144
145/// Format all FTL files for a crate.
146fn format_crate(
147    krate: &CrateInfo,
148    all_locales: bool,
149    check_only: bool,
150) -> Result<Vec<FormatResult>> {
151    let ctx = LocaleContext::from_crate(krate, all_locales)?;
152
153    let mut results = Vec::new();
154
155    for locale in &ctx.locales {
156        let locale_dir = ctx.locale_dir(locale);
157        if !locale_dir.exists() {
158            continue;
159        }
160
161        // Format main + namespaced files for this crate.
162        let ftl_files = discover_ftl_files(&ctx.assets_dir, locale, &ctx.crate_name)?;
163        for file_info in ftl_files {
164            let ftl_file = fs::canonicalize(&file_info.abs_path).unwrap_or(file_info.abs_path);
165            let result = format_ftl_file(&ftl_file, check_only);
166            results.push(result);
167        }
168    }
169
170    Ok(results)
171}
172
173/// Format a single FTL file by sorting entries A-Z.
174fn format_ftl_file(path: &Path, check_only: bool) -> FormatResult {
175    let content = match fs::read_to_string(path) {
176        Ok(c) => c,
177        Err(e) => return FormatResult::error(path, format!("Failed to read file: {}", e)),
178    };
179
180    if content.trim().is_empty() {
181        return FormatResult::unchanged(path);
182    }
183
184    let resource = match parser::parse(content.clone()) {
185        Ok(res) => res,
186        Err((res, _errors)) => res, // Use the partial result even with errors
187    };
188
189    // Use shared formatting logic from es-fluent-generate
190    let formatted = es_fluent_generate::formatting::sort_ftl_resource(&resource);
191    let formatted_content = format!("{}\n", formatted.trim_end());
192
193    if content == formatted_content {
194        return FormatResult::unchanged(path);
195    }
196
197    // Try to write if not in check-only mode
198    if !check_only && let Err(e) = fs::write(path, &formatted_content) {
199        return FormatResult::error(path, format!("Failed to write file: {}", e));
200    }
201
202    let diff = if check_only {
203        Some(DryRunDiff::new(content, formatted_content))
204    } else {
205        None
206    };
207
208    FormatResult::changed(path, diff)
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use std::path::Path;
215    use tempfile::tempdir;
216
217    use crate::test_fixtures::{CARGO_TOML, HELLO_FTL, I18N_TOML, LIB_RS, UI_UNSORTED_FTL};
218
219    fn write_test_crate(temp_dir: &Path) -> CrateInfo {
220        let src_dir = temp_dir.join("src");
221        let assets_dir = temp_dir.join("i18n/en");
222        std::fs::create_dir_all(&src_dir).expect("create src");
223        std::fs::create_dir_all(&assets_dir).expect("create assets");
224        std::fs::create_dir_all(assets_dir.join("test-app")).expect("create namespace dir");
225
226        let config_path = temp_dir.join("i18n.toml");
227        std::fs::write(&config_path, I18N_TOML).expect("write i18n.toml");
228
229        // Main file unchanged.
230        std::fs::write(assets_dir.join("test-app.ftl"), HELLO_FTL).expect("write main ftl");
231
232        // Namespaced file intentionally unsorted.
233        std::fs::write(assets_dir.join("test-app/ui.ftl"), UI_UNSORTED_FTL)
234            .expect("write namespaced ftl");
235
236        CrateInfo {
237            name: "test-app".to_string(),
238            manifest_dir: temp_dir.to_path_buf(),
239            src_dir,
240            i18n_config_path: config_path,
241            ftl_output_dir: temp_dir.join("i18n/en"),
242            has_lib_rs: true,
243            fluent_features: Vec::new(),
244        }
245    }
246
247    fn write_workspace_files(temp_dir: &Path) {
248        std::fs::create_dir_all(temp_dir.join("src")).expect("create src");
249        std::fs::write(temp_dir.join("Cargo.toml"), CARGO_TOML).expect("write Cargo.toml");
250        std::fs::write(temp_dir.join("src/lib.rs"), LIB_RS).expect("write lib.rs");
251    }
252
253    #[test]
254    fn format_crate_formats_namespaced_files() {
255        let temp = tempfile::tempdir().expect("create temp dir");
256        let krate = write_test_crate(temp.path());
257
258        let results = format_crate(&krate, false, false).expect("format crate");
259        assert_eq!(
260            results.len(),
261            2,
262            "main + namespaced files should be visited"
263        );
264
265        let namespaced_path = temp.path().join("i18n/en/test-app/ui.ftl");
266        let namespaced_suffix = Path::new("test-app").join("ui.ftl");
267        let namespaced_result = results
268            .iter()
269            .find(|r| r.path.ends_with(&namespaced_suffix))
270            .expect("namespaced result exists");
271        assert!(
272            namespaced_result.changed,
273            "namespaced file should be formatted"
274        );
275
276        let content = std::fs::read_to_string(&namespaced_path).expect("read namespaced file");
277        assert!(
278            content.starts_with("alpha = A\nzeta = Z"),
279            "expected sorted content, got:\n{content}"
280        );
281    }
282
283    #[test]
284    fn format_crate_dry_run_keeps_namespaced_file_unchanged() {
285        let temp = tempfile::tempdir().expect("create temp dir");
286        let krate = write_test_crate(temp.path());
287        let namespaced_path = temp.path().join("i18n/en/test-app/ui.ftl");
288        let before = std::fs::read_to_string(&namespaced_path).expect("read before");
289
290        let results = format_crate(&krate, false, true).expect("dry run format");
291        let namespaced_suffix = Path::new("test-app").join("ui.ftl");
292        let namespaced_result = results
293            .iter()
294            .find(|r| r.path.ends_with(&namespaced_suffix))
295            .expect("namespaced result exists");
296
297        assert!(namespaced_result.changed);
298        assert!(namespaced_result.diff_info.is_some());
299
300        let after = std::fs::read_to_string(&namespaced_path).expect("read after");
301        assert_eq!(before, after, "dry run should not write files");
302    }
303
304    #[test]
305    fn run_format_dry_run_and_real_cover_command_paths() {
306        let temp = tempdir().expect("tempdir");
307        write_workspace_files(temp.path());
308        write_test_crate(temp.path());
309        let namespaced_path = temp.path().join("i18n/en/test-app/ui.ftl");
310        let before = std::fs::read_to_string(&namespaced_path).expect("read before");
311
312        let dry_run = run_format(FormatArgs {
313            workspace: WorkspaceArgs {
314                path: Some(temp.path().to_path_buf()),
315                package: None,
316            },
317            all: false,
318            dry_run: true,
319        });
320        assert!(dry_run.is_ok());
321        let after_dry_run = std::fs::read_to_string(&namespaced_path).expect("read after dry-run");
322        assert_eq!(before, after_dry_run);
323
324        let real = run_format(FormatArgs {
325            workspace: WorkspaceArgs {
326                path: Some(temp.path().to_path_buf()),
327                package: None,
328            },
329            all: false,
330            dry_run: false,
331        });
332        assert!(real.is_ok());
333
334        let after_real = std::fs::read_to_string(&namespaced_path).expect("read after real");
335        assert_ne!(before, after_real);
336        assert!(after_real.starts_with("alpha = A\nzeta = Z"));
337    }
338
339    #[test]
340    fn run_format_returns_ok_when_package_filter_matches_nothing() {
341        let temp = tempdir().expect("tempdir");
342        write_workspace_files(temp.path());
343        write_test_crate(temp.path());
344
345        let result = run_format(FormatArgs {
346            workspace: WorkspaceArgs {
347                path: Some(temp.path().to_path_buf()),
348                package: Some("missing-package".to_string()),
349            },
350            all: false,
351            dry_run: false,
352        });
353
354        assert!(result.is_ok());
355    }
356
357    #[test]
358    fn format_ftl_file_covers_read_empty_and_partial_parse_paths() {
359        let temp = tempdir().expect("tempdir");
360
361        let missing = temp.path().join("missing.ftl");
362        let missing_result = format_ftl_file(&missing, false);
363        assert!(missing_result.error.is_some());
364
365        let empty = temp.path().join("empty.ftl");
366        std::fs::write(&empty, "   \n").expect("write empty");
367        let empty_result = format_ftl_file(&empty, false);
368        assert!(!empty_result.changed);
369        assert!(empty_result.error.is_none());
370
371        let invalid = temp.path().join("invalid.ftl");
372        std::fs::write(&invalid, "zeta = { $name\nalpha = A\n").expect("write invalid");
373        let partial = format_ftl_file(&invalid, true);
374        assert!(partial.changed);
375        assert!(partial.diff_info.is_some());
376    }
377
378    #[cfg(unix)]
379    #[test]
380    fn format_ftl_file_returns_write_error_for_read_only_file() {
381        use std::os::unix::fs::PermissionsExt;
382
383        let temp = tempdir().expect("tempdir");
384        let ftl = temp.path().join("read-only.ftl");
385        std::fs::write(&ftl, "zeta = Z\nalpha = A\n").expect("write ftl");
386
387        let mut perms = std::fs::metadata(&ftl).unwrap().permissions();
388        perms.set_mode(0o444);
389        std::fs::set_permissions(&ftl, perms).unwrap();
390
391        let result = format_ftl_file(&ftl, false);
392
393        let mut restore = std::fs::metadata(&ftl).unwrap().permissions();
394        restore.set_mode(0o644);
395        std::fs::set_permissions(&ftl, restore).unwrap();
396
397        assert!(
398            result
399                .error
400                .as_deref()
401                .is_some_and(|err| err.contains("Failed to write file")),
402            "expected write error, got: {:?}",
403            result.error
404        );
405    }
406}