1use 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
45const 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 "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 "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 "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 "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#[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 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
304pub 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;