use serde::{de::DeserializeOwned, Deserialize, Serialize};
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
const APP_CACHE_DIR: &str = "biodex";
const LEGACY_APP_CACHE_DIR: &str = "ncbi_poketext";
#[derive(Debug, Serialize, Deserialize)]
struct CacheEntry<T> {
timestamp: u64,
data: T,
}
pub struct Cache {
cache_dir: PathBuf,
ttl: Duration,
}
impl Cache {
pub fn new(cache_dir: PathBuf, ttl_hours: u64) -> Self {
Self {
cache_dir,
ttl: Duration::from_secs(ttl_hours * 3600),
}
}
pub fn default_location(ttl_hours: u64) -> io::Result<Self> {
let cache_root = dirs::cache_dir().ok_or_else(|| {
io::Error::new(io::ErrorKind::NotFound, "Could not find cache directory")
})?;
let cache_dir = cache_root.join(APP_CACHE_DIR);
let legacy_cache_dir = cache_root.join(LEGACY_APP_CACHE_DIR);
migrate_legacy_cache_dir_if_needed(&legacy_cache_dir, &cache_dir)?;
fs::create_dir_all(&cache_dir)?;
Ok(Self::new(cache_dir, ttl_hours))
}
pub fn get<T: DeserializeOwned>(&self, key: &str) -> Option<T> {
let path = self.key_to_path(key);
let content = fs::read(&path).ok()?;
let entry: CacheEntry<T> = serde_json::from_slice(&content).ok()?;
let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
if now - entry.timestamp > self.ttl.as_secs() {
let _ = fs::remove_file(&path);
return None;
}
Some(entry.data)
}
pub fn set<T: Serialize>(&self, key: &str, value: &T) -> io::Result<()> {
fs::create_dir_all(&self.cache_dir)?;
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(io::Error::other)?
.as_secs();
let entry = CacheEntry {
timestamp,
data: value,
};
let content = serde_json::to_vec(&entry)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
let path = self.key_to_path(key);
fs::write(&path, content)
}
pub fn invalidate(&self, key: &str) -> io::Result<()> {
let path = self.key_to_path(key);
if path.exists() {
fs::remove_file(path)?;
}
Ok(())
}
fn key_to_path(&self, key: &str) -> PathBuf {
let safe_key: String = key
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();
self.cache_dir.join(format!("{}.json", safe_key))
}
}
fn migrate_legacy_cache_dir_if_needed(legacy_dir: &Path, current_dir: &Path) -> io::Result<()> {
if !legacy_dir.exists() || current_dir_has_files(current_dir)? {
return Ok(());
}
if current_dir.exists() {
fs::remove_dir_all(current_dir)?;
}
match fs::rename(legacy_dir, current_dir) {
Ok(()) => Ok(()),
Err(_) => {
fs::create_dir_all(current_dir)?;
Ok(())
}
}
}
fn current_dir_has_files(path: &Path) -> io::Result<bool> {
if !path.exists() {
return Ok(false);
}
Ok(fs::read_dir(path)?.next().is_some())
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
#[test]
fn test_cache_roundtrip() {
let temp_dir = env::temp_dir().join("biodex_test_cache");
let cache = Cache::new(temp_dir.clone(), 1);
cache.set("test_key", &"test_value".to_string()).unwrap();
let result: Option<String> = cache.get("test_key");
assert_eq!(result, Some("test_value".to_string()));
let _ = fs::remove_dir_all(temp_dir);
}
}