Skip to main content

es_fluent_cli/commands/sync/
mod.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
6mod locale;
7mod merge;
8
9use crate::commands::{DryRunSummary, WorkspaceArgs, WorkspaceCrates};
10use crate::core::{CliError, LocaleNotFoundError, SyncMissingKey};
11use crate::ftl::collect_all_available_locales;
12use crate::utils::ui;
13use clap::Parser;
14use std::collections::HashSet;
15
16/// Arguments for the sync command.
17#[derive(Debug, Parser)]
18pub struct SyncArgs {
19    #[command(flatten)]
20    pub workspace: WorkspaceArgs,
21
22    /// Specific locale(s) to sync to (can be specified multiple times).
23    #[arg(short, long)]
24    pub locale: Vec<String>,
25
26    /// Sync to all locales (excluding the fallback language).
27    #[arg(long)]
28    pub all: bool,
29
30    /// Dry run - show what would be synced without making changes.
31    #[arg(long)]
32    pub dry_run: bool,
33}
34
35/// Run the sync command.
36pub fn run_sync(args: SyncArgs) -> Result<(), CliError> {
37    let workspace = WorkspaceCrates::discover(args.workspace)?;
38
39    ui::print_sync_header();
40
41    let crates = workspace.crates;
42
43    if crates.is_empty() {
44        ui::print_no_crates_found();
45        return Ok(());
46    }
47
48    let target_locales: Option<HashSet<String>> = if args.all {
49        None // Will sync to all locales
50    } else if args.locale.is_empty() {
51        ui::print_no_locales_specified();
52        return Ok(());
53    } else {
54        Some(args.locale.iter().cloned().collect())
55    };
56
57    // Validate that specified locales exist
58    if let Some(ref targets) = target_locales {
59        let all_available_locales = collect_all_available_locales(&crates)?;
60
61        for locale in targets {
62            if !all_available_locales.contains(locale) {
63                let mut available: Vec<String> = all_available_locales.into_iter().collect();
64                available.sort();
65                ui::print_locale_not_found(locale, &available);
66                return Err(CliError::LocaleNotFound(LocaleNotFoundError {
67                    locale: locale.clone(),
68                    available: available.join(", "),
69                }));
70            }
71        }
72    }
73
74    let mut total_keys_added = 0;
75    let mut total_locales_affected = 0;
76    let mut all_synced_keys: Vec<SyncMissingKey> = Vec::new();
77
78    let pb = ui::create_progress_bar(crates.len() as u64, "Syncing crates...");
79
80    for krate in &crates {
81        pb.set_message(format!("Syncing {}", krate.name));
82
83        let results = locale::sync_crate(krate, target_locales.as_ref(), args.dry_run)?;
84
85        for result in results {
86            if result.keys_added > 0 {
87                total_locales_affected += 1;
88                total_keys_added += result.keys_added;
89
90                pb.suspend(|| {
91                    if args.dry_run {
92                        ui::print_would_add_keys(result.keys_added, &result.locale, &krate.name);
93                        if let Some(diff) = &result.diff_info {
94                            diff.print();
95                        }
96                    } else {
97                        ui::print_added_keys(result.keys_added, &result.locale);
98                        for key in &result.added_keys {
99                            ui::print_synced_key(key);
100                            all_synced_keys.push(SyncMissingKey {
101                                key: key.clone(),
102                                target_locale: result.locale.clone(),
103                                source_locale: "fallback".to_string(),
104                            });
105                        }
106                    }
107                });
108            }
109        }
110        pb.inc(1);
111    }
112    pb.finish_and_clear();
113
114    if total_keys_added == 0 {
115        ui::print_all_in_sync();
116        Ok(())
117    } else if args.dry_run {
118        DryRunSummary::Sync {
119            keys: total_keys_added,
120            locales: total_locales_affected,
121        }
122        .print();
123        Ok(())
124    } else {
125        ui::print_sync_summary(total_keys_added, total_locales_affected);
126        Ok(())
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::ftl::extract_message_keys;
134    use fluent_syntax::parser;
135    use std::fs;
136    use tempfile::tempdir;
137
138    fn create_workspace_with_i18n() -> tempfile::TempDir {
139        let temp = tempdir().expect("tempdir");
140
141        fs::create_dir_all(temp.path().join("src")).expect("create src");
142        fs::create_dir_all(temp.path().join("i18n/en")).expect("create i18n/en");
143        fs::create_dir_all(temp.path().join("i18n/es")).expect("create i18n/es");
144
145        fs::write(
146            temp.path().join("Cargo.toml"),
147            r#"[package]
148name = "test-app"
149version = "0.1.0"
150edition = "2024"
151"#,
152        )
153        .expect("write Cargo.toml");
154        fs::write(temp.path().join("src/lib.rs"), "pub struct Demo;\n").expect("write lib.rs");
155        fs::write(
156            temp.path().join("i18n.toml"),
157            "fallback_language = \"en\"\nassets_dir = \"i18n\"\n",
158        )
159        .expect("write i18n.toml");
160
161        fs::write(
162            temp.path().join("i18n/en/test-app.ftl"),
163            "hello = Hello\nworld = World\n",
164        )
165        .expect("write fallback ftl");
166        fs::write(temp.path().join("i18n/es/test-app.ftl"), "hello = Hola\n")
167            .expect("write es ftl");
168
169        temp
170    }
171
172    #[test]
173    fn test_extract_message_keys() {
174        let content = r#"hello = Hello
175world = World"#;
176        let resource = parser::parse(content.to_string()).unwrap();
177        let keys = extract_message_keys(&resource);
178
179        assert!(keys.contains("hello"));
180        assert!(keys.contains("world"));
181        assert_eq!(keys.len(), 2);
182    }
183
184    #[test]
185    fn run_sync_returns_ok_when_no_locales_specified() {
186        let temp = create_workspace_with_i18n();
187
188        let result = run_sync(SyncArgs {
189            workspace: WorkspaceArgs {
190                path: Some(temp.path().to_path_buf()),
191                package: None,
192            },
193            locale: Vec::new(),
194            all: false,
195            dry_run: false,
196        });
197
198        assert!(result.is_ok());
199    }
200
201    #[test]
202    fn run_sync_returns_ok_when_no_crates_match_filter() {
203        let temp = create_workspace_with_i18n();
204
205        let result = run_sync(SyncArgs {
206            workspace: WorkspaceArgs {
207                path: Some(temp.path().to_path_buf()),
208                package: Some("missing-package".to_string()),
209            },
210            locale: vec!["es".to_string()],
211            all: false,
212            dry_run: false,
213        });
214
215        assert!(result.is_ok());
216    }
217
218    #[test]
219    fn run_sync_fails_for_unknown_locale() {
220        let temp = create_workspace_with_i18n();
221
222        let result = run_sync(SyncArgs {
223            workspace: WorkspaceArgs {
224                path: Some(temp.path().to_path_buf()),
225                package: None,
226            },
227            locale: vec!["zz-unknown".to_string()],
228            all: false,
229            dry_run: false,
230        });
231
232        assert!(matches!(result, Err(CliError::LocaleNotFound(_))));
233    }
234
235    #[test]
236    fn run_sync_dry_run_does_not_write_missing_keys() {
237        let temp = create_workspace_with_i18n();
238        let es_path = temp.path().join("i18n/es/test-app.ftl");
239        let before = fs::read_to_string(&es_path).expect("read before");
240
241        let result = run_sync(SyncArgs {
242            workspace: WorkspaceArgs {
243                path: Some(temp.path().to_path_buf()),
244                package: None,
245            },
246            locale: vec!["es".to_string()],
247            all: false,
248            dry_run: true,
249        });
250
251        assert!(result.is_ok());
252        let after = fs::read_to_string(&es_path).expect("read after");
253        assert_eq!(before, after, "dry-run should not modify locale files");
254    }
255
256    #[test]
257    fn run_sync_writes_missing_keys_for_target_locale() {
258        let temp = create_workspace_with_i18n();
259        let es_path = temp.path().join("i18n/es/test-app.ftl");
260
261        let result = run_sync(SyncArgs {
262            workspace: WorkspaceArgs {
263                path: Some(temp.path().to_path_buf()),
264                package: None,
265            },
266            locale: vec!["es".to_string()],
267            all: false,
268            dry_run: false,
269        });
270
271        assert!(result.is_ok());
272        let es_content = fs::read_to_string(&es_path).expect("read synced es");
273        assert!(es_content.contains("world = World"));
274    }
275
276    #[test]
277    fn run_sync_all_processes_non_fallback_locales() {
278        let temp = create_workspace_with_i18n();
279        fs::create_dir_all(temp.path().join("i18n/fr")).expect("create fr");
280        fs::write(temp.path().join("i18n/fr/test-app.ftl"), "hello = Salut\n").expect("write fr");
281
282        let result = run_sync(SyncArgs {
283            workspace: WorkspaceArgs {
284                path: Some(temp.path().to_path_buf()),
285                package: None,
286            },
287            locale: Vec::new(),
288            all: true,
289            dry_run: false,
290        });
291
292        assert!(result.is_ok());
293        let fr_content =
294            fs::read_to_string(temp.path().join("i18n/fr/test-app.ftl")).expect("read fr");
295        assert!(fr_content.contains("world = World"));
296    }
297}