use std::num::NonZeroUsize;
use std::sync::Mutex;
use std::sync::atomic::{AtomicU64, Ordering};
use lru::LruCache;
use super::persistence::PersistedDocument;
use crate::Error;
use crate::error::Result;
const DEFAULT_CACHE_SIZE: usize = 100;
#[derive(Debug)]
pub struct DocumentCache {
inner: Mutex<LruCache<String, PersistedDocument>>,
capacity: usize,
hits: AtomicU64,
misses: AtomicU64,
evictions: AtomicU64,
}
impl DocumentCache {
#[must_use]
pub fn new() -> Self {
Self::with_capacity(DEFAULT_CACHE_SIZE)
}
#[must_use]
pub fn with_capacity(capacity: usize) -> Self {
let capacity = capacity.max(1);
let non_zero = NonZeroUsize::new(capacity)
.unwrap_or_else(|| NonZeroUsize::new(DEFAULT_CACHE_SIZE).expect("default is non-zero"));
Self {
inner: Mutex::new(LruCache::new(non_zero)),
capacity,
hits: AtomicU64::new(0),
misses: AtomicU64::new(0),
evictions: AtomicU64::new(0),
}
}
pub fn get(&self, id: &str) -> Result<Option<PersistedDocument>> {
let mut cache = self.lock()?;
let result = cache.get(id).cloned();
if result.is_some() {
self.hits.fetch_add(1, Ordering::Relaxed);
} else {
self.misses.fetch_add(1, Ordering::Relaxed);
}
Ok(result)
}
pub fn contains(&self, id: &str) -> bool {
self.lock().map(|cache| cache.contains(id)).unwrap_or(false)
}
pub fn put(&self, id: String, doc: PersistedDocument) -> Result<Option<PersistedDocument>> {
let mut cache = self.lock()?;
let was_full = cache.len() >= self.capacity;
let evicted = cache.put(id, doc);
if evicted.is_some() || was_full {
self.evictions.fetch_add(1, Ordering::Relaxed);
}
Ok(evicted)
}
pub fn remove(&self, id: &str) -> Result<Option<PersistedDocument>> {
let mut cache = self.lock()?;
Ok(cache.pop(id))
}
pub fn clear(&self) -> Result<()> {
let mut cache = self.lock()?;
cache.clear();
Ok(())
}
pub fn len(&self) -> usize {
self.lock().map(|cache| cache.len()).unwrap_or(0)
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn capacity(&self) -> usize {
self.capacity
}
pub fn utilization(&self) -> f64 {
let len = self.len();
if self.capacity == 0 {
return 0.0;
}
len as f64 / self.capacity as f64
}
pub fn keys(&self) -> Result<Vec<String>> {
let cache = self.lock()?;
Ok(cache.iter().map(|(k, _)| k.clone()).collect())
}
pub fn stats(&self) -> CacheStats {
CacheStats {
len: self.len(),
capacity: self.capacity,
utilization: self.utilization(),
hits: self.hits.load(Ordering::Relaxed),
misses: self.misses.load(Ordering::Relaxed),
evictions: self.evictions.load(Ordering::Relaxed),
}
}
pub fn hits(&self) -> u64 {
self.hits.load(Ordering::Relaxed)
}
pub fn misses(&self) -> u64 {
self.misses.load(Ordering::Relaxed)
}
pub fn evictions(&self) -> u64 {
self.evictions.load(Ordering::Relaxed)
}
pub fn hit_rate(&self) -> f64 {
let hits = self.hits.load(Ordering::Relaxed);
let misses = self.misses.load(Ordering::Relaxed);
let total = hits + misses;
if total == 0 {
0.0
} else {
hits as f64 / total as f64
}
}
pub fn reset_metrics(&self) {
self.hits.store(0, Ordering::Relaxed);
self.misses.store(0, Ordering::Relaxed);
self.evictions.store(0, Ordering::Relaxed);
}
fn lock(&self) -> Result<std::sync::MutexGuard<'_, LruCache<String, PersistedDocument>>> {
self.inner
.lock()
.map_err(|_| Error::Cache("Cache lock poisoned".to_string()))
}
}
impl Default for DocumentCache {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Copy)]
pub struct CacheStats {
pub len: usize,
pub capacity: usize,
pub utilization: f64,
pub hits: u64,
pub misses: u64,
pub evictions: u64,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::document::DocumentTree;
use crate::storage::{DocumentMeta, PersistedDocument};
fn create_test_doc(id: &str) -> PersistedDocument {
let meta = DocumentMeta::new(id, "Test Doc", "md");
let tree = DocumentTree::new("Root", "Content");
PersistedDocument::new(meta, tree)
}
#[test]
fn test_cache_basic() {
let cache = DocumentCache::with_capacity(3);
let doc1 = create_test_doc("doc1");
let doc2 = create_test_doc("doc2");
cache.put("doc1".to_string(), doc1.clone()).unwrap();
cache.put("doc2".to_string(), doc2.clone()).unwrap();
assert_eq!(cache.len(), 2);
assert!(cache.contains("doc1"));
assert!(cache.contains("doc2"));
}
#[test]
fn test_cache_get() {
let cache = DocumentCache::with_capacity(3);
let doc = create_test_doc("doc1");
cache.put("doc1".to_string(), doc).unwrap();
let retrieved = cache.get("doc1").unwrap();
assert!(retrieved.is_some());
assert_eq!(retrieved.unwrap().meta.id, "doc1");
let missing = cache.get("missing").unwrap();
assert!(missing.is_none());
}
#[test]
fn test_cache_eviction() {
let cache = DocumentCache::with_capacity(2);
cache
.put("doc1".to_string(), create_test_doc("doc1"))
.unwrap();
cache
.put("doc2".to_string(), create_test_doc("doc2"))
.unwrap();
cache
.put("doc3".to_string(), create_test_doc("doc3"))
.unwrap();
assert!(!cache.contains("doc1"));
assert!(cache.contains("doc2"));
assert!(cache.contains("doc3"));
}
#[test]
fn test_cache_remove() {
let cache = DocumentCache::new();
cache
.put("doc1".to_string(), create_test_doc("doc1"))
.unwrap();
assert!(cache.contains("doc1"));
let removed = cache.remove("doc1").unwrap();
assert!(removed.is_some());
assert!(!cache.contains("doc1"));
let not_found = cache.remove("missing").unwrap();
assert!(not_found.is_none());
}
#[test]
fn test_cache_clear() {
let cache = DocumentCache::new();
cache
.put("doc1".to_string(), create_test_doc("doc1"))
.unwrap();
cache
.put("doc2".to_string(), create_test_doc("doc2"))
.unwrap();
assert_eq!(cache.len(), 2);
cache.clear().unwrap();
assert!(cache.is_empty());
}
#[test]
fn test_cache_utilization() {
let cache = DocumentCache::with_capacity(10);
assert_eq!(cache.utilization(), 0.0);
cache
.put("doc1".to_string(), create_test_doc("doc1"))
.unwrap();
assert!((cache.utilization() - 0.1).abs() < 0.01);
cache
.put("doc2".to_string(), create_test_doc("doc2"))
.unwrap();
assert!((cache.utilization() - 0.2).abs() < 0.01);
}
}