use std::collections::VecDeque;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::Instant;
use dashmap::DashMap;
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub struct CacheKey {
pub path: PathBuf,
pub subpath: Option<String>,
}
impl CacheKey {
pub fn new(path: PathBuf, subpath: Option<String>) -> Self {
Self {
path: crate::path_compat::dedup_key(&path),
subpath,
}
}
}
#[derive(Debug, Clone)]
pub struct CacheEntry {
pub path: PathBuf,
pub subpath: Option<String>,
pub path_hash: [u8; 32],
pub content_hash: [u8; 32],
pub last_observed: Instant,
pub kind: String,
pub agent: String,
}
impl CacheEntry {
pub fn key(&self) -> CacheKey {
CacheKey::new(self.path.clone(), self.subpath.clone())
}
}
pub struct ContentHashCache {
inner: DashMap<CacheKey, CacheEntry>,
lru: Mutex<VecDeque<CacheKey>>,
max_entries: usize,
}
impl ContentHashCache {
pub fn new(max_entries: usize) -> Self {
let cap = max_entries.max(64);
Self {
inner: DashMap::new(),
lru: Mutex::new(VecDeque::with_capacity(cap)),
max_entries: cap,
}
}
pub fn insert(&self, entry: CacheEntry) -> Option<[u8; 32]> {
let key = entry.key();
let prev = self
.inner
.insert(key.clone(), entry)
.map(|e| e.content_hash);
let mut lru = self.lock_lru();
lru.retain(|k| k != &key);
lru.push_back(key);
while lru.len() > self.max_entries {
if let Some(evicted) = lru.pop_front() {
self.inner.remove(&evicted);
}
}
prev
}
pub fn get(&self, path: &Path) -> Option<CacheEntry> {
self.get_subpath(path, None)
}
pub fn get_subpath(&self, path: &Path, subpath: Option<&str>) -> Option<CacheEntry> {
let key = CacheKey::new(path.to_path_buf(), subpath.map(str::to_string));
self.inner.get(&key).map(|r| r.clone())
}
pub fn entries_for_path(&self, path: &Path) -> Vec<CacheEntry> {
let wanted = crate::path_compat::dedup_key(path);
self.inner
.iter()
.filter(|r| r.key().path == wanted)
.map(|r| r.clone())
.collect()
}
pub fn remove(&self, path: &Path) -> Option<CacheEntry> {
self.remove_subpath(path, None)
}
pub fn remove_subpath(&self, path: &Path, subpath: Option<&str>) -> Option<CacheEntry> {
let key = CacheKey::new(path.to_path_buf(), subpath.map(str::to_string));
self.lock_lru().retain(|k| k != &key);
self.inner.remove(&key).map(|(_, v)| v)
}
pub fn remove_all_under_path(&self, path: &Path) -> Vec<CacheEntry> {
let wanted = crate::path_compat::dedup_key(path);
let keys: Vec<CacheKey> = self
.inner
.iter()
.filter(|r| r.key().path == wanted)
.map(|r| r.key().clone())
.collect();
let mut removed = Vec::with_capacity(keys.len());
let mut lru = self.lock_lru();
for key in keys {
lru.retain(|k| k != &key);
if let Some((_, v)) = self.inner.remove(&key) {
removed.push(v);
}
}
removed
}
fn lock_lru(&self) -> std::sync::MutexGuard<'_, VecDeque<CacheKey>> {
match self.lru.lock() {
Ok(g) => g,
Err(poisoned) => {
tracing::warn!("ContentHashCache LRU mutex was poisoned — recovering");
poisoned.into_inner()
}
}
}
pub fn len(&self) -> usize {
self.inner.len()
}
pub fn is_empty(&self) -> bool {
self.inner.is_empty()
}
pub fn snapshot(&self) -> Vec<CacheEntry> {
self.inner.iter().map(|r| r.clone()).collect()
}
pub fn check_and_update(&self, entry: CacheEntry) -> bool {
let key = entry.key();
if let Some(existing) = self.inner.get(&key) {
if existing.content_hash == entry.content_hash {
return false;
}
}
self.insert(entry);
true
}
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(path: &str, content: u8) -> CacheEntry {
CacheEntry {
path: PathBuf::from(path),
subpath: None,
path_hash: [0u8; 32],
content_hash: [content; 32],
last_observed: Instant::now(),
kind: "mcp".to_string(),
agent: "claude-code".to_string(),
}
}
fn subpath_entry(path: &str, subpath: &str, content: u8) -> CacheEntry {
CacheEntry {
path: PathBuf::from(path),
subpath: Some(subpath.to_string()),
path_hash: [0u8; 32],
content_hash: [content; 32],
last_observed: Instant::now(),
kind: "mcp".to_string(),
agent: "claude-code".to_string(),
}
}
#[test]
fn insert_and_get_round_trip() {
let cache = ContentHashCache::new(64);
cache.insert(entry("/tmp/a", 1));
let got = cache.get(Path::new("/tmp/a")).unwrap();
assert_eq!(got.content_hash, [1u8; 32]);
}
#[test]
fn insert_returns_previous_hash_on_replace() {
let cache = ContentHashCache::new(64);
assert!(cache.insert(entry("/tmp/a", 1)).is_none());
let prev = cache.insert(entry("/tmp/a", 2)).unwrap();
assert_eq!(prev, [1u8; 32]);
}
#[test]
fn remove_clears_both_inner_and_lru() {
let cache = ContentHashCache::new(64);
cache.insert(entry("/tmp/a", 1));
let removed = cache.remove(Path::new("/tmp/a")).unwrap();
assert_eq!(removed.content_hash, [1u8; 32]);
assert!(cache.get(Path::new("/tmp/a")).is_none());
assert!(cache.is_empty());
}
#[test]
fn check_and_update_returns_false_on_identical_hash() {
let cache = ContentHashCache::new(64);
assert!(cache.check_and_update(entry("/tmp/a", 1)));
assert!(!cache.check_and_update(entry("/tmp/a", 1)));
}
#[test]
fn check_and_update_returns_true_on_changed_hash() {
let cache = ContentHashCache::new(64);
cache.check_and_update(entry("/tmp/a", 1));
assert!(cache.check_and_update(entry("/tmp/a", 2)));
assert_eq!(
cache.get(Path::new("/tmp/a")).unwrap().content_hash,
[2u8; 32]
);
}
#[test]
fn lru_evicts_oldest_when_over_capacity() {
let cache = ContentHashCache::new(0);
assert_eq!(cache.max_entries, 64);
for i in 0..65u8 {
cache.insert(entry(&format!("/tmp/{i}"), i));
}
assert_eq!(cache.len(), 64);
assert!(cache.get(Path::new("/tmp/0")).is_none());
assert!(cache.get(Path::new("/tmp/64")).is_some());
}
#[test]
fn touching_an_entry_updates_recency() {
let cache = ContentHashCache::new(64);
for i in 0..64u8 {
cache.insert(entry(&format!("/tmp/{i}"), i));
}
cache.insert(entry("/tmp/0", 99));
cache.insert(entry("/tmp/new", 1));
assert!(cache.get(Path::new("/tmp/0")).is_some());
assert!(cache.get(Path::new("/tmp/1")).is_none());
assert!(cache.get(Path::new("/tmp/new")).is_some());
}
#[test]
fn snapshot_returns_all_entries() {
let cache = ContentHashCache::new(64);
cache.insert(entry("/tmp/a", 1));
cache.insert(entry("/tmp/b", 2));
let snap = cache.snapshot();
assert_eq!(snap.len(), 2);
}
#[test]
fn same_path_different_subpath_coexist() {
let cache = ContentHashCache::new(64);
cache.insert(subpath_entry("/tmp/a", "ctx7", 1));
cache.insert(subpath_entry("/tmp/a", "github", 2));
let entries = cache.entries_for_path(Path::new("/tmp/a"));
assert_eq!(entries.len(), 2);
assert_eq!(
cache
.get_subpath(Path::new("/tmp/a"), Some("ctx7"))
.unwrap()
.content_hash,
[1u8; 32]
);
assert_eq!(
cache
.get_subpath(Path::new("/tmp/a"), Some("github"))
.unwrap()
.content_hash,
[2u8; 32]
);
}
#[test]
fn remove_all_under_path_clears_every_subpath() {
let cache = ContentHashCache::new(64);
cache.insert(subpath_entry("/tmp/a", "ctx7", 1));
cache.insert(subpath_entry("/tmp/a", "github", 2));
cache.insert(entry("/tmp/other", 9));
let removed = cache.remove_all_under_path(Path::new("/tmp/a"));
assert_eq!(removed.len(), 2);
assert!(cache.entries_for_path(Path::new("/tmp/a")).is_empty());
assert!(cache.get(Path::new("/tmp/other")).is_some());
}
}