use sha2::{Digest, Sha256};
use std::{
fs,
io::{self, Read, Write},
path::{Path, PathBuf},
sync::atomic::{AtomicU64, Ordering},
time::{Duration, SystemTime},
};
pub const DEFAULT_TTL: Duration = Duration::from_secs(60 * 60 * 24 * 90);
const ENTRY_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, Default)]
pub struct CacheStats {
pub hits: u64,
pub misses: u64,
pub stores: u64,
pub evictions: u64,
}
#[derive(Debug)]
pub struct LlmCache {
root: PathBuf,
ttl: Duration,
hits: AtomicU64,
misses: AtomicU64,
stores: AtomicU64,
evictions: AtomicU64,
}
impl LlmCache {
#[must_use]
pub const fn new(root: PathBuf) -> Self {
Self::with_ttl(root, DEFAULT_TTL)
}
#[must_use]
pub const fn with_ttl(root: PathBuf, ttl: Duration) -> Self {
Self {
root,
ttl,
hits: AtomicU64::new(0),
misses: AtomicU64::new(0),
stores: AtomicU64::new(0),
evictions: AtomicU64::new(0),
}
}
#[must_use]
pub fn default_cache_dir() -> PathBuf {
if let Ok(explicit) = std::env::var("SSG_LLM_CACHE_DIR") {
if !explicit.is_empty() {
return PathBuf::from(explicit);
}
}
if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") {
if !xdg.is_empty() {
return PathBuf::from(xdg).join("ssg").join("llm");
}
}
#[cfg(target_os = "macos")]
{
if let Ok(home) = std::env::var("HOME") {
if !home.is_empty() {
return PathBuf::from(home)
.join("Library")
.join("Caches")
.join("ssg")
.join("llm");
}
}
}
#[cfg(target_os = "windows")]
{
if let Ok(local) = std::env::var("LOCALAPPDATA") {
if !local.is_empty() {
return PathBuf::from(local).join("ssg").join("llm");
}
}
}
if let Ok(home) = std::env::var("HOME") {
if !home.is_empty() {
return PathBuf::from(home)
.join(".cache")
.join("ssg")
.join("llm");
}
}
PathBuf::from(".ssg-llm-cache")
}
#[must_use]
pub fn compute_key(
endpoint: &str,
model: &str,
prompt: &str,
timeout_secs: u64,
) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(b"ssg-llm-cache-v1\x00");
hasher.update((endpoint.len() as u64).to_le_bytes());
hasher.update(endpoint.as_bytes());
hasher.update(b"\x00");
hasher.update((model.len() as u64).to_le_bytes());
hasher.update(model.as_bytes());
hasher.update(b"\x00");
hasher.update((prompt.len() as u64).to_le_bytes());
hasher.update(prompt.as_bytes());
hasher.update(b"\x00");
hasher.update(timeout_secs.to_le_bytes());
hasher.finalize().into()
}
pub fn get(&self, key: &[u8; 32]) -> Option<String> {
let path = self.entry_path(key);
let mut file = match fs::File::open(&path) {
Ok(f) => f,
Err(e) if e.kind() == io::ErrorKind::NotFound => {
let _ = self.misses.fetch_add(1, Ordering::Relaxed);
return None;
}
Err(_) => {
let _ = self.misses.fetch_add(1, Ordering::Relaxed);
return None;
}
};
if let Ok(meta) = file.metadata() {
if let Ok(modified) = meta.modified() {
if let Ok(age) = SystemTime::now().duration_since(modified) {
if age > self.ttl {
let _ = fs::remove_file(&path);
let _ = self.evictions.fetch_add(1, Ordering::Relaxed);
let _ = self.misses.fetch_add(1, Ordering::Relaxed);
return None;
}
}
}
}
let mut buf = String::new();
if file.read_to_string(&mut buf).is_err() {
let _ = fs::remove_file(&path);
let _ = self.evictions.fetch_add(1, Ordering::Relaxed);
let _ = self.misses.fetch_add(1, Ordering::Relaxed);
return None;
}
if let Some(payload) = parse_entry(&buf, key) {
let _ = self.hits.fetch_add(1, Ordering::Relaxed);
Some(payload)
} else {
let _ = fs::remove_file(&path);
let _ = self.evictions.fetch_add(1, Ordering::Relaxed);
let _ = self.misses.fetch_add(1, Ordering::Relaxed);
None
}
}
pub fn set(&self, key: &[u8; 32], payload: &str) -> io::Result<()> {
let path = self.entry_path(key);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let key_hex = encode_hex(key);
let body = serde_json::json!({
"version": ENTRY_VERSION,
"key_hex": key_hex,
"payload_len": payload.len(),
"payload": payload,
})
.to_string();
let tmp = path.with_extension(format!(
"tmp.{}.{}",
std::process::id(),
next_tmp_seq(),
));
{
let mut f = fs::File::create(&tmp)?;
f.write_all(body.as_bytes())?;
f.sync_all()?;
}
fs::rename(&tmp, &path)?;
let _ = self.stores.fetch_add(1, Ordering::Relaxed);
Ok(())
}
pub fn evict(&self, key: &[u8; 32]) -> io::Result<()> {
let path = self.entry_path(key);
match fs::remove_file(&path) {
Ok(()) => {
let _ = self.evictions.fetch_add(1, Ordering::Relaxed);
Ok(())
}
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
#[must_use]
pub fn stats(&self) -> CacheStats {
CacheStats {
hits: self.hits.load(Ordering::Relaxed),
misses: self.misses.load(Ordering::Relaxed),
stores: self.stores.load(Ordering::Relaxed),
evictions: self.evictions.load(Ordering::Relaxed),
}
}
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
fn entry_path(&self, key: &[u8; 32]) -> PathBuf {
let hex = encode_hex(key);
let (shard, rest) = hex.split_at(2);
self.root.join(shard).join(format!("{rest}.json"))
}
}
fn encode_hex(bytes: &[u8; 32]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(64);
for &b in bytes {
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0f) as usize] as char);
}
out
}
fn parse_entry(text: &str, key: &[u8; 32]) -> Option<String> {
let v: serde_json::Value = serde_json::from_str(text).ok()?;
let version = v.get("version")?.as_u64()?;
if u32::try_from(version).ok()? != ENTRY_VERSION {
return None;
}
let key_hex = v.get("key_hex")?.as_str()?;
if key_hex != encode_hex(key) {
return None;
}
let payload = v.get("payload")?.as_str()?;
let stored_len = usize::try_from(v.get("payload_len")?.as_u64()?).ok()?;
if stored_len != payload.len() {
return None;
}
Some(payload.to_string())
}
fn next_tmp_seq() -> u64 {
static SEQ: AtomicU64 = AtomicU64::new(0);
SEQ.fetch_add(1, Ordering::Relaxed)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use std::thread;
use std::time::Duration;
fn cache_for_test() -> (tempfile::TempDir, LlmCache) {
let dir = tempfile::tempdir().unwrap();
let cache = LlmCache::new(dir.path().to_path_buf());
(dir, cache)
}
#[test]
fn encode_hex_zero_padded() {
let mut bytes = [0u8; 32];
bytes[0] = 0x0a;
bytes[31] = 0xff;
let hex = encode_hex(&bytes);
assert_eq!(hex.len(), 64);
assert!(hex.starts_with("0a"));
assert!(hex.ends_with("ff"));
}
#[test]
fn compute_key_is_deterministic() {
let k1 = LlmCache::compute_key("e", "m", "p", 1);
let k2 = LlmCache::compute_key("e", "m", "p", 1);
assert_eq!(k1, k2);
}
#[test]
fn compute_key_differs_on_endpoint() {
let a = LlmCache::compute_key("e1", "m", "p", 1);
let b = LlmCache::compute_key("e2", "m", "p", 1);
assert_ne!(a, b);
}
#[test]
fn compute_key_differs_on_model() {
let a = LlmCache::compute_key("e", "m1", "p", 1);
let b = LlmCache::compute_key("e", "m2", "p", 1);
assert_ne!(a, b);
}
#[test]
fn compute_key_differs_on_prompt() {
let a = LlmCache::compute_key("e", "m", "p1", 1);
let b = LlmCache::compute_key("e", "m", "p2", 1);
assert_ne!(a, b);
}
#[test]
fn compute_key_differs_on_timeout() {
let a = LlmCache::compute_key("e", "m", "p", 1);
let b = LlmCache::compute_key("e", "m", "p", 2);
assert_ne!(a, b);
}
#[test]
fn compute_key_resists_length_collision() {
let a = LlmCache::compute_key("ab", "cd", "p", 1);
let b = LlmCache::compute_key("abc", "d", "p", 1);
assert_ne!(a, b);
}
#[test]
fn round_trip_hit() {
let (_d, cache) = cache_for_test();
let key = LlmCache::compute_key("e", "m", "p", 1);
assert!(cache.get(&key).is_none());
cache.set(&key, "the answer").unwrap();
assert_eq!(cache.get(&key).as_deref(), Some("the answer"));
}
#[test]
fn miss_counter_advances_on_absent_key() {
let (_d, cache) = cache_for_test();
let key = LlmCache::compute_key("e", "m", "p", 1);
let _ = cache.get(&key);
let _ = cache.get(&key);
assert_eq!(cache.stats().misses, 2);
assert_eq!(cache.stats().hits, 0);
}
#[test]
fn hit_counter_advances_on_present_key() {
let (_d, cache) = cache_for_test();
let key = LlmCache::compute_key("e", "m", "p", 1);
cache.set(&key, "x").unwrap();
let _ = cache.get(&key);
let _ = cache.get(&key);
assert_eq!(cache.stats().hits, 2);
}
#[test]
fn evict_removes_entry() {
let (_d, cache) = cache_for_test();
let key = LlmCache::compute_key("e", "m", "p", 1);
cache.set(&key, "x").unwrap();
cache.evict(&key).unwrap();
assert!(cache.get(&key).is_none());
}
#[test]
fn evict_missing_is_ok() {
let (_d, cache) = cache_for_test();
let key = LlmCache::compute_key("e", "m", "p", 1);
cache.evict(&key).unwrap();
}
#[test]
fn ttl_zero_expires_immediately() {
let dir = tempfile::tempdir().unwrap();
let cache = LlmCache::with_ttl(
dir.path().to_path_buf(),
Duration::from_nanos(1),
);
let key = LlmCache::compute_key("e", "m", "p", 1);
cache.set(&key, "x").unwrap();
thread::sleep(Duration::from_millis(5));
assert!(cache.get(&key).is_none());
assert!(cache.stats().evictions >= 1);
}
#[test]
fn corrupt_json_evicts_and_misses() {
let (_d, cache) = cache_for_test();
let key = LlmCache::compute_key("e", "m", "p", 1);
cache.set(&key, "x").unwrap();
let p = cache.entry_path(&key);
fs::write(&p, "{ not json").unwrap();
assert!(cache.get(&key).is_none());
assert!(!p.exists(), "corrupt entry should have been evicted");
}
#[test]
fn length_mismatch_evicts() {
let (_d, cache) = cache_for_test();
let key = LlmCache::compute_key("e", "m", "p", 1);
cache.set(&key, "abcdef").unwrap();
let p = cache.entry_path(&key);
let body = serde_json::json!({
"version": ENTRY_VERSION,
"key_hex": encode_hex(&key),
"payload_len": 9999,
"payload": "abcdef",
});
fs::write(&p, body.to_string()).unwrap();
assert!(cache.get(&key).is_none());
}
#[test]
fn version_mismatch_evicts() {
let (_d, cache) = cache_for_test();
let key = LlmCache::compute_key("e", "m", "p", 1);
cache.set(&key, "x").unwrap();
let p = cache.entry_path(&key);
let body = serde_json::json!({
"version": 9999,
"key_hex": encode_hex(&key),
"payload_len": 1,
"payload": "x",
});
fs::write(&p, body.to_string()).unwrap();
assert!(cache.get(&key).is_none());
}
#[test]
fn key_mismatch_evicts() {
let (_d, cache) = cache_for_test();
let key = LlmCache::compute_key("e", "m", "p", 1);
let other = LlmCache::compute_key("x", "y", "z", 9);
cache.set(&key, "x").unwrap();
let p = cache.entry_path(&key);
let body = serde_json::json!({
"version": ENTRY_VERSION,
"key_hex": encode_hex(&other),
"payload_len": 1,
"payload": "x",
});
fs::write(&p, body.to_string()).unwrap();
assert!(cache.get(&key).is_none());
}
#[test]
fn sharding_uses_first_two_hex_chars() {
let (dir, cache) = cache_for_test();
let key = [0xab; 32];
cache.set(&key, "x").unwrap();
let shard = dir.path().join("ab");
assert!(shard.is_dir(), "expected shard dir {shard:?}");
}
#[test]
fn concurrent_distinct_keys_do_not_collide() {
let (_d, cache) = cache_for_test();
let cache = std::sync::Arc::new(cache);
let mut handles = Vec::new();
for i in 0..50 {
let c = std::sync::Arc::clone(&cache);
handles.push(thread::spawn(move || {
let key = LlmCache::compute_key("e", "m", &format!("p{i}"), 1);
c.set(&key, &format!("v{i}")).unwrap();
}));
}
for h in handles {
h.join().unwrap();
}
for i in 0..50 {
let key = LlmCache::compute_key("e", "m", &format!("p{i}"), 1);
assert_eq!(
cache.get(&key).as_deref(),
Some(format!("v{i}").as_str()),
"missing entry for key {i}"
);
}
}
#[test]
fn concurrent_same_key_last_writer_wins_no_corruption() {
let (_d, cache) = cache_for_test();
let cache = std::sync::Arc::new(cache);
let key = LlmCache::compute_key("e", "m", "p", 1);
let mut handles = Vec::new();
for i in 0..20 {
let c = std::sync::Arc::clone(&cache);
handles.push(thread::spawn(move || {
c.set(&key, &format!("v{i}")).unwrap();
}));
}
for h in handles {
h.join().unwrap();
}
let got = cache.get(&key).expect("entry should exist");
assert!(got.starts_with('v'));
}
#[test]
fn default_cache_dir_respects_explicit_override() {
let prev = std::env::var("SSG_LLM_CACHE_DIR").ok();
std::env::set_var("SSG_LLM_CACHE_DIR", "/tmp/ssg-test-cache");
assert_eq!(
LlmCache::default_cache_dir(),
PathBuf::from("/tmp/ssg-test-cache")
);
match prev {
Some(v) => std::env::set_var("SSG_LLM_CACHE_DIR", v),
None => std::env::remove_var("SSG_LLM_CACHE_DIR"),
}
}
#[test]
fn root_returns_constructor_path() {
let dir = tempfile::tempdir().unwrap();
let cache = LlmCache::new(dir.path().to_path_buf());
assert_eq!(cache.root(), dir.path());
}
#[test]
fn stats_default_is_zero() {
let s = CacheStats::default();
assert_eq!(s.hits, 0);
assert_eq!(s.misses, 0);
assert_eq!(s.stores, 0);
assert_eq!(s.evictions, 0);
}
#[test]
fn stats_store_counter_increments() {
let (_d, cache) = cache_for_test();
let k1 = LlmCache::compute_key("e", "m", "p1", 1);
let k2 = LlmCache::compute_key("e", "m", "p2", 1);
cache.set(&k1, "x").unwrap();
cache.set(&k2, "y").unwrap();
assert_eq!(cache.stats().stores, 2);
}
#[test]
fn get_returns_none_when_entry_is_a_directory() {
let (_d, cache) = cache_for_test();
let key = LlmCache::compute_key("e", "m", "p", 1);
let path = cache.entry_path(&key);
fs::create_dir_all(&path).unwrap();
assert!(cache.get(&key).is_none());
}
#[test]
fn get_returns_none_on_missing_entry_increments_miss() {
let (_d, cache) = cache_for_test();
let key = LlmCache::compute_key("e", "m", "missing", 1);
let before = cache.stats().misses;
assert!(cache.get(&key).is_none());
assert!(cache.stats().misses > before);
}
#[test]
fn parse_entry_returns_none_for_missing_fields() {
let key = LlmCache::compute_key("e", "m", "p", 1);
let json = format!(
r#"{{"key_hex":"{}","payload":"x","payload_len":1}}"#,
encode_hex(&key)
);
assert!(parse_entry(&json, &key).is_none());
}
#[test]
fn parse_entry_returns_none_for_non_object_payload() {
let key = LlmCache::compute_key("e", "m", "p", 1);
assert!(parse_entry("[1,2,3]", &key).is_none());
assert!(parse_entry("null", &key).is_none());
}
#[test]
fn evict_propagates_unexpected_io_error() {
let (_d, cache) = cache_for_test();
let key = LlmCache::compute_key("e", "m", "evict-dir", 1);
let path = cache.entry_path(&key);
fs::create_dir_all(&path).unwrap();
let res = cache.evict(&key);
let _ = res;
}
#[test]
fn next_tmp_seq_is_monotonic() {
let a = next_tmp_seq();
let b = next_tmp_seq();
let c = next_tmp_seq();
assert!(b > a);
assert!(c > b);
}
}