use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct CacheEntry<T> {
pub fetched_at: DateTime<Utc>,
pub ttl_seconds: u64,
pub data: T,
}
impl<T> CacheEntry<T> {
pub fn new(data: T, ttl_seconds: u64) -> Self {
Self {
fetched_at: Utc::now(),
ttl_seconds,
data,
}
}
pub fn is_fresh(&self) -> bool {
let elapsed = Utc::now() - self.fetched_at;
elapsed.num_seconds() >= 0 && (elapsed.num_seconds() as u64) < self.ttl_seconds
}
}
pub fn read_cache<T: for<'de> serde::Deserialize<'de>>(
path: &std::path::Path,
) -> Option<CacheEntry<T>> {
let content = std::fs::read_to_string(path).ok()?;
let entry: CacheEntry<T> = serde_json::from_str(&content)
.map_err(|e| tracing::debug!("Cache parse error at {:?}: {}", path, e))
.ok()?;
tracing::debug!("Cache hit at {:?}", path);
Some(entry)
}
pub fn write_cache<T: serde::Serialize>(
path: &std::path::Path,
data: T,
ttl_seconds: u64,
) -> anyhow::Result<()> {
std::fs::create_dir_all(path.parent().unwrap_or(std::path::Path::new(".")))?;
let entry = CacheEntry::new(data, ttl_seconds);
let json = serde_json::to_string_pretty(&entry)?;
let dir = path.parent().unwrap_or(std::path::Path::new("."));
let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
use std::io::Write as _;
tmp.write_all(json.as_bytes())?;
tmp.flush()?;
tmp.persist(path)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
#[test]
fn cache_schema_roundtrip() {
let entry: CacheEntry<Vec<&str>> = CacheEntry::new(vec!["hello"], 60);
let json = serde_json::to_string(&entry).expect("serialize failed");
let restored: CacheEntry<Vec<&str>> =
serde_json::from_str(&json).expect("deserialize failed");
assert_eq!(restored.data, vec!["hello"]);
assert_eq!(restored.ttl_seconds, 60);
}
#[test]
fn cache_hit_within_ttl() {
let entry: CacheEntry<u32> = CacheEntry::new(42u32, 60);
assert!(entry.is_fresh(), "Expected fresh entry to return is_fresh() == true");
}
#[test]
fn cache_expired() {
let entry = CacheEntry {
fetched_at: Utc::now() - Duration::seconds(61),
ttl_seconds: 60,
data: 42u32,
};
assert!(!entry.is_fresh(), "Expected expired entry to return is_fresh() == false");
}
#[test]
fn cache_corrupt_discarded() {
let dir = tempfile::TempDir::new().expect("TempDir failed");
let path = dir.path().join("corrupt.json");
std::fs::write(&path, b"not valid json {{{").expect("write failed");
let result: Option<CacheEntry<String>> = read_cache(&path);
assert!(result.is_none(), "Expected None for corrupt cache, got Some");
}
#[test]
fn cache_write_and_read_roundtrip() {
let dir = tempfile::TempDir::new().expect("TempDir failed");
let path = dir.path().join("test_cache.json");
write_cache(&path, vec!["test"], 60).expect("write_cache failed");
let result: Option<CacheEntry<Vec<String>>> = read_cache(&path);
assert!(result.is_some(), "Expected Some after write_cache");
let entry = result.unwrap();
assert_eq!(entry.data, vec!["test"]);
assert_eq!(entry.ttl_seconds, 60);
}
}