Skip to main content

dsp_cli/update/
cache.rs

1//! On-disk cache for the update check at `~/.config/dsp-cli/update_check.toml`.
2//! See dsp-cli/ADR-0015.
3//!
4//! Modeled on [`crate::config::auth_cache::AuthCache`], with two deliberate
5//! differences:
6//! - This cache is a single flat struct (one file, two fields), not a per-server `BTreeMap`.
7//! - **A malformed/oversize/unreadable file is never an error.** Every load failure degrades to
8//!   [`UpdateCheckCache::default`] (logged at `tracing::debug!`). This cache is a disposable
9//!   convenience — a corrupt copy must never break an unrelated command.
10//! - Saves are plain atomic writes (temp-sibling + rename) with no `0600` permission tightening:
11//!   this file holds no secret, unlike `auth.toml`.
12
13use std::fs;
14use std::path::{Path, PathBuf};
15
16use chrono::{DateTime, Utc};
17use serde::{Deserialize, Serialize};
18
19use crate::diagnostic::Diagnostic;
20
21/// Hard cap on `update_check.toml` size. A real cache holds a handful of
22/// bytes; anything past 1 MiB is almost certainly a misconfigured symlink
23/// and must not be slurped into memory. Unlike `AuthCache`, breaching this
24/// cap degrades to `Self::default()` rather than erroring — see the module
25/// doc comment.
26const MAX_CACHE_FILE_BYTES: u64 = 1 << 20;
27
28/// In-memory view of `~/.config/dsp-cli/update_check.toml`.
29///
30/// Load with [`UpdateCheckCache::load`] (or [`UpdateCheckCache::load_from`]
31/// in tests). Persist with [`UpdateCheckCache::save`] (or
32/// [`UpdateCheckCache::save_to`] in tests).
33#[derive(Debug, Default, Serialize, Deserialize)]
34pub struct UpdateCheckCache {
35    /// When the crates.io sparse index was last fetched (successfully or
36    /// not — a failed attempt still counts, so the 24h backoff holds).
37    pub last_checked: Option<DateTime<Utc>>,
38
39    /// The highest stable version last observed on the index, as a plain
40    /// string (re-parsed through `semver::Version` before use — see
41    /// `run_check_and_notify`'s sanitisation guard).
42    pub latest_seen: Option<String>,
43}
44
45impl UpdateCheckCache {
46    /// The canonical cache path: `~/.config/dsp-cli/update_check.toml`.
47    ///
48    /// Returns an error if the home directory cannot be resolved. Note this
49    /// is the one failure `default_path` itself can produce; callers of
50    /// [`Self::load`] still degrade it to `Self::default()` rather than
51    /// propagating it, per this cache's disposable-on-failure contract.
52    pub fn default_path() -> Result<PathBuf, Diagnostic> {
53        // Same home-dir resolution as `AuthCache::default_path` — do not
54        // substitute `dirs::config_dir()`, which returns a different
55        // platform-specific path on macOS.
56        let home =
57            dirs::home_dir().ok_or_else(|| Diagnostic::Internal("could not resolve home directory".to_string()))?;
58        Ok(home.join(".config").join("dsp-cli").join("update_check.toml"))
59    }
60
61    /// Load the cache from [`Self::default_path`].
62    ///
63    /// Every failure — including an unresolvable home directory — degrades
64    /// to `Self::default()` (logged at `tracing::debug!`). This cache is
65    /// disposable; no failure here is ever surfaced as an error.
66    pub fn load() -> Self {
67        match Self::default_path() {
68            Ok(path) => Self::load_from(&path),
69            Err(e) => {
70                tracing::debug!(error = %e, "could not resolve update check cache path; using default");
71                Self::default()
72            }
73        }
74    }
75
76    /// Load the cache from an explicit path.
77    ///
78    /// Unlike [`crate::config::auth_cache::AuthCache::load_from`], every
79    /// failure path here — missing file, oversize file, unreadable file,
80    /// malformed TOML — degrades to `Self::default()` rather than an `Err`.
81    pub fn load_from(path: &Path) -> Self {
82        let metadata = match fs::metadata(path) {
83            Ok(md) => md,
84            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
85                tracing::debug!(path = %path.display(), "update check cache not found; using default");
86                return Self::default();
87            }
88            Err(e) => {
89                tracing::debug!(path = %path.display(), error = %e, "failed to stat update check cache; using default");
90                return Self::default();
91            }
92        };
93
94        if metadata.len() > MAX_CACHE_FILE_BYTES {
95            tracing::debug!(
96                path = %path.display(),
97                size = metadata.len(),
98                max = MAX_CACHE_FILE_BYTES,
99                "update check cache too large; using default"
100            );
101            return Self::default();
102        }
103
104        let contents = match fs::read_to_string(path) {
105            Ok(c) => c,
106            Err(e) => {
107                tracing::debug!(path = %path.display(), error = %e, "failed to read update check cache; using default");
108                return Self::default();
109            }
110        };
111
112        match toml::from_str(&contents) {
113            Ok(cache) => {
114                tracing::debug!(path = %path.display(), "loaded update check cache");
115                cache
116            }
117            Err(e) => {
118                tracing::debug!(path = %path.display(), error = %e, "failed to parse update check cache; using default");
119                Self::default()
120            }
121        }
122    }
123
124    /// Save the cache to [`Self::default_path`].
125    ///
126    /// Unlike `load`, a save failure is real and is returned as `Err` — the
127    /// caller (a later plan step) decides whether to swallow it.
128    pub fn save(&self) -> Result<(), Diagnostic> {
129        let path = Self::default_path()?;
130        self.save_to(&path)
131    }
132
133    /// Save the cache to an explicit path.
134    ///
135    /// Creates the parent directory if it does not exist. Writes atomically
136    /// via a `<filename>.<pid>` sibling file followed by a `rename`. No
137    /// `0600` permission tightening — this file holds no secret.
138    pub fn save_to(&self, path: &Path) -> Result<(), Diagnostic> {
139        if let Some(parent) = path.parent() {
140            fs::create_dir_all(parent).map_err(|e| {
141                Diagnostic::Internal(format!(
142                    "failed to create update check cache directory at {}: {}",
143                    parent.display(),
144                    e
145                ))
146            })?;
147        }
148
149        let contents = toml::to_string_pretty(self).map_err(|e| {
150            Diagnostic::Internal(format!("failed to serialise update check cache for {}: {}", path.display(), e))
151        })?;
152
153        write_atomically(path, &contents)?;
154        tracing::debug!(path = %path.display(), "saved update check cache");
155        Ok(())
156    }
157}
158
159// The atomic-write helpers below are a deliberate copy of the pattern in
160// `src/config/auth_cache.rs` (`write_atomically`/`temp_sibling_path`), not a
161// shared util: those are private to that module and carry `0600`
162// secret-cache semantics this cache does not need. Two cache files don't yet
163// justify extraction (rule of three) — extract a shared helper if a third
164// on-disk cache appears.
165
166/// Write `contents` to `path` atomically via a `<filename>.<pid>` temp file
167/// in the same directory, then `rename`. No permission tightening — this
168/// file holds no secret, unlike `auth.toml`.
169fn write_atomically(path: &Path, contents: &str) -> Result<(), Diagnostic> {
170    let tmp_path = temp_sibling_path(path)?;
171
172    fs::write(&tmp_path, contents).map_err(|e| {
173        Diagnostic::Internal(format!(
174            "failed to write update check cache temp file at {}: {}",
175            tmp_path.display(),
176            e
177        ))
178    })?;
179
180    if let Err(e) = fs::rename(&tmp_path, path) {
181        // Rename failed but the temp file is still on disk. Best-effort
182        // cleanup so the directory does not accrete
183        // `update_check.toml.<pid>` residue across failed writes; the
184        // cleanup result is intentionally discarded — the rename error is
185        // what we want to surface.
186        let _ = fs::remove_file(&tmp_path);
187        return Err(Diagnostic::Internal(format!(
188            "failed to rename update check cache temp file to {}: {}",
189            path.display(),
190            e
191        )));
192    }
193    Ok(())
194}
195
196/// `<path>` → `<dirname>/<filename>.<pid>`. Derived from the actual filename
197/// (not via `Path::with_extension`, which would replace the existing
198/// extension and silently misbehave if `path` ever ended in something other
199/// than `.toml`).
200fn temp_sibling_path(path: &Path) -> Result<PathBuf, Diagnostic> {
201    let mut name = path
202        .file_name()
203        .ok_or_else(|| {
204            Diagnostic::Internal(format!("update check cache path has no filename component: {}", path.display()))
205        })?
206        .to_os_string();
207    name.push(format!(".{}", std::process::id()));
208    Ok(path.with_file_name(name))
209}
210
211#[cfg(test)]
212mod tests {
213    use chrono::TimeZone;
214    use tempfile::TempDir;
215
216    use super::*;
217
218    #[test]
219    fn round_trip_sets_both_fields() {
220        let dir = TempDir::new().unwrap();
221        let path = dir.path().join("update_check.toml");
222
223        let checked = Utc.with_ymd_and_hms(2026, 7, 21, 9, 0, 0).unwrap();
224        let cache = UpdateCheckCache {
225            last_checked: Some(checked),
226            latest_seen: Some("0.1.5".to_string()),
227        };
228        cache.save_to(&path).unwrap();
229
230        let loaded = UpdateCheckCache::load_from(&path);
231        assert_eq!(loaded.last_checked, Some(checked));
232        assert_eq!(loaded.latest_seen, Some("0.1.5".to_string()));
233    }
234
235    #[test]
236    fn load_from_missing_file_returns_default() {
237        let dir = TempDir::new().unwrap();
238        let path = dir.path().join("update_check.toml");
239
240        let cache = UpdateCheckCache::load_from(&path);
241        assert_eq!(cache.last_checked, None);
242        assert_eq!(cache.latest_seen, None);
243    }
244
245    #[test]
246    fn load_from_malformed_toml_returns_default_not_error() {
247        let dir = TempDir::new().unwrap();
248        let path = dir.path().join("update_check.toml");
249
250        fs::write(&path, b"not valid toml [[[").unwrap();
251
252        // No `.unwrap_err()` here on purpose: `load_from` returns `Self`
253        // directly, never `Result` — a malformed file must never surface as
254        // an error for this disposable cache.
255        let cache = UpdateCheckCache::load_from(&path);
256        assert_eq!(cache.last_checked, None);
257        assert_eq!(cache.latest_seen, None);
258    }
259
260    #[test]
261    fn save_creates_parent_directory() {
262        let dir = TempDir::new().unwrap();
263        let path = dir.path().join("nested").join("dir").join("update_check.toml");
264
265        let cache = UpdateCheckCache { last_checked: None, latest_seen: Some("0.1.3".to_string()) };
266        cache.save_to(&path).unwrap();
267
268        assert!(path.exists());
269    }
270
271    #[test]
272    fn atomic_write_does_not_leave_temp_file() {
273        let dir = TempDir::new().unwrap();
274        let path = dir.path().join("update_check.toml");
275
276        let cache = UpdateCheckCache { last_checked: None, latest_seen: Some("0.1.3".to_string()) };
277        cache.save_to(&path).unwrap();
278
279        let tmp_path = temp_sibling_path(&path).unwrap();
280        assert!(
281            !tmp_path.exists(),
282            "temp file should not exist after save: {}",
283            tmp_path.display()
284        );
285    }
286
287    #[test]
288    fn load_from_oversize_file_returns_default_not_error() {
289        // Behavioural contrast with `AuthCache::load_from`, which returns
290        // `Err(Diagnostic::Internal(_))` on an oversize file: this cache
291        // degrades to `Self::default()` instead, since a corrupt/huge
292        // update-check cache must never break an unrelated command.
293        let dir = TempDir::new().unwrap();
294        let path = dir.path().join("update_check.toml");
295        let oversize = vec![b'x'; (MAX_CACHE_FILE_BYTES + 1) as usize];
296        fs::write(&path, &oversize).unwrap();
297
298        let cache = UpdateCheckCache::load_from(&path);
299        assert_eq!(cache.last_checked, None);
300        assert_eq!(cache.latest_seen, None);
301    }
302}