Skip to main content

parsentry_cache/
lib.rs

1//! Content-addressable file cache with namespace isolation
2//!
3//! This crate provides a generic caching layer with file-based persistence,
4//! namespace-based isolation, and configurable cleanup policies.
5
6pub mod cleanup;
7pub mod entry;
8pub mod key;
9pub mod storage;
10
11pub use cleanup::{CleanupManager, CleanupPolicy, CleanupStats, CleanupTrigger};
12pub use entry::{CacheEntry, CacheMetadata};
13pub use key::{hash_key, CACHE_VERSION};
14pub use storage::CacheStorage;
15
16use anyhow::Result;
17use std::path::Path;
18
19/// Content-addressable file cache with namespace isolation
20pub struct Cache {
21    storage: CacheStorage,
22    cleanup: CleanupManager,
23    enabled: bool,
24}
25
26impl Cache {
27    /// Create a new cache with default configuration
28    pub fn new<P: AsRef<Path>>(cache_dir: P) -> Result<Self> {
29        let cache_dir = cache_dir.as_ref().to_path_buf();
30        let storage = CacheStorage::new(&cache_dir)?;
31        let cleanup = CleanupManager::new(&cache_dir)?;
32
33        Ok(Self {
34            storage,
35            cleanup,
36            enabled: true,
37        })
38    }
39
40    /// Create a cache with custom cleanup configuration
41    pub fn with_cleanup_config<P: AsRef<Path>>(
42        cache_dir: P,
43        policy: CleanupPolicy,
44        trigger: CleanupTrigger,
45    ) -> Result<Self> {
46        let cache_dir = cache_dir.as_ref().to_path_buf();
47        let storage = CacheStorage::new(&cache_dir)?;
48        let cleanup = CleanupManager::with_config(&cache_dir, policy, trigger)?;
49
50        Ok(Self {
51            storage,
52            cleanup,
53            enabled: true,
54        })
55    }
56
57    /// Disable the cache (no-op operations)
58    pub fn disable(&mut self) {
59        self.enabled = false;
60    }
61
62    /// Enable the cache
63    pub fn enable(&mut self) {
64        self.enabled = true;
65    }
66
67    /// Check if cache is enabled
68    pub fn is_enabled(&self) -> bool {
69        self.enabled
70    }
71
72    /// Get a cached value by namespace and key
73    pub fn get(&self, namespace: &str, key: &str) -> Result<Option<String>> {
74        if !self.enabled {
75            return Ok(None);
76        }
77
78        log::debug!(
79            "Cache lookup: ns={}, key={}",
80            namespace,
81            &key[..key.len().min(8)]
82        );
83
84        if let Some(entry) = self.storage.get(namespace, key)? {
85            log::info!("Cache hit: {}", &key[..key.len().min(8)]);
86            Ok(Some(entry.value))
87        } else {
88            log::info!("Cache miss: {}", &key[..key.len().min(8)]);
89            Ok(None)
90        }
91    }
92
93    /// Set a cached value under a namespace and key
94    pub fn set(&self, namespace: &str, key: &str, value: &str, input_size: usize) -> Result<()> {
95        if !self.enabled {
96            return Ok(());
97        }
98
99        let entry = CacheEntry::new(
100            CACHE_VERSION.to_string(),
101            namespace.to_string(),
102            key.to_string(),
103            value.to_string(),
104            input_size,
105        );
106
107        self.storage.set(&entry)?;
108        log::info!(
109            "Cache stored: ns={}, key={}",
110            namespace,
111            &key[..key.len().min(8)]
112        );
113
114        Ok(())
115    }
116
117    /// Check if periodic cleanup should run
118    pub fn should_cleanup_periodic(&self) -> Result<bool> {
119        self.cleanup.should_run_periodic_cleanup()
120    }
121
122    /// Check if cache is over size limit
123    pub fn should_cleanup_size(&self) -> Result<bool> {
124        self.cleanup.is_over_size_limit()
125    }
126
127    /// Run stale entry cleanup
128    pub fn cleanup_stale(&self) -> Result<CleanupStats> {
129        self.cleanup.cleanup_stale_entries()
130    }
131
132    /// Run size-based cleanup
133    pub fn cleanup_by_size(&self) -> Result<CleanupStats> {
134        self.cleanup.cleanup_by_size()
135    }
136
137    /// Get cache statistics
138    pub fn stats(&self) -> Result<CacheStats> {
139        let total_size = self.storage.total_size()?;
140        let entry_count = self.storage.entry_count()?;
141
142        Ok(CacheStats {
143            total_entries: entry_count,
144            total_size_bytes: total_size,
145            total_size_mb: total_size / 1_048_576,
146        })
147    }
148
149    /// Clear all cache entries
150    pub fn clear_all(&self) -> Result<usize> {
151        self.storage.clear_all()
152    }
153
154    /// Get the cache directory path
155    pub fn cache_dir(&self) -> &Path {
156        self.storage.cache_dir()
157    }
158}
159
160/// Cache statistics
161#[derive(Debug, Clone)]
162pub struct CacheStats {
163    pub total_entries: usize,
164    pub total_size_bytes: u64,
165    pub total_size_mb: u64,
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use tempfile::TempDir;
172
173    #[test]
174    fn test_cache_creation() {
175        let temp_dir = TempDir::new().unwrap();
176        let cache = Cache::new(temp_dir.path()).unwrap();
177        assert!(cache.is_enabled());
178    }
179
180    #[test]
181    fn test_cache_get_set() {
182        let temp_dir = TempDir::new().unwrap();
183        let cache = Cache::new(temp_dir.path()).unwrap();
184
185        let ns = "test-ns";
186        let key = "abc123def456";
187        let value = "test value";
188
189        // Cache miss
190        let result = cache.get(ns, key).unwrap();
191        assert!(result.is_none());
192
193        // Set cache
194        cache.set(ns, key, value, 42).unwrap();
195
196        // Cache hit
197        let result = cache.get(ns, key).unwrap();
198        assert_eq!(result, Some(value.to_string()));
199    }
200
201    #[test]
202    fn test_cache_disabled() {
203        let temp_dir = TempDir::new().unwrap();
204        let mut cache = Cache::new(temp_dir.path()).unwrap();
205
206        cache.disable();
207        assert!(!cache.is_enabled());
208
209        // Set should be no-op
210        cache.set("ns", "key123", "value", 5).unwrap();
211
212        // Get should return None
213        let result = cache.get("ns", "key123").unwrap();
214        assert!(result.is_none());
215
216        // Re-enable
217        cache.enable();
218        assert!(cache.is_enabled());
219    }
220
221    #[test]
222    fn test_cache_stats() {
223        let temp_dir = TempDir::new().unwrap();
224        let cache = Cache::new(temp_dir.path()).unwrap();
225
226        cache.set("ns", "key1abc", "value1", 10).unwrap();
227        cache.set("ns", "key2def", "value2", 10).unwrap();
228
229        let stats = cache.stats().unwrap();
230        assert_eq!(stats.total_entries, 2);
231        assert!(stats.total_size_bytes > 0);
232    }
233
234    #[test]
235    fn test_cache_clear_all() {
236        let temp_dir = TempDir::new().unwrap();
237        let cache = Cache::new(temp_dir.path()).unwrap();
238
239        cache.set("ns", "key1abc", "value1", 10).unwrap();
240        cache.set("ns", "key2def", "value2", 10).unwrap();
241
242        let stats = cache.stats().unwrap();
243        assert_eq!(stats.total_entries, 2);
244
245        let removed = cache.clear_all().unwrap();
246        assert_eq!(removed, 2);
247
248        let stats = cache.stats().unwrap();
249        assert_eq!(stats.total_entries, 0);
250    }
251
252    #[test]
253    fn test_stats_mb_calculation() {
254        let temp_dir = TempDir::new().unwrap();
255        let cache = Cache::new(temp_dir.path()).unwrap();
256
257        let stats = cache.stats().unwrap();
258        assert_eq!(stats.total_size_bytes, 0);
259        assert_eq!(stats.total_size_mb, 0);
260
261        cache.set("ns", "key1abc", &"x".repeat(1000), 1000).unwrap();
262        let stats = cache.stats().unwrap();
263        assert!(stats.total_size_bytes > 0);
264        assert_eq!(stats.total_size_mb, stats.total_size_bytes / 1_048_576);
265    }
266
267    #[test]
268    fn test_should_cleanup_periodic_delegates() {
269        let temp_dir = TempDir::new().unwrap();
270        let cache = Cache::new(temp_dir.path()).unwrap();
271        let result = cache.should_cleanup_periodic().unwrap();
272        assert!(result);
273    }
274
275    #[test]
276    fn test_should_cleanup_size_delegates() {
277        let temp_dir = TempDir::new().unwrap();
278        let cache = Cache::new(temp_dir.path()).unwrap();
279        let result = cache.should_cleanup_size().unwrap();
280        assert!(!result);
281    }
282
283    #[test]
284    fn test_cleanup_stale_delegates() {
285        let temp_dir = TempDir::new().unwrap();
286        let cache = Cache::new(temp_dir.path()).unwrap();
287        let stats = cache.cleanup_stale().unwrap();
288        assert_eq!(stats.removed_count, 0);
289        assert_eq!(stats.freed_bytes, 0);
290    }
291
292    #[test]
293    fn test_cleanup_by_size_delegates() {
294        let temp_dir = TempDir::new().unwrap();
295        let cache = Cache::new(temp_dir.path()).unwrap();
296        let stats = cache.cleanup_by_size().unwrap();
297        assert_eq!(stats.removed_count, 0);
298        assert_eq!(stats.freed_bytes, 0);
299    }
300
301    #[test]
302    fn test_stats_mb_is_division_not_modulo() {
303        let temp_dir = TempDir::new().unwrap();
304        let cache = Cache::new(temp_dir.path()).unwrap();
305
306        cache.set("ns", "key1abc", &"x".repeat(1000), 1000).unwrap();
307        let stats = cache.stats().unwrap();
308
309        assert_eq!(stats.total_size_mb, 0);
310        assert!(stats.total_size_bytes > 0);
311        assert!(stats.total_size_mb < stats.total_size_bytes);
312    }
313
314    #[test]
315    fn test_with_cleanup_config() {
316        let temp_dir = TempDir::new().unwrap();
317        let policy = CleanupPolicy {
318            max_cache_size_mb: 100,
319            max_age_days: 30,
320            max_idle_days: 10,
321            remove_version_mismatch: true,
322        };
323        let trigger = CleanupTrigger::Manual;
324
325        let cache = Cache::with_cleanup_config(temp_dir.path(), policy, trigger).unwrap();
326        assert!(cache.is_enabled());
327        assert!(!cache.should_cleanup_periodic().unwrap());
328    }
329
330    #[test]
331    fn test_should_cleanup_size_with_data() {
332        let temp_dir = TempDir::new().unwrap();
333        let policy = CleanupPolicy::default();
334        let trigger = CleanupTrigger::OnSizeLimit { threshold_mb: 0 };
335
336        let cache = Cache::with_cleanup_config(temp_dir.path(), policy, trigger).unwrap();
337        cache.set("ns", "key1abc", "value", 5).unwrap();
338
339        assert!(cache.should_cleanup_size().unwrap());
340    }
341
342    #[test]
343    fn test_cleanup_stale_with_stale_data() {
344        let temp_dir = TempDir::new().unwrap();
345        let policy = CleanupPolicy {
346            max_cache_size_mb: 500,
347            max_age_days: 90,
348            max_idle_days: 30,
349            remove_version_mismatch: true,
350        };
351        let trigger = CleanupTrigger::Manual;
352
353        let cache = Cache::with_cleanup_config(temp_dir.path(), policy, trigger).unwrap();
354
355        let entry = CacheEntry::new(
356            "0.0.1".to_string(),
357            "ns".to_string(),
358            "stalekey".to_string(),
359            "resp".to_string(),
360            10,
361        );
362        let dir = temp_dir.path().join("ns").join("st");
363        std::fs::create_dir_all(&dir).unwrap();
364        std::fs::write(
365            dir.join("stalekey.json"),
366            serde_json::to_string(&entry).unwrap(),
367        )
368        .unwrap();
369
370        let stats = cache.cleanup_stale().unwrap();
371        assert_eq!(stats.removed_count, 1);
372        assert!(stats.freed_bytes > 0);
373    }
374
375    #[test]
376    fn test_cleanup_by_size_with_data() {
377        let temp_dir = TempDir::new().unwrap();
378        let policy = CleanupPolicy {
379            max_cache_size_mb: 0,
380            max_age_days: 90,
381            max_idle_days: 30,
382            remove_version_mismatch: true,
383        };
384        let trigger = CleanupTrigger::Manual;
385
386        let cache = Cache::with_cleanup_config(temp_dir.path(), policy, trigger).unwrap();
387
388        let entry = CacheEntry::new(
389            CACHE_VERSION.to_string(),
390            "ns".to_string(),
391            "sizekey".to_string(),
392            "resp".to_string(),
393            10,
394        );
395        let dir = temp_dir.path().join("ns").join("si");
396        std::fs::create_dir_all(&dir).unwrap();
397        std::fs::write(
398            dir.join("sizekey.json"),
399            serde_json::to_string(&entry).unwrap(),
400        )
401        .unwrap();
402
403        let stats = cache.cleanup_by_size().unwrap();
404        assert_eq!(stats.removed_count, 1);
405        assert!(stats.freed_bytes > 0);
406    }
407}