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