Skip to main content

mermaid_cli/cache/
file_cache.rs

1use anyhow::Result;
2use sha2::{Digest, Sha256};
3use std::fs::{self, File};
4use std::io::{BufReader, Read};
5use std::path::Path;
6use std::time::SystemTime;
7
8use super::types::{CacheEntry, CacheKey, CacheMetadata};
9
10/// File-level cache operations
11#[derive(Debug)]
12pub struct FileCache {
13    cache_dir: std::path::PathBuf,
14}
15
16impl FileCache {
17    /// Create a new file cache
18    pub fn new(cache_dir: std::path::PathBuf) -> Result<Self> {
19        // Ensure cache directory exists
20        fs::create_dir_all(&cache_dir)?;
21        Ok(Self { cache_dir })
22    }
23
24    /// Compute SHA256 hash of a file using buffered streaming
25    /// Uses 64KB chunks to avoid loading entire file into memory
26    pub fn hash_file(path: &Path) -> Result<String> {
27        let file = File::open(path)?;
28        let mut reader = BufReader::with_capacity(65536, file); // 64KB buffer
29        let mut hasher = Sha256::new();
30        let mut buffer = [0u8; 65536];
31
32        loop {
33            let bytes_read = reader.read(&mut buffer)?;
34            if bytes_read == 0 {
35                break;
36            }
37            hasher.update(&buffer[..bytes_read]);
38        }
39
40        let result = hasher.finalize();
41        Ok(format!("{:x}", result))
42    }
43
44    /// Generate cache key for a file
45    pub fn generate_key(path: &Path) -> Result<CacheKey> {
46        let file_hash = Self::hash_file(path)?;
47        Ok(CacheKey {
48            file_path: path.to_path_buf(),
49            file_hash,
50        })
51    }
52
53    /// Save data to cache with compression
54    pub fn save<T>(&self, key: &CacheKey, data: &T) -> Result<()>
55    where
56        T: serde::Serialize,
57    {
58        // Serialize data
59        let serialized = bincode::serialize(data)?;
60        let original_size = serialized.len();
61
62        // Compress data
63        let compressed = lz4::block::compress(&serialized, None, true)?;
64        let compressed_size = compressed.len();
65
66        // Create metadata
67        let metadata = CacheMetadata {
68            created_at: SystemTime::now(),
69            last_accessed: SystemTime::now(),
70            file_size: original_size as u64,
71            compressed_size,
72            compression_ratio: original_size as f32 / compressed_size as f32,
73        };
74
75        // Create cache entry
76        let entry = CacheEntry {
77            key: key.clone(),
78            data: compressed,
79            metadata,
80        };
81
82        // Generate cache file path
83        let cache_path = self.cache_path(key);
84
85        // Ensure parent directory exists
86        if let Some(parent) = cache_path.parent() {
87            fs::create_dir_all(parent)?;
88        }
89
90        // Write to file
91        let entry_data = bincode::serialize(&entry)?;
92        fs::write(cache_path, entry_data)?;
93
94        Ok(())
95    }
96
97    /// Load data from cache
98    pub fn load<T>(&self, key: &CacheKey) -> Result<Option<T>>
99    where
100        T: serde::de::DeserializeOwned,
101    {
102        let cache_path = self.cache_path(key);
103
104        // Check if cache file exists
105        if !cache_path.exists() {
106            return Ok(None);
107        }
108
109        // Read cache entry
110        let entry_data = fs::read(&cache_path)?;
111        let mut entry: CacheEntry<Vec<u8>> = bincode::deserialize(&entry_data)?;
112
113        // Update last accessed time
114        entry.metadata.last_accessed = SystemTime::now();
115
116        // Decompress data
117        let decompressed =
118            lz4::block::decompress(&entry.data, Some(entry.metadata.file_size as i32))?;
119
120        // Deserialize data
121        let data: T = bincode::deserialize(&decompressed)?;
122
123        Ok(Some(data))
124    }
125
126    /// Check if cache entry is valid (file hasn't changed)
127    pub fn is_valid(&self, key: &CacheKey) -> Result<bool> {
128        // Check if file still exists
129        if !key.file_path.exists() {
130            return Ok(false);
131        }
132
133        // Check if hash matches
134        let current_hash = Self::hash_file(&key.file_path)?;
135        Ok(current_hash == key.file_hash)
136    }
137
138    /// Remove cache entry
139    pub fn remove(&self, key: &CacheKey) -> Result<()> {
140        let cache_path = self.cache_path(key);
141        if cache_path.exists() {
142            fs::remove_file(cache_path)?;
143        }
144        Ok(())
145    }
146
147    /// Generate cache file path for a key
148    fn cache_path(&self, key: &CacheKey) -> std::path::PathBuf {
149        // Use first 2 chars of hash for directory sharding
150        let hash_prefix = &key.file_hash[..2];
151        let cache_name = format!(
152            "{}_{}.cache",
153            key.file_path
154                .file_name()
155                .and_then(|n| n.to_str())
156                .unwrap_or("unknown"),
157            &key.file_hash[..8]
158        );
159
160        self.cache_dir.join(hash_prefix).join(cache_name)
161    }
162
163    /// Get cache statistics
164    pub fn get_stats(&self) -> Result<CacheStats> {
165        let mut total_entries = 0;
166        let mut total_size = 0;
167        let mut total_compressed_size = 0;
168
169        // Walk cache directory
170        for entry in fs::read_dir(&self.cache_dir)? {
171            let entry = entry?;
172            if entry.path().is_dir() {
173                for cache_file in fs::read_dir(entry.path())? {
174                    let cache_file = cache_file?;
175                    let metadata = cache_file.metadata()?;
176                    total_entries += 1;
177                    total_compressed_size += metadata.len() as usize;
178                    // Estimate original size (we'd need to read entries for exact)
179                    total_size += (metadata.len() as f32 * 3.0) as usize;
180                }
181            }
182        }
183
184        Ok(CacheStats {
185            total_entries,
186            total_size,
187            total_compressed_size,
188            compression_ratio: if total_compressed_size > 0 {
189                total_size as f32 / total_compressed_size as f32
190            } else {
191                1.0
192            },
193            cache_dir: self.cache_dir.clone(),
194        })
195    }
196}
197
198/// Cache statistics
199#[derive(Debug, Clone)]
200pub struct CacheStats {
201    pub total_entries: usize,
202    pub total_size: usize,
203    pub total_compressed_size: usize,
204    pub compression_ratio: f32,
205    pub cache_dir: std::path::PathBuf,
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    // Phase 4 Test Suite: FileCache - core file cache operations
213
214    #[test]
215    fn test_cache_key_structure() {
216        // Test CacheKey components
217        let path = Path::new("src/main.rs");
218        let file_hash = "abc123def456".to_string();
219
220        let key = CacheKey {
221            file_path: path.to_path_buf(),
222            file_hash: file_hash.clone(),
223        };
224
225        assert_eq!(key.file_hash, "abc123def456");
226        assert_eq!(key.file_path.file_name().unwrap(), "main.rs");
227    }
228
229    #[test]
230    fn test_cache_metadata_structure() {
231        // Test CacheMetadata creation and structure
232        let now = SystemTime::now();
233
234        let metadata = CacheMetadata {
235            created_at: now,
236            last_accessed: now,
237            file_size: 1024,
238            compressed_size: 512,
239            compression_ratio: 2.0,
240        };
241
242        assert_eq!(metadata.file_size, 1024);
243        assert_eq!(metadata.compressed_size, 512);
244        assert_eq!(metadata.compression_ratio, 2.0);
245    }
246
247    #[test]
248    fn test_cache_entry_structure() {
249        // Test CacheEntry structure
250        let key = CacheKey {
251            file_path: Path::new("test.rs").to_path_buf(),
252            file_hash: "test_hash".to_string(),
253        };
254
255        let metadata = CacheMetadata {
256            created_at: SystemTime::now(),
257            last_accessed: SystemTime::now(),
258            file_size: 100,
259            compressed_size: 50,
260            compression_ratio: 2.0,
261        };
262
263        let entry = CacheEntry {
264            key: key.clone(),
265            data: vec![1, 2, 3, 4, 5],
266            metadata,
267        };
268
269        assert_eq!(entry.data.len(), 5);
270        assert_eq!(entry.key.file_hash, "test_hash");
271    }
272
273    #[test]
274    fn test_cache_stats_structure() {
275        // Test CacheStats structure and values
276        let stats = CacheStats {
277            total_entries: 100,
278            total_size: 1_000_000,
279            total_compressed_size: 500_000,
280            compression_ratio: 2.0,
281            cache_dir: Path::new("/cache").to_path_buf(),
282        };
283
284        assert_eq!(stats.total_entries, 100);
285        assert_eq!(stats.total_size, 1_000_000);
286        assert_eq!(stats.compression_ratio, 2.0);
287    }
288
289    #[test]
290    fn test_compression_ratio_calculation() {
291        // Test compression ratio calculation logic
292        let test_cases = vec![
293            (1000, 500, 2.0),
294            (2000, 1000, 2.0),
295            (3000, 1000, 3.0),
296            (1000, 250, 4.0),
297        ];
298
299        for (original, compressed, expected) in test_cases {
300            let ratio = original as f32 / compressed as f32;
301            assert!((ratio - expected).abs() < 0.01);
302        }
303    }
304
305    #[test]
306    fn test_cache_path_construction() {
307        // Test cache path construction with hash prefixing
308        let hash = "abc123def456";
309        let prefix = &hash[..2]; // "ab"
310
311        assert_eq!(prefix, "ab", "Prefix should be first 2 chars of hash");
312    }
313
314    #[test]
315    fn test_cache_file_naming() {
316        // Test cache file naming convention
317        let file_name = "main.rs";
318        let hash_short = "abc12345";
319
320        let cache_name = format!("{}_{}.cache", file_name, hash_short);
321
322        assert!(
323            cache_name.contains("main.rs"),
324            "Should include original filename"
325        );
326        assert!(cache_name.contains("abc12345"), "Should include hash short");
327        assert!(cache_name.ends_with(".cache"), "Should end with .cache");
328    }
329
330    #[test]
331    fn test_cache_stats_compression_ratio_zero_handling() {
332        // Test compression ratio when compressed size is zero
333        let total_size = 1000;
334        let compressed_size = 0;
335
336        let ratio = if compressed_size > 0 {
337            total_size as f32 / compressed_size as f32
338        } else {
339            1.0 // Default when no compression
340        };
341
342        assert_eq!(
343            ratio, 1.0,
344            "Should default to 1.0 when compressed size is 0"
345        );
346    }
347}