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
216    fn write_test_crate(temp_dir: &Path) -> CrateInfo {
217        let src_dir = temp_dir.join("src");
218        let assets_dir = temp_dir.join("i18n/en");
219        std::fs::create_dir_all(&src_dir).expect("create src");
220        std::fs::create_dir_all(&assets_dir).expect("create assets");
221        std::fs::create_dir_all(assets_dir.join("test-crate")).expect("create namespace dir");
222
223        let config_path = temp_dir.join("i18n.toml");
224        std::fs::write(
225            &config_path,
226            "fallback_language = \"en\"\nassets_dir = \"i18n\"\n",
227        )
228        .expect("write i18n.toml");
229
230        // Main file unchanged.
231        std::fs::write(assets_dir.join("test-crate.ftl"), "hello = Hello\n")
232            .expect("write main ftl");
233
234        // Namespaced file intentionally unsorted.
235        std::fs::write(
236            assets_dir.join("test-crate/ui.ftl"),
237            "zeta = Z\nalpha = A\n",
238        )
239        .expect("write namespaced ftl");
240
241        CrateInfo {
242            name: "test-crate".to_string(),
243            manifest_dir: temp_dir.to_path_buf(),
244            src_dir,
245            i18n_config_path: config_path,
246            ftl_output_dir: temp_dir.join("i18n/en"),
247            has_lib_rs: true,
248            fluent_features: Vec::new(),
249        }
250    }
251
252    #[test]
253    fn format_crate_formats_namespaced_files() {
254        let temp = tempfile::tempdir().expect("create temp dir");
255        let krate = write_test_crate(temp.path());
256
257        let results = format_crate(&krate, false, false).expect("format crate");
258        assert_eq!(
259            results.len(),
260            2,
261            "main + namespaced files should be visited"
262        );
263
264        let namespaced_path = temp.path().join("i18n/en/test-crate/ui.ftl");
265        let namespaced_suffix = Path::new("test-crate").join("ui.ftl");
266        let namespaced_result = results
267            .iter()
268            .find(|r| r.path.ends_with(&namespaced_suffix))
269            .expect("namespaced result exists");
270        assert!(
271            namespaced_result.changed,
272            "namespaced file should be formatted"
273        );
274
275        let content = std::fs::read_to_string(&namespaced_path).expect("read namespaced file");
276        assert!(
277            content.starts_with("alpha = A\nzeta = Z"),
278            "expected sorted content, got:\n{content}"
279        );
280    }
281
282    #[test]
283    fn format_crate_dry_run_keeps_namespaced_file_unchanged() {
284        let temp = tempfile::tempdir().expect("create temp dir");
285        let krate = write_test_crate(temp.path());
286        let namespaced_path = temp.path().join("i18n/en/test-crate/ui.ftl");
287        let before = std::fs::read_to_string(&namespaced_path).expect("read before");
288
289        let results = format_crate(&krate, false, true).expect("dry run format");
290        let namespaced_suffix = Path::new("test-crate").join("ui.ftl");
291        let namespaced_result = results
292            .iter()
293            .find(|r| r.path.ends_with(&namespaced_suffix))
294            .expect("namespaced result exists");
295
296        assert!(namespaced_result.changed);
297        assert!(namespaced_result.diff_info.is_some());
298
299        let after = std::fs::read_to_string(&namespaced_path).expect("read after");
300        assert_eq!(before, after, "dry run should not write files");
301    }
302}