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::{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}