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::{WorkspaceArgs, WorkspaceCrates};
7use crate::core::{CliError, CrateInfo, FormatError, FormatReport};
8use crate::utils::{get_all_locales, ui};
9use anyhow::{Context as _, Result};
10use clap::Parser;
11use es_fluent_toml::I18nConfig;
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<(String, String)>,
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<(String, String)>) -> 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((old, new)) = &result.diff_info {
112                            ui::print_diff(old, new);
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            ui::print_format_dry_run_summary(total_formatted);
129        } else {
130            ui::print_format_summary(total_formatted, total_unchanged);
131        }
132        Ok(())
133    } else {
134        Err(CliError::Format(FormatReport {
135            formatted_count: total_formatted,
136            error_count: errors.len(),
137            errors,
138        }))
139    }
140}
141
142/// Format all FTL files for a crate.
143fn format_crate(
144    krate: &CrateInfo,
145    all_locales: bool,
146    check_only: bool,
147) -> Result<Vec<FormatResult>> {
148    let config = I18nConfig::read_from_path(&krate.i18n_config_path)
149        .with_context(|| format!("Failed to read {}", krate.i18n_config_path.display()))?;
150
151    let assets_dir = krate.manifest_dir.join(&config.assets_dir);
152
153    let locales: Vec<String> = if all_locales {
154        // Get all locale directories
155        get_all_locales(&assets_dir)?
156    } else {
157        vec![config.fallback_language.clone()]
158    };
159
160    let mut results = Vec::new();
161
162    for locale in &locales {
163        let locale_dir = assets_dir.join(locale);
164        if !locale_dir.exists() {
165            continue;
166        }
167
168        // Format only the FTL file for this crate
169        let ftl_file = locale_dir.join(format!("{}.ftl", krate.name));
170        if ftl_file.exists() {
171            let ftl_file = fs::canonicalize(&ftl_file).unwrap_or(ftl_file);
172            let result = format_ftl_file(&ftl_file, check_only);
173            results.push(result);
174        }
175    }
176
177    Ok(results)
178}
179
180/// Format a single FTL file by sorting entries A-Z.
181fn format_ftl_file(path: &Path, check_only: bool) -> FormatResult {
182    let content = match fs::read_to_string(path) {
183        Ok(c) => c,
184        Err(e) => return FormatResult::error(path, format!("Failed to read file: {}", e)),
185    };
186
187    if content.trim().is_empty() {
188        return FormatResult::unchanged(path);
189    }
190
191    let resource = match parser::parse(content.clone()) {
192        Ok(res) => res,
193        Err((res, _errors)) => res, // Use the partial result even with errors
194    };
195
196    // Use shared formatting logic from es-fluent-generate
197    let formatted = es_fluent_generate::formatting::sort_ftl_resource(&resource);
198    let formatted_content = format!("{}\n", formatted.trim_end());
199
200    if content == formatted_content {
201        return FormatResult::unchanged(path);
202    }
203
204    // Try to write if not in check-only mode
205    if !check_only && let Err(e) = fs::write(path, &formatted_content) {
206        return FormatResult::error(path, format!("Failed to write file: {}", e));
207    }
208
209    let diff = if check_only {
210        Some((content, formatted_content))
211    } else {
212        None
213    };
214
215    FormatResult::changed(path, diff)
216}