use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use cleanlib_client::types::Verdict;
use serde::{Deserialize, Serialize};
pub const DEFAULT_TTL_DAYS: i64 = 7;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedEntry {
pub envelope: Verdict,
pub cached_at_unix: i64,
}
impl CachedEntry {
pub fn cached_at(&self) -> Option<DateTime<Utc>> {
DateTime::from_timestamp(self.cached_at_unix, 0)
}
}
pub fn default_cache_dir() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".cleanlib").join("cache.sled"))
}
pub fn cache_key(ecosystem: &str, package: &str, version: &str) -> String {
format!("{}/{}/{}", ecosystem, package, version)
}
pub struct PersistentCache {
db: sled::Db,
ttl_days: i64,
}
#[allow(dead_code)]
impl PersistentCache {
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
let db = sled::open(path.as_ref()).with_context(|| {
format!(
"failed to open verdict cache at {}",
path.as_ref().display()
)
})?;
Ok(Self {
db,
ttl_days: DEFAULT_TTL_DAYS,
})
}
pub fn open_with_ttl<P: AsRef<Path>>(path: P, ttl_days: i64) -> Result<Self> {
let mut c = Self::open(path)?;
c.ttl_days = ttl_days;
Ok(c)
}
pub fn get(&self, ecosystem: &str, package: &str, version: &str) -> Result<Option<CachedEntry>> {
let key = cache_key(ecosystem, package, version);
let raw = match self.db.get(&key)? {
Some(v) => v,
None => return Ok(None),
};
let entry: CachedEntry = bincode::deserialize(&raw)
.context("verdict cache entry deserialize failed; entry skipped")?;
let now = Utc::now().timestamp();
let age_secs = now.saturating_sub(entry.cached_at_unix);
let ttl_secs = self.ttl_days * 24 * 3600;
if age_secs > ttl_secs {
let _ = self.db.remove(&key);
return Ok(None);
}
Ok(Some(entry))
}
pub fn put(&self, ecosystem: &str, package: &str, version: &str, verdict: Verdict) -> Result<()> {
let entry = CachedEntry {
envelope: verdict,
cached_at_unix: Utc::now().timestamp(),
};
let raw = bincode::serialize(&entry).context("verdict cache entry serialize failed")?;
let key = cache_key(ecosystem, package, version);
self.db
.insert(key.as_bytes(), raw)
.context("verdict cache insert failed")?;
Ok(())
}
pub fn flush(&self) -> Result<()> {
self.db.flush().context("verdict cache flush failed")?;
Ok(())
}
pub fn len(&self) -> usize {
self.db.len()
}
pub fn is_empty(&self) -> bool {
self.db.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("cleanlib-cache-test-{}", name));
let _ = std::fs::remove_dir_all(&dir);
dir
}
fn mk_verdict(id: &str) -> Verdict {
Verdict {
verdict_id: id.to_string(),
verdict: "ALLOWED_NO_FINDINGS".to_string(),
source: "ALLOWED_NO_FINDINGS".to_string(),
..Verdict::default()
}
}
#[test]
fn cache_key_is_stable() {
assert_eq!(cache_key("npm", "cors", "2.8.5"), "npm/cors/2.8.5");
assert_eq!(
cache_key("npm", "@scope/pkg", "1.0.0"),
"npm/@scope/pkg/1.0.0"
);
}
#[test]
fn miss_returns_none() {
let dir = tmp_dir("miss");
let cache = PersistentCache::open(&dir).unwrap();
assert!(cache.get("npm", "missing", "1.0.0").unwrap().is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn put_then_get_round_trips() {
let dir = tmp_dir("rt");
let cache = PersistentCache::open(&dir).unwrap();
cache.put("npm", "cors", "2.8.5", mk_verdict("v1")).unwrap();
let entry = cache.get("npm", "cors", "2.8.5").unwrap().unwrap();
assert_eq!(entry.envelope.verdict_id, "v1");
assert_eq!(entry.envelope.verdict, "ALLOWED_NO_FINDINGS");
assert!(entry.cached_at().is_some());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn ttl_expired_evicted_lazily() {
let dir = tmp_dir("ttl");
let cache = PersistentCache::open_with_ttl(&dir, 0).unwrap();
cache.put("npm", "stale", "1.0.0", mk_verdict("vstale")).unwrap();
std::thread::sleep(std::time::Duration::from_secs(2));
let entry = cache.get("npm", "stale", "1.0.0").unwrap();
assert!(entry.is_none());
assert_eq!(cache.len(), 0);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn put_replaces_existing_entry() {
let dir = tmp_dir("rep");
let cache = PersistentCache::open(&dir).unwrap();
cache.put("npm", "cors", "2.8.5", mk_verdict("v1")).unwrap();
cache.put("npm", "cors", "2.8.5", mk_verdict("v2")).unwrap();
let entry = cache.get("npm", "cors", "2.8.5").unwrap().unwrap();
assert_eq!(entry.envelope.verdict_id, "v2");
let _ = std::fs::remove_dir_all(&dir);
}
}