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, 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/// 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    // Parse fallback locale to get reference messages
159    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    // Get all locales to sync to
166    let locales = get_all_locales(&assets_dir)?;
167
168    for locale in &locales {
169        // Skip the fallback locale
170        if locale == fallback_locale {
171            continue;
172        }
173
174        // Filter by target locales if specified
175        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
197/// Sync a single locale with missing keys from the fallback.
198fn 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    // Ensure the locale directory exists
209    if !locale_dir.exists() && !dry_run {
210        fs::create_dir_all(locale_dir)?;
211    }
212
213    // Parse existing locale file
214    // Read content first to allow diffing later
215    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    // Find missing keys
228    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    // Build the merged resource
243    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    // Serialize and write
252    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    // If dry run and we have changes (missing_keys was not empty), compute diff
260    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
274/// Classification of an FTL entry for merge operations.
275enum EntryKind<'a> {
276    /// Group or resource comment (section header).
277    SectionComment,
278    /// Regular comment.
279    Comment,
280    /// Message with key.
281    Message(std::borrow::Cow<'a, str>),
282    /// Term with key (prefixed with -).
283    Term(std::borrow::Cow<'a, str>),
284    /// Junk or other entries.
285    Other,
286}
287
288/// Classify an FTL entry for merge operations.
289fn 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
300/// Merge missing keys from the fallback into the existing resource.
301fn 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    // Group existing entries by key for preservation
310    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    // Process existing entries
315    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    // Add missing entries from fallback
339    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                // ResourceComment is skipped in original, GroupComment starts fresh
345                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    // Build sorted body
369    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
380/// Collect all available locales across all crates.
381fn 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        // The merged resource should have all 3 messages
435        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        // Create a minimal i18n.toml
452        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}