use super::error::{DiscoveryError, Result};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
const DEFAULT_CACHE_TTL_SECS: u64 = 24 * 60 * 60;
#[derive(Debug, Serialize, Deserialize)]
pub struct CacheEntry {
pub data: serde_json::Value,
pub created_at: u64,
pub ttl_secs: u64,
}
impl CacheEntry {
pub fn new<T: Serialize>(data: T, ttl_secs: u64) -> Result<Self> {
let data = serde_json::to_value(&data)
.map_err(|e| DiscoveryError::Cache(format!("Failed to serialize cache data: {e}")))?;
Ok(Self {
data,
created_at: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
ttl_secs,
})
}
pub fn is_expired(&self) -> bool {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
now > self.created_at + self.ttl_secs
}
}
pub struct CacheManager {
cache_dir: PathBuf,
default_ttl: Duration,
}
impl CacheManager {
pub fn new() -> Result<Self> {
let cache_dir = dirs::cache_dir()
.ok_or_else(|| DiscoveryError::Cache("Cannot determine cache directory".to_string()))?
.join("opencode-provider-manager");
std::fs::create_dir_all(&cache_dir)
.map_err(|e| DiscoveryError::Cache(format!("Failed to create cache dir: {e}")))?;
Ok(Self {
cache_dir,
default_ttl: Duration::from_secs(DEFAULT_CACHE_TTL_SECS),
})
}
pub fn with_dir(cache_dir: PathBuf) -> Result<Self> {
std::fs::create_dir_all(&cache_dir)
.map_err(|e| DiscoveryError::Cache(format!("Failed to create cache dir: {e}")))?;
Ok(Self {
cache_dir,
default_ttl: Duration::from_secs(DEFAULT_CACHE_TTL_SECS),
})
}
pub fn with_default_ttl(mut self, ttl: Duration) -> Self {
self.default_ttl = ttl;
self
}
pub fn get<T: serde::de::DeserializeOwned>(&self, key: &str) -> Result<Option<T>> {
let path = self.cache_dir.join(format!("{}.json", key));
if !path.exists() {
return Ok(None);
}
let content = std::fs::read_to_string(&path)
.map_err(|e| DiscoveryError::Cache(format!("Failed to read cache: {e}")))?;
let entry: CacheEntry = serde_json::from_str(&content)
.map_err(|e| DiscoveryError::Cache(format!("Failed to parse cache: {e}")))?;
if entry.is_expired() {
let _ = std::fs::remove_file(&path);
Ok(None)
} else {
let value: T = serde_json::from_value(entry.data).map_err(|e| {
DiscoveryError::Cache(format!("Failed to deserialize cache data: {e}"))
})?;
Ok(Some(value))
}
}
pub fn set<T: Serialize>(&self, key: &str, data: T) -> Result<()> {
self.set_with_ttl(key, data, self.default_ttl.as_secs())
}
pub fn set_with_ttl<T: Serialize>(&self, key: &str, data: T, ttl_secs: u64) -> Result<()> {
let path = self.cache_dir.join(format!("{}.json", key));
let entry = CacheEntry::new(data, ttl_secs)?;
let content = serde_json::to_string_pretty(&entry)
.map_err(|e| DiscoveryError::Cache(format!("Failed to serialize cache: {e}")))?;
std::fs::write(&path, content)
.map_err(|e| DiscoveryError::Cache(format!("Failed to write cache: {e}")))?;
Ok(())
}
pub fn remove(&self, key: &str) -> Result<()> {
let path = self.cache_dir.join(format!("{}.json", key));
if path.exists() {
std::fs::remove_file(&path)
.map_err(|e| DiscoveryError::Cache(format!("Failed to remove cache: {e}")))?;
}
Ok(())
}
pub fn clear(&self) -> Result<()> {
if self.cache_dir.exists() {
for entry in std::fs::read_dir(&self.cache_dir)
.map_err(|e| DiscoveryError::Cache(format!("Failed to read cache dir: {e}")))?
{
let entry = entry
.map_err(|e| DiscoveryError::Cache(format!("Failed to read dir entry: {e}")))?;
if entry.path().extension().is_some_and(|ext| ext == "json") {
let _ = std::fs::remove_file(entry.path());
}
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cache_entry_new() {
let entry = CacheEntry::new("test_data", 3600).unwrap();
assert!(!entry.is_expired());
assert_eq!(
entry.data,
serde_json::Value::String("test_data".to_string())
);
}
#[test]
fn test_cache_entry_expired() {
let mut entry = CacheEntry::new("test_data", 1).unwrap();
entry.created_at = 0; assert!(entry.is_expired());
}
#[test]
fn test_cache_manager_crud() {
let dir = std::env::temp_dir().join("opm-test-cache");
let manager = CacheManager::with_dir(dir.clone()).unwrap();
manager.set("test_key", "test_value").unwrap();
let value: Option<String> = manager.get("test_key").unwrap();
assert_eq!(value, Some("test_value".to_string()));
manager.remove("test_key").unwrap();
let value: Option<String> = manager.get("test_key").unwrap();
assert_eq!(value, None);
let _ = std::fs::remove_dir_all(&dir);
}
}