use std::collections::HashMap;
use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
use sui_graph_store::{GraphHash, GraphKind, GraphStore};
const EVALUATOR_ID: &str = concat!("sui-eval/", env!("CARGO_PKG_VERSION"));
#[derive(Hash, Eq, PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct CacheKey {
pub source_hash: String,
pub lock_hash: Option<String>,
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct CachedValue {
pub value_json: String,
pub timestamp: i64,
}
#[derive(serde::Serialize, serde::Deserialize)]
struct CacheEntry {
key: CacheKey,
value: CachedValue,
}
pub struct EvalCache {
memory: HashMap<CacheKey, CachedValue>,
db_path: Option<PathBuf>,
graph_store: Option<GraphStore>,
enabled: bool,
}
impl EvalCache {
pub fn new() -> Self {
Self {
memory: HashMap::new(),
db_path: None,
graph_store: None,
enabled: true,
}
}
pub fn with_persistent(db_path: PathBuf) -> Self {
let memory = Self::load_from_disk(&db_path).unwrap_or_default();
Self {
memory,
db_path: Some(db_path),
graph_store: None,
enabled: true,
}
}
pub fn default_persistent() -> Self {
match default_cache_path() {
Some(p) => Self::with_persistent(p),
None => Self::new(),
}
}
pub fn disabled() -> Self {
Self {
memory: HashMap::new(),
db_path: None,
graph_store: None,
enabled: false,
}
}
#[must_use]
pub fn with_graph_store(mut self, store: GraphStore) -> Self {
self.graph_store = Some(store);
self
}
#[must_use]
pub fn with_all_tiers(db_path: PathBuf, store: GraphStore) -> Self {
Self::with_persistent(db_path).with_graph_store(store)
}
pub fn is_enabled(&self) -> bool {
self.enabled
}
pub fn has_graph_store(&self) -> bool {
self.graph_store.is_some()
}
pub fn get(&mut self, key: &CacheKey) -> Option<&CachedValue> {
if !self.enabled {
return None;
}
if self.memory.contains_key(key) {
return self.memory.get(key);
}
if let Some(store) = &self.graph_store {
let gh = graph_hash_for_key(key);
if let Ok(blob) = store.get(GraphKind::EvalCacheEntry, gh) {
if let Ok(value) = serde_json::from_slice::<CachedValue>(&blob) {
self.memory.insert(key.clone(), value);
return self.memory.get(key);
}
}
}
None
}
pub fn put(&mut self, key: CacheKey, value: CachedValue) {
if !self.enabled {
return;
}
self.memory.insert(key.clone(), value.clone());
if let Some(ref path) = self.db_path {
let _ = Self::save_to_disk(path, &self.memory);
}
if let Some(store) = &self.graph_store {
if let Ok(blob) = serde_json::to_vec(&value) {
let lookup_hash = graph_hash_for_key(&key);
let _ = store.put_unchecked(GraphKind::EvalCacheEntry, lookup_hash, &blob);
}
}
}
pub fn len(&self) -> usize {
self.memory.len()
}
pub fn is_empty(&self) -> bool {
self.memory.is_empty()
}
pub fn key_for_file(path: &Path) -> Option<CacheKey> {
let content = std::fs::read(path).ok()?;
let source_hash = {
let mut h = Sha256::new();
h.update(EVALUATOR_ID.as_bytes());
h.update(b"::");
h.update(&content);
format!("{:x}", h.finalize())
};
let lock_hash = path
.parent()
.map(|dir| dir.join("flake.lock"))
.filter(|p| p.exists())
.and_then(|p| std::fs::read(p).ok())
.map(|c| sha256_hex(&c));
Some(CacheKey {
source_hash,
lock_hash,
})
}
fn load_from_disk(path: &Path) -> Option<HashMap<CacheKey, CachedValue>> {
let data = std::fs::read_to_string(path).ok()?;
let entries: Vec<CacheEntry> = serde_json::from_str(&data).ok()?;
let mut map = HashMap::with_capacity(entries.len());
for entry in entries {
map.insert(entry.key, entry.value);
}
Some(map)
}
fn save_to_disk(
path: &Path,
memory: &HashMap<CacheKey, CachedValue>,
) -> Result<(), std::io::Error> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let entries: Vec<CacheEntry> = memory
.iter()
.map(|(k, v)| CacheEntry {
key: k.clone(),
value: v.clone(),
})
.collect();
let json = serde_json::to_string(&entries)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
std::fs::write(path, json)
}
}
impl Default for EvalCache {
fn default() -> Self {
Self::new()
}
}
fn graph_hash_for_key(key: &CacheKey) -> GraphHash {
let mut hasher = blake3::Hasher::new();
hasher.update(b"evalcache::v2::");
hasher.update(env!("CARGO_PKG_VERSION").as_bytes());
hasher.update(b"::");
hasher.update(key.source_hash.as_bytes());
hasher.update(b"::");
if let Some(lock) = &key.lock_hash {
hasher.update(lock.as_bytes());
} else {
hasher.update(b"<no-lock>");
}
GraphHash(hasher.finalize().into())
}
fn sha256_hex(data: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(data);
format!("{:x}", hasher.finalize())
}
fn default_cache_path() -> Option<PathBuf> {
if let Ok(p) = std::env::var("SUI_EVAL_CACHE_PATH") {
if !p.is_empty() {
return Some(PathBuf::from(p));
}
}
dirs_next().map(|d| d.join("sui").join("eval-cache.json"))
}
fn dirs_next() -> Option<PathBuf> {
if let Ok(val) = std::env::var("XDG_CACHE_HOME") {
if !val.is_empty() {
return Some(PathBuf::from(val));
}
}
#[cfg(target_os = "macos")]
{
home_dir().map(|h| h.join("Library").join("Caches"))
}
#[cfg(not(target_os = "macos"))]
{
home_dir().map(|h| h.join(".cache"))
}
}
fn home_dir() -> Option<PathBuf> {
std::env::var("HOME").ok().map(PathBuf::from)
}
pub fn now_timestamp() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cache_hit_returns_same_value() {
let mut cache = EvalCache::new();
let key = CacheKey {
source_hash: "abc123".to_string(),
lock_hash: None,
};
let value = CachedValue {
value_json: r#"{"type":"int","value":42}"#.to_string(),
timestamp: 1000,
};
cache.put(key.clone(), value.clone());
let got = cache.get(&key).unwrap();
assert_eq!(got.value_json, value.value_json);
}
#[test]
fn cache_miss_returns_none() {
let mut cache = EvalCache::new();
let key = CacheKey {
source_hash: "nonexistent".to_string(),
lock_hash: None,
};
assert!(cache.get(&key).is_none());
}
#[test]
fn different_content_different_key() {
let mut cache = EvalCache::new();
let k1 = CacheKey {
source_hash: sha256_hex(b"file content A"),
lock_hash: None,
};
let k2 = CacheKey {
source_hash: sha256_hex(b"file content B"),
lock_hash: None,
};
cache.put(
k1.clone(),
CachedValue {
value_json: "A".to_string(),
timestamp: 1,
},
);
assert!(cache.get(&k1).is_some());
assert!(cache.get(&k2).is_none());
}
#[test]
fn lock_hash_change_invalidates() {
let mut cache = EvalCache::new();
let k1 = CacheKey {
source_hash: "same".to_string(),
lock_hash: Some("lock-v1".to_string()),
};
let k2 = CacheKey {
source_hash: "same".to_string(),
lock_hash: Some("lock-v2".to_string()),
};
cache.put(
k1.clone(),
CachedValue {
value_json: "v1".to_string(),
timestamp: 1,
},
);
assert!(cache.get(&k1).is_some());
assert!(cache.get(&k2).is_none());
}
#[test]
fn disabled_cache_always_misses() {
let mut cache = EvalCache::disabled();
let key = CacheKey {
source_hash: "abc".to_string(),
lock_hash: None,
};
cache.put(
key.clone(),
CachedValue {
value_json: "x".to_string(),
timestamp: 1,
},
);
assert!(cache.get(&key).is_none());
}
#[test]
fn cache_key_is_scoped_to_the_evaluator_version() {
let key = CacheKey {
source_hash: "deadbeef".to_string(),
lock_hash: None,
};
let got = graph_hash_for_key(&key);
let mut expect = blake3::Hasher::new();
expect.update(b"evalcache::v2::");
expect.update(env!("CARGO_PKG_VERSION").as_bytes());
expect.update(b"::");
expect.update(b"deadbeef");
expect.update(b"::");
expect.update(b"<no-lock>");
assert_eq!(
got,
GraphHash(expect.finalize().into()),
"graph_hash_for_key must domain-separate on v2 AND the evaluator version"
);
let mut other = blake3::Hasher::new();
other.update(b"evalcache::v2::");
other.update(b"0.0.0-not-this-build");
other.update(b"::");
other.update(b"deadbeef");
other.update(b"::");
other.update(b"<no-lock>");
assert_ne!(
got,
GraphHash(other.finalize().into()),
"two evaluator versions must not share a cache entry"
);
}
#[test]
fn key_for_file_hashes_content() {
let dir = std::env::temp_dir().join("sui-eval-cache-test");
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("test.nix");
std::fs::write(&path, "1 + 2").unwrap();
let key = EvalCache::key_for_file(&path).unwrap();
assert!(!key.source_hash.is_empty());
assert!(key.lock_hash.is_none());
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_dir(&dir);
}
#[test]
fn key_for_file_with_flake_lock() {
let dir = std::env::temp_dir().join("sui-eval-cache-test-lock");
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("flake.nix");
let lock = dir.join("flake.lock");
std::fs::write(&path, "{ }").unwrap();
std::fs::write(&lock, r#"{"nodes":{}}"#).unwrap();
let key = EvalCache::key_for_file(&path).unwrap();
assert!(key.lock_hash.is_some());
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&lock);
let _ = std::fs::remove_dir(&dir);
}
#[test]
fn persistent_roundtrip() {
let dir = std::env::temp_dir().join("sui-eval-cache-persist");
let _ = std::fs::create_dir_all(&dir);
let db = dir.join("test-cache.json");
{
let mut c = EvalCache::with_persistent(db.clone());
c.put(
CacheKey {
source_hash: "h1".to_string(),
lock_hash: None,
},
CachedValue {
value_json: r#""hello""#.to_string(),
timestamp: now_timestamp(),
},
);
assert_eq!(c.len(), 1);
}
{
let mut c = EvalCache::with_persistent(db.clone());
let key = CacheKey {
source_hash: "h1".to_string(),
lock_hash: None,
};
let v = c.get(&key).unwrap();
assert_eq!(v.value_json, r#""hello""#);
}
let _ = std::fs::remove_file(&db);
let _ = std::fs::remove_dir(&dir);
}
fn temp_graph_store() -> (tempfile::TempDir, GraphStore) {
let dir = tempfile::tempdir().unwrap();
let store = GraphStore::open(dir.path().to_path_buf()).unwrap();
(dir, store)
}
#[test]
fn graph_store_tier_round_trips_a_value() {
let (_dir, store) = temp_graph_store();
let mut cache = EvalCache::new().with_graph_store(store);
assert!(cache.has_graph_store());
let key = CacheKey {
source_hash: sha256_hex(b"some source"),
lock_hash: Some(sha256_hex(b"some lock")),
};
let value = CachedValue {
value_json: r#"{"answer":42}"#.to_string(),
timestamp: 1_700_000_000,
};
cache.put(key.clone(), value.clone());
let got = cache.get(&key).expect("memory tier hits");
assert_eq!(got.value_json, value.value_json);
}
#[test]
fn graph_store_tier_survives_fresh_cache_instance() {
let (_dir, store) = temp_graph_store();
let key = CacheKey {
source_hash: sha256_hex(b"persist me"),
lock_hash: None,
};
let value = CachedValue {
value_json: r#""persisted""#.to_string(),
timestamp: 42,
};
{
let mut c = EvalCache::new().with_graph_store(store.clone());
c.put(key.clone(), value.clone());
}
let mut c2 = EvalCache::new().with_graph_store(store);
let got = c2.get(&key).expect("graph_store tier hits");
assert_eq!(got.value_json, value.value_json);
let again = c2.get(&key).expect("memory promotion");
assert_eq!(again.value_json, value.value_json);
}
#[test]
fn graph_store_tier_isolates_by_cache_key() {
let (_dir, store) = temp_graph_store();
let mut cache = EvalCache::new().with_graph_store(store);
let k_a = CacheKey {
source_hash: sha256_hex(b"file a"),
lock_hash: None,
};
let k_b = CacheKey {
source_hash: sha256_hex(b"file b"),
lock_hash: None,
};
cache.put(
k_a.clone(),
CachedValue {
value_json: "A".to_string(),
timestamp: 1,
},
);
assert!(cache.get(&k_b).is_none());
assert!(cache.get(&k_a).is_some());
}
#[test]
fn graph_store_tier_disabled_when_cache_disabled() {
let (_dir, store) = temp_graph_store();
let mut cache = EvalCache::disabled().with_graph_store(store);
let key = CacheKey {
source_hash: "x".to_string(),
lock_hash: None,
};
cache.put(
key.clone(),
CachedValue {
value_json: "y".to_string(),
timestamp: 0,
},
);
assert!(cache.get(&key).is_none());
}
#[test]
fn all_three_tiers_stack_cleanly() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("eval-cache.json");
let (_gdir, store) = temp_graph_store();
let key = CacheKey {
source_hash: sha256_hex(b"triple-tier source"),
lock_hash: None,
};
let value = CachedValue {
value_json: r#""triple-tier""#.to_string(),
timestamp: 99,
};
{
let mut c = EvalCache::with_all_tiers(db_path.clone(), store.clone());
c.put(key.clone(), value.clone());
}
{
let mut c = EvalCache::with_persistent(db_path.clone());
assert!(c.get(&key).is_some(), "tier 2 (JSON) must still serve");
}
{
let mut c = EvalCache::new().with_graph_store(store);
assert!(c.get(&key).is_some(), "tier 3 (GraphStore) must still serve");
}
}
#[test]
fn sha256_hex_deterministic() {
let a = sha256_hex(b"hello");
let b = sha256_hex(b"hello");
assert_eq!(a, b);
assert_ne!(a, sha256_hex(b"world"));
}
#[test]
fn now_timestamp_reasonable() {
let ts = now_timestamp();
assert!(ts > 1_577_836_800);
assert!(ts < 4_102_444_800);
}
#[test]
fn len_and_is_empty() {
let mut cache = EvalCache::new();
assert!(cache.is_empty());
assert_eq!(cache.len(), 0);
cache.put(
CacheKey { source_hash: "x".to_string(), lock_hash: None },
CachedValue { value_json: "1".to_string(), timestamp: 1 },
);
assert!(!cache.is_empty());
assert_eq!(cache.len(), 1);
}
}