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}