#![allow(clippy::async_yields_async)]
use std::path::PathBuf;
use std::sync::OnceLock;
use anyhow::{Context, Result};
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};
static CACHE_UNAVAILABLE_WARNING: OnceLock<()> = OnceLock::new();
pub const DEFAULT_ISSUE_TTL_MINS: i64 = 60;
pub const DEFAULT_REPO_TTL_HOURS: i64 = 24;
pub const DEFAULT_MODEL_TTL_SECS: u64 = 86400;
pub const DEFAULT_SECURITY_TTL_DAYS: i64 = 7;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheEntry<T> {
pub data: T,
pub cached_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub etag: Option<String>,
}
impl<T> CacheEntry<T> {
pub fn new(data: T) -> Self {
Self {
data,
cached_at: Utc::now(),
etag: None,
}
}
pub fn with_etag(data: T, etag: String) -> Self {
Self {
data,
cached_at: Utc::now(),
etag: Some(etag),
}
}
pub fn is_valid(&self, ttl: Duration) -> bool {
let now = Utc::now();
now.signed_duration_since(self.cached_at) < ttl
}
}
#[must_use]
pub fn cache_dir() -> Option<PathBuf> {
dirs::cache_dir().map(|dir| dir.join("aptu"))
}
#[allow(async_fn_in_trait)]
pub trait FileCache<V> {
async fn get(&self, key: &str) -> Result<Option<V>>;
async fn get_stale(&self, key: &str) -> Result<Option<V>>;
async fn set(&self, key: &str, value: &V) -> Result<()>;
async fn remove(&self, key: &str) -> Result<()>;
}
pub struct FileCacheImpl<V> {
cache_dir: Option<PathBuf>,
ttl: Duration,
subdirectory: String,
_phantom: std::marker::PhantomData<V>,
}
impl<V> FileCacheImpl<V>
where
V: Serialize + for<'de> Deserialize<'de>,
{
#[must_use]
pub fn new(subdirectory: impl Into<String>, ttl: Duration) -> Self {
let cache_dir = cache_dir();
if cache_dir.is_none() {
CACHE_UNAVAILABLE_WARNING.get_or_init(|| {
warn!("Cache directory unavailable, caching disabled");
});
}
Self::with_dir(cache_dir, subdirectory, ttl)
}
#[must_use]
pub fn with_dir(
cache_dir: Option<PathBuf>,
subdirectory: impl Into<String>,
ttl: Duration,
) -> Self {
Self {
cache_dir,
ttl,
subdirectory: subdirectory.into(),
_phantom: std::marker::PhantomData,
}
}
fn is_enabled(&self) -> bool {
self.cache_dir.is_some()
}
fn cache_path(&self, key: &str) -> Option<PathBuf> {
assert!(
!key.contains('/') && !key.contains('\\') && !key.contains(".."),
"cache key must not contain path separators or '..': {key}"
);
let filename = if std::path::Path::new(key)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
{
key.to_string()
} else {
format!("{key}.json")
};
self.cache_dir
.as_ref()
.map(|dir| dir.join(&self.subdirectory).join(filename))
}
pub async fn evict_stale(&self, eviction_days: i64) -> usize {
if !self.is_enabled() {
return 0;
}
let Some(cache_dir) = &self.cache_dir else {
return 0;
};
let subdir = cache_dir.join(&self.subdirectory);
if !tokio::fs::try_exists(&subdir).await.unwrap_or(false) {
return 0;
}
let Ok(mut read_dir) = tokio::fs::read_dir(&subdir).await else {
return 0;
};
let mut evicted_count = 0;
let cutoff_time = Utc::now() - Duration::days(eviction_days);
while let Ok(Some(entry)) = read_dir.next_entry().await {
let path = entry.path();
if !path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
{
continue;
}
let Ok(contents) = tokio::fs::read_to_string(&path).await else {
continue;
};
let Ok(entry_data) = serde_json::from_str::<CacheEntry<serde_json::Value>>(&contents)
else {
continue;
};
if entry_data.cached_at < cutoff_time && tokio::fs::remove_file(&path).await.is_ok() {
debug!("Evicted stale cache file: {}", path.display());
evicted_count += 1;
}
}
evicted_count
}
}
impl<V> FileCache<V> for FileCacheImpl<V>
where
V: Serialize + for<'de> Deserialize<'de>,
{
async fn get(&self, key: &str) -> Result<Option<V>> {
if !self.is_enabled() {
return Ok(None);
}
let Some(path) = self.cache_path(key) else {
return Ok(None);
};
if !tokio::fs::try_exists(&path)
.await
.with_context(|| format!("Failed to check cache file: {}", path.display()))?
{
return Ok(None);
}
let contents = tokio::fs::read_to_string(&path)
.await
.with_context(|| format!("Failed to read cache file: {}", path.display()))?;
let entry: CacheEntry<V> = serde_json::from_str(&contents)
.with_context(|| format!("Failed to parse cache file: {}", path.display()))?;
if entry.is_valid(self.ttl) {
Ok(Some(entry.data))
} else {
Ok(None)
}
}
async fn get_stale(&self, key: &str) -> Result<Option<V>> {
if !self.is_enabled() {
return Ok(None);
}
let Some(path) = self.cache_path(key) else {
return Ok(None);
};
if !tokio::fs::try_exists(&path)
.await
.with_context(|| format!("Failed to check cache file: {}", path.display()))?
{
return Ok(None);
}
let contents = tokio::fs::read_to_string(&path)
.await
.with_context(|| format!("Failed to read cache file: {}", path.display()))?;
let entry: CacheEntry<V> = serde_json::from_str(&contents)
.with_context(|| format!("Failed to parse cache file: {}", path.display()))?;
Ok(Some(entry.data))
}
async fn set(&self, key: &str, value: &V) -> Result<()> {
if !self.is_enabled() {
return Ok(());
}
let Some(path) = self.cache_path(key) else {
return Ok(());
};
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await.with_context(|| {
format!("Failed to create cache directory: {}", parent.display())
})?;
}
let entry = CacheEntry::new(value);
let contents =
serde_json::to_string_pretty(&entry).context("Failed to serialize cache entry")?;
let temp_path = path.with_extension("tmp");
tokio::fs::write(&temp_path, contents)
.await
.with_context(|| format!("Failed to write cache temp file: {}", temp_path.display()))?;
tokio::fs::rename(&temp_path, &path)
.await
.with_context(|| format!("Failed to rename cache file: {}", path.display()))?;
Ok(())
}
async fn remove(&self, key: &str) -> Result<()> {
if !self.is_enabled() {
return Ok(());
}
let Some(path) = self.cache_path(key) else {
return Ok(());
};
if tokio::fs::try_exists(&path)
.await
.with_context(|| format!("Failed to check cache file: {}", path.display()))?
{
tokio::fs::remove_file(&path)
.await
.with_context(|| format!("Failed to remove cache file: {}", path.display()))?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
struct TestData {
value: String,
count: u32,
}
#[test]
fn test_cache_entry_new() {
let data = TestData {
value: "test".to_string(),
count: 42,
};
let entry = CacheEntry::new(data.clone());
assert_eq!(entry.data, data);
assert!(entry.etag.is_none());
}
#[test]
fn test_cache_entry_with_etag() {
let data = TestData {
value: "test".to_string(),
count: 42,
};
let etag = "abc123".to_string();
let entry = CacheEntry::with_etag(data.clone(), etag.clone());
assert_eq!(entry.data, data);
assert_eq!(entry.etag, Some(etag));
}
#[test]
fn test_cache_entry_is_valid_within_ttl() {
let data = TestData {
value: "test".to_string(),
count: 42,
};
let entry = CacheEntry::new(data);
let ttl = Duration::hours(1);
assert!(entry.is_valid(ttl));
}
#[test]
fn test_cache_entry_is_valid_expired() {
let data = TestData {
value: "test".to_string(),
count: 42,
};
let mut entry = CacheEntry::new(data);
entry.cached_at = Utc::now() - Duration::hours(2);
let ttl = Duration::hours(1);
assert!(!entry.is_valid(ttl));
}
#[test]
fn test_cache_dir_path() {
let dir = cache_dir();
assert!(dir.is_some());
assert!(dir.unwrap().ends_with("aptu"));
}
#[test]
fn test_cache_serialization_with_etag() {
let data = TestData {
value: "test".to_string(),
count: 42,
};
let etag = "xyz789".to_string();
let entry = CacheEntry::with_etag(data.clone(), etag.clone());
let json = serde_json::to_string(&entry).expect("serialize");
let parsed: CacheEntry<TestData> = serde_json::from_str(&json).expect("deserialize");
assert_eq!(parsed.data, data);
assert_eq!(parsed.etag, Some(etag));
}
#[tokio::test]
async fn test_file_cache_get_set() {
let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
let data = TestData {
value: "test".to_string(),
count: 42,
};
cache.set("test_key", &data).await.expect("set cache");
let result = cache.get("test_key").await.expect("get cache");
assert!(result.is_some());
assert_eq!(result.unwrap(), data);
cache.remove("test_key").await.ok();
}
#[tokio::test]
async fn test_file_cache_get_miss() {
let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
let result = cache.get("nonexistent").await.expect("get cache");
assert!(result.is_none());
}
#[tokio::test]
async fn test_file_cache_get_stale() {
let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::seconds(0));
let data = TestData {
value: "stale".to_string(),
count: 99,
};
cache.set("stale_key", &data).await.expect("set cache");
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
let result = cache.get("stale_key").await.expect("get cache");
assert!(result.is_none());
let stale_result = cache.get_stale("stale_key").await.expect("get stale cache");
assert!(stale_result.is_some());
assert_eq!(stale_result.unwrap(), data);
cache.remove("stale_key").await.ok();
}
#[tokio::test]
async fn test_file_cache_remove() {
let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
let data = TestData {
value: "remove_me".to_string(),
count: 1,
};
cache.set("remove_key", &data).await.expect("set cache");
assert!(cache.get("remove_key").await.expect("get cache").is_some());
cache.remove("remove_key").await.expect("remove cache");
assert!(cache.get("remove_key").await.expect("get cache").is_none());
}
#[tokio::test]
#[should_panic(expected = "cache key must not contain path separators")]
async fn test_cache_key_rejects_forward_slash() {
let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
let _ = cache.get("../etc/passwd").await;
}
#[tokio::test]
#[should_panic(expected = "cache key must not contain path separators")]
async fn test_cache_key_rejects_backslash() {
let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
let _ = cache.get("..\\windows\\system32").await;
}
#[tokio::test]
#[should_panic(expected = "cache key must not contain path separators")]
async fn test_cache_key_rejects_parent_dir() {
let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
let _ = cache.get("foo..bar").await;
}
#[tokio::test]
async fn test_disabled_cache_get_returns_none() {
let cache: FileCacheImpl<TestData> =
FileCacheImpl::with_dir(None, "test_cache", Duration::hours(1));
let result = cache.get("any_key").await.expect("get should succeed");
assert!(result.is_none());
}
#[tokio::test]
async fn test_disabled_cache_set_succeeds_silently() {
let cache: FileCacheImpl<TestData> =
FileCacheImpl::with_dir(None, "test_cache", Duration::hours(1));
let data = TestData {
value: "test".to_string(),
count: 42,
};
cache
.set("any_key", &data)
.await
.expect("set should succeed");
}
#[tokio::test]
async fn test_disabled_cache_remove_succeeds_silently() {
let cache: FileCacheImpl<TestData> =
FileCacheImpl::with_dir(None, "test_cache", Duration::hours(1));
cache
.remove("any_key")
.await
.expect("remove should succeed");
}
#[tokio::test]
async fn test_disabled_cache_get_stale_returns_none() {
let cache: FileCacheImpl<TestData> =
FileCacheImpl::with_dir(None, "test_cache", Duration::hours(1));
let result = cache
.get_stale("any_key")
.await
.expect("get_stale should succeed");
assert!(result.is_none());
}
#[tokio::test]
async fn test_evict_stale_removes_old_files() {
let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_evict", Duration::hours(1));
let data = TestData {
value: "old".to_string(),
count: 1,
};
cache.set("old_key", &data).await.expect("set cache");
if let Some(path) = cache.cache_path("old_key") {
let contents = tokio::fs::read_to_string(&path)
.await
.expect("read cache file");
let mut entry: CacheEntry<TestData> =
serde_json::from_str(&contents).expect("parse cache entry");
entry.cached_at = Utc::now() - Duration::days(10);
let new_contents = serde_json::to_string_pretty(&entry).expect("serialize cache entry");
tokio::fs::write(&path, new_contents)
.await
.expect("write cache file");
}
let evicted = cache.evict_stale(7).await;
assert_eq!(evicted, 1);
let result = cache.get("old_key").await.expect("get cache");
assert!(result.is_none());
}
#[tokio::test]
async fn test_evict_stale_preserves_fresh_files() {
let cache: FileCacheImpl<TestData> =
FileCacheImpl::new("test_evict_fresh", Duration::hours(1));
let data = TestData {
value: "fresh".to_string(),
count: 2,
};
cache.set("fresh_key", &data).await.expect("set cache");
let evicted = cache.evict_stale(7).await;
assert_eq!(evicted, 0);
let result = cache.get("fresh_key").await.expect("get cache");
assert!(result.is_some());
assert_eq!(result.unwrap(), data);
cache.remove("fresh_key").await.ok();
}
}