Skip to main content

futu_core/
localization.rs

1//! C++ OpenD-compatible language-pack lookup.
2//!
3//! C++ loads `Resources/Languages/{zh_cn,en}/*.ini` at GUI startup and then
4//! resolves `FTStringDefine.StringID` through `core::lang::tr(...)`.
5//! Refs:
6//! - FutuOpenD/Src/FTGatewayGui/Application.cpp:66-99
7//! - FutuOpenD/Src/NNProtoCenter/Basic/MultiLanguage/FTQueryLanguage.cpp:285-334
8//! - FutuOpenD/Src/NNProtoCenter/Basic/MultiLanguage/FTQueryLanguage.cpp:380-401
9
10use std::collections::BTreeMap;
11use std::fs;
12use std::path::{Path, PathBuf};
13use std::sync::OnceLock;
14use std::time::{SystemTime, UNIX_EPOCH};
15
16use serde::{Deserialize, Serialize};
17use sha2::{Digest, Sha256};
18
19mod remote;
20pub use remote::{
21    LanguagePackAutoUpdateOptions, LanguagePackAutoUpdateOutcome, LanguagePackAutoUpdateState,
22    LanguagePackRemoteUpdateOptions, auto_update_language_pack,
23    ensure_allowed_language_pack_remote_scheme, language_pack_manifest_url,
24    update_language_pack_from_endpoint,
25};
26
27const CACHE_DIR_ENV: &str = "FUTU_LANGUAGE_PACK_CACHE_DIR";
28const FALLBACK_MAX_BYTES: usize = 32 * 1024;
29const MANIFEST_SCHEMA_VERSION: u32 = 1;
30const UPDATE_STATUS_FILE: &str = "update-status.json";
31const LAST_ERROR_MAX_BYTES: usize = 1024;
32
33// Minimal fallback deliberately covers only StringID values already exercised by
34// Rust OpenD public handlers/fixtures. Unknown backend StringID values must stay
35// `None` until a cache/imported language pack provides them.
36const MINIMAL_FALLBACK_INI: &[(LanguageId, &str)] = &[
37    (
38        LanguageId::English,
39        concat!(
40            "150183=No\n",
41            "191555=Thank you for trusting and using Futubull's products and services. ",
42            "View <a href=\"{{privacy_url}}\">Privacy Policy</a>.\n",
43            "204444={1} SPAC Warrant(s) for every {0} SPAC Share(s)\n",
44            "204549=The current valuation is reasonable\n",
45            "204552=is in the industry overvaluation range\n",
46        ),
47    ),
48    (
49        LanguageId::SimplifiedChinese,
50        concat!(
51            "150183=否\n",
52            "191555=感谢您信任并使用富途牛牛的产品和服务。查看",
53            "<a href=\"{{privacy_url}}\">《隐私政策》</a>。\n",
54            "204444=每{0}股SPAC股份获发{1}份SPAC权证\n",
55            "204549=当前估值合理\n",
56            "204552=处于行业高估区间\n",
57        ),
58    ),
59];
60
61/// C++ language ids that are actually accepted by `FTQueryLanguage`.
62///
63/// `LanguageID_HK=1` exists in C++ headers, but `CheckLanguageIDInternal` only
64/// accepts Simplified Chinese and English, so `1` normalizes to English.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
66pub enum LanguageId {
67    SimplifiedChinese = 0,
68    English = 2,
69}
70
71impl LanguageId {
72    pub const fn cache_name(self) -> &'static str {
73        match self {
74            Self::SimplifiedChinese => "zh_cn",
75            Self::English => "en",
76        }
77    }
78
79    pub fn from_cache_name(value: &str) -> Option<Self> {
80        match value.trim().to_ascii_lowercase().as_str() {
81            "zh_cn" | "zh-cn" | "sc" | "cn" => Some(Self::SimplifiedChinese),
82            "en" | "en_us" | "en-us" => Some(Self::English),
83            _ => None,
84        }
85    }
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
89#[serde(rename_all = "snake_case")]
90pub enum LanguagePackKind {
91    Api,
92    Static,
93}
94
95impl LanguagePackKind {
96    pub const fn file_name(self) -> &'static str {
97        match self {
98            Self::Api => "api_lang.ini",
99            Self::Static => "static_lang.ini",
100        }
101    }
102
103    pub const fn as_str(self) -> &'static str {
104        match self {
105            Self::Api => "api",
106            Self::Static => "static",
107        }
108    }
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(rename_all = "snake_case")]
113pub enum LanguagePackRuntimeSource {
114    LastGoodCache,
115    MinimalFallback,
116}
117
118impl LanguagePackRuntimeSource {
119    pub const fn as_str(self) -> &'static str {
120        match self {
121            Self::LastGoodCache => "last_good_cache",
122            Self::MinimalFallback => "minimal_fallback",
123        }
124    }
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct LanguagePackManifest {
129    pub schema_version: u32,
130    pub pack_version: String,
131    pub source: String,
132    pub profile: Option<String>,
133    pub updated_at_unix: u64,
134    pub entries: Vec<LanguagePackManifestEntry>,
135    pub last_error: Option<String>,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct LanguagePackManifestEntry {
140    pub lang: String,
141    pub kind: LanguagePackKind,
142    pub path: String,
143    pub sha256: String,
144    pub bytes: u64,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
148struct LanguagePackUpdateStatus {
149    updated_at_unix: u64,
150    last_error: Option<String>,
151}
152
153#[derive(Debug, Clone)]
154pub struct LanguagePackFile {
155    pub language: LanguageId,
156    pub kind: LanguagePackKind,
157    pub bytes: Vec<u8>,
158}
159
160#[derive(Debug, Clone)]
161pub struct LanguagePackInstallOptions {
162    pub cache_root: PathBuf,
163    pub pack_version: String,
164    pub source: String,
165    pub profile: Option<String>,
166}
167
168#[derive(Debug, Clone, Serialize)]
169pub struct LanguagePackStatus {
170    pub cache_root: String,
171    pub runtime_source: &'static str,
172    pub pack_version: Option<String>,
173    pub source: Option<String>,
174    pub profile: Option<String>,
175    pub entries: Vec<LanguagePackStatusEntry>,
176    pub last_error: Option<String>,
177}
178
179#[derive(Debug, Clone, Serialize)]
180pub struct LanguagePackStatusEntry {
181    pub lang: String,
182    pub kind: &'static str,
183    pub sha256: String,
184    pub bytes: u64,
185    pub path: String,
186}
187
188pub trait LanguagePackSource {
189    fn source(&self) -> LanguagePackRuntimeSource;
190    fn load(&self) -> Result<LanguageStore, String>;
191}
192
193#[derive(Debug, Clone)]
194pub struct FileCacheLanguagePackSource {
195    pub cache_root: PathBuf,
196}
197
198impl LanguagePackSource for FileCacheLanguagePackSource {
199    fn source(&self) -> LanguagePackRuntimeSource {
200        LanguagePackRuntimeSource::LastGoodCache
201    }
202
203    fn load(&self) -> Result<LanguageStore, String> {
204        load_last_good_store(&self.cache_root)
205    }
206}
207
208#[derive(Debug, Clone, Copy)]
209pub struct MinimalFallbackLanguagePackSource;
210
211impl LanguagePackSource for MinimalFallbackLanguagePackSource {
212    fn source(&self) -> LanguagePackRuntimeSource {
213        LanguagePackRuntimeSource::MinimalFallback
214    }
215
216    fn load(&self) -> Result<LanguageStore, String> {
217        Ok(load_minimal_fallback_store())
218    }
219}
220
221/// Normalize an OpenD app language id the same way C++ `FTQueryLanguage` does.
222pub fn normalize_language_id(language_id: i32) -> LanguageId {
223    match language_id {
224        0 => LanguageId::SimplifiedChinese,
225        2 => LanguageId::English,
226        _ => LanguageId::English,
227    }
228}
229
230#[derive(Debug, Clone, Default)]
231pub struct LanguageStore {
232    texts: BTreeMap<LanguageId, BTreeMap<u32, String>>,
233}
234
235impl LanguageStore {
236    pub fn new() -> Self {
237        Self::default()
238    }
239
240    /// Load a C++ language `.ini` text file.
241    ///
242    /// Lines shaped as `numeric_key=value` start or replace one entry. A line
243    /// without a numeric key is appended to the previous entry with a newline,
244    /// matching C++ multiline behavior.
245    pub fn load_ini(&mut self, language: LanguageId, text: &str) {
246        self.load_ini_count(language, text);
247    }
248
249    pub fn load_ini_count(&mut self, language: LanguageId, text: &str) -> usize {
250        let mut last_key: Option<u32> = None;
251        let mut loaded = 0usize;
252        let mut lines = text.split('\n').peekable();
253        while let Some(raw_line) = lines.next() {
254            if raw_line.is_empty() && lines.peek().is_none() {
255                break;
256            }
257            let line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
258            if let Some((key_text, value)) = line.split_once('=')
259                && let Ok(key) = key_text.parse::<u32>()
260            {
261                self.texts
262                    .entry(language)
263                    .or_default()
264                    .insert(key, decode_escaped_text(value));
265                last_key = Some(key);
266                loaded += 1;
267                continue;
268            }
269
270            if let Some(key) = last_key
271                && let Some(existing) = self
272                    .texts
273                    .get_mut(&language)
274                    .and_then(|texts| texts.get_mut(&key))
275            {
276                existing.push('\n');
277                existing.push_str(line);
278            }
279        }
280        loaded
281    }
282
283    pub fn translate_key(
284        &self,
285        language: LanguageId,
286        key: u32,
287        fallback: Option<&str>,
288        allow_english_fallback: bool,
289    ) -> Option<String> {
290        if let Some(text) = self.find_non_empty(language, key) {
291            return Some(text.to_string());
292        }
293        if allow_english_fallback
294            && language != LanguageId::English
295            && let Some(text) = self.find_non_empty(LanguageId::English, key)
296        {
297            return Some(text.to_string());
298        }
299        fallback.map(ToOwned::to_owned)
300    }
301
302    pub fn translate_key_with_template(
303        &self,
304        language: LanguageId,
305        key: u32,
306        fallback: Option<&str>,
307        allow_english_fallback: bool,
308        template: &BTreeMap<String, String>,
309    ) -> Option<String> {
310        let text = self.translate_key(language, key, fallback, allow_english_fallback)?;
311        Some(replace_placeholders(text, template))
312    }
313
314    fn find_non_empty(&self, language: LanguageId, key: u32) -> Option<&str> {
315        self.texts
316            .get(&language)
317            .and_then(|texts| texts.get(&key))
318            .map(String::as_str)
319            .filter(|value| !value.is_empty())
320    }
321}
322
323pub fn translate_embedded_key(language: LanguageId, key: u32) -> Option<String> {
324    translate_runtime_key(language, key)
325}
326
327pub fn translate_runtime_key(language: LanguageId, key: u32) -> Option<String> {
328    if let Some(text) = runtime_language_store().translate_key(language, key, None, false) {
329        return Some(text);
330    }
331    if language != LanguageId::English {
332        return runtime_language_store().translate_key(LanguageId::English, key, None, false);
333    }
334    None
335}
336
337pub fn translate_embedded_key_with_template(
338    language: LanguageId,
339    key: u32,
340    template: &BTreeMap<String, String>,
341) -> Option<String> {
342    translate_runtime_key_with_template(language, key, template)
343}
344
345pub fn translate_runtime_key_with_template(
346    language: LanguageId,
347    key: u32,
348    template: &BTreeMap<String, String>,
349) -> Option<String> {
350    if let Some(text) =
351        runtime_language_store().translate_key_with_template(language, key, None, false, template)
352    {
353        return Some(text);
354    }
355    if language != LanguageId::English {
356        return runtime_language_store().translate_key_with_template(
357            LanguageId::English,
358            key,
359            None,
360            false,
361            template,
362        );
363    }
364    None
365}
366
367pub fn default_language_pack_cache_root() -> PathBuf {
368    if let Some(dir) = std::env::var_os(CACHE_DIR_ENV) {
369        return PathBuf::from(dir);
370    }
371    dirs::cache_dir()
372        .unwrap_or_else(|| PathBuf::from("."))
373        .join("futu-opend-rs")
374        .join("language-packs")
375}
376
377pub fn language_pack_status(cache_root: Option<&Path>) -> LanguagePackStatus {
378    let cache_root = cache_root
379        .map(Path::to_path_buf)
380        .unwrap_or_else(default_language_pack_cache_root);
381    let manifest = read_manifest(&cache_root).ok();
382    let loaded = FileCacheLanguagePackSource {
383        cache_root: cache_root.clone(),
384    }
385    .load()
386    .is_ok();
387    let runtime_source = if loaded {
388        LanguagePackRuntimeSource::LastGoodCache
389    } else {
390        LanguagePackRuntimeSource::MinimalFallback
391    };
392    let update_status = read_update_status(&cache_root).ok();
393    let entries = manifest
394        .as_ref()
395        .map(|manifest| {
396            manifest
397                .entries
398                .iter()
399                .map(|entry| LanguagePackStatusEntry {
400                    lang: entry.lang.clone(),
401                    kind: entry.kind.as_str(),
402                    sha256: entry.sha256.clone(),
403                    bytes: entry.bytes,
404                    path: entry.path.clone(),
405                })
406                .collect()
407        })
408        .unwrap_or_default();
409
410    LanguagePackStatus {
411        cache_root: cache_root.display().to_string(),
412        runtime_source: runtime_source.as_str(),
413        pack_version: manifest
414            .as_ref()
415            .map(|manifest| manifest.pack_version.clone()),
416        source: manifest.as_ref().map(|manifest| manifest.source.clone()),
417        profile: manifest
418            .as_ref()
419            .and_then(|manifest| manifest.profile.clone()),
420        entries,
421        last_error: update_status
422            .and_then(|status| status.last_error)
423            .or_else(|| manifest.and_then(|manifest| manifest.last_error)),
424    }
425}
426
427pub fn record_language_pack_update_error(cache_root: &Path, error: &str) -> Result<(), String> {
428    write_update_status(cache_root, Some(truncate_error(error)))
429}
430
431pub fn import_language_pack_dir(
432    dir: &Path,
433    options: LanguagePackInstallOptions,
434) -> Result<LanguagePackManifest, String> {
435    let files = collect_language_pack_files(dir)?;
436    install_language_pack_files(files, options)
437}
438
439pub fn install_language_pack_files(
440    files: Vec<LanguagePackFile>,
441    options: LanguagePackInstallOptions,
442) -> Result<LanguagePackManifest, String> {
443    if files.is_empty() {
444        return Err("language pack import contains no supported ini files".to_string());
445    }
446
447    validate_language_pack_files(&files)?;
448
449    fs::create_dir_all(&options.cache_root).map_err(|error| {
450        format!(
451            "create language-pack cache dir {}: {error}",
452            options.cache_root.display()
453        )
454    })?;
455    let suffix = unique_install_suffix();
456    let temp_last_good_root = options.cache_root.join(format!("last-good.tmp-{suffix}"));
457    if temp_last_good_root.exists() {
458        fs::remove_dir_all(&temp_last_good_root).map_err(|error| {
459            format!(
460                "remove stale language-pack temp dir {}: {error}",
461                temp_last_good_root.display()
462            )
463        })?;
464    }
465
466    let mut entries = Vec::new();
467    for file in files {
468        let rel = format!("{}/{}", file.language.cache_name(), file.kind.file_name());
469        let dst = temp_last_good_root.join(&rel);
470        if let Some(parent) = dst.parent() {
471            fs::create_dir_all(parent).map_err(|error| {
472                format!("create language-pack dir {}: {error}", parent.display())
473            })?;
474        }
475        write_atomic(&dst, &file.bytes)?;
476        entries.push(LanguagePackManifestEntry {
477            lang: file.language.cache_name().to_string(),
478            kind: file.kind,
479            path: format!("last-good/{rel}"),
480            sha256: language_pack_sha256_hex(&file.bytes),
481            bytes: file.bytes.len() as u64,
482        });
483    }
484
485    entries.sort_by(|left, right| {
486        (left.lang.as_str(), left.kind).cmp(&(right.lang.as_str(), right.kind))
487    });
488
489    let manifest = LanguagePackManifest {
490        schema_version: MANIFEST_SCHEMA_VERSION,
491        pack_version: options.pack_version,
492        source: options.source,
493        profile: options.profile,
494        updated_at_unix: unix_now(),
495        entries,
496        last_error: None,
497    };
498    let manifest_bytes = serde_json::to_vec_pretty(&manifest)
499        .map_err(|error| format!("serialize language-pack manifest: {error}"))?;
500    let staged_manifest = options
501        .cache_root
502        .join(format!("manifest.json.next-{suffix}"));
503    write_atomic(&staged_manifest, &manifest_bytes)?;
504    replace_last_good_root(&options.cache_root, &temp_last_good_root, &suffix)?;
505    rename_file(&staged_manifest, &options.cache_root.join("manifest.json"))?;
506    write_update_status(&options.cache_root, None)?;
507    Ok(manifest)
508}
509
510fn runtime_language_store() -> &'static LanguageStore {
511    static STORE: OnceLock<LanguageStore> = OnceLock::new();
512    STORE.get_or_init(|| {
513        let cache_root = default_language_pack_cache_root();
514        FileCacheLanguagePackSource {
515            cache_root: cache_root.clone(),
516        }
517        .load()
518        .unwrap_or_else(|error| {
519            tracing::debug!(
520                cache_root = %cache_root.display(),
521                %error,
522                "language_pack_status source=minimal_fallback"
523            );
524            match MinimalFallbackLanguagePackSource.load() {
525                Ok(store) => store,
526                Err(fallback_error) => {
527                    tracing::error!(
528                        %fallback_error,
529                        "language_pack_status source=empty fallback_load_failed=true"
530                    );
531                    LanguageStore::new()
532                }
533            }
534        })
535    })
536}
537
538fn collect_language_pack_files(dir: &Path) -> Result<Vec<LanguagePackFile>, String> {
539    let mut files = Vec::new();
540    for language in [LanguageId::English, LanguageId::SimplifiedChinese] {
541        for kind in [LanguagePackKind::Api, LanguagePackKind::Static] {
542            let path = find_language_pack_file(dir, language, kind);
543            let Some(path) = path else {
544                continue;
545            };
546            let bytes = fs::read(&path)
547                .map_err(|error| format!("read language pack {}: {error}", path.display()))?;
548            files.push(LanguagePackFile {
549                language,
550                kind,
551                bytes,
552            });
553        }
554    }
555    Ok(files)
556}
557
558fn find_language_pack_file(
559    dir: &Path,
560    language: LanguageId,
561    kind: LanguagePackKind,
562) -> Option<PathBuf> {
563    let rel = language.cache_name();
564    [
565        dir.join(rel).join(kind.file_name()),
566        dir.join("last-good").join(rel).join(kind.file_name()),
567    ]
568    .into_iter()
569    .find(|path| path.is_file())
570}
571
572fn validate_language_pack_files(files: &[LanguagePackFile]) -> Result<(), String> {
573    for file in files {
574        let text = std::str::from_utf8(&file.bytes).map_err(|error| {
575            format!(
576                "{} {} is not valid UTF-8: {error}",
577                file.language.cache_name(),
578                file.kind.as_str()
579            )
580        })?;
581        let mut store = LanguageStore::new();
582        let count = store.load_ini_count(file.language, text);
583        if count == 0 {
584            return Err(format!(
585                "{} {} contains no numeric language keys",
586                file.language.cache_name(),
587                file.kind.as_str()
588            ));
589        }
590    }
591    Ok(())
592}
593
594fn load_last_good_store(cache_root: &Path) -> Result<LanguageStore, String> {
595    let manifest = read_manifest(cache_root)?;
596    let mut store = LanguageStore::new();
597    let mut entries = manifest.entries.clone();
598    entries.sort_by_key(|entry| {
599        let lang_order = match LanguageId::from_cache_name(&entry.lang) {
600            Some(LanguageId::English) => 0,
601            Some(LanguageId::SimplifiedChinese) => 1,
602            None => 9,
603        };
604        let kind_order = match entry.kind {
605            LanguagePackKind::Api => 0,
606            LanguagePackKind::Static => 1,
607        };
608        (lang_order, kind_order)
609    });
610
611    for entry in entries {
612        let Some(language) = LanguageId::from_cache_name(&entry.lang) else {
613            continue;
614        };
615        let path = cache_root.join(&entry.path);
616        let bytes = fs::read(&path)
617            .map_err(|error| format!("read cached language pack {}: {error}", path.display()))?;
618        let actual = language_pack_sha256_hex(&bytes);
619        if actual != entry.sha256 {
620            return Err(format!(
621                "cached language pack checksum mismatch for {}: expected {}, got {}",
622                entry.path, entry.sha256, actual
623            ));
624        }
625        let text = std::str::from_utf8(&bytes).map_err(|error| {
626            format!("cached language pack {} is not UTF-8: {error}", entry.path)
627        })?;
628        store.load_ini(language, text);
629    }
630
631    Ok(store)
632}
633
634fn read_manifest(cache_root: &Path) -> Result<LanguagePackManifest, String> {
635    let path = cache_root.join("manifest.json");
636    let bytes = fs::read(&path)
637        .map_err(|error| format!("read language-pack manifest {}: {error}", path.display()))?;
638    let manifest: LanguagePackManifest = serde_json::from_slice(&bytes)
639        .map_err(|error| format!("parse language-pack manifest {}: {error}", path.display()))?;
640    if manifest.schema_version != MANIFEST_SCHEMA_VERSION {
641        return Err(format!(
642            "unsupported language-pack manifest schema_version {}",
643            manifest.schema_version
644        ));
645    }
646    Ok(manifest)
647}
648
649fn read_update_status(cache_root: &Path) -> Result<LanguagePackUpdateStatus, String> {
650    let path = cache_root.join(UPDATE_STATUS_FILE);
651    let bytes = fs::read(&path).map_err(|error| {
652        format!(
653            "read language-pack update status {}: {error}",
654            path.display()
655        )
656    })?;
657    serde_json::from_slice(&bytes).map_err(|error| {
658        format!(
659            "parse language-pack update status {}: {error}",
660            path.display()
661        )
662    })
663}
664
665fn write_update_status(cache_root: &Path, last_error: Option<String>) -> Result<(), String> {
666    fs::create_dir_all(cache_root).map_err(|error| {
667        format!(
668            "create language-pack cache dir {}: {error}",
669            cache_root.display()
670        )
671    })?;
672    let status = LanguagePackUpdateStatus {
673        updated_at_unix: unix_now(),
674        last_error,
675    };
676    let bytes = serde_json::to_vec_pretty(&status)
677        .map_err(|error| format!("serialize language-pack update status: {error}"))?;
678    write_atomic(&cache_root.join(UPDATE_STATUS_FILE), &bytes)
679}
680
681fn load_minimal_fallback_store() -> LanguageStore {
682    debug_assert!(
683        minimal_fallback_bytes() <= FALLBACK_MAX_BYTES,
684        "minimal fallback catalog must stay small"
685    );
686    let mut store = LanguageStore::new();
687    for (language, text) in MINIMAL_FALLBACK_INI {
688        store.load_ini(*language, text);
689    }
690    store
691}
692
693fn minimal_fallback_bytes() -> usize {
694    MINIMAL_FALLBACK_INI
695        .iter()
696        .map(|(_, text)| text.len())
697        .sum()
698}
699
700pub fn language_pack_sha256_hex(bytes: &[u8]) -> String {
701    let mut hasher = Sha256::new();
702    hasher.update(bytes);
703    format!("{:x}", hasher.finalize())
704}
705
706fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> {
707    if let Some(parent) = path.parent() {
708        fs::create_dir_all(parent)
709            .map_err(|error| format!("create dir {}: {error}", parent.display()))?;
710    }
711    let tmp = path.with_extension("tmp");
712    fs::write(&tmp, bytes).map_err(|error| format!("write {}: {error}", tmp.display()))?;
713    fs::rename(&tmp, path)
714        .map_err(|error| format!("rename {} to {}: {error}", tmp.display(), path.display()))
715}
716
717fn replace_last_good_root(cache_root: &Path, temp_root: &Path, suffix: &str) -> Result<(), String> {
718    let final_root = cache_root.join("last-good");
719    let backup_root = cache_root.join(format!("last-good.old-{suffix}"));
720    if backup_root.exists() {
721        fs::remove_dir_all(&backup_root).map_err(|error| {
722            format!(
723                "remove stale language-pack backup dir {}: {error}",
724                backup_root.display()
725            )
726        })?;
727    }
728    if final_root.exists() {
729        rename_dir(&final_root, &backup_root)?;
730    }
731    if let Err(error) = rename_dir(temp_root, &final_root) {
732        if backup_root.exists() {
733            let _ = rename_dir(&backup_root, &final_root);
734        }
735        return Err(error);
736    }
737    if backup_root.exists() {
738        fs::remove_dir_all(&backup_root).map_err(|error| {
739            format!(
740                "remove language-pack backup dir {}: {error}",
741                backup_root.display()
742            )
743        })?;
744    }
745    Ok(())
746}
747
748fn rename_dir(from: &Path, to: &Path) -> Result<(), String> {
749    fs::rename(from, to).map_err(|error| {
750        format!(
751            "rename language-pack dir {} to {}: {error}",
752            from.display(),
753            to.display()
754        )
755    })
756}
757
758fn rename_file(from: &Path, to: &Path) -> Result<(), String> {
759    fs::rename(from, to).map_err(|error| {
760        format!(
761            "rename language-pack file {} to {}: {error}",
762            from.display(),
763            to.display()
764        )
765    })
766}
767
768fn unix_now() -> u64 {
769    SystemTime::now()
770        .duration_since(UNIX_EPOCH)
771        .map(|value| value.as_secs())
772        .unwrap_or_default()
773}
774
775fn unique_install_suffix() -> String {
776    let nanos = SystemTime::now()
777        .duration_since(UNIX_EPOCH)
778        .map(|value| value.as_nanos())
779        .unwrap_or_default();
780    format!("{}-{nanos}", std::process::id())
781}
782
783fn truncate_error(error: &str) -> String {
784    if error.len() <= LAST_ERROR_MAX_BYTES {
785        return error.to_string();
786    }
787    let mut end = LAST_ERROR_MAX_BYTES;
788    while !error.is_char_boundary(end) {
789        end -= 1;
790    }
791    format!("{}...", &error[..end])
792}
793
794fn decode_escaped_text(text: &str) -> String {
795    let mut decoded = String::with_capacity(text.len());
796    let mut chars = text.chars();
797    while let Some(ch) = chars.next() {
798        if ch != '\\' {
799            decoded.push(ch);
800            continue;
801        }
802
803        match chars.next() {
804            Some('r') => decoded.push('\r'),
805            Some('n') => decoded.push('\n'),
806            Some('t') => decoded.push('\t'),
807            Some('\\') => decoded.push('\\'),
808            Some(other) => {
809                decoded.push('\\');
810                decoded.push(other);
811            }
812            None => decoded.push('\\'),
813        }
814    }
815    decoded
816}
817
818fn replace_placeholders(mut text: String, template: &BTreeMap<String, String>) -> String {
819    for (key, value) in template {
820        text = text.replace(&format!("{{{{{key}}}}}"), value);
821        text = text.replace(&format!("{{{key}}}"), value);
822    }
823    text
824}
825
826#[cfg(test)]
827mod tests;