Skip to main content

es_fluent_cli/commands/
sync.rs

1//! Sync command for synchronizing missing translations across locales.
2//!
3//! This module provides functionality to sync missing translation keys
4//! from the fallback language to other locales, preserving existing translations.
5
6use 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/// Arguments for the sync command.
19#[derive(Debug, Parser)]
20pub struct SyncArgs {
21    #[command(flatten)]
22    pub workspace: WorkspaceArgs,
23
24    /// Specific locale(s) to sync to (can be specified multiple times).
25    #[arg(short, long)]
26    pub locale: Vec<String>,
27
28    /// Sync to all locales (excluding the fallback language).
29    #[arg(long)]
30    pub all: bool,
31
32    /// Dry run - show what would be synced without making changes.
33    #[arg(long)]
34    pub dry_run: bool,
35}
36
37/// Result of syncing a single locale.
38#[derive(Debug)]
39pub struct SyncLocaleResult {
40    /// The locale that was synced.
41    pub locale: String,
42    /// Number of keys added.
43    pub keys_added: usize,
44    /// The keys that were added.
45    pub added_keys: Vec<String>,
46    /// Diff info (original, new) if dry run and changed.
47    pub diff_info: Option<(String, String)>,
48}
49
50/// Run the sync command.
51pub 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 // Will sync to all locales
65    } 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    // Validate that specified locales exist
73    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
141/// Sync all FTL files for a crate.
142fn 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    // Discover all FTL files in the fallback locale (including namespaced ones)
159    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    // Get all locales to sync to
169    let locales = get_all_locales(&assets_dir)?;
170
171    for locale in &locales {
172        // Skip the fallback locale
173        if locale == fallback_locale {
174            continue;
175        }
176
177        // Filter by target locales if specified
178        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        // Sync each FTL file (main + namespaced)
187        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
204/// Sync a single FTL file (main or namespaced) with missing keys from the fallback.
205fn 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    // Ensure the parent directory exists (handles namespaced subdirectories)
216    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    // Parse existing locale file
222    // Read content first to allow diffing later
223    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    // Find missing keys
236    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    // Build the merged resource
251    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    // Serialize and write
260    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    // If dry run and we have changes (missing_keys was not empty), compute diff
268    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
282/// Classification of an FTL entry for merge operations.
283enum EntryKind<'a> {
284    /// Group or resource comment (section header).
285    SectionComment,
286    /// Regular comment.
287    Comment,
288    /// Message with key.
289    Message(std::borrow::Cow<'a, str>),
290    /// Term with key (prefixed with -).
291    Term(std::borrow::Cow<'a, str>),
292    /// Junk or other entries.
293    Other,
294}
295
296/// Classify an FTL entry for merge operations.
297fn 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
308/// Merge missing keys from the fallback into the existing resource.
309fn 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    // Group existing entries by key for preservation
318    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    // Process existing entries
323    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    // Add missing entries from fallback
347    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                // ResourceComment is skipped in original, GroupComment starts fresh
353                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    // Build sorted body
377    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
388/// Collect all available locales across all crates.
389fn 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        // The merged resource should have all 3 messages
444        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        // Create a minimal i18n.toml
461        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}