pub mod config;
pub mod key;
pub mod persist;
pub mod policy;
pub mod prune;
pub mod storage;
pub mod summary;
pub use config::{CacheConfig, CachePolicy};
pub use key::CacheKey;
pub use persist::PersistManager;
pub use policy::{CachePolicyConfig, CachePolicyKind, CachePolicyMetrics};
pub use prune::{
PruneEngine, PruneOperation, PruneOptions, PruneOutputMode, PruneReason, PruneReport,
};
pub use storage::{CacheStats, CacheStorage};
pub use summary::GraphNodeSummary;
use crate::hash::Blake3Hash;
use std::path::Path;
use std::sync::Arc;
pub struct CacheManager {
config: CacheConfig,
storage: Arc<CacheStorage>,
persist: Option<Arc<PersistManager>>,
}
impl CacheManager {
#[must_use]
pub fn new(config: CacheConfig) -> Self {
let max_bytes = config.max_bytes();
let persist = if config.is_persistence_enabled() {
match PersistManager::new(config.cache_root()) {
Ok(manager) => {
log::debug!("Persistence enabled at: {}", config.cache_root().display());
Some(Arc::new(manager))
}
Err(e) => {
log::warn!(
"Failed to initialize persistence: {e}. Cache will operate in-memory only."
);
None
}
}
} else {
log::debug!("Persistence disabled by configuration");
None
};
let policy_config = CachePolicyConfig::new(
config.policy_kind(),
max_bytes,
config.policy_window_ratio(),
);
Self {
storage: Arc::new(CacheStorage::with_policy(&policy_config)),
config,
persist,
}
}
pub fn get(
&self,
path: impl AsRef<Path>,
language: impl AsRef<str>,
content_hash: Blake3Hash,
) -> Option<Arc<[GraphNodeSummary]>> {
let key = CacheKey::new(path.as_ref(), language.as_ref(), content_hash);
if let Some(summaries) = self.storage.get(&key) {
return Some(summaries);
}
if let Some(persist) = &self.persist
&& let Ok(Some(summaries)) = persist.read_entry(&key)
{
log::debug!("Disk cache hit for: {}", key.path().display());
self.storage.insert(key, summaries.clone());
return Some(Arc::from(summaries.into_boxed_slice()));
}
None
}
pub fn insert(
&self,
path: impl AsRef<Path>,
language: impl AsRef<str>,
content_hash: Blake3Hash,
summaries: Vec<GraphNodeSummary>,
) {
let key = CacheKey::new(path.as_ref(), language.as_ref(), content_hash);
if let Some(persist) = &self.persist
&& let Err(e) = persist.write_entry(&key, &summaries)
{
log::warn!(
"Failed to persist cache entry for {}: {}",
key.path().display(),
e
);
}
self.storage.insert(key, summaries);
}
#[must_use]
pub fn stats(&self) -> CacheStats {
self.storage.stats()
}
pub fn clear(&self) {
self.storage.clear();
if let Some(persist) = &self.persist
&& let Err(e) = persist.clear_all()
{
log::warn!("Failed to clear disk cache: {e}");
}
}
#[must_use]
pub fn config(&self) -> &CacheConfig {
&self.config
}
pub fn prune(&self, options: &PruneOptions) -> anyhow::Result<PruneReport> {
options.validate()?;
let cache_dir = if let Some(ref dir) = options.target_dir {
dir.clone()
} else if let Some(ref persist) = self.persist {
persist.user_cache_dir()
} else {
anyhow::bail!(
"Cannot prune cache: persistence is disabled and no --path specified. \
Enable persistence or provide --path to target cache directory."
);
};
let engine = PruneEngine::new(options.clone())?;
engine.execute(&cache_dir)
}
}
impl Default for CacheManager {
fn default() -> Self {
Self::new(CacheConfig::default())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::unified::node::NodeKind;
use crate::hash::hash_bytes;
use approx::assert_abs_diff_eq;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tempfile::TempDir;
fn make_test_hash(byte: u8) -> Blake3Hash {
hash_bytes(&[byte; 32])
}
fn make_test_summary(name: &str) -> GraphNodeSummary {
GraphNodeSummary::new(
Arc::from(name),
NodeKind::Function,
Arc::from(Path::new("test.rs")),
1,
0,
1,
10,
)
}
fn make_test_cache() -> (CacheManager, TempDir) {
let tmp_cache_dir = TempDir::new().unwrap();
let config = CacheConfig::default().with_cache_root(tmp_cache_dir.path().to_path_buf());
let cache = CacheManager::new(config);
(cache, tmp_cache_dir)
}
#[test]
fn test_cache_manager_new() {
let config = CacheConfig::default();
let cache = CacheManager::new(config);
let stats = cache.stats();
assert_eq!(stats.entry_count, 0);
assert_eq!(stats.total_bytes, 0);
assert_eq!(stats.hits, 0);
assert_eq!(stats.misses, 0);
}
#[test]
fn test_cache_manager_default() {
let cache = CacheManager::default();
assert_eq!(cache.config().max_bytes(), CacheConfig::DEFAULT_MAX_BYTES);
assert!(cache.config().is_persistence_enabled());
}
#[test]
fn test_cache_manager_get_miss() {
let (cache, _tmp_cache_dir) = make_test_cache();
let hash = make_test_hash(0x42);
let result = cache.get("test.rs", "rust", hash);
assert!(result.is_none());
let stats = cache.stats();
assert_eq!(stats.hits, 0);
assert_eq!(stats.misses, 1);
}
#[test]
fn test_cache_manager_insert_and_get() {
let (cache, _tmp_cache_dir) = make_test_cache();
let hash = make_test_hash(0x42);
let summaries = vec![make_test_summary("test_fn")];
cache.insert("test.rs", "rust", hash, summaries.clone());
let retrieved = cache
.get("test.rs", "rust", hash)
.expect("Should be cached");
assert_eq!(retrieved.len(), 1);
assert_eq!(retrieved[0].name.as_ref(), "test_fn");
let stats = cache.stats();
assert_eq!(stats.hits, 1);
assert_eq!(stats.misses, 0);
assert_eq!(stats.entry_count, 1);
assert!(stats.total_bytes > 0);
}
#[test]
fn test_cache_manager_different_hashes() {
let (cache, _tmp_cache_dir) = make_test_cache();
let hash1 = make_test_hash(0x42);
let hash2 = make_test_hash(0x43);
let summaries = vec![make_test_summary("test_fn")];
cache.insert("test.rs", "rust", hash1, summaries.clone());
let result = cache.get("test.rs", "rust", hash2);
assert!(result.is_none());
let result = cache.get("test.rs", "rust", hash1);
assert!(result.is_some());
}
#[test]
fn test_cache_manager_different_languages() {
let (cache, _tmp_cache_dir) = make_test_cache();
let hash = make_test_hash(0x42);
let summaries = vec![make_test_summary("test_fn")];
cache.insert("test.txt", "rust", hash, summaries.clone());
let result = cache.get("test.txt", "python", hash);
assert!(result.is_none());
let result = cache.get("test.txt", "rust", hash);
assert!(result.is_some());
}
#[test]
fn test_cache_manager_clear() {
let (cache, _tmp_cache_dir) = make_test_cache();
let hash = make_test_hash(0x42);
let summaries = vec![make_test_summary("test_fn")];
cache.insert("test.rs", "rust", hash, summaries);
assert!(cache.get("test.rs", "rust", hash).is_some());
assert_eq!(cache.stats().entry_count, 1);
cache.clear();
assert!(cache.get("test.rs", "rust", hash).is_none());
let stats = cache.stats();
assert_eq!(stats.entry_count, 0);
assert_eq!(stats.total_bytes, 0);
}
#[test]
fn test_cache_manager_stats_tracking() {
let (cache, _tmp_cache_dir) = make_test_cache();
let hash = make_test_hash(0x42);
let summaries = vec![make_test_summary("test_fn")];
cache.insert("test.rs", "rust", hash, summaries);
cache.get("test.rs", "rust", hash);
cache.get("test.rs", "rust", hash);
cache.get("other.rs", "rust", hash);
let stats = cache.stats();
assert_eq!(stats.hits, 2);
assert_eq!(stats.misses, 1);
assert_abs_diff_eq!(stats.hit_rate(), 2.0 / 3.0, epsilon = 1e-10);
}
#[test]
fn test_cache_manager_eviction() {
let config = CacheConfig::new().with_max_bytes(100);
let cache = CacheManager::new(config);
let summaries = vec![make_test_summary("test_fn")];
for i in 0..10 {
let hash = make_test_hash(i);
cache.insert(format!("file{i}.rs"), "rust", hash, summaries.clone());
}
let stats = cache.stats();
assert!(stats.evictions > 0);
assert!(stats.total_bytes <= 100);
}
#[test]
fn test_cache_manager_config_access() {
let config = CacheConfig::new()
.with_max_bytes(100 * 1024 * 1024)
.with_cache_root(PathBuf::from("/tmp/test-cache"));
let cache = CacheManager::new(config);
assert_eq!(cache.config().max_bytes(), 100 * 1024 * 1024);
assert_eq!(
cache.config().cache_root(),
&PathBuf::from("/tmp/test-cache")
);
}
#[test]
fn test_persistence_enabled_by_default() {
use tempfile::TempDir;
let tmp_cache_dir = TempDir::new().unwrap();
let config = CacheConfig::new()
.with_cache_root(tmp_cache_dir.path().to_path_buf())
.with_persistence(true);
let cache = CacheManager::new(config);
assert!(cache.persist.is_some());
}
#[test]
fn test_persistence_disabled() {
use tempfile::TempDir;
let tmp_cache_dir = TempDir::new().unwrap();
let config = CacheConfig::new()
.with_cache_root(tmp_cache_dir.path().to_path_buf())
.with_persistence(false);
let cache = CacheManager::new(config);
assert!(cache.persist.is_none());
}
#[test]
fn test_disk_cache_write_and_read() {
use tempfile::TempDir;
let tmp_cache_dir = TempDir::new().unwrap();
let config = CacheConfig::new()
.with_cache_root(tmp_cache_dir.path().to_path_buf())
.with_persistence(true);
let cache = CacheManager::new(config);
let hash = make_test_hash(0x42);
let summaries = vec![make_test_summary("test_fn")];
cache.insert("test.rs", "rust", hash, summaries.clone());
let cache2 = CacheManager::new(
CacheConfig::new()
.with_cache_root(tmp_cache_dir.path().to_path_buf())
.with_persistence(true),
);
let retrieved = cache2.get("test.rs", "rust", hash);
assert!(retrieved.is_some(), "Should retrieve from disk");
let retrieved = retrieved.unwrap();
assert_eq!(retrieved.len(), 1);
assert_eq!(retrieved[0].name.as_ref(), "test_fn");
}
#[test]
fn test_disk_cache_miss() {
use tempfile::TempDir;
let tmp_cache_dir = TempDir::new().unwrap();
let config = CacheConfig::new()
.with_cache_root(tmp_cache_dir.path().to_path_buf())
.with_persistence(true);
let cache = CacheManager::new(config);
let hash = make_test_hash(0x99);
let result = cache.get("missing.rs", "rust", hash);
assert!(result.is_none());
let stats = cache.stats();
assert_eq!(stats.misses, 1);
}
#[test]
fn test_clear_removes_disk_cache() {
use tempfile::TempDir;
let tmp_cache_dir = TempDir::new().unwrap();
let config = CacheConfig::new()
.with_cache_root(tmp_cache_dir.path().to_path_buf())
.with_persistence(true);
let cache = CacheManager::new(config);
let hash = make_test_hash(0x42);
let summaries = vec![make_test_summary("test_fn")];
cache.insert("test.rs", "rust", hash, summaries.clone());
cache.clear();
let stats = cache.stats();
assert_eq!(stats.entry_count, 0);
let cache2 = CacheManager::new(
CacheConfig::new()
.with_cache_root(tmp_cache_dir.path().to_path_buf())
.with_persistence(true),
);
let result = cache2.get("test.rs", "rust", hash);
assert!(result.is_none(), "Disk cache should be cleared");
}
#[test]
fn test_memory_cache_populated_on_disk_hit() {
use tempfile::TempDir;
let tmp_cache_dir = TempDir::new().unwrap();
let config = CacheConfig::new()
.with_cache_root(tmp_cache_dir.path().to_path_buf())
.with_persistence(true);
let cache1 = CacheManager::new(config.clone());
let hash = make_test_hash(0x42);
let summaries = vec![make_test_summary("test_fn")];
cache1.insert("test.rs", "rust", hash, summaries.clone());
let cache2 = CacheManager::new(config);
let result1 = cache2.get("test.rs", "rust", hash);
assert!(result1.is_some());
assert_eq!(cache2.stats().hits, 0);
let result2 = cache2.get("test.rs", "rust", hash);
assert!(result2.is_some());
assert_eq!(cache2.stats().hits, 1); }
#[test]
fn test_persistence_graceful_failure() {
use tempfile::TempDir;
let tmp_cache_dir = TempDir::new().unwrap();
let config = CacheConfig::new()
.with_cache_root(tmp_cache_dir.path().to_path_buf())
.with_persistence(true);
let cache = CacheManager::new(config);
assert!(cache.persist.is_some());
let hash = make_test_hash(0x42);
let summaries = vec![make_test_summary("test_fn")];
cache.insert("test.rs", "rust", hash, summaries.clone());
let result = cache.get("test.rs", "rust", hash);
assert!(
result.is_some(),
"Memory cache should work even if disk write fails"
);
let stats = cache.stats();
assert_eq!(stats.entry_count, 1);
assert_eq!(stats.hits, 1);
}
}