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 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 std::fs::write(assets_dir.join("test-app.ftl"), HELLO_FTL).expect("write main ftl");
231
232 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}