es_fluent_cli/commands/sync/
mod.rs1mod 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#[derive(Debug, Parser)]
18pub struct SyncArgs {
19 #[command(flatten)]
20 pub workspace: WorkspaceArgs,
21
22 #[arg(short, long)]
24 pub locale: Vec<String>,
25
26 #[arg(long)]
28 pub all: bool,
29
30 #[arg(long)]
32 pub dry_run: bool,
33}
34
35pub 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 } 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 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}