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