Skip to main content

langcodec_cli/
tolgee.rs

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}