use crate::error::{Error, Result};
use crate::logging::debug;
use crate::upgrade::monitor::{Asset, GitHubRelease};
use fs2::FileExt;
use serde::{Deserialize, Serialize};
use std::fs::{self, File};
use std::io::Write;
use std::path::PathBuf;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
#[derive(Clone)]
pub struct ReleaseCache {
cache_dir: PathBuf,
ttl: Duration,
}
#[derive(Serialize, Deserialize)]
struct CachedReleases {
repo: String,
fetched_at_epoch_secs: u64,
releases: Vec<CachedRelease>,
}
#[derive(Serialize, Deserialize)]
struct CachedRelease {
tag_name: String,
name: String,
body: String,
prerelease: bool,
assets: Vec<CachedAsset>,
}
#[derive(Serialize, Deserialize)]
struct CachedAsset {
name: String,
browser_download_url: String,
}
impl From<&GitHubRelease> for CachedRelease {
fn from(r: &GitHubRelease) -> Self {
Self {
tag_name: r.tag_name.clone(),
name: r.name.clone(),
body: r.body.clone(),
prerelease: r.prerelease,
assets: r.assets.iter().map(CachedAsset::from).collect(),
}
}
}
impl From<CachedRelease> for GitHubRelease {
fn from(c: CachedRelease) -> Self {
Self {
tag_name: c.tag_name,
name: c.name,
body: c.body,
prerelease: c.prerelease,
assets: c.assets.into_iter().map(Asset::from).collect(),
}
}
}
impl From<&Asset> for CachedAsset {
fn from(a: &Asset) -> Self {
Self {
name: a.name.clone(),
browser_download_url: a.browser_download_url.clone(),
}
}
}
impl From<CachedAsset> for Asset {
fn from(c: CachedAsset) -> Self {
Self {
name: c.name,
browser_download_url: c.browser_download_url,
}
}
}
impl ReleaseCache {
#[must_use]
pub fn new(cache_dir: PathBuf, ttl: Duration) -> Self {
Self { cache_dir, ttl }
}
#[must_use]
pub fn read_if_valid(&self, repo: &str) -> Option<Vec<GitHubRelease>> {
let data = fs::read_to_string(self.cache_file()).ok()?;
let cached: CachedReleases = serde_json::from_str(&data).ok()?;
if cached.repo != repo {
debug!(
"Release cache repo mismatch: cached={}, wanted={}",
cached.repo, repo
);
return None;
}
let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
let age_secs = now.saturating_sub(cached.fetched_at_epoch_secs);
if age_secs >= self.ttl.as_secs() {
debug!(
"Release cache expired (age={}s, ttl={}s)",
age_secs,
self.ttl.as_secs()
);
return None;
}
Some(
cached
.releases
.into_iter()
.map(GitHubRelease::from)
.collect(),
)
}
pub fn lock_and_recheck(
&self,
repo: &str,
) -> Result<(ReleaseCacheLockGuard, Option<Vec<GitHubRelease>>)> {
let lock_path = self.lock_file();
let lock = File::create(&lock_path)
.map_err(|e| Error::Upgrade(format!("Failed to create release cache lock: {e}")))?;
lock.lock_exclusive()
.map_err(|e| Error::Upgrade(format!("Failed to acquire release cache lock: {e}")))?;
let cached = self.read_if_valid(repo);
Ok((ReleaseCacheLockGuard { _file: lock }, cached))
}
pub fn write(&self, repo: &str, releases: &[GitHubRelease]) -> Result<()> {
let lock_path = self.lock_file();
let lock = File::create(&lock_path)
.map_err(|e| Error::Upgrade(format!("Failed to create release cache lock: {e}")))?;
lock.lock_exclusive()
.map_err(|e| Error::Upgrade(format!("Failed to acquire release cache lock: {e}")))?;
let result = self.write_inner(repo, releases);
drop(lock); result
}
pub fn write_under_lock(
&self,
_guard: ReleaseCacheLockGuard,
repo: &str,
releases: &[GitHubRelease],
) -> Result<()> {
self.write_inner(repo, releases)
}
fn write_inner(&self, repo: &str, releases: &[GitHubRelease]) -> Result<()> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|e| Error::Upgrade(format!("System clock error: {e}")))?
.as_secs();
let cached = CachedReleases {
repo: repo.to_string(),
fetched_at_epoch_secs: now,
releases: releases.iter().map(CachedRelease::from).collect(),
};
let json = serde_json::to_string(&cached)
.map_err(|e| Error::Upgrade(format!("Failed to serialize release cache: {e}")))?;
let tmp_path = self.cache_dir.join("releases.json.tmp");
{
let mut f = File::create(&tmp_path)?;
f.write_all(json.as_bytes())?;
f.sync_all()?;
}
let cache_file = self.cache_file();
let _ = fs::remove_file(&cache_file);
fs::rename(&tmp_path, &cache_file)?;
debug!("Wrote release cache ({} releases)", releases.len());
Ok(())
}
fn cache_file(&self) -> PathBuf {
self.cache_dir.join("releases.json")
}
fn lock_file(&self) -> PathBuf {
self.cache_dir.join("releases.lock")
}
}
pub struct ReleaseCacheLockGuard {
_file: File,
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use tempfile::TempDir;
fn sample_releases() -> Vec<GitHubRelease> {
vec![GitHubRelease {
tag_name: "v1.2.0".to_string(),
name: "Release 1.2.0".to_string(),
body: "Notes".to_string(),
prerelease: false,
assets: vec![Asset {
name: "ant-node-x86_64-linux.tar.gz".to_string(),
browser_download_url: "https://example.com/bin".to_string(),
}],
}]
}
#[test]
fn test_write_read_roundtrip() {
let tmp = TempDir::new().unwrap();
let cache = ReleaseCache::new(tmp.path().to_path_buf(), Duration::from_secs(300));
cache.write("owner/repo", &sample_releases()).unwrap();
let loaded = cache.read_if_valid("owner/repo").unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].tag_name, "v1.2.0");
assert_eq!(loaded[0].assets.len(), 1);
assert_eq!(loaded[0].assets[0].name, "ant-node-x86_64-linux.tar.gz");
}
#[test]
fn test_ttl_expiry_returns_none() {
let tmp = TempDir::new().unwrap();
let cache = ReleaseCache::new(tmp.path().to_path_buf(), Duration::from_secs(0));
cache.write("owner/repo", &sample_releases()).unwrap();
assert!(cache.read_if_valid("owner/repo").is_none());
}
#[test]
fn test_wrong_repo_returns_none() {
let tmp = TempDir::new().unwrap();
let cache = ReleaseCache::new(tmp.path().to_path_buf(), Duration::from_secs(300));
cache.write("owner/repo", &sample_releases()).unwrap();
assert!(cache.read_if_valid("other/repo").is_none());
}
#[test]
fn test_corrupted_file_returns_none() {
let tmp = TempDir::new().unwrap();
let cache = ReleaseCache::new(tmp.path().to_path_buf(), Duration::from_secs(300));
fs::write(cache.cache_file(), "not valid json!!!").unwrap();
assert!(cache.read_if_valid("owner/repo").is_none());
}
#[test]
fn test_missing_file_returns_none() {
let tmp = TempDir::new().unwrap();
let cache = ReleaseCache::new(tmp.path().to_path_buf(), Duration::from_secs(300));
assert!(cache.read_if_valid("owner/repo").is_none());
}
}