1use 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#[derive(Debug, Parser)]
18pub struct FormatArgs {
19 #[command(flatten)]
20 pub workspace: WorkspaceArgs,
21
22 #[arg(long)]
24 pub all: bool,
25
26 #[arg(long)]
28 pub dry_run: bool,
29}
30
31#[derive(Debug)]
33pub struct FormatResult {
34 pub path: PathBuf,
36 pub changed: bool,
38 pub error: Option<String>,
40 pub diff_info: Option<DryRunDiff>,
42}
43
44impl FormatResult {
45 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 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 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
76pub 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
145fn 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 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
173fn 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, };
188
189 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 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 std::fs::write(assets_dir.join("test-crate.ftl"), "hello = Hello\n")
232 .expect("write main ftl");
233
234 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}