ricecoder_images/
cache.rs

1//! Image analysis caching with LRU eviction and TTL.
2
3use crate::error::{ImageError, ImageResult};
4use crate::models::ImageAnalysisResult;
5use ricecoder_storage::cache::{CacheInvalidationStrategy, CacheManager};
6use sha2::{Digest, Sha256};
7use std::path::PathBuf;
8use tracing::{debug, warn};
9
10/// Caches image analysis results with TTL and LRU eviction.
11///
12/// Wraps ricecoder-storage's CacheManager to provide image-specific caching
13/// with SHA256-based cache keys, TTL support, and LRU eviction.
14pub struct ImageCache {
15    /// Underlying cache manager
16    cache_manager: CacheManager,
17    /// TTL in seconds (24 hours by default)
18    ttl_seconds: u64,
19    /// Maximum cache size in MB (100 MB by default)
20    max_size_mb: u64,
21}
22
23impl ImageCache {
24    /// Create a new image cache with default settings.
25    ///
26    /// Uses default cache paths:
27    /// - User cache: `~/.ricecoder/cache/images/`
28    /// - Project cache: `projects/ricecoder/.agent/cache/images/`
29    ///
30    /// # Errors
31    ///
32    /// Returns error if cache directory cannot be created
33    pub fn new() -> ImageResult<Self> {
34        Self::with_config(86400, 100) // 24 hours, 100 MB
35    }
36
37    /// Create a new image cache with custom settings.
38    ///
39    /// # Arguments
40    ///
41    /// * `ttl_seconds` - Time-to-live for cache entries in seconds
42    /// * `max_size_mb` - Maximum cache size in MB
43    ///
44    /// # Errors
45    ///
46    /// Returns error if cache directory cannot be created
47    pub fn with_config(ttl_seconds: u64, max_size_mb: u64) -> ImageResult<Self> {
48        // Try to use project cache first, fall back to user cache
49        let cache_dir = if let Ok(project_cache) = std::env::current_dir() {
50            project_cache
51                .join(".agent")
52                .join("cache")
53                .join("images")
54        } else {
55            // Fall back to user cache
56            if let Ok(home) = std::env::var("HOME") {
57                PathBuf::from(home)
58                    .join(".ricecoder")
59                    .join("cache")
60                    .join("images")
61            } else {
62                return Err(ImageError::CacheError(
63                    "Cannot determine cache directory: HOME not set".to_string(),
64                ));
65            }
66        };
67
68        let cache_manager = CacheManager::new(&cache_dir)
69            .map_err(|e| ImageError::CacheError(format!("Failed to create cache manager: {}", e)))?;
70
71        debug!(
72            "Created image cache at: {} (TTL: {}s, Max: {}MB)",
73            cache_dir.display(),
74            ttl_seconds,
75            max_size_mb
76        );
77
78        Ok(Self {
79            cache_manager,
80            ttl_seconds,
81            max_size_mb,
82        })
83    }
84
85    /// Get a cached analysis result by image hash.
86    ///
87    /// # Arguments
88    ///
89    /// * `image_hash` - SHA256 hash of the image
90    ///
91    /// # Returns
92    ///
93    /// Returns the cached analysis result if found and not expired, None if not found or expired
94    pub fn get(&self, image_hash: &str) -> ImageResult<Option<ImageAnalysisResult>> {
95        let cache_key = self.hash_to_cache_key(image_hash);
96
97        match self.cache_manager.get(&cache_key) {
98            Ok(Some(json)) => {
99                match serde_json::from_str::<ImageAnalysisResult>(&json) {
100                    Ok(result) => {
101                        debug!("Cache hit for image: {}", image_hash);
102                        Ok(Some(result))
103                    }
104                    Err(e) => {
105                        warn!("Failed to deserialize cached analysis: {}", e);
106                        // Try to invalidate corrupted entry
107                        let _ = self.cache_manager.invalidate(&cache_key);
108                        Ok(None)
109                    }
110                }
111            }
112            Ok(None) => {
113                debug!("Cache miss for image: {}", image_hash);
114                Ok(None)
115            }
116            Err(e) => {
117                warn!("Cache lookup failed: {}", e);
118                Ok(None) // Graceful degradation: treat cache errors as misses
119            }
120        }
121    }
122
123    /// Cache an analysis result.
124    ///
125    /// # Arguments
126    ///
127    /// * `image_hash` - SHA256 hash of the image
128    /// * `result` - Analysis result to cache
129    ///
130    /// # Errors
131    ///
132    /// Returns error if cache operation fails
133    pub fn set(&self, image_hash: &str, result: &ImageAnalysisResult) -> ImageResult<()> {
134        let cache_key = self.hash_to_cache_key(image_hash);
135
136        let json = serde_json::to_string(result)
137            .map_err(|e| ImageError::CacheError(format!("Failed to serialize analysis: {}", e)))?;
138
139        self.cache_manager
140            .set(
141                &cache_key,
142                json,
143                CacheInvalidationStrategy::Ttl(self.ttl_seconds),
144            )
145            .map_err(|e| ImageError::CacheError(format!("Failed to cache analysis: {}", e)))?;
146
147        debug!("Cached analysis for image: {}", image_hash);
148        Ok(())
149    }
150
151    /// Check if an image analysis is cached and not expired.
152    ///
153    /// # Arguments
154    ///
155    /// * `image_hash` - SHA256 hash of the image
156    pub fn exists(&self, image_hash: &str) -> ImageResult<bool> {
157        let cache_key = self.hash_to_cache_key(image_hash);
158
159        self.cache_manager
160            .exists(&cache_key)
161            .map_err(|e| ImageError::CacheError(format!("Failed to check cache: {}", e)))
162    }
163
164    /// Invalidate a cached analysis result.
165    ///
166    /// # Arguments
167    ///
168    /// * `image_hash` - SHA256 hash of the image
169    ///
170    /// # Returns
171    ///
172    /// Returns Ok(true) if entry was deleted, Ok(false) if entry didn't exist
173    pub fn invalidate(&self, image_hash: &str) -> ImageResult<bool> {
174        let cache_key = self.hash_to_cache_key(image_hash);
175
176        self.cache_manager
177            .invalidate(&cache_key)
178            .map_err(|e| ImageError::CacheError(format!("Failed to invalidate cache: {}", e)))
179    }
180
181    /// Clear all cached analysis results.
182    ///
183    /// # Errors
184    ///
185    /// Returns error if cache cannot be cleared
186    pub fn clear(&self) -> ImageResult<()> {
187        self.cache_manager
188            .clear()
189            .map_err(|e| ImageError::CacheError(format!("Failed to clear cache: {}", e)))?;
190
191        debug!("Cleared all cached analyses");
192        Ok(())
193    }
194
195    /// Clean up expired cache entries.
196    ///
197    /// # Returns
198    ///
199    /// Returns the number of entries cleaned up
200    pub fn cleanup_expired(&self) -> ImageResult<usize> {
201        let cleaned = self
202            .cache_manager
203            .cleanup_expired()
204            .map_err(|e| ImageError::CacheError(format!("Failed to cleanup cache: {}", e)))?;
205
206        debug!("Cleaned up {} expired cache entries", cleaned);
207        Ok(cleaned)
208    }
209
210    /// Compute SHA256 hash of image data.
211    ///
212    /// # Arguments
213    ///
214    /// * `data` - Image file data
215    ///
216    /// # Returns
217    ///
218    /// SHA256 hash as hex string
219    pub fn compute_hash(data: &[u8]) -> String {
220        let mut hasher = Sha256::new();
221        hasher.update(data);
222        format!("{:x}", hasher.finalize())
223    }
224
225    /// Convert image hash to cache key.
226    fn hash_to_cache_key(&self, image_hash: &str) -> String {
227        format!("image_{}", image_hash)
228    }
229
230    /// Get cache statistics.
231    ///
232    /// # Returns
233    ///
234    /// Tuple of (ttl_seconds, max_size_mb)
235    pub fn stats(&self) -> (u64, u64) {
236        (self.ttl_seconds, self.max_size_mb)
237    }
238}
239
240impl Default for ImageCache {
241    fn default() -> Self {
242        Self::new().expect("Failed to create default image cache")
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use crate::models::ImageAnalysisResult;
250
251    #[test]
252    fn test_cache_creation() {
253        let cache = ImageCache::new();
254        assert!(cache.is_ok());
255    }
256
257    #[test]
258    fn test_cache_with_config() {
259        let cache = ImageCache::with_config(3600, 50);
260        assert!(cache.is_ok());
261
262        let cache = cache.unwrap();
263        let (ttl, max_size) = cache.stats();
264        assert_eq!(ttl, 3600);
265        assert_eq!(max_size, 50);
266    }
267
268    #[test]
269    fn test_compute_hash() {
270        let data = b"test image data";
271        let hash1 = ImageCache::compute_hash(data);
272        let hash2 = ImageCache::compute_hash(data);
273
274        // Same data should produce same hash
275        assert_eq!(hash1, hash2);
276
277        // Different data should produce different hash
278        let different_data = b"different data";
279        let hash3 = ImageCache::compute_hash(different_data);
280        assert_ne!(hash1, hash3);
281    }
282
283    #[test]
284    fn test_hash_to_cache_key() {
285        let cache = ImageCache::new().unwrap();
286        let key = cache.hash_to_cache_key("abc123");
287        assert_eq!(key, "image_abc123");
288    }
289
290    #[test]
291    fn test_cache_set_and_get() {
292        let cache = ImageCache::new().unwrap();
293        let unique_hash = format!("hash123_test_{}", std::time::SystemTime::now()
294            .duration_since(std::time::UNIX_EPOCH)
295            .unwrap()
296            .as_nanos());
297        let result = ImageAnalysisResult::new(
298            unique_hash.clone(),
299            "This is a test image".to_string(),
300            "openai".to_string(),
301            100,
302        );
303
304        // Set cache
305        let set_result = cache.set(&unique_hash, &result);
306        assert!(set_result.is_ok());
307
308        // Get cache
309        let get_result = cache.get(&unique_hash);
310        assert!(get_result.is_ok());
311
312        if let Ok(Some(cached)) = get_result {
313            assert_eq!(cached.image_hash, unique_hash);
314            assert_eq!(cached.analysis, "This is a test image");
315            assert_eq!(cached.provider, "openai");
316            assert_eq!(cached.tokens_used, 100);
317        } else {
318            panic!("Expected cached result");
319        }
320    }
321
322    #[test]
323    fn test_cache_miss() {
324        let cache = ImageCache::new().unwrap();
325        let unique_hash = format!("nonexistent_{}", std::time::SystemTime::now()
326            .duration_since(std::time::UNIX_EPOCH)
327            .unwrap()
328            .as_nanos());
329        let result = cache.get(&unique_hash);
330        assert!(result.is_ok());
331        assert!(result.unwrap().is_none());
332    }
333
334    #[test]
335    fn test_cache_exists() {
336        let cache = ImageCache::new().unwrap();
337        let unique_hash = format!("hash_exists_{}", std::time::SystemTime::now()
338            .duration_since(std::time::UNIX_EPOCH)
339            .unwrap()
340            .as_nanos());
341        let result = ImageAnalysisResult::new(
342            unique_hash.clone(),
343            "Analysis".to_string(),
344            "openai".to_string(),
345            100,
346        );
347
348        cache.set(&unique_hash, &result).unwrap();
349
350        let exists = cache.exists(&unique_hash);
351        assert!(exists.is_ok());
352        assert!(exists.unwrap());
353
354        let not_exists = cache.exists("nonexistent_hash_that_never_existed");
355        assert!(not_exists.is_ok());
356        assert!(!not_exists.unwrap());
357    }
358
359    #[test]
360    fn test_cache_invalidate() {
361        let cache = ImageCache::new().unwrap();
362        let unique_hash = format!("hash_invalidate_{}", std::time::SystemTime::now()
363            .duration_since(std::time::UNIX_EPOCH)
364            .unwrap()
365            .as_nanos());
366        let result = ImageAnalysisResult::new(
367            unique_hash.clone(),
368            "Analysis".to_string(),
369            "openai".to_string(),
370            100,
371        );
372
373        cache.set(&unique_hash, &result).unwrap();
374        assert!(cache.exists(&unique_hash).unwrap());
375
376        let invalidated = cache.invalidate(&unique_hash);
377        assert!(invalidated.is_ok());
378        assert!(invalidated.unwrap());
379
380        assert!(!cache.exists(&unique_hash).unwrap());
381    }
382
383    #[test]
384    fn test_cache_clear() {
385        // Use a unique cache directory for this test to avoid conflicts
386        let test_id = std::time::SystemTime::now()
387            .duration_since(std::time::UNIX_EPOCH)
388            .unwrap()
389            .as_nanos();
390        
391        // Create a temporary cache directory
392        let temp_dir = std::env::temp_dir().join(format!("ricecoder_cache_test_{}", test_id));
393        let _ = std::fs::create_dir_all(&temp_dir);
394        
395        let cache_manager = ricecoder_storage::cache::CacheManager::new(&temp_dir)
396            .expect("Failed to create test cache manager");
397        
398        let cache = ImageCache {
399            cache_manager,
400            ttl_seconds: 86400,
401            max_size_mb: 100,
402        };
403        
404        let unique_hash = format!("hash_clear_{}", test_id);
405        let result = ImageAnalysisResult::new(
406            unique_hash.clone(),
407            "Analysis".to_string(),
408            "openai".to_string(),
409            100,
410        );
411
412        cache.set(&unique_hash, &result).unwrap();
413        assert!(cache.exists(&unique_hash).unwrap());
414
415        let clear_result = cache.clear();
416        assert!(clear_result.is_ok());
417
418        assert!(!cache.exists(&unique_hash).unwrap());
419        
420        // Clean up
421        let _ = std::fs::remove_dir_all(&temp_dir);
422    }
423}