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;
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
33const 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#[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
221pub 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 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;