clnrm_core/cache/
memory_cache.rs

1//! In-memory cache implementation for testing
2//!
3//! Provides a fast, thread-safe cache that doesn't persist to disk.
4//! Ideal for unit tests and development workflows.
5
6use super::cache_trait::{Cache, CacheStats};
7use super::hash;
8use crate::error::{CleanroomError, Result};
9use chrono::Utc;
10use std::collections::HashMap;
11use std::path::Path;
12use std::sync::{Arc, Mutex};
13use tracing::debug;
14
15/// In-memory cache for testing and development
16///
17/// London School TDD Design:
18/// - Implements Cache trait for collaboration contract
19/// - Thread-safe with Arc<Mutex<>> for concurrent access
20/// - No persistence - perfect for testing
21/// - Fast operations without disk I/O
22///
23/// # Example
24/// ```
25/// use clnrm_core::cache::{MemoryCache, Cache};
26/// use std::path::Path;
27///
28/// # fn main() -> clnrm_core::Result<()> {
29/// let cache = MemoryCache::new();
30/// let file_path = Path::new("tests/api.clnrm.toml");
31/// let content = "rendered content";
32///
33/// if cache.has_changed(file_path, content)? {
34///     // Run test
35///     cache.update(file_path, content)?;
36/// }
37/// # Ok(())
38/// # }
39/// ```
40#[derive(Debug, Clone)]
41pub struct MemoryCache {
42    /// In-memory hash storage (thread-safe)
43    hashes: Arc<Mutex<HashMap<String, String>>>,
44}
45
46impl MemoryCache {
47    /// Create a new in-memory cache
48    pub fn new() -> Self {
49        Self {
50            hashes: Arc::new(Mutex::new(HashMap::new())),
51        }
52    }
53
54    /// Get the number of entries in cache (for testing)
55    pub fn len(&self) -> usize {
56        self.hashes.lock().map(|h| h.len()).unwrap_or(0)
57    }
58
59    /// Check if cache is empty (for testing)
60    pub fn is_empty(&self) -> bool {
61        self.len() == 0
62    }
63}
64
65impl Cache for MemoryCache {
66    fn has_changed(&self, file_path: &Path, rendered_content: &str) -> Result<bool> {
67        let file_key = file_path
68            .to_str()
69            .ok_or_else(|| CleanroomError::validation_error("Invalid file path encoding"))?
70            .to_string();
71
72        // Calculate current hash
73        let current_hash = hash::hash_content(rendered_content)?;
74
75        // Check against cached hash
76        let hashes = self.hashes.lock().map_err(|e| {
77            CleanroomError::internal_error(format!("Failed to acquire cache lock: {}", e))
78        })?;
79
80        match hashes.get(&file_key) {
81            Some(cached_hash) if cached_hash == &current_hash => {
82                debug!("Memory cache hit: {} (unchanged)", file_key);
83                Ok(false)
84            }
85            Some(_) => {
86                debug!("Memory cache miss: {} (changed)", file_key);
87                Ok(true)
88            }
89            None => {
90                debug!("Memory cache miss: {} (new file)", file_key);
91                Ok(true)
92            }
93        }
94    }
95
96    fn update(&self, file_path: &Path, rendered_content: &str) -> Result<()> {
97        let file_key = file_path
98            .to_str()
99            .ok_or_else(|| CleanroomError::validation_error("Invalid file path encoding"))?
100            .to_string();
101
102        let hash = hash::hash_content(rendered_content)?;
103
104        let mut hashes = self.hashes.lock().map_err(|e| {
105            CleanroomError::internal_error(format!("Failed to acquire cache lock: {}", e))
106        })?;
107
108        hashes.insert(file_key.clone(), hash);
109        debug!("Memory cache updated: {}", file_key);
110
111        Ok(())
112    }
113
114    fn remove(&self, file_path: &Path) -> Result<()> {
115        let file_key = file_path
116            .to_str()
117            .ok_or_else(|| CleanroomError::validation_error("Invalid file path encoding"))?
118            .to_string();
119
120        let mut hashes = self.hashes.lock().map_err(|e| {
121            CleanroomError::internal_error(format!("Failed to acquire cache lock: {}", e))
122        })?;
123
124        if hashes.remove(&file_key).is_some() {
125            debug!("Removed from memory cache: {}", file_key);
126        }
127
128        Ok(())
129    }
130
131    fn save(&self) -> Result<()> {
132        // No-op for memory cache
133        Ok(())
134    }
135
136    fn stats(&self) -> Result<CacheStats> {
137        let hashes = self.hashes.lock().map_err(|e| {
138            CleanroomError::internal_error(format!("Failed to acquire cache lock: {}", e))
139        })?;
140
141        Ok(CacheStats {
142            total_files: hashes.len(),
143            last_updated: Utc::now(),
144            cache_path: None,
145        })
146    }
147
148    fn clear(&self) -> Result<()> {
149        let mut hashes = self.hashes.lock().map_err(|e| {
150            CleanroomError::internal_error(format!("Failed to acquire cache lock: {}", e))
151        })?;
152
153        hashes.clear();
154        debug!("Memory cache cleared");
155
156        Ok(())
157    }
158}
159
160impl Default for MemoryCache {
161    fn default() -> Self {
162        Self::new()
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    #![allow(
169        clippy::unwrap_used,
170        clippy::expect_used,
171        clippy::indexing_slicing,
172        clippy::panic
173    )]
174
175    use super::*;
176    use std::path::PathBuf;
177
178    #[test]
179    fn test_memory_cache_implements_trait() -> Result<()> {
180        // Arrange & Act - create as trait object
181        let cache: Box<dyn Cache> = Box::new(MemoryCache::new());
182        let test_path = PathBuf::from("/test/file.toml");
183        let content = "test content";
184
185        // Assert - can use through trait interface
186        let changed = cache.has_changed(&test_path, content)?;
187        assert!(changed);
188
189        Ok(())
190    }
191
192    #[test]
193    fn test_memory_cache_has_changed_new_file() -> Result<()> {
194        // Arrange
195        let cache = MemoryCache::new();
196        let test_path = PathBuf::from("/test/file.toml");
197        let content = "test content";
198
199        // Act
200        let changed = cache.has_changed(&test_path, content)?;
201
202        // Assert
203        assert!(changed, "New file should be marked as changed");
204
205        Ok(())
206    }
207
208    #[test]
209    fn test_memory_cache_update_and_check() -> Result<()> {
210        // Arrange
211        let cache = MemoryCache::new();
212        let test_path = PathBuf::from("/test/file.toml");
213        let content = "test content";
214
215        // Act
216        cache.update(&test_path, content)?;
217        let changed = cache.has_changed(&test_path, content)?;
218
219        // Assert - verify interaction pattern
220        assert!(!changed, "Unchanged file should not be marked as changed");
221
222        Ok(())
223    }
224
225    #[test]
226    fn test_memory_cache_detects_changes() -> Result<()> {
227        // Arrange
228        let cache = MemoryCache::new();
229        let test_path = PathBuf::from("/test/file.toml");
230        let content1 = "test content 1";
231        let content2 = "test content 2";
232
233        // Act
234        cache.update(&test_path, content1)?;
235        let changed = cache.has_changed(&test_path, content2)?;
236
237        // Assert
238        assert!(changed, "Changed file should be marked as changed");
239
240        Ok(())
241    }
242
243    #[test]
244    fn test_memory_cache_thread_safety() -> Result<()> {
245        use std::thread;
246
247        // Arrange
248        let cache = MemoryCache::new();
249
250        // Act - spawn multiple threads updating cache
251        let mut handles = vec![];
252        for i in 0..10 {
253            let cache_clone = cache.clone();
254            let handle = thread::spawn(move || {
255                let path = PathBuf::from(format!("/test/file{}.toml", i));
256                let content = format!("content {}", i);
257                cache_clone.update(&path, &content).unwrap();
258            });
259            handles.push(handle);
260        }
261
262        // Wait for all threads
263        for handle in handles {
264            // Thread panic should not fail the test - threads are updating cache independently
265            let _ = handle.join();
266        }
267
268        // Assert
269        let stats = cache.stats()?;
270        assert_eq!(stats.total_files, 10);
271
272        Ok(())
273    }
274
275    #[test]
276    fn test_memory_cache_remove() -> Result<()> {
277        // Arrange
278        let cache = MemoryCache::new();
279        let test_path = PathBuf::from("/test/file.toml");
280        let content = "test content";
281
282        // Act
283        cache.update(&test_path, content)?;
284        cache.remove(&test_path)?;
285        let changed = cache.has_changed(&test_path, content)?;
286
287        // Assert
288        assert!(changed, "Removed file should be marked as changed");
289
290        Ok(())
291    }
292
293    #[test]
294    fn test_memory_cache_clear() -> Result<()> {
295        // Arrange
296        let cache = MemoryCache::new();
297        cache.update(&PathBuf::from("/test/file1.toml"), "content1")?;
298        cache.update(&PathBuf::from("/test/file2.toml"), "content2")?;
299
300        // Act
301        cache.clear()?;
302        let stats = cache.stats()?;
303
304        // Assert
305        assert_eq!(stats.total_files, 0);
306        assert!(cache.is_empty());
307
308        Ok(())
309    }
310
311    #[test]
312    fn test_memory_cache_save_noop() -> Result<()> {
313        // Arrange
314        let cache = MemoryCache::new();
315
316        // Act & Assert - save should succeed but do nothing
317        cache.save()?;
318
319        Ok(())
320    }
321
322    #[test]
323    fn test_memory_cache_stats_no_path() -> Result<()> {
324        // Arrange
325        let cache = MemoryCache::new();
326        cache.update(&PathBuf::from("/test/file.toml"), "content")?;
327
328        // Act
329        let stats = cache.stats()?;
330
331        // Assert
332        assert_eq!(stats.total_files, 1);
333        assert!(stats.cache_path.is_none());
334
335        Ok(())
336    }
337
338    #[test]
339    fn test_memory_cache_collaboration_workflow() -> Result<()> {
340        // Arrange
341        let cache = MemoryCache::new();
342        let file1 = PathBuf::from("/test/file1.toml");
343        let file2 = PathBuf::from("/test/file2.toml");
344        let content = "shared content";
345
346        // Act - simulate typical test runner workflow
347        // First run: both files are new
348        assert!(cache.has_changed(&file1, content)?);
349        assert!(cache.has_changed(&file2, content)?);
350
351        cache.update(&file1, content)?;
352        cache.update(&file2, content)?;
353
354        // Second run: both files unchanged
355        assert!(!cache.has_changed(&file1, content)?);
356        assert!(!cache.has_changed(&file2, content)?);
357
358        // Third run: file1 changes
359        let new_content = "new content";
360        assert!(cache.has_changed(&file1, new_content)?);
361        assert!(!cache.has_changed(&file2, content)?);
362
363        // Assert final state
364        let stats = cache.stats()?;
365        assert_eq!(stats.total_files, 2);
366
367        Ok(())
368    }
369}