Skip to main content

par_term/profile/dynamic/
cache.rs

1//! Cache storage for dynamic profiles.
2//!
3//! Provides functions to read and write fetched remote profiles to the local
4//! filesystem cache, keyed by a SHA-256 hash of the source URL.
5
6use anyhow::Context;
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9use std::path::PathBuf;
10use std::time::SystemTime;
11
12/// Get the cache directory for dynamic profiles.
13pub fn cache_dir() -> PathBuf {
14    par_term_config::Config::config_dir()
15        .join("cache")
16        .join("dynamic_profiles")
17}
18
19/// Generate a deterministic filename from a URL.
20pub fn url_to_cache_filename(url: &str) -> String {
21    let mut hasher = Sha256::new();
22    hasher.update(url.as_bytes());
23    let hash = hasher.finalize();
24    hash.iter().map(|b| format!("{:02x}", b)).collect()
25}
26
27/// Cache metadata stored alongside profile data.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct CacheMeta {
30    /// The source URL this cache entry corresponds to.
31    pub url: String,
32    /// When the profiles were last fetched.
33    pub last_fetched: SystemTime,
34    /// HTTP ETag header from the server (for conditional requests).
35    pub etag: Option<String>,
36    /// Number of profiles in the cached data.
37    pub profile_count: usize,
38}
39
40/// Read cached profiles for a given URL.
41pub fn read_cache(url: &str) -> anyhow::Result<(Vec<par_term_config::Profile>, CacheMeta)> {
42    let dir = cache_dir();
43    let hash = url_to_cache_filename(url);
44    let data_path = dir.join(format!("{hash}.yaml"));
45    let meta_path = dir.join(format!("{hash}.meta"));
46
47    let data = std::fs::read_to_string(&data_path)
48        .with_context(|| format!("Failed to read cache data from {data_path:?}"))?;
49    let meta_str = std::fs::read_to_string(&meta_path)
50        .with_context(|| format!("Failed to read cache meta from {meta_path:?}"))?;
51
52    let profiles: Vec<par_term_config::Profile> =
53        serde_yaml_ng::from_str(&data).with_context(|| "Failed to parse cached profiles")?;
54    let meta: CacheMeta =
55        serde_json::from_str(&meta_str).with_context(|| "Failed to parse cache metadata")?;
56
57    Ok((profiles, meta))
58}
59
60/// Write profiles and metadata to cache.
61pub fn write_cache(
62    url: &str,
63    profiles: &[par_term_config::Profile],
64    etag: Option<String>,
65) -> anyhow::Result<()> {
66    let dir = cache_dir();
67    std::fs::create_dir_all(&dir)
68        .with_context(|| format!("Failed to create cache directory {dir:?}"))?;
69
70    let hash = url_to_cache_filename(url);
71    let data_path = dir.join(format!("{hash}.yaml"));
72    let meta_path = dir.join(format!("{hash}.meta"));
73
74    // QA-010: the cache is what dynamic profiles fall back to when the network is
75    // unavailable, so a half-written entry costs the user their profiles exactly
76    // when they cannot be re-fetched. Both files are staged and renamed.
77    crate::atomic_save::save_yaml_atomic(&data_path, profiles)
78        .with_context(|| format!("Failed to write cache data to {data_path:?}"))?;
79
80    let meta = CacheMeta {
81        url: url.to_string(),
82        last_fetched: SystemTime::now(),
83        etag,
84        profile_count: profiles.len(),
85    };
86    let meta_str = serde_json::to_string_pretty(&meta)
87        .with_context(|| "Failed to serialize cache metadata")?;
88    crate::atomic_save::save_string_atomic(&meta_path, &meta_str)
89        .with_context(|| format!("Failed to write cache meta to {meta_path:?}"))?;
90
91    Ok(())
92}