clnrm_core/cache/
file_cache.rs

1//! File-based cache implementation with persistent storage
2//!
3//! Implements the Cache trait with JSON file persistence.
4//! Thread-safe with Arc<Mutex<>> for concurrent access.
5
6use super::cache_trait::{Cache, CacheStats};
7use super::hash;
8use crate::error::{CleanroomError, Result};
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::fs;
13use std::path::{Path, PathBuf};
14use std::sync::{Arc, Mutex};
15use tracing::{debug, info, warn};
16
17/// Cache format version for invalidation when structure changes
18const CACHE_VERSION: &str = "1.0.0";
19
20/// Default cache directory under user home
21fn default_cache_dir() -> Result<PathBuf> {
22    let home = std::env::var("HOME")
23        .or_else(|_| std::env::var("USERPROFILE"))
24        .map_err(|_| CleanroomError::configuration_error("Cannot determine home directory"))?;
25
26    Ok(PathBuf::from(home).join(".clnrm").join("cache"))
27}
28
29/// Cache file structure
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct CacheFile {
32    /// Cache format version
33    pub version: String,
34    /// File path to hash mapping
35    pub hashes: HashMap<String, String>,
36    /// Last update timestamp
37    pub last_updated: DateTime<Utc>,
38}
39
40impl CacheFile {
41    /// Create a new empty cache file
42    pub fn new() -> Self {
43        Self {
44            version: CACHE_VERSION.to_string(),
45            hashes: HashMap::new(),
46            last_updated: Utc::now(),
47        }
48    }
49
50    /// Check if cache file version is compatible
51    pub fn is_compatible(&self) -> bool {
52        self.version == CACHE_VERSION
53    }
54}
55
56impl Default for CacheFile {
57    fn default() -> Self {
58        Self::new()
59    }
60}
61
62/// File-based cache manager for test result caching
63///
64/// London School TDD Design:
65/// - Implements Cache trait for collaboration contract
66/// - Thread-safe with Arc<Mutex<>> for concurrent access
67/// - Proper error handling with Result<T, CleanroomError>
68/// - No unwrap() or expect() calls
69///
70/// # Example
71/// ```no_run
72/// use clnrm_core::cache::{FileCache, Cache};
73/// use std::path::Path;
74///
75/// # fn main() -> clnrm_core::Result<()> {
76/// let cache = FileCache::new()?;
77/// let file_path = Path::new("tests/api.clnrm.toml");
78/// let content = "rendered content";
79///
80/// if cache.has_changed(file_path, content)? {
81///     // Run test
82///     cache.update(file_path, content)?;
83///     cache.save()?;
84/// }
85/// # Ok(())
86/// # }
87/// ```
88#[derive(Debug, Clone)]
89pub struct FileCache {
90    /// Path to cache file
91    cache_path: PathBuf,
92    /// In-memory cache data (thread-safe)
93    cache: Arc<Mutex<CacheFile>>,
94}
95
96impl FileCache {
97    /// Create a new cache manager with default cache directory
98    pub fn new() -> Result<Self> {
99        let cache_dir = default_cache_dir()?;
100        let cache_path = cache_dir.join("hashes.json");
101        Self::with_path(cache_path)
102    }
103
104    /// Create a cache manager with custom cache file path
105    pub fn with_path(cache_path: PathBuf) -> Result<Self> {
106        // Ensure cache directory exists
107        if let Some(parent) = cache_path.parent() {
108            if !parent.exists() {
109                fs::create_dir_all(parent).map_err(|e| {
110                    CleanroomError::io_error(format!(
111                        "Failed to create cache directory '{}': {}",
112                        parent.display(),
113                        e
114                    ))
115                })?;
116                info!("Created cache directory: {}", parent.display());
117            }
118        }
119
120        // Load existing cache or create new one
121        let cache = if cache_path.exists() {
122            match Self::load_cache_file(&cache_path) {
123                Ok(mut cache_file) => {
124                    // Validate cache version
125                    if !cache_file.is_compatible() {
126                        warn!(
127                            "Cache version mismatch (expected {}, got {}). Creating new cache.",
128                            CACHE_VERSION, cache_file.version
129                        );
130                        cache_file = CacheFile::new();
131                    }
132                    cache_file
133                }
134                Err(e) => {
135                    warn!("Failed to load cache file: {}. Creating new cache.", e);
136                    CacheFile::new()
137                }
138            }
139        } else {
140            debug!("Cache file not found. Creating new cache.");
141            CacheFile::new()
142        };
143
144        Ok(Self {
145            cache_path,
146            cache: Arc::new(Mutex::new(cache)),
147        })
148    }
149
150    /// Load cache file from disk
151    fn load_cache_file(path: &Path) -> Result<CacheFile> {
152        let content = fs::read_to_string(path).map_err(|e| {
153            CleanroomError::io_error(format!(
154                "Failed to read cache file '{}': {}",
155                path.display(),
156                e
157            ))
158        })?;
159
160        serde_json::from_str(&content).map_err(|e| {
161            CleanroomError::serialization_error(format!(
162                "Failed to parse cache file '{}': {}",
163                path.display(),
164                e
165            ))
166        })
167    }
168
169    /// Get the cache file path
170    pub fn cache_path(&self) -> &Path {
171        &self.cache_path
172    }
173}
174
175impl Cache for FileCache {
176    fn has_changed(&self, file_path: &Path, rendered_content: &str) -> Result<bool> {
177        let file_key = file_path
178            .to_str()
179            .ok_or_else(|| CleanroomError::validation_error("Invalid file path encoding"))?
180            .to_string();
181
182        // Calculate current hash
183        let current_hash = hash::hash_content(rendered_content)?;
184
185        // Check against cached hash
186        let cache = self.cache.lock().map_err(|e| {
187            CleanroomError::internal_error(format!("Failed to acquire cache lock: {}", e))
188        })?;
189
190        match cache.hashes.get(&file_key) {
191            Some(cached_hash) if cached_hash == &current_hash => {
192                debug!("Cache hit: {} (unchanged)", file_key);
193                Ok(false)
194            }
195            Some(_) => {
196                debug!("Cache miss: {} (changed)", file_key);
197                Ok(true)
198            }
199            None => {
200                debug!("Cache miss: {} (new file)", file_key);
201                Ok(true)
202            }
203        }
204    }
205
206    fn update(&self, file_path: &Path, rendered_content: &str) -> Result<()> {
207        let file_key = file_path
208            .to_str()
209            .ok_or_else(|| CleanroomError::validation_error("Invalid file path encoding"))?
210            .to_string();
211
212        let hash = hash::hash_content(rendered_content)?;
213
214        let mut cache = self.cache.lock().map_err(|e| {
215            CleanroomError::internal_error(format!("Failed to acquire cache lock: {}", e))
216        })?;
217
218        cache.hashes.insert(file_key.clone(), hash);
219        debug!("Cache updated: {}", file_key);
220
221        Ok(())
222    }
223
224    fn remove(&self, file_path: &Path) -> Result<()> {
225        let file_key = file_path
226            .to_str()
227            .ok_or_else(|| CleanroomError::validation_error("Invalid file path encoding"))?
228            .to_string();
229
230        let mut cache = self.cache.lock().map_err(|e| {
231            CleanroomError::internal_error(format!("Failed to acquire cache lock: {}", e))
232        })?;
233
234        if cache.hashes.remove(&file_key).is_some() {
235            debug!("Removed from cache: {}", file_key);
236        }
237
238        Ok(())
239    }
240
241    fn save(&self) -> Result<()> {
242        let cache = self.cache.lock().map_err(|e| {
243            CleanroomError::internal_error(format!("Failed to acquire cache lock: {}", e))
244        })?;
245
246        // Update timestamp
247        let mut cache_to_save = cache.clone();
248        cache_to_save.last_updated = Utc::now();
249
250        let content = serde_json::to_string_pretty(&cache_to_save).map_err(|e| {
251            CleanroomError::serialization_error(format!("Failed to serialize cache: {}", e))
252        })?;
253
254        fs::write(&self.cache_path, content).map_err(|e| {
255            CleanroomError::io_error(format!(
256                "Failed to write cache file '{}': {}",
257                self.cache_path.display(),
258                e
259            ))
260        })?;
261
262        debug!("Cache saved to: {}", self.cache_path.display());
263        Ok(())
264    }
265
266    fn stats(&self) -> Result<CacheStats> {
267        let cache = self.cache.lock().map_err(|e| {
268            CleanroomError::internal_error(format!("Failed to acquire cache lock: {}", e))
269        })?;
270
271        Ok(CacheStats {
272            total_files: cache.hashes.len(),
273            last_updated: cache.last_updated,
274            cache_path: Some(self.cache_path.clone()),
275        })
276    }
277
278    fn clear(&self) -> Result<()> {
279        let mut cache = self.cache.lock().map_err(|e| {
280            CleanroomError::internal_error(format!("Failed to acquire cache lock: {}", e))
281        })?;
282
283        let count = cache.hashes.len();
284        cache.hashes.clear();
285        cache.last_updated = Utc::now();
286
287        info!("Cleared {} entries from cache", count);
288        Ok(())
289    }
290}
291
292// Note: Default implementation removed to avoid panic risk.
293// FileCache creation is fallible and MUST return Result.
294// Use FileCache::new() or FileCache::with_path() instead.
295//
296// Reasoning:
297// - Cache creation can fail due to filesystem permissions
298// - Default trait cannot return Result, forcing unwrap/panic
299// - Core team standard: No unwrap/expect in production code
300// - Explicit Result handling provides better error messages
301
302#[cfg(test)]
303mod tests {
304    #![allow(
305        clippy::unwrap_used,
306        clippy::expect_used,
307        clippy::indexing_slicing,
308        clippy::panic
309    )]
310
311    use super::*;
312    use tempfile::TempDir;
313
314    #[test]
315    fn test_cache_file_creation() -> Result<()> {
316        // Arrange & Act
317        let cache = CacheFile::new();
318
319        // Assert
320        assert_eq!(cache.version, CACHE_VERSION);
321        assert!(cache.hashes.is_empty());
322        Ok(())
323    }
324
325    #[test]
326    fn test_file_cache_implements_trait() -> Result<()> {
327        // Arrange
328        let temp_dir = TempDir::new().map_err(|e| {
329            CleanroomError::internal_error(format!("Failed to create temp dir: {}", e))
330        })?;
331        let cache_path = temp_dir.path().join("cache.json");
332
333        // Act - create as trait object
334        let cache: Box<dyn Cache> = Box::new(FileCache::with_path(cache_path)?);
335        let test_path = PathBuf::from("/test/file.toml");
336        let content = "test content";
337
338        // Assert - can use through trait interface
339        let changed = cache.has_changed(&test_path, content)?;
340        assert!(changed);
341
342        Ok(())
343    }
344
345    #[test]
346    fn test_file_cache_has_changed_new_file() -> Result<()> {
347        // Arrange
348        let temp_dir = TempDir::new().map_err(|e| {
349            CleanroomError::internal_error(format!("Failed to create temp dir: {}", e))
350        })?;
351        let cache_path = temp_dir.path().join("cache.json");
352        let cache = FileCache::with_path(cache_path)?;
353
354        let test_path = PathBuf::from("/test/file.toml");
355        let content = "test content";
356
357        // Act
358        let changed = cache.has_changed(&test_path, content)?;
359
360        // Assert
361        assert!(changed, "New file should be marked as changed");
362
363        Ok(())
364    }
365
366    #[test]
367    fn test_file_cache_update_and_check() -> Result<()> {
368        // Arrange
369        let temp_dir = TempDir::new().map_err(|e| {
370            CleanroomError::internal_error(format!("Failed to create temp dir: {}", e))
371        })?;
372        let cache_path = temp_dir.path().join("cache.json");
373        let cache = FileCache::with_path(cache_path)?;
374
375        let test_path = PathBuf::from("/test/file.toml");
376        let content = "test content";
377
378        // Act
379        cache.update(&test_path, content)?;
380        let changed = cache.has_changed(&test_path, content)?;
381
382        // Assert - verify interaction pattern
383        assert!(!changed, "Unchanged file should not be marked as changed");
384
385        Ok(())
386    }
387
388    #[test]
389    fn test_file_cache_persistence() -> Result<()> {
390        // Arrange
391        let temp_dir = TempDir::new().map_err(|e| {
392            CleanroomError::internal_error(format!("Failed to create temp dir: {}", e))
393        })?;
394        let cache_path = temp_dir.path().join("cache.json");
395
396        let test_path = PathBuf::from("/test/file.toml");
397        let content = "test content";
398
399        // Act - create cache, update, save
400        {
401            let cache = FileCache::with_path(cache_path.clone())?;
402            cache.update(&test_path, content)?;
403            cache.save()?;
404        }
405
406        // Assert - load in new instance and verify
407        let cache = FileCache::with_path(cache_path)?;
408        let changed = cache.has_changed(&test_path, content)?;
409        assert!(!changed, "Cache should persist across instances");
410
411        Ok(())
412    }
413
414    #[test]
415    fn test_file_cache_thread_safety() -> Result<()> {
416        use std::thread;
417
418        // Arrange
419        let temp_dir = TempDir::new().map_err(|e| {
420            CleanroomError::internal_error(format!("Failed to create temp dir: {}", e))
421        })?;
422        let cache_path = temp_dir.path().join("cache.json");
423        let cache = FileCache::with_path(cache_path)?;
424
425        // Act - spawn multiple threads updating cache
426        let mut handles = vec![];
427        for i in 0..10 {
428            let cache_clone = cache.clone();
429            let handle = thread::spawn(move || {
430                let path = PathBuf::from(format!("/test/file{}.toml", i));
431                let content = format!("content {}", i);
432                cache_clone.update(&path, &content).unwrap();
433            });
434            handles.push(handle);
435        }
436
437        // Wait for all threads
438        for handle in handles {
439            // Thread panic should not fail the test - threads are updating cache independently
440            let _ = handle.join();
441        }
442
443        // Assert
444        let stats = cache.stats()?;
445        assert_eq!(stats.total_files, 10);
446
447        Ok(())
448    }
449
450    #[test]
451    fn test_file_cache_remove() -> Result<()> {
452        // Arrange
453        let temp_dir = TempDir::new().map_err(|e| {
454            CleanroomError::internal_error(format!("Failed to create temp dir: {}", e))
455        })?;
456        let cache_path = temp_dir.path().join("cache.json");
457        let cache = FileCache::with_path(cache_path)?;
458
459        let test_path = PathBuf::from("/test/file.toml");
460        let content = "test content";
461
462        // Act
463        cache.update(&test_path, content)?;
464        cache.remove(&test_path)?;
465        let changed = cache.has_changed(&test_path, content)?;
466
467        // Assert
468        assert!(changed, "Removed file should be marked as changed");
469
470        Ok(())
471    }
472
473    #[test]
474    fn test_file_cache_clear() -> Result<()> {
475        // Arrange
476        let temp_dir = TempDir::new().map_err(|e| {
477            CleanroomError::internal_error(format!("Failed to create temp dir: {}", e))
478        })?;
479        let cache_path = temp_dir.path().join("cache.json");
480        let cache = FileCache::with_path(cache_path)?;
481
482        cache.update(&PathBuf::from("/test/file1.toml"), "content1")?;
483        cache.update(&PathBuf::from("/test/file2.toml"), "content2")?;
484
485        // Act
486        cache.clear()?;
487        let stats = cache.stats()?;
488
489        // Assert
490        assert_eq!(stats.total_files, 0);
491
492        Ok(())
493    }
494}