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