1use crate::commands::{WorkspaceArgs, WorkspaceCrates};
7use crate::core::{CliError, CrateInfo, LocaleNotFoundError, SyncMissingKey};
8use crate::ftl::extract_message_keys;
9use crate::utils::{discover_and_load_ftl_files, get_all_locales, ui};
10use anyhow::{Context as _, Result};
11use clap::Parser;
12use es_fluent_toml::I18nConfig;
13use fluent_syntax::{ast, parser, serializer};
14use std::collections::{BTreeMap, HashSet};
15use std::fs;
16use std::path::Path;
17
18#[derive(Debug, Parser)]
20pub struct SyncArgs {
21 #[command(flatten)]
22 pub workspace: WorkspaceArgs,
23
24 #[arg(short, long)]
26 pub locale: Vec<String>,
27
28 #[arg(long)]
30 pub all: bool,
31
32 #[arg(long)]
34 pub dry_run: bool,
35}
36
37#[derive(Debug)]
39pub struct SyncLocaleResult {
40 pub locale: String,
42 pub keys_added: usize,
44 pub added_keys: Vec<String>,
46 pub diff_info: Option<(String, String)>,
48}
49
50pub fn run_sync(args: SyncArgs) -> Result<(), CliError> {
52 let workspace = WorkspaceCrates::discover(args.workspace)?;
53
54 ui::print_sync_header();
55
56 let crates = workspace.crates;
57
58 if crates.is_empty() {
59 ui::print_no_crates_found();
60 return Ok(());
61 }
62
63 let target_locales: Option<HashSet<String>> = if args.all {
64 None } else if args.locale.is_empty() {
66 ui::print_no_locales_specified();
67 return Ok(());
68 } else {
69 Some(args.locale.iter().cloned().collect())
70 };
71
72 if let Some(ref targets) = target_locales {
74 let all_available_locales = collect_all_available_locales(&crates)?;
75
76 for locale in targets {
77 if !all_available_locales.contains(locale) {
78 let mut available: Vec<String> = all_available_locales.into_iter().collect();
79 available.sort();
80 ui::print_locale_not_found(locale, &available);
81 return Err(CliError::LocaleNotFound(LocaleNotFoundError {
82 locale: locale.clone(),
83 available: available.join(", "),
84 }));
85 }
86 }
87 }
88
89 let mut total_keys_added = 0;
90 let mut total_locales_affected = 0;
91 let mut all_synced_keys: Vec<SyncMissingKey> = Vec::new();
92
93 let pb = ui::create_progress_bar(crates.len() as u64, "Syncing crates...");
94
95 for krate in &crates {
96 pb.set_message(format!("Syncing {}", krate.name));
97
98 let results = sync_crate(krate, target_locales.as_ref(), args.dry_run)?;
99
100 for result in results {
101 if result.keys_added > 0 {
102 total_locales_affected += 1;
103 total_keys_added += result.keys_added;
104
105 pb.suspend(|| {
106 if args.dry_run {
107 ui::print_would_add_keys(result.keys_added, &result.locale, &krate.name);
108 if let Some((old, new)) = &result.diff_info {
109 ui::print_diff(old, new);
110 }
111 } else {
112 ui::print_added_keys(result.keys_added, &result.locale);
113 for key in &result.added_keys {
114 ui::print_synced_key(key);
115 all_synced_keys.push(SyncMissingKey {
116 key: key.clone(),
117 target_locale: result.locale.clone(),
118 source_locale: "fallback".to_string(),
119 });
120 }
121 }
122 });
123 }
124 }
125 pb.inc(1);
126 }
127 pb.finish_and_clear();
128
129 if total_keys_added == 0 {
130 ui::print_all_in_sync();
131 Ok(())
132 } else if args.dry_run {
133 ui::print_sync_dry_run_summary(total_keys_added, total_locales_affected);
134 Ok(())
135 } else {
136 ui::print_sync_summary(total_keys_added, total_locales_affected);
137 Ok(())
138 }
139}
140
141fn sync_crate(
143 krate: &CrateInfo,
144 target_locales: Option<&HashSet<String>>,
145 dry_run: bool,
146) -> Result<Vec<SyncLocaleResult>> {
147 let config = I18nConfig::read_from_path(&krate.i18n_config_path)
148 .with_context(|| format!("Failed to read {}", krate.i18n_config_path.display()))?;
149
150 let assets_dir = krate.manifest_dir.join(&config.assets_dir);
151 let fallback_locale = &config.fallback_language;
152 let fallback_dir = assets_dir.join(fallback_locale);
153
154 if !fallback_dir.exists() {
155 return Ok(Vec::new());
156 }
157
158 let fallback_files =
160 discover_and_load_ftl_files(&assets_dir, &config.fallback_language, &krate.name)?;
161
162 if fallback_files.is_empty() {
163 return Ok(Vec::new());
164 }
165
166 let mut results = Vec::new();
167
168 let locales = get_all_locales(&assets_dir)?;
170
171 for locale in &locales {
172 if locale == fallback_locale {
174 continue;
175 }
176
177 if let Some(targets) = target_locales
179 && !targets.contains(locale)
180 {
181 continue;
182 }
183
184 let locale_dir = assets_dir.join(locale);
185
186 for ftl_info in &fallback_files {
188 let result = sync_locale_file(
189 &locale_dir,
190 &ftl_info.relative_path,
191 locale,
192 &ftl_info.resource,
193 &ftl_info.keys,
194 dry_run,
195 )?;
196
197 results.push(result);
198 }
199 }
200
201 Ok(results)
202}
203
204fn sync_locale_file(
206 locale_dir: &Path,
207 relative_ftl_path: &Path,
208 locale: &str,
209 fallback_resource: &ast::Resource<String>,
210 fallback_keys: &HashSet<String>,
211 dry_run: bool,
212) -> Result<SyncLocaleResult> {
213 let ftl_file = locale_dir.join(relative_ftl_path);
214
215 let parent_dir = ftl_file.parent().unwrap_or(locale_dir);
217 if !parent_dir.exists() && !dry_run {
218 fs::create_dir_all(parent_dir)?;
219 }
220
221 let existing_content = if ftl_file.exists() {
224 fs::read_to_string(&ftl_file)?
225 } else {
226 String::new()
227 };
228
229 let existing_resource = parser::parse(existing_content.clone())
230 .map_err(|(res, _)| res)
231 .unwrap_or_else(|res| res);
232
233 let existing_keys = extract_message_keys(&existing_resource);
234
235 let missing_keys: Vec<&String> = fallback_keys
237 .iter()
238 .filter(|k| !existing_keys.contains(*k))
239 .collect();
240
241 if missing_keys.is_empty() {
242 return Ok(SyncLocaleResult {
243 locale: locale.to_string(),
244 keys_added: 0,
245 added_keys: Vec::new(),
246 diff_info: None,
247 });
248 }
249
250 let mut added_keys: Vec<String> = Vec::new();
252
253 let merged = merge_missing_keys(
254 &existing_resource,
255 fallback_resource,
256 &missing_keys,
257 &mut added_keys,
258 );
259 let content = serializer::serialize(&merged);
261 let final_content = format!("{}\n", content.trim_end());
262
263 if !dry_run {
264 fs::write(&ftl_file, &final_content)?;
265 }
266
267 let diff_info = if dry_run && !missing_keys.is_empty() {
269 Some((existing_content, final_content))
270 } else {
271 None
272 };
273
274 Ok(SyncLocaleResult {
275 locale: locale.to_string(),
276 keys_added: added_keys.len(),
277 added_keys,
278 diff_info,
279 })
280}
281
282enum EntryKind<'a> {
284 SectionComment,
286 Comment,
288 Message(std::borrow::Cow<'a, str>),
290 Term(std::borrow::Cow<'a, str>),
292 Other,
294}
295
296fn classify_entry(entry: &ast::Entry<String>) -> EntryKind<'_> {
298 use std::borrow::Cow;
299 match entry {
300 ast::Entry::GroupComment(_) | ast::Entry::ResourceComment(_) => EntryKind::SectionComment,
301 ast::Entry::Comment(_) => EntryKind::Comment,
302 ast::Entry::Message(msg) => EntryKind::Message(Cow::Borrowed(&msg.id.name)),
303 ast::Entry::Term(term) => EntryKind::Term(Cow::Owned(format!("-{}", term.id.name))),
304 _ => EntryKind::Other,
305 }
306}
307
308fn merge_missing_keys(
310 existing: &ast::Resource<String>,
311 fallback: &ast::Resource<String>,
312 missing_keys: &[&String],
313 added_keys: &mut Vec<String>,
314) -> ast::Resource<String> {
315 let missing_set: HashSet<&String> = missing_keys.iter().copied().collect();
316
317 let mut entries_by_key: BTreeMap<String, Vec<ast::Entry<String>>> = BTreeMap::new();
319 let mut standalone_comments: Vec<ast::Entry<String>> = Vec::new();
320 let mut current_comments: Vec<ast::Entry<String>> = Vec::new();
321
322 for entry in &existing.body {
324 match classify_entry(entry) {
325 EntryKind::SectionComment => {
326 standalone_comments.append(&mut current_comments);
327 current_comments.push(entry.clone());
328 },
329 EntryKind::Comment => {
330 current_comments.push(entry.clone());
331 },
332 EntryKind::Message(key) => {
333 let mut entries = std::mem::take(&mut current_comments);
334 entries.push(entry.clone());
335 entries_by_key.insert(key.to_string(), entries);
336 },
337 EntryKind::Term(key) => {
338 let mut entries = std::mem::take(&mut current_comments);
339 entries.push(entry.clone());
340 entries_by_key.insert(key.to_string(), entries);
341 },
342 EntryKind::Other => {},
343 }
344 }
345
346 let mut fallback_comments: Vec<ast::Entry<String>> = Vec::new();
348
349 for entry in &fallback.body {
350 match classify_entry(entry) {
351 EntryKind::SectionComment => {
352 if matches!(entry, ast::Entry::GroupComment(_)) {
354 fallback_comments.clear();
355 fallback_comments.push(entry.clone());
356 }
357 },
358 EntryKind::Comment => {
359 fallback_comments.push(entry.clone());
360 },
361 EntryKind::Message(key) | EntryKind::Term(key) => {
362 let key_str = key.to_string();
363 if missing_set.contains(&key_str) {
364 added_keys.push(key_str.clone());
365 let mut entries = std::mem::take(&mut fallback_comments);
366 entries.push(entry.clone());
367 entries_by_key.insert(key_str, entries);
368 } else {
369 fallback_comments.clear();
370 }
371 },
372 EntryKind::Other => {},
373 }
374 }
375
376 let mut body: Vec<ast::Entry<String>> = Vec::new();
378 body.extend(standalone_comments);
379 body.append(&mut current_comments);
380
381 for (_key, entries) in entries_by_key {
382 body.extend(entries);
383 }
384
385 ast::Resource { body }
386}
387
388fn collect_all_available_locales(crates: &[CrateInfo]) -> Result<HashSet<String>> {
390 let mut all_locales = HashSet::new();
391
392 for krate in crates {
393 let config = I18nConfig::read_from_path(&krate.i18n_config_path)
394 .with_context(|| format!("Failed to read {}", krate.i18n_config_path.display()))?;
395
396 let assets_dir = krate.manifest_dir.join(&config.assets_dir);
397 let locales = get_all_locales(&assets_dir)?;
398
399 for locale in locales {
400 all_locales.insert(locale);
401 }
402 }
403
404 Ok(all_locales)
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410 use fluent_syntax::parser;
411
412 #[test]
413 fn test_extract_message_keys() {
414 let content = r#"hello = Hello
415world = World"#;
416 let resource = parser::parse(content.to_string()).unwrap();
417 let keys = extract_message_keys(&resource);
418
419 assert!(keys.contains("hello"));
420 assert!(keys.contains("world"));
421 assert_eq!(keys.len(), 2);
422 }
423
424 #[test]
425 fn test_merge_missing_keys() {
426 let existing_content = "hello = Hello";
427 let fallback_content = "hello = Hello\nworld = World\ngoodbye = Goodbye";
428
429 let existing = parser::parse(existing_content.to_string()).unwrap();
430 let fallback = parser::parse(fallback_content.to_string()).unwrap();
431
432 let world = "world".to_string();
433 let goodbye = "goodbye".to_string();
434 let missing_keys: Vec<&String> = vec![&world, &goodbye];
435 let mut added = Vec::new();
436
437 let merged = merge_missing_keys(&existing, &fallback, &missing_keys, &mut added);
438
439 assert_eq!(added.len(), 2);
440 assert!(added.contains(&"world".to_string()));
441 assert!(added.contains(&"goodbye".to_string()));
442
443 let merged_keys = extract_message_keys(&merged);
445 assert_eq!(merged_keys.len(), 3);
446 }
447
448 #[test]
449 fn test_collect_all_available_locales() {
450 use std::path::PathBuf;
451 use tempfile::tempdir;
452
453 let temp_dir = tempdir().unwrap();
454 let assets = temp_dir.path().join("i18n");
455 fs::create_dir(&assets).unwrap();
456 fs::create_dir(assets.join("en")).unwrap();
457 fs::create_dir(assets.join("fr")).unwrap();
458 fs::create_dir(assets.join("de")).unwrap();
459
460 let config_path = temp_dir.path().join("i18n.toml");
462 fs::write(
463 &config_path,
464 "fallback_language = \"en\"\nassets_dir = \"i18n\"\n",
465 )
466 .unwrap();
467
468 let crates = vec![CrateInfo {
469 name: "test-crate".to_string(),
470 manifest_dir: temp_dir.path().to_path_buf(),
471 src_dir: PathBuf::new(),
472 i18n_config_path: config_path,
473 ftl_output_dir: PathBuf::new(),
474 has_lib_rs: true,
475 fluent_features: Vec::new(),
476 }];
477
478 let locales = collect_all_available_locales(&crates).unwrap();
479
480 assert!(locales.contains("en"));
481 assert!(locales.contains("fr"));
482 assert!(locales.contains("de"));
483 assert_eq!(locales.len(), 3);
484 assert!(!locales.contains("awd"));
485 }
486}