ricecoder_storage/cache/
manager.rs

1//! Cache manager implementation for RiceCoder storage
2//!
3//! Provides file-based cache storage with TTL and manual invalidation strategies.
4//! Adapted from automation/src/infrastructure/cache/cache_manager.rs
5
6use crate::error::{IoOperation, StorageError, StorageResult};
7use serde::{Deserialize, Serialize};
8use std::fs;
9use std::path::{Path, PathBuf};
10use std::time::{SystemTime, UNIX_EPOCH};
11use tracing::{debug, warn};
12
13/// Cache invalidation strategy
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15pub enum CacheInvalidationStrategy {
16    /// Time-to-live: cache expires after specified duration (in seconds)
17    #[serde(rename = "ttl")]
18    Ttl(u64),
19    /// Manual: cache must be explicitly invalidated
20    #[serde(rename = "manual")]
21    Manual,
22}
23
24/// Cache entry with metadata
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct CacheEntry {
27    /// Cached data
28    pub data: String,
29    /// Timestamp when entry was created
30    pub created_at: u64,
31    /// Invalidation strategy
32    pub strategy: CacheInvalidationStrategy,
33}
34
35impl CacheEntry {
36    /// Create a new cache entry
37    pub fn new(data: String, strategy: CacheInvalidationStrategy) -> Self {
38        let created_at = SystemTime::now()
39            .duration_since(UNIX_EPOCH)
40            .unwrap_or_default()
41            .as_secs();
42
43        Self {
44            data,
45            created_at,
46            strategy,
47        }
48    }
49
50    /// Check if the entry has expired
51    pub fn is_expired(&self) -> bool {
52        match self.strategy {
53            CacheInvalidationStrategy::Ttl(ttl_secs) => {
54                let now = SystemTime::now()
55                    .duration_since(UNIX_EPOCH)
56                    .unwrap_or_default()
57                    .as_secs();
58                now > self.created_at + ttl_secs
59            }
60            CacheInvalidationStrategy::Manual => false,
61        }
62    }
63}
64
65/// File-based cache manager
66///
67/// Stores cache entries as JSON files in a cache directory.
68/// Supports TTL and manual invalidation strategies.
69pub struct CacheManager {
70    /// Cache directory path
71    cache_dir: PathBuf,
72}
73
74impl CacheManager {
75    /// Create a new cache manager
76    ///
77    /// # Arguments
78    ///
79    /// * `cache_dir` - Directory to store cache files
80    ///
81    /// # Errors
82    ///
83    /// Returns error if cache directory cannot be created
84    pub fn new(cache_dir: impl AsRef<Path>) -> StorageResult<Self> {
85        let cache_dir = cache_dir.as_ref().to_path_buf();
86
87        // Create cache directory if it doesn't exist
88        if !cache_dir.exists() {
89            fs::create_dir_all(&cache_dir)
90                .map_err(|e| StorageError::directory_creation_failed(cache_dir.clone(), e))?;
91            debug!("Created cache directory: {}", cache_dir.display());
92        }
93
94        Ok(Self { cache_dir })
95    }
96
97    /// Get a cached value
98    ///
99    /// # Arguments
100    ///
101    /// * `key` - Cache key
102    ///
103    /// # Returns
104    ///
105    /// Returns the cached data if found and not expired, None if not found or expired
106    pub fn get(&self, key: &str) -> StorageResult<Option<String>> {
107        let path = self.key_to_path(key);
108
109        if !path.exists() {
110            debug!("Cache miss for key: {}", key);
111            return Ok(None);
112        }
113
114        let content = fs::read_to_string(&path)
115            .map_err(|e| StorageError::io_error(path.clone(), IoOperation::Read, e))?;
116
117        let entry: CacheEntry = serde_json::from_str(&content).map_err(|e| {
118            StorageError::parse_error(
119                path.clone(),
120                "JSON",
121                format!("Failed to deserialize cache entry: {}", e),
122            )
123        })?;
124
125        if entry.is_expired() {
126            debug!("Cache expired for key: {}", key);
127            // Delete expired entry
128            let _ = fs::remove_file(&path);
129            return Ok(None);
130        }
131
132        debug!("Cache hit for key: {}", key);
133        Ok(Some(entry.data))
134    }
135
136    /// Set a cached value
137    ///
138    /// # Arguments
139    ///
140    /// * `key` - Cache key
141    /// * `data` - Data to cache
142    /// * `strategy` - Invalidation strategy
143    ///
144    /// # Errors
145    ///
146    /// Returns error if cache entry cannot be written
147    pub fn set(
148        &self,
149        key: &str,
150        data: String,
151        strategy: CacheInvalidationStrategy,
152    ) -> StorageResult<()> {
153        let path = self.key_to_path(key);
154
155        // Create parent directory if needed
156        if let Some(parent) = path.parent() {
157            if !parent.exists() {
158                fs::create_dir_all(parent).map_err(|e| {
159                    StorageError::directory_creation_failed(parent.to_path_buf(), e)
160                })?;
161            }
162        }
163
164        let created_at = SystemTime::now()
165            .duration_since(UNIX_EPOCH)
166            .unwrap_or_default()
167            .as_secs();
168
169        let entry = CacheEntry {
170            data,
171            created_at,
172            strategy,
173        };
174
175        let json = serde_json::to_string_pretty(&entry).map_err(|e| {
176            StorageError::parse_error(
177                path.clone(),
178                "JSON",
179                format!("Failed to serialize cache entry: {}", e),
180            )
181        })?;
182
183        fs::write(&path, json)
184            .map_err(|e| StorageError::io_error(path.clone(), IoOperation::Write, e))?;
185
186        debug!("Cached value for key: {}", key);
187        Ok(())
188    }
189
190    /// Invalidate a cached value
191    ///
192    /// # Arguments
193    ///
194    /// * `key` - Cache key to invalidate
195    ///
196    /// # Returns
197    ///
198    /// Returns Ok(true) if entry was deleted, Ok(false) if entry didn't exist
199    pub fn invalidate(&self, key: &str) -> StorageResult<bool> {
200        let path = self.key_to_path(key);
201
202        if !path.exists() {
203            debug!("Cache entry not found for invalidation: {}", key);
204            return Ok(false);
205        }
206
207        fs::remove_file(&path)
208            .map_err(|e| StorageError::io_error(path.clone(), IoOperation::Delete, e))?;
209
210        debug!("Invalidated cache for key: {}", key);
211        Ok(true)
212    }
213
214    /// Check if a key exists in cache and is not expired
215    ///
216    /// # Arguments
217    ///
218    /// * `key` - Cache key
219    pub fn exists(&self, key: &str) -> StorageResult<bool> {
220        let path = self.key_to_path(key);
221
222        if !path.exists() {
223            return Ok(false);
224        }
225
226        let content = fs::read_to_string(&path)
227            .map_err(|e| StorageError::io_error(path.clone(), IoOperation::Read, e))?;
228
229        let entry: CacheEntry = serde_json::from_str(&content).map_err(|e| {
230            StorageError::parse_error(
231                path.clone(),
232                "JSON",
233                format!("Failed to deserialize cache entry: {}", e),
234            )
235        })?;
236
237        Ok(!entry.is_expired())
238    }
239
240    /// Clear all cache entries
241    ///
242    /// # Errors
243    ///
244    /// Returns error if cache directory cannot be cleared
245    pub fn clear(&self) -> StorageResult<()> {
246        if !self.cache_dir.exists() {
247            return Ok(());
248        }
249
250        fs::remove_dir_all(&self.cache_dir)
251            .map_err(|e| StorageError::io_error(self.cache_dir.clone(), IoOperation::Delete, e))?;
252
253        fs::create_dir_all(&self.cache_dir)
254            .map_err(|e| StorageError::directory_creation_failed(self.cache_dir.clone(), e))?;
255
256        debug!("Cleared all cache entries");
257        Ok(())
258    }
259
260    /// Clean up expired entries
261    ///
262    /// Scans the cache directory and removes all expired entries.
263    ///
264    /// # Returns
265    ///
266    /// Returns the number of entries cleaned up
267    pub fn cleanup_expired(&self) -> StorageResult<usize> {
268        if !self.cache_dir.exists() {
269            return Ok(0);
270        }
271
272        let mut cleaned = 0;
273
274        for entry in fs::read_dir(&self.cache_dir)
275            .map_err(|e| StorageError::io_error(self.cache_dir.clone(), IoOperation::Read, e))?
276        {
277            let entry = entry.map_err(|e| {
278                StorageError::io_error(self.cache_dir.clone(), IoOperation::Read, e)
279            })?;
280
281            let path = entry.path();
282
283            if path.is_file() {
284                if let Ok(content) = fs::read_to_string(&path) {
285                    if let Ok(cache_entry) = serde_json::from_str::<CacheEntry>(&content) {
286                        if cache_entry.is_expired() {
287                            if let Err(e) = fs::remove_file(&path) {
288                                warn!("Failed to remove expired cache entry: {}", e);
289                            } else {
290                                cleaned += 1;
291                                debug!("Cleaned up expired cache entry: {}", path.display());
292                            }
293                        }
294                    }
295                }
296            }
297        }
298
299        debug!("Cleaned up {} expired cache entries", cleaned);
300        Ok(cleaned)
301    }
302
303    /// Convert a cache key to a file path
304    fn key_to_path(&self, key: &str) -> PathBuf {
305        // Sanitize key to create valid filename
306        let sanitized = key
307            .chars()
308            .map(|c| {
309                if c.is_alphanumeric() || c == '_' || c == '-' {
310                    c
311                } else {
312                    '_'
313                }
314            })
315            .collect::<String>();
316
317        self.cache_dir.join(format!("{}.json", sanitized))
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use std::time::Duration;
325    use tempfile::TempDir;
326
327    #[test]
328    fn test_cache_set_and_get() -> StorageResult<()> {
329        let temp_dir = TempDir::new().unwrap();
330        let cache = CacheManager::new(temp_dir.path())?;
331
332        cache.set(
333            "test_key",
334            "test_data".to_string(),
335            CacheInvalidationStrategy::Manual,
336        )?;
337
338        let result = cache.get("test_key")?;
339        assert_eq!(result, Some("test_data".to_string()));
340
341        Ok(())
342    }
343
344    #[test]
345    fn test_cache_not_found() -> StorageResult<()> {
346        let temp_dir = TempDir::new().unwrap();
347        let cache = CacheManager::new(temp_dir.path())?;
348
349        let result = cache.get("nonexistent")?;
350        assert_eq!(result, None);
351
352        Ok(())
353    }
354
355    #[test]
356    fn test_cache_invalidate() -> StorageResult<()> {
357        let temp_dir = TempDir::new().unwrap();
358        let cache = CacheManager::new(temp_dir.path())?;
359
360        cache.set(
361            "test_key",
362            "test_data".to_string(),
363            CacheInvalidationStrategy::Manual,
364        )?;
365
366        let invalidated = cache.invalidate("test_key")?;
367        assert!(invalidated);
368
369        let result = cache.get("test_key")?;
370        assert_eq!(result, None);
371
372        Ok(())
373    }
374
375    #[test]
376    fn test_cache_exists() -> StorageResult<()> {
377        let temp_dir = TempDir::new().unwrap();
378        let cache = CacheManager::new(temp_dir.path())?;
379
380        cache.set(
381            "test_key",
382            "test_data".to_string(),
383            CacheInvalidationStrategy::Manual,
384        )?;
385
386        assert!(cache.exists("test_key")?);
387        assert!(!cache.exists("nonexistent")?);
388
389        Ok(())
390    }
391
392    #[test]
393    fn test_cache_clear() -> StorageResult<()> {
394        let temp_dir = TempDir::new().unwrap();
395        let cache = CacheManager::new(temp_dir.path())?;
396
397        cache.set(
398            "key1",
399            "data1".to_string(),
400            CacheInvalidationStrategy::Manual,
401        )?;
402        cache.set(
403            "key2",
404            "data2".to_string(),
405            CacheInvalidationStrategy::Manual,
406        )?;
407
408        cache.clear()?;
409
410        assert!(!cache.exists("key1")?);
411        assert!(!cache.exists("key2")?);
412
413        Ok(())
414    }
415
416    #[test]
417    fn test_cache_ttl_expiration() -> StorageResult<()> {
418        let temp_dir = TempDir::new().unwrap();
419        let cache = CacheManager::new(temp_dir.path())?;
420
421        // Set cache with very short TTL (1 second)
422        cache.set(
423            "test_key",
424            "test_data".to_string(),
425            CacheInvalidationStrategy::Ttl(1),
426        )?;
427
428        // Should exist immediately
429        assert!(cache.exists("test_key")?);
430
431        // Wait for expiration
432        std::thread::sleep(Duration::from_secs(2));
433
434        // Should be expired now
435        let result = cache.get("test_key")?;
436        assert_eq!(result, None);
437
438        Ok(())
439    }
440
441    #[test]
442    fn test_cache_cleanup_expired() -> StorageResult<()> {
443        let temp_dir = TempDir::new().unwrap();
444        let cache = CacheManager::new(temp_dir.path())?;
445
446        // Set cache with short TTL (1 second)
447        cache.set(
448            "expired_key",
449            "data".to_string(),
450            CacheInvalidationStrategy::Ttl(1),
451        )?;
452
453        // Set cache with manual invalidation (won't expire)
454        cache.set(
455            "manual_key",
456            "data".to_string(),
457            CacheInvalidationStrategy::Manual,
458        )?;
459
460        // Wait for first entry to expire
461        std::thread::sleep(Duration::from_secs(2));
462
463        // Cleanup should remove only expired entries
464        let cleaned = cache.cleanup_expired()?;
465        assert_eq!(cleaned, 1);
466
467        // Manual entry should still exist
468        assert!(cache.exists("manual_key")?);
469
470        Ok(())
471    }
472}