1use crate::commands::{WorkspaceArgs, WorkspaceCrates};
7use crate::core::{CliError, CrateInfo, LocaleNotFoundError, SyncMissingKey};
8use crate::ftl::{extract_message_keys, parse_ftl_file};
9use crate::utils::{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_ftl = fallback_dir.join(format!("{}.ftl", krate.name));
160 let fallback_resource = parse_ftl_file(&fallback_ftl)?;
161 let fallback_keys = extract_message_keys(&fallback_resource);
162
163 let mut results = Vec::new();
164
165 let locales = get_all_locales(&assets_dir)?;
167
168 for locale in &locales {
169 if locale == fallback_locale {
171 continue;
172 }
173
174 if let Some(targets) = target_locales
176 && !targets.contains(locale)
177 {
178 continue;
179 }
180
181 let locale_dir = assets_dir.join(locale);
182 let result = sync_locale(
183 &locale_dir,
184 &krate.name,
185 locale,
186 &fallback_resource,
187 &fallback_keys,
188 dry_run,
189 )?;
190
191 results.push(result);
192 }
193
194 Ok(results)
195}
196
197fn sync_locale(
199 locale_dir: &Path,
200 crate_name: &str,
201 locale: &str,
202 fallback_resource: &ast::Resource<String>,
203 fallback_keys: &HashSet<String>,
204 dry_run: bool,
205) -> Result<SyncLocaleResult> {
206 let ftl_file = locale_dir.join(format!("{}.ftl", crate_name));
207
208 if !locale_dir.exists() && !dry_run {
210 fs::create_dir_all(locale_dir)?;
211 }
212
213 let existing_content = if ftl_file.exists() {
216 fs::read_to_string(&ftl_file)?
217 } else {
218 String::new()
219 };
220
221 let existing_resource = parser::parse(existing_content.clone())
222 .map_err(|(res, _)| res)
223 .unwrap_or_else(|res| res);
224
225 let existing_keys = extract_message_keys(&existing_resource);
226
227 let missing_keys: Vec<&String> = fallback_keys
229 .iter()
230 .filter(|k| !existing_keys.contains(*k))
231 .collect();
232
233 if missing_keys.is_empty() {
234 return Ok(SyncLocaleResult {
235 locale: locale.to_string(),
236 keys_added: 0,
237 added_keys: Vec::new(),
238 diff_info: None,
239 });
240 }
241
242 let mut added_keys: Vec<String> = Vec::new();
244
245 let merged = merge_missing_keys(
246 &existing_resource,
247 fallback_resource,
248 &missing_keys,
249 &mut added_keys,
250 );
251 let content = serializer::serialize(&merged);
253 let final_content = format!("{}\n", content.trim_end());
254
255 if !dry_run {
256 fs::write(&ftl_file, &final_content)?;
257 }
258
259 let diff_info = if dry_run && !missing_keys.is_empty() {
261 Some((existing_content, final_content))
262 } else {
263 None
264 };
265
266 Ok(SyncLocaleResult {
267 locale: locale.to_string(),
268 keys_added: added_keys.len(),
269 added_keys,
270 diff_info,
271 })
272}
273
274enum EntryKind<'a> {
276 SectionComment,
278 Comment,
280 Message(std::borrow::Cow<'a, str>),
282 Term(std::borrow::Cow<'a, str>),
284 Other,
286}
287
288fn classify_entry(entry: &ast::Entry<String>) -> EntryKind<'_> {
290 use std::borrow::Cow;
291 match entry {
292 ast::Entry::GroupComment(_) | ast::Entry::ResourceComment(_) => EntryKind::SectionComment,
293 ast::Entry::Comment(_) => EntryKind::Comment,
294 ast::Entry::Message(msg) => EntryKind::Message(Cow::Borrowed(&msg.id.name)),
295 ast::Entry::Term(term) => EntryKind::Term(Cow::Owned(format!("-{}", term.id.name))),
296 _ => EntryKind::Other,
297 }
298}
299
300fn merge_missing_keys(
302 existing: &ast::Resource<String>,
303 fallback: &ast::Resource<String>,
304 missing_keys: &[&String],
305 added_keys: &mut Vec<String>,
306) -> ast::Resource<String> {
307 let missing_set: HashSet<&String> = missing_keys.iter().copied().collect();
308
309 let mut entries_by_key: BTreeMap<String, Vec<ast::Entry<String>>> = BTreeMap::new();
311 let mut standalone_comments: Vec<ast::Entry<String>> = Vec::new();
312 let mut current_comments: Vec<ast::Entry<String>> = Vec::new();
313
314 for entry in &existing.body {
316 match classify_entry(entry) {
317 EntryKind::SectionComment => {
318 standalone_comments.append(&mut current_comments);
319 current_comments.push(entry.clone());
320 },
321 EntryKind::Comment => {
322 current_comments.push(entry.clone());
323 },
324 EntryKind::Message(key) => {
325 let mut entries = std::mem::take(&mut current_comments);
326 entries.push(entry.clone());
327 entries_by_key.insert(key.to_string(), entries);
328 },
329 EntryKind::Term(key) => {
330 let mut entries = std::mem::take(&mut current_comments);
331 entries.push(entry.clone());
332 entries_by_key.insert(key.to_string(), entries);
333 },
334 EntryKind::Other => {},
335 }
336 }
337
338 let mut fallback_comments: Vec<ast::Entry<String>> = Vec::new();
340
341 for entry in &fallback.body {
342 match classify_entry(entry) {
343 EntryKind::SectionComment => {
344 if matches!(entry, ast::Entry::GroupComment(_)) {
346 fallback_comments.clear();
347 fallback_comments.push(entry.clone());
348 }
349 },
350 EntryKind::Comment => {
351 fallback_comments.push(entry.clone());
352 },
353 EntryKind::Message(key) | EntryKind::Term(key) => {
354 let key_str = key.to_string();
355 if missing_set.contains(&key_str) {
356 added_keys.push(key_str.clone());
357 let mut entries = std::mem::take(&mut fallback_comments);
358 entries.push(entry.clone());
359 entries_by_key.insert(key_str, entries);
360 } else {
361 fallback_comments.clear();
362 }
363 },
364 EntryKind::Other => {},
365 }
366 }
367
368 let mut body: Vec<ast::Entry<String>> = Vec::new();
370 body.extend(standalone_comments);
371 body.append(&mut current_comments);
372
373 for (_key, entries) in entries_by_key {
374 body.extend(entries);
375 }
376
377 ast::Resource { body }
378}
379
380fn collect_all_available_locales(crates: &[CrateInfo]) -> Result<HashSet<String>> {
382 let mut all_locales = HashSet::new();
383
384 for krate in crates {
385 let config = I18nConfig::read_from_path(&krate.i18n_config_path)
386 .with_context(|| format!("Failed to read {}", krate.i18n_config_path.display()))?;
387
388 let assets_dir = krate.manifest_dir.join(&config.assets_dir);
389 let locales = get_all_locales(&assets_dir)?;
390
391 for locale in locales {
392 all_locales.insert(locale);
393 }
394 }
395
396 Ok(all_locales)
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402 use fluent_syntax::parser;
403
404 #[test]
405 fn test_extract_message_keys() {
406 let content = "hello = Hello\nworld = World";
407 let resource = parser::parse(content.to_string()).unwrap();
408 let keys = extract_message_keys(&resource);
409
410 assert!(keys.contains("hello"));
411 assert!(keys.contains("world"));
412 assert_eq!(keys.len(), 2);
413 }
414
415 #[test]
416 fn test_merge_missing_keys() {
417 let existing_content = "hello = Hello";
418 let fallback_content = "hello = Hello\nworld = World\ngoodbye = Goodbye";
419
420 let existing = parser::parse(existing_content.to_string()).unwrap();
421 let fallback = parser::parse(fallback_content.to_string()).unwrap();
422
423 let world = "world".to_string();
424 let goodbye = "goodbye".to_string();
425 let missing_keys: Vec<&String> = vec![&world, &goodbye];
426 let mut added = Vec::new();
427
428 let merged = merge_missing_keys(&existing, &fallback, &missing_keys, &mut added);
429
430 assert_eq!(added.len(), 2);
431 assert!(added.contains(&"world".to_string()));
432 assert!(added.contains(&"goodbye".to_string()));
433
434 let merged_keys = extract_message_keys(&merged);
436 assert_eq!(merged_keys.len(), 3);
437 }
438
439 #[test]
440 fn test_collect_all_available_locales() {
441 use std::path::PathBuf;
442 use tempfile::tempdir;
443
444 let temp_dir = tempdir().unwrap();
445 let assets = temp_dir.path().join("i18n");
446 fs::create_dir(&assets).unwrap();
447 fs::create_dir(assets.join("en")).unwrap();
448 fs::create_dir(assets.join("fr")).unwrap();
449 fs::create_dir(assets.join("de")).unwrap();
450
451 let config_path = temp_dir.path().join("i18n.toml");
453 fs::write(
454 &config_path,
455 "fallback_language = \"en\"\nassets_dir = \"i18n\"\n",
456 )
457 .unwrap();
458
459 let crates = vec![CrateInfo {
460 name: "test-crate".to_string(),
461 manifest_dir: temp_dir.path().to_path_buf(),
462 src_dir: PathBuf::new(),
463 i18n_config_path: config_path,
464 ftl_output_dir: PathBuf::new(),
465 has_lib_rs: true,
466 fluent_features: Vec::new(),
467 }];
468
469 let locales = collect_all_available_locales(&crates).unwrap();
470
471 assert!(locales.contains("en"));
472 assert!(locales.contains("fr"));
473 assert!(locales.contains("de"));
474 assert_eq!(locales.len(), 3);
475 assert!(!locales.contains("awd"));
476 }
477}