1use 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
17const CACHE_VERSION: &str = "1.0.0";
19
20fn 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#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct CacheFile {
32 pub version: String,
34 pub hashes: HashMap<String, String>,
36 pub last_updated: DateTime<Utc>,
38}
39
40impl CacheFile {
41 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 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#[derive(Debug, Clone)]
89pub struct FileCache {
90 cache_path: PathBuf,
92 cache: Arc<Mutex<CacheFile>>,
94}
95
96impl FileCache {
97 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 pub fn with_path(cache_path: PathBuf) -> Result<Self> {
106 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 let cache = if cache_path.exists() {
122 match Self::load_cache_file(&cache_path) {
123 Ok(mut cache_file) => {
124 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 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 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 let current_hash = hash::hash_content(rendered_content)?;
184
185 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 == ¤t_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 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#[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 let cache = CacheFile::new();
318
319 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 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 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 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 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 let changed = cache.has_changed(&test_path, content)?;
359
360 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 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 cache.update(&test_path, content)?;
380 let changed = cache.has_changed(&test_path, content)?;
381
382 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 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 {
401 let cache = FileCache::with_path(cache_path.clone())?;
402 cache.update(&test_path, content)?;
403 cache.save()?;
404 }
405
406 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 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 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 for handle in handles {
439 let _ = handle.join();
441 }
442
443 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 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 cache.update(&test_path, content)?;
464 cache.remove(&test_path)?;
465 let changed = cache.has_changed(&test_path, content)?;
466
467 assert!(changed, "Removed file should be marked as changed");
469
470 Ok(())
471 }
472
473 #[test]
474 fn test_file_cache_clear() -> Result<()> {
475 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 cache.clear()?;
487 let stats = cache.stats()?;
488
489 assert_eq!(stats.total_files, 0);
491
492 Ok(())
493 }
494}