use std::fs;
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::diagnostic::Diagnostic;
const MAX_CACHE_FILE_BYTES: u64 = 1 << 20;
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct UpdateCheckCache {
pub last_checked: Option<DateTime<Utc>>,
pub latest_seen: Option<String>,
}
impl UpdateCheckCache {
pub fn default_path() -> Result<PathBuf, Diagnostic> {
let home = dirs::home_dir()
.ok_or_else(|| Diagnostic::Internal("could not resolve home directory".to_string()))?;
Ok(home
.join(".config")
.join("dsp-cli")
.join("update_check.toml"))
}
pub fn load() -> Self {
match Self::default_path() {
Ok(path) => Self::load_from(&path),
Err(e) => {
tracing::debug!(error = %e, "could not resolve update check cache path; using default");
Self::default()
}
}
}
pub fn load_from(path: &Path) -> Self {
let metadata = match fs::metadata(path) {
Ok(md) => md,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
tracing::debug!(path = %path.display(), "update check cache not found; using default");
return Self::default();
}
Err(e) => {
tracing::debug!(path = %path.display(), error = %e, "failed to stat update check cache; using default");
return Self::default();
}
};
if metadata.len() > MAX_CACHE_FILE_BYTES {
tracing::debug!(
path = %path.display(),
size = metadata.len(),
max = MAX_CACHE_FILE_BYTES,
"update check cache too large; using default"
);
return Self::default();
}
let contents = match fs::read_to_string(path) {
Ok(c) => c,
Err(e) => {
tracing::debug!(path = %path.display(), error = %e, "failed to read update check cache; using default");
return Self::default();
}
};
match toml::from_str(&contents) {
Ok(cache) => {
tracing::debug!(path = %path.display(), "loaded update check cache");
cache
}
Err(e) => {
tracing::debug!(path = %path.display(), error = %e, "failed to parse update check cache; using default");
Self::default()
}
}
}
pub fn save(&self) -> Result<(), Diagnostic> {
let path = Self::default_path()?;
self.save_to(&path)
}
pub fn save_to(&self, path: &Path) -> Result<(), Diagnostic> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| {
Diagnostic::Internal(format!(
"failed to create update check cache directory at {}: {}",
parent.display(),
e
))
})?;
}
let contents = toml::to_string_pretty(self).map_err(|e| {
Diagnostic::Internal(format!(
"failed to serialise update check cache for {}: {}",
path.display(),
e
))
})?;
write_atomically(path, &contents)?;
tracing::debug!(path = %path.display(), "saved update check cache");
Ok(())
}
}
fn write_atomically(path: &Path, contents: &str) -> Result<(), Diagnostic> {
let tmp_path = temp_sibling_path(path)?;
fs::write(&tmp_path, contents).map_err(|e| {
Diagnostic::Internal(format!(
"failed to write update check cache temp file at {}: {}",
tmp_path.display(),
e
))
})?;
if let Err(e) = fs::rename(&tmp_path, path) {
let _ = fs::remove_file(&tmp_path);
return Err(Diagnostic::Internal(format!(
"failed to rename update check cache temp file to {}: {}",
path.display(),
e
)));
}
Ok(())
}
fn temp_sibling_path(path: &Path) -> Result<PathBuf, Diagnostic> {
let mut name = path
.file_name()
.ok_or_else(|| {
Diagnostic::Internal(format!(
"update check cache path has no filename component: {}",
path.display()
))
})?
.to_os_string();
name.push(format!(".{}", std::process::id()));
Ok(path.with_file_name(name))
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
use tempfile::TempDir;
#[test]
fn round_trip_sets_both_fields() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("update_check.toml");
let checked = Utc.with_ymd_and_hms(2026, 7, 21, 9, 0, 0).unwrap();
let cache = UpdateCheckCache {
last_checked: Some(checked),
latest_seen: Some("0.1.5".to_string()),
};
cache.save_to(&path).unwrap();
let loaded = UpdateCheckCache::load_from(&path);
assert_eq!(loaded.last_checked, Some(checked));
assert_eq!(loaded.latest_seen, Some("0.1.5".to_string()));
}
#[test]
fn load_from_missing_file_returns_default() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("update_check.toml");
let cache = UpdateCheckCache::load_from(&path);
assert_eq!(cache.last_checked, None);
assert_eq!(cache.latest_seen, None);
}
#[test]
fn load_from_malformed_toml_returns_default_not_error() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("update_check.toml");
fs::write(&path, b"not valid toml [[[").unwrap();
let cache = UpdateCheckCache::load_from(&path);
assert_eq!(cache.last_checked, None);
assert_eq!(cache.latest_seen, None);
}
#[test]
fn save_creates_parent_directory() {
let dir = TempDir::new().unwrap();
let path = dir
.path()
.join("nested")
.join("dir")
.join("update_check.toml");
let cache = UpdateCheckCache {
last_checked: None,
latest_seen: Some("0.1.3".to_string()),
};
cache.save_to(&path).unwrap();
assert!(path.exists());
}
#[test]
fn atomic_write_does_not_leave_temp_file() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("update_check.toml");
let cache = UpdateCheckCache {
last_checked: None,
latest_seen: Some("0.1.3".to_string()),
};
cache.save_to(&path).unwrap();
let tmp_path = temp_sibling_path(&path).unwrap();
assert!(
!tmp_path.exists(),
"temp file should not exist after save: {}",
tmp_path.display()
);
}
#[test]
fn load_from_oversize_file_returns_default_not_error() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("update_check.toml");
let oversize = vec![b'x'; (MAX_CACHE_FILE_BYTES + 1) as usize];
fs::write(&path, &oversize).unwrap();
let cache = UpdateCheckCache::load_from(&path);
assert_eq!(cache.last_checked, None);
assert_eq!(cache.latest_seen, None);
}
}