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