1use langcodec::{
2 Codec, FormatType, Metadata, ReadOptions, Resource, Translation, convert_resources_to_format,
3};
4use serde::{Deserialize, Serialize};
5use serde_json::{Map, Value, json};
6use std::{
7 collections::{BTreeSet, HashMap},
8 env, fs,
9 path::{Path, PathBuf},
10 process::Command,
11 time::{SystemTime, UNIX_EPOCH},
12};
13
14use crate::config::{LoadedConfig, TolgeeConfig, load_config, resolve_config_relative_path};
15
16const DEFAULT_TOLGEE_CONFIG: &str = ".tolgeerc.json";
17const TOLGEE_FORMAT_APPLE_XCSTRINGS: &str = "APPLE_XCSTRINGS";
18const DEFAULT_PULL_TEMPLATE: &str = "/{namespace}/Localizable.{extension}";
19
20#[derive(Debug, Clone)]
21pub struct TolgeePullOptions {
22 pub config: Option<String>,
23 pub namespaces: Vec<String>,
24 pub dry_run: bool,
25 pub strict: bool,
26}
27
28#[derive(Debug, Clone)]
29pub struct TolgeePushOptions {
30 pub config: Option<String>,
31 pub namespaces: Vec<String>,
32 pub dry_run: bool,
33}
34
35#[derive(Debug, Clone, Default)]
36pub struct TranslateTolgeeSettings {
37 pub enabled: bool,
38 pub config: Option<String>,
39 pub namespaces: Vec<String>,
40}
41
42#[derive(Debug, Clone)]
43pub struct TranslateTolgeeContext {
44 project: TolgeeProject,
45 namespace: String,
46}
47
48impl TranslateTolgeeContext {
49 pub fn namespace(&self) -> &str {
50 &self.namespace
51 }
52}
53
54#[derive(Debug, Clone, Deserialize, Serialize)]
55struct TolgeePushFileConfig {
56 path: String,
57 namespace: String,
58}
59
60#[derive(Debug, Clone)]
61struct TolgeeMappedFile {
62 namespace: String,
63 relative_path: String,
64 absolute_path: PathBuf,
65}
66
67#[derive(Debug, Clone)]
68struct TolgeeProject {
69 config_path: PathBuf,
70 project_root: PathBuf,
71 raw: Value,
72 pull_template: String,
73 mappings: Vec<TolgeeMappedFile>,
74}
75
76#[derive(Debug, Default, Clone)]
77struct MergeReport {
78 merged: usize,
79 skipped_new_keys: usize,
80}
81
82#[derive(Debug, Clone)]
83enum TolgeeCliInvocation {
84 Direct(PathBuf),
85 PnpmExec,
86 NpmExec,
87}
88
89pub fn run_tolgee_pull_command(opts: TolgeePullOptions) -> Result<(), String> {
90 let project = load_tolgee_project(opts.config.as_deref())?;
91 let mappings = select_mappings(&project, &opts.namespaces)?;
92 let selected_namespaces = mappings
93 .iter()
94 .map(|mapping| mapping.namespace.clone())
95 .collect::<Vec<_>>();
96
97 println!(
98 "Preparing Tolgee pull for {} namespace(s): {}",
99 selected_namespaces.len(),
100 describe_namespaces(&selected_namespaces)
101 );
102 println!("Pulling Tolgee catalogs into a temporary workspace before merging locally...");
103 let pulled = pull_catalogs(&project, &selected_namespaces, opts.strict)?;
104
105 let mut changed_files = 0usize;
106 for mapping in mappings {
107 if !mapping.absolute_path.is_file() {
108 return Err(format!(
109 "Mapped xcstrings file does not exist: {}",
110 mapping.absolute_path.display()
111 ));
112 }
113
114 println!(
115 "Merging namespace '{}' into {}",
116 mapping.namespace, mapping.relative_path
117 );
118 let mut local_codec = read_xcstrings_codec(&mapping.absolute_path, opts.strict)?;
119 let pulled_codec = pulled
120 .get(&mapping.namespace)
121 .ok_or_else(|| format!("Tolgee did not export namespace '{}'", mapping.namespace))?;
122 let report = merge_tolgee_catalog(&mut local_codec, pulled_codec, &[]);
123
124 println!(
125 "Namespace {} -> {} merged={} skipped_new_keys={}",
126 mapping.namespace, mapping.relative_path, report.merged, report.skipped_new_keys
127 );
128
129 if report.merged > 0 {
130 changed_files += 1;
131 if !opts.dry_run {
132 println!("Writing merged catalog to {}", mapping.relative_path);
133 write_xcstrings_codec(&local_codec, &mapping.absolute_path)?;
134 }
135 }
136 }
137
138 if opts.dry_run {
139 println!("Dry-run mode: no files were written");
140 } else {
141 println!("Tolgee pull complete: updated {} file(s)", changed_files);
142 }
143 Ok(())
144}
145
146pub fn run_tolgee_push_command(opts: TolgeePushOptions) -> Result<(), String> {
147 let project = load_tolgee_project(opts.config.as_deref())?;
148 let mappings = select_mappings(&project, &opts.namespaces)?;
149 if mappings.is_empty() {
150 return Err("No Tolgee namespaces matched the request".to_string());
151 }
152
153 for mapping in &mappings {
154 if !mapping.absolute_path.is_file() {
155 return Err(format!(
156 "Mapped xcstrings file does not exist: {}",
157 mapping.absolute_path.display()
158 ));
159 }
160 }
161
162 let namespaces = mappings
163 .iter()
164 .map(|mapping| mapping.namespace.clone())
165 .collect::<Vec<_>>();
166
167 println!(
168 "Preparing Tolgee push for {} namespace(s): {}",
169 namespaces.len(),
170 describe_namespaces(&namespaces)
171 );
172 println!("Validating mapped xcstrings files before upload...");
173
174 if opts.dry_run {
175 println!(
176 "Dry-run mode: would push namespaces {}",
177 namespaces.join(", ")
178 );
179 return Ok(());
180 }
181
182 println!("Uploading catalogs to Tolgee...");
183 invoke_tolgee(&project, "push", &namespaces, None)?;
184 println!("Tolgee push complete: {}", namespaces.join(", "));
185 Ok(())
186}
187
188pub fn prefill_translate_from_tolgee(
189 settings: &TranslateTolgeeSettings,
190 local_catalog_path: &str,
191 target_codec: &mut Codec,
192 target_langs: &[String],
193 strict: bool,
194) -> Result<Option<TranslateTolgeeContext>, String> {
195 if !settings.enabled {
196 return Ok(None);
197 }
198
199 let project = load_tolgee_project(settings.config.as_deref())?;
200 let mapping = resolve_mapping_for_catalog(&project, local_catalog_path)?;
201
202 if !settings.namespaces.is_empty()
203 && !settings
204 .namespaces
205 .iter()
206 .any(|namespace| namespace == &mapping.namespace)
207 {
208 return Err(format!(
209 "Catalog '{}' maps to Tolgee namespace '{}' which is not included in --tolgee-namespace/[tolgee].namespaces",
210 local_catalog_path, mapping.namespace
211 ));
212 }
213
214 let pulled = pull_catalogs(&project, std::slice::from_ref(&mapping.namespace), strict)?;
215 let pulled_codec = pulled
216 .get(&mapping.namespace)
217 .ok_or_else(|| format!("Tolgee did not export namespace '{}'", mapping.namespace))?;
218 merge_tolgee_catalog(target_codec, pulled_codec, target_langs);
219
220 Ok(Some(TranslateTolgeeContext {
221 project,
222 namespace: mapping.namespace,
223 }))
224}
225
226pub fn push_translate_results_to_tolgee(
227 context: &TranslateTolgeeContext,
228 dry_run: bool,
229) -> Result<(), String> {
230 if dry_run {
231 return Ok(());
232 }
233
234 invoke_tolgee(
235 &context.project,
236 "push",
237 std::slice::from_ref(&context.namespace),
238 None,
239 )
240}
241
242fn load_tolgee_project(explicit_path: Option<&str>) -> Result<TolgeeProject, String> {
243 if let Some(path) = explicit_path {
244 return load_tolgee_project_from_path(path);
245 }
246
247 if let Some(loaded) = load_config(None)? {
248 let tolgee = &loaded.data.tolgee;
249 if let Some(source_path) = tolgee.config.as_deref() {
250 let resolved = resolve_config_relative_path(loaded.config_dir(), source_path);
251 return load_tolgee_project_from_path(&resolved);
252 }
253 if tolgee.has_inline_runtime_config() {
254 return load_tolgee_project_from_langcodec(&loaded);
255 }
256 }
257
258 let config_path = resolve_default_tolgee_json_path()?;
259 load_tolgee_project_from_json(config_path)
260}
261
262fn load_tolgee_project_from_path(path: &str) -> Result<TolgeeProject, String> {
263 let resolved = absolute_from_current_dir(path)?;
264 let extension = resolved
265 .extension()
266 .and_then(|ext| ext.to_str())
267 .map(|ext| ext.to_ascii_lowercase());
268
269 match extension.as_deref() {
270 Some("json") => load_tolgee_project_from_json(resolved),
271 Some("toml") => {
272 let loaded = load_config(Some(&resolved.to_string_lossy()))?
273 .ok_or_else(|| format!("Config file does not exist: {}", resolved.display()))?;
274 let tolgee = &loaded.data.tolgee;
275 if let Some(source_path) = tolgee.config.as_deref() {
276 let nested = resolve_config_relative_path(loaded.config_dir(), source_path);
277 load_tolgee_project_from_path(&nested)
278 } else {
279 load_tolgee_project_from_langcodec(&loaded)
280 }
281 }
282 _ => Err(format!(
283 "Unsupported Tolgee config source '{}'. Expected .json or .toml",
284 resolved.display()
285 )),
286 }
287}
288
289fn load_tolgee_project_from_json(config_path: PathBuf) -> Result<TolgeeProject, String> {
290 let text = fs::read_to_string(&config_path).map_err(|e| {
291 format!(
292 "Failed to read Tolgee config '{}': {}",
293 config_path.display(),
294 e
295 )
296 })?;
297 let raw: Value = serde_json::from_str(&text).map_err(|e| {
298 format!(
299 "Failed to parse Tolgee config '{}': {}",
300 config_path.display(),
301 e
302 )
303 })?;
304 let project_root = config_path
305 .parent()
306 .ok_or_else(|| {
307 format!(
308 "Tolgee config path has no parent: {}",
309 config_path.display()
310 )
311 })?
312 .to_path_buf();
313 build_tolgee_project_from_raw(config_path, project_root, raw)
314}
315
316fn load_tolgee_project_from_langcodec(loaded: &LoadedConfig) -> Result<TolgeeProject, String> {
317 let project_root = loaded
318 .config_dir()
319 .ok_or_else(|| format!("Config path has no parent: {}", loaded.path.display()))?
320 .to_path_buf();
321 let tolgee = &loaded.data.tolgee;
322 if !tolgee.has_inline_runtime_config() {
323 return Err(format!(
324 "Config '{}' does not contain inline [tolgee] runtime settings",
325 loaded.path.display()
326 ));
327 }
328
329 let raw = build_tolgee_json_from_toml(tolgee)?;
330 build_tolgee_project_from_raw(loaded.path.clone(), project_root, raw)
331}
332
333fn build_tolgee_json_from_toml(tolgee: &TolgeeConfig) -> Result<Value, String> {
334 if tolgee.push.files.is_empty() {
335 return Err("Tolgee [push.files] must contain at least one mapping".to_string());
336 }
337
338 let push_files = tolgee
339 .push
340 .files
341 .iter()
342 .map(|file| {
343 json!({
344 "path": file.path,
345 "namespace": file.namespace,
346 })
347 })
348 .collect::<Vec<_>>();
349
350 let mut root = json!({
351 "format": tolgee.format.as_deref().unwrap_or(TOLGEE_FORMAT_APPLE_XCSTRINGS),
352 "push": {
353 "files": push_files,
354 },
355 "pull": {
356 "path": tolgee.pull.path.as_deref().unwrap_or("./tolgee-temp"),
357 "fileStructureTemplate": tolgee.pull.file_structure_template.as_deref().unwrap_or(DEFAULT_PULL_TEMPLATE),
358 }
359 });
360
361 if let Some(schema) = tolgee.schema.as_deref() {
362 set_nested_string(&mut root, &["$schema"], schema);
363 }
364 if let Some(project_id) = tolgee.project_id {
365 set_nested_value(&mut root, &["projectId"], json!(project_id));
366 }
367 if let Some(api_url) = tolgee.api_url.as_deref() {
368 set_nested_string(&mut root, &["apiUrl"], api_url);
369 }
370 if let Some(api_key) = tolgee.api_key.as_deref() {
371 set_nested_string(&mut root, &["apiKey"], api_key);
372 }
373 if let Some(languages) = tolgee.push.languages.as_ref() {
374 set_nested_array(&mut root, &["push", "languages"], languages);
375 }
376 if let Some(force_mode) = tolgee.push.force_mode.as_deref() {
377 set_nested_string(&mut root, &["push", "forceMode"], force_mode);
378 }
379
380 Ok(root)
381}
382
383fn build_tolgee_project_from_raw(
384 config_path: PathBuf,
385 project_root: PathBuf,
386 mut raw: Value,
387) -> Result<TolgeeProject, String> {
388 normalize_tolgee_raw(&mut raw);
389
390 let format = raw
391 .get("format")
392 .and_then(Value::as_str)
393 .ok_or_else(|| "Tolgee config is missing 'format'".to_string())?;
394 if format != TOLGEE_FORMAT_APPLE_XCSTRINGS {
395 return Err(format!(
396 "Unsupported Tolgee format '{}'. v1 supports only {}",
397 format, TOLGEE_FORMAT_APPLE_XCSTRINGS
398 ));
399 }
400
401 let push_files_value = raw
402 .get("push")
403 .and_then(|value| value.get("files"))
404 .cloned()
405 .ok_or_else(|| "Tolgee config is missing push.files".to_string())?;
406 let push_files: Vec<TolgeePushFileConfig> = serde_json::from_value(push_files_value)
407 .map_err(|e| format!("Tolgee config push.files is invalid: {}", e))?;
408 if push_files.is_empty() {
409 return Err("Tolgee config push.files is empty".to_string());
410 }
411
412 let mappings = push_files
413 .into_iter()
414 .map(|file| TolgeeMappedFile {
415 absolute_path: normalize_path(project_root.join(&file.path)),
416 relative_path: file.path,
417 namespace: file.namespace,
418 })
419 .collect::<Vec<_>>();
420
421 let pull_template = raw
422 .get("pull")
423 .and_then(|value| value.get("fileStructureTemplate"))
424 .and_then(Value::as_str)
425 .unwrap_or(DEFAULT_PULL_TEMPLATE)
426 .to_string();
427
428 Ok(TolgeeProject {
429 config_path,
430 project_root,
431 raw,
432 pull_template,
433 mappings,
434 })
435}
436
437fn normalize_tolgee_raw(raw: &mut Value) {
438 let Some(push) = raw.get_mut("push").and_then(Value::as_object_mut) else {
439 return;
440 };
441
442 if push.contains_key("languages") {
443 return;
444 }
445
446 if let Some(language) = push.remove("language") {
447 push.insert("languages".to_string(), language);
448 }
449}
450
451fn describe_namespaces(namespaces: &[String]) -> String {
452 match namespaces {
453 [] => "(none)".to_string(),
454 [namespace] => namespace.clone(),
455 _ => namespaces.join(", "),
456 }
457}
458
459fn resolve_default_tolgee_json_path() -> Result<PathBuf, String> {
460 let mut current =
461 env::current_dir().map_err(|e| format!("Failed to determine current directory: {}", e))?;
462 loop {
463 let candidate = current.join(DEFAULT_TOLGEE_CONFIG);
464 if candidate.is_file() {
465 return Ok(candidate);
466 }
467 if !current.pop() {
468 return Err(format!(
469 "Could not find {} in the current directory or any parent",
470 DEFAULT_TOLGEE_CONFIG
471 ));
472 }
473 }
474}
475
476fn absolute_from_current_dir(path: &str) -> Result<PathBuf, String> {
477 let candidate = Path::new(path);
478 if candidate.is_absolute() {
479 return Ok(normalize_path(candidate.to_path_buf()));
480 }
481
482 let current_dir =
483 env::current_dir().map_err(|e| format!("Failed to determine current directory: {}", e))?;
484 Ok(normalize_path(current_dir.join(candidate)))
485}
486
487fn normalize_path(path: PathBuf) -> PathBuf {
488 let mut normalized = PathBuf::new();
489 for component in path.components() {
490 normalized.push(component);
491 }
492 normalized
493}
494
495fn select_mappings(
496 project: &TolgeeProject,
497 namespaces: &[String],
498) -> Result<Vec<TolgeeMappedFile>, String> {
499 if namespaces.is_empty() {
500 return Ok(project.mappings.clone());
501 }
502
503 let mut selected = Vec::new();
504 for namespace in namespaces {
505 let mapping = project
506 .mappings
507 .iter()
508 .find(|mapping| mapping.namespace == *namespace)
509 .cloned()
510 .ok_or_else(|| {
511 format!(
512 "Tolgee namespace '{}' is not configured in push.files",
513 namespace
514 )
515 })?;
516 if !selected
517 .iter()
518 .any(|existing: &TolgeeMappedFile| existing.namespace == mapping.namespace)
519 {
520 selected.push(mapping);
521 }
522 }
523 Ok(selected)
524}
525
526fn resolve_mapping_for_catalog(
527 project: &TolgeeProject,
528 local_catalog_path: &str,
529) -> Result<TolgeeMappedFile, String> {
530 let resolved = absolute_from_current_dir(local_catalog_path)?;
531 project
532 .mappings
533 .iter()
534 .find(|mapping| mapping.absolute_path == resolved)
535 .cloned()
536 .ok_or_else(|| {
537 format!(
538 "Catalog '{}' is not configured in Tolgee push.files",
539 local_catalog_path
540 )
541 })
542}
543
544fn discover_tolgee_cli(project_root: &Path) -> Result<TolgeeCliInvocation, String> {
545 let local_name = if cfg!(windows) {
546 "node_modules/.bin/tolgee.cmd"
547 } else {
548 "node_modules/.bin/tolgee"
549 };
550 let local_cli = project_root.join(local_name);
551 if local_cli.is_file() {
552 return Ok(TolgeeCliInvocation::Direct(local_cli));
553 }
554
555 match Command::new("tolgee").arg("--version").output() {
556 Ok(output) if output.status.success() => {
557 return Ok(TolgeeCliInvocation::Direct(PathBuf::from("tolgee")));
558 }
559 Ok(_) | Err(_) => {}
560 }
561
562 if let Ok(output) = Command::new("pnpm")
563 .args(["exec", "tolgee", "--version"])
564 .current_dir(project_root)
565 .output()
566 && output.status.success()
567 {
568 return Ok(TolgeeCliInvocation::PnpmExec);
569 }
570
571 if let Ok(output) = Command::new("npm")
572 .args(["exec", "--", "tolgee", "--version"])
573 .current_dir(project_root)
574 .output()
575 && output.status.success()
576 {
577 return Ok(TolgeeCliInvocation::NpmExec);
578 }
579
580 Err(
581 "Tolgee CLI not found. Install @tolgee/cli locally in node_modules, make 'tolgee' available on PATH, or ensure 'pnpm exec tolgee'/'npm exec -- tolgee' works in the project"
582 .to_string(),
583 )
584}
585
586fn invoke_tolgee(
587 project: &TolgeeProject,
588 subcommand: &str,
589 namespaces: &[String],
590 pull_root_override: Option<&Path>,
591) -> Result<(), String> {
592 let cli = discover_tolgee_cli(&project.project_root)?;
593 let config_path = if project_uses_json_config(project)
594 && namespaces.is_empty()
595 && pull_root_override.is_none()
596 {
597 project.config_path.clone()
598 } else {
599 write_overlay_config(project, namespaces, pull_root_override)?
600 };
601
602 let mut command = match cli {
603 TolgeeCliInvocation::Direct(path) => Command::new(path),
604 TolgeeCliInvocation::PnpmExec => {
605 let mut command = Command::new("pnpm");
606 command.args(["exec", "tolgee"]);
607 command
608 }
609 TolgeeCliInvocation::NpmExec => {
610 let mut command = Command::new("npm");
611 command.args(["exec", "--", "tolgee"]);
612 command
613 }
614 };
615
616 let output = command
617 .arg("--config")
618 .arg(&config_path)
619 .arg(subcommand)
620 .arg("--verbose")
621 .current_dir(&project.project_root)
622 .output()
623 .map_err(|e| format!("Failed to run Tolgee CLI: {}", e))?;
624
625 if config_path != project.config_path {
626 let _ = fs::remove_file(&config_path);
627 }
628
629 if output.status.success() {
630 return Ok(());
631 }
632
633 let stdout = String::from_utf8_lossy(&output.stdout);
634 let stderr = String::from_utf8_lossy(&output.stderr);
635 Err(format!(
636 "Tolgee CLI {} failed (status={}): stdout={} stderr={}",
637 subcommand,
638 output.status,
639 stdout.trim(),
640 stderr.trim()
641 ))
642}
643
644fn project_uses_json_config(project: &TolgeeProject) -> bool {
645 project
646 .config_path
647 .extension()
648 .and_then(|ext| ext.to_str())
649 .is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
650}
651
652fn write_overlay_config(
653 project: &TolgeeProject,
654 namespaces: &[String],
655 pull_root_override: Option<&Path>,
656) -> Result<PathBuf, String> {
657 let mut raw = project.raw.clone();
658 if !namespaces.is_empty() {
659 set_nested_array(&mut raw, &["pull", "namespaces"], namespaces);
660 set_nested_array(&mut raw, &["push", "namespaces"], namespaces);
661 }
662 if let Some(pull_root) = pull_root_override {
663 set_nested_string(
664 &mut raw,
665 &["pull", "path"],
666 pull_root.to_string_lossy().as_ref(),
667 );
668 }
669
670 let unique = format!(
671 ".langcodec-tolgee-{}-{}.json",
672 std::process::id(),
673 SystemTime::now()
674 .duration_since(UNIX_EPOCH)
675 .map_err(|e| format!("System clock error: {}", e))?
676 .as_nanos()
677 );
678 let overlay_path = project.project_root.join(unique);
679 fs::write(
680 &overlay_path,
681 serde_json::to_vec_pretty(&raw)
682 .map_err(|e| format!("Failed to serialize Tolgee overlay config: {}", e))?,
683 )
684 .map_err(|e| format!("Failed to write Tolgee overlay config: {}", e))?;
685 Ok(overlay_path)
686}
687
688fn set_nested_array(root: &mut Value, path: &[&str], values: &[String]) {
689 set_nested_value(
690 root,
691 path,
692 Value::Array(values.iter().map(|value| json!(value)).collect()),
693 );
694}
695
696fn set_nested_string(root: &mut Value, path: &[&str], value: &str) {
697 set_nested_value(root, path, Value::String(value.to_string()));
698}
699
700fn set_nested_value(root: &mut Value, path: &[&str], value: Value) {
701 let mut current = root;
702 for key in &path[..path.len() - 1] {
703 if !current.is_object() {
704 *current = Value::Object(Map::new());
705 }
706 let object = current.as_object_mut().expect("object");
707 current = object
708 .entry((*key).to_string())
709 .or_insert_with(|| Value::Object(Map::new()));
710 }
711
712 if !current.is_object() {
713 *current = Value::Object(Map::new());
714 }
715 current
716 .as_object_mut()
717 .expect("object")
718 .insert(path[path.len() - 1].to_string(), value);
719}
720
721fn pull_catalogs(
722 project: &TolgeeProject,
723 namespaces: &[String],
724 strict: bool,
725) -> Result<HashMap<String, Codec>, String> {
726 let selected = select_mappings(project, namespaces)?;
727 let temp_root = create_temp_dir("langcodec-tolgee-pull")?;
728 let result = (|| {
729 invoke_tolgee(project, "pull", namespaces, Some(&temp_root))?;
730 let mut pulled = HashMap::new();
731 for mapping in selected {
732 let pulled_path = pulled_path_for_namespace(project, &temp_root, &mapping.namespace)?;
733 if !pulled_path.is_file() {
734 return Err(format!(
735 "Tolgee pull did not produce '{}'",
736 pulled_path.display()
737 ));
738 }
739 pulled.insert(
740 mapping.namespace,
741 read_xcstrings_codec(&pulled_path, strict)?,
742 );
743 }
744 Ok(pulled)
745 })();
746 let _ = fs::remove_dir_all(&temp_root);
747 result
748}
749
750fn create_temp_dir(prefix: &str) -> Result<PathBuf, String> {
751 let dir = env::temp_dir().join(format!(
752 "{}-{}-{}",
753 prefix,
754 std::process::id(),
755 SystemTime::now()
756 .duration_since(UNIX_EPOCH)
757 .map_err(|e| format!("System clock error: {}", e))?
758 .as_nanos()
759 ));
760 fs::create_dir_all(&dir).map_err(|e| {
761 format!(
762 "Failed to create temporary directory '{}': {}",
763 dir.display(),
764 e
765 )
766 })?;
767 Ok(dir)
768}
769
770fn pulled_path_for_namespace(
771 project: &TolgeeProject,
772 pull_root: &Path,
773 namespace: &str,
774) -> Result<PathBuf, String> {
775 if project.pull_template.contains("{languageTag}") {
776 return Err(
777 "Tolgee pull.fileStructureTemplate with {languageTag} is not supported for APPLE_XCSTRINGS in v1"
778 .to_string(),
779 );
780 }
781
782 let relative = project
783 .pull_template
784 .replace("{namespace}", namespace)
785 .replace("{extension}", "xcstrings");
786 if relative.contains('{') {
787 return Err(format!(
788 "Unsupported placeholders in Tolgee pull.fileStructureTemplate: {}",
789 project.pull_template
790 ));
791 }
792 Ok(pull_root.join(relative.trim_start_matches('/')))
793}
794
795fn merge_tolgee_catalog(
796 local_codec: &mut Codec,
797 pulled_codec: &Codec,
798 allowed_langs: &[String],
799) -> MergeReport {
800 let existing_keys = local_codec
801 .resources
802 .iter()
803 .flat_map(|resource| resource.entries.iter().map(|entry| entry.id.clone()))
804 .collect::<BTreeSet<_>>();
805 let mut report = MergeReport::default();
806
807 for pulled_resource in &pulled_codec.resources {
808 if !allowed_langs.is_empty()
809 && !allowed_langs
810 .iter()
811 .any(|lang| lang_matches(lang, &pulled_resource.metadata.language))
812 {
813 continue;
814 }
815
816 ensure_resource(local_codec, &pulled_resource.metadata);
817
818 for pulled_entry in &pulled_resource.entries {
819 if !existing_keys.contains(&pulled_entry.id) {
820 report.skipped_new_keys += 1;
821 continue;
822 }
823 if translation_is_empty(&pulled_entry.value) {
824 continue;
825 }
826
827 if let Some(existing) =
828 local_codec.find_entry_mut(&pulled_entry.id, &pulled_resource.metadata.language)
829 {
830 if existing.value != pulled_entry.value
831 || existing.status != pulled_entry.status
832 || existing.comment != pulled_entry.comment
833 {
834 existing.value = pulled_entry.value.clone();
835 existing.status = pulled_entry.status.clone();
836 existing.comment = pulled_entry.comment.clone();
837 report.merged += 1;
838 }
839 continue;
840 }
841
842 let _ = local_codec.add_entry(
843 &pulled_entry.id,
844 &pulled_resource.metadata.language,
845 pulled_entry.value.clone(),
846 pulled_entry.comment.clone(),
847 Some(pulled_entry.status.clone()),
848 );
849 report.merged += 1;
850 }
851 }
852
853 report
854}
855
856fn ensure_resource(codec: &mut Codec, metadata: &Metadata) {
857 if codec.get_by_language(&metadata.language).is_some() {
858 return;
859 }
860
861 codec.add_resource(Resource {
862 metadata: metadata.clone(),
863 entries: Vec::new(),
864 });
865}
866
867fn translation_is_empty(translation: &Translation) -> bool {
868 match translation {
869 Translation::Empty => true,
870 Translation::Singular(value) => value.trim().is_empty(),
871 Translation::Plural(_) => false,
872 }
873}
874
875fn read_xcstrings_codec(path: &Path, strict: bool) -> Result<Codec, String> {
876 let format = path
877 .extension()
878 .and_then(|ext| ext.to_str())
879 .filter(|ext| ext.eq_ignore_ascii_case("xcstrings"))
880 .ok_or_else(|| {
881 format!(
882 "Tolgee v1 supports only .xcstrings files, got '{}'",
883 path.display()
884 )
885 })?;
886 let _ = format;
887
888 let mut codec = Codec::new();
889 codec
890 .read_file_by_extension_with_options(path, &ReadOptions::new().with_strict(strict))
891 .map_err(|e| format!("Failed to read '{}': {}", path.display(), e))?;
892 Ok(codec)
893}
894
895fn write_xcstrings_codec(codec: &Codec, path: &Path) -> Result<(), String> {
896 convert_resources_to_format(
897 codec.resources.clone(),
898 &path.to_string_lossy(),
899 FormatType::Xcstrings,
900 )
901 .map_err(|e| format!("Failed to write '{}': {}", path.display(), e))
902}
903
904fn lang_matches(left: &str, right: &str) -> bool {
905 normalize_lang(left) == normalize_lang(right)
906 || normalize_lang(left).split('-').next().unwrap_or(left)
907 == normalize_lang(right).split('-').next().unwrap_or(right)
908}
909
910fn normalize_lang(value: &str) -> String {
911 value.trim().replace('_', "-").to_ascii_lowercase()
912}
913
914#[cfg(test)]
915mod tests {
916 use super::*;
917 use langcodec::EntryStatus;
918 use tempfile::TempDir;
919
920 #[test]
921 fn merge_tolgee_catalog_updates_existing_keys_and_skips_new_ones() {
922 let mut local = Codec::new();
923 local.add_resource(Resource {
924 metadata: Metadata {
925 language: "en".to_string(),
926 domain: String::new(),
927 custom: HashMap::new(),
928 },
929 entries: vec![langcodec::Entry {
930 id: "welcome".to_string(),
931 value: Translation::Singular("Welcome".to_string()),
932 comment: None,
933 status: EntryStatus::Translated,
934 custom: HashMap::new(),
935 }],
936 });
937
938 let pulled = Codec {
939 resources: vec![Resource {
940 metadata: Metadata {
941 language: "fr".to_string(),
942 domain: String::new(),
943 custom: HashMap::new(),
944 },
945 entries: vec![
946 langcodec::Entry {
947 id: "welcome".to_string(),
948 value: Translation::Singular("Bienvenue".to_string()),
949 comment: Some("Greeting".to_string()),
950 status: EntryStatus::Translated,
951 custom: HashMap::new(),
952 },
953 langcodec::Entry {
954 id: "new_only".to_string(),
955 value: Translation::Singular("Nouveau".to_string()),
956 comment: None,
957 status: EntryStatus::Translated,
958 custom: HashMap::new(),
959 },
960 ],
961 }],
962 };
963
964 let report = merge_tolgee_catalog(&mut local, &pulled, &[]);
965 assert_eq!(report.merged, 1);
966 assert_eq!(report.skipped_new_keys, 1);
967
968 let fr_entry = local.find_entry("welcome", "fr").expect("fr welcome");
969 assert_eq!(
970 fr_entry.value,
971 Translation::Singular("Bienvenue".to_string())
972 );
973 assert_eq!(fr_entry.comment.as_deref(), Some("Greeting"));
974 assert!(local.find_entry("new_only", "fr").is_none());
975 }
976
977 #[test]
978 fn pulled_path_rejects_language_tag_templates() {
979 let project = TolgeeProject {
980 config_path: PathBuf::from("/tmp/.tolgeerc.json"),
981 project_root: PathBuf::from("/tmp"),
982 raw: json!({}),
983 pull_template: "/{namespace}/{languageTag}.{extension}".to_string(),
984 mappings: Vec::new(),
985 };
986 let err = pulled_path_for_namespace(&project, Path::new("/tmp/pull"), "Core").unwrap_err();
987 assert!(err.contains("{languageTag}"));
988 }
989
990 #[test]
991 fn loads_inline_tolgee_from_langcodec_toml() {
992 let temp_dir = TempDir::new().unwrap();
993 let config_path = temp_dir.path().join("langcodec.toml");
994 fs::write(
995 &config_path,
996 r#"
997[tolgee]
998project_id = 36
999api_url = "https://tolgee.example/api"
1000api_key = "tgpak_example"
1001namespaces = ["Core"]
1002
1003[tolgee.push]
1004languages = ["en"]
1005force_mode = "KEEP"
1006
1007[[tolgee.push.files]]
1008path = "Localizable.xcstrings"
1009namespace = "Core"
1010
1011[tolgee.pull]
1012path = "./tolgee-temp"
1013file_structure_template = "/{namespace}/Localizable.{extension}"
1014"#,
1015 )
1016 .unwrap();
1017
1018 let previous_dir = env::current_dir().unwrap();
1019 env::set_current_dir(temp_dir.path()).unwrap();
1020 let project = load_tolgee_project(None).unwrap();
1021 env::set_current_dir(previous_dir).unwrap();
1022
1023 assert_eq!(
1024 project
1025 .config_path
1026 .file_name()
1027 .and_then(|name| name.to_str()),
1028 Some("langcodec.toml")
1029 );
1030 assert_eq!(project.mappings.len(), 1);
1031 assert_eq!(project.mappings[0].namespace, "Core");
1032 assert_eq!(project.mappings[0].relative_path, "Localizable.xcstrings");
1033 assert_eq!(project.raw["projectId"], json!(36));
1034 assert_eq!(project.raw["apiUrl"], json!("https://tolgee.example/api"));
1035 assert_eq!(project.raw["apiKey"], json!("tgpak_example"));
1036 assert_eq!(project.raw["push"]["languages"], json!(["en"]));
1037 assert_eq!(project.raw["push"]["forceMode"], json!("KEEP"));
1038 assert_eq!(project.raw["pull"]["path"], json!("./tolgee-temp"));
1039 }
1040
1041 #[test]
1042 fn loads_legacy_tolgee_json_language_key() {
1043 let temp_dir = TempDir::new().unwrap();
1044 let config_path = temp_dir.path().join(".tolgeerc.json");
1045 fs::write(
1046 &config_path,
1047 r#"{
1048 "projectId": 36,
1049 "apiUrl": "https://tolgee.example/api",
1050 "apiKey": "tgpak_example",
1051 "format": "APPLE_XCSTRINGS",
1052 "push": {
1053 "language": ["en"],
1054 "files": [
1055 {
1056 "path": "Localizable.xcstrings",
1057 "namespace": "Core"
1058 }
1059 ]
1060 },
1061 "pull": {
1062 "path": "./tolgee-temp",
1063 "fileStructureTemplate": "/{namespace}/Localizable.{extension}"
1064 }
1065}"#,
1066 )
1067 .unwrap();
1068
1069 let project = load_tolgee_project_from_json(config_path).unwrap();
1070 assert_eq!(project.raw["push"]["languages"], json!(["en"]));
1071 assert!(project.raw["push"].get("language").is_none());
1072 }
1073}