1use std::fs;
14use std::path::{Path, PathBuf};
15
16use chrono::{DateTime, Utc};
17use serde::{Deserialize, Serialize};
18
19use crate::diagnostic::Diagnostic;
20
21const MAX_CACHE_FILE_BYTES: u64 = 1 << 20;
27
28#[derive(Debug, Default, Serialize, Deserialize)]
34pub struct UpdateCheckCache {
35 pub last_checked: Option<DateTime<Utc>>,
38
39 pub latest_seen: Option<String>,
43}
44
45impl UpdateCheckCache {
46 pub fn default_path() -> Result<PathBuf, Diagnostic> {
53 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 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 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 pub fn save(&self) -> Result<(), Diagnostic> {
129 let path = Self::default_path()?;
130 self.save_to(&path)
131 }
132
133 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
159fn 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 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
196fn 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 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 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}