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