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