Skip to main content

parsentry_cache/
storage.rs

1//! Namespace-based file storage with content-addressable paths
2
3use anyhow::{Context, Result};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use crate::entry::CacheEntry;
8
9/// Cache storage manager
10///
11/// Path structure: `cache_dir/namespace/prefix/hash.json`
12pub struct CacheStorage {
13    /// Root cache directory
14    cache_dir: PathBuf,
15}
16
17impl CacheStorage {
18    /// Create a new cache storage with the given directory
19    pub fn new<P: AsRef<Path>>(cache_dir: P) -> Result<Self> {
20        let cache_dir = cache_dir.as_ref().to_path_buf();
21
22        // Use symlink_metadata to detect broken symlinks (exists() follows symlinks and returns false for broken ones)
23        if cache_dir.symlink_metadata().is_ok() && !cache_dir.is_dir() {
24            fs::remove_file(&cache_dir).with_context(|| {
25                format!(
26                    "Failed to remove invalid cache path: {}",
27                    cache_dir.display()
28                )
29            })?;
30        }
31        if !cache_dir.exists() {
32            fs::create_dir_all(&cache_dir).with_context(|| {
33                format!("Failed to create cache directory: {}", cache_dir.display())
34            })?;
35        }
36
37        Ok(Self { cache_dir })
38    }
39
40    /// Get the cache file path for a given namespace and key.
41    ///
42    /// Uses first 2 characters of key as subdirectory for distribution.
43    /// Example: key=abc123... -> cache_dir/namespace/ab/abc123....json
44    fn get_cache_path(&self, namespace: &str, key: &str) -> PathBuf {
45        let prefix = if key.len() >= 2 { &key[..2] } else { key };
46
47        self.cache_dir
48            .join(namespace)
49            .join(prefix)
50            .join(format!("{}.json", key))
51    }
52
53    /// Check if a cache entry exists
54    pub fn exists(&self, namespace: &str, key: &str) -> bool {
55        self.get_cache_path(namespace, key).exists()
56    }
57
58    /// Get a cache entry by namespace and key
59    pub fn get(&self, namespace: &str, key: &str) -> Result<Option<CacheEntry>> {
60        let path = self.get_cache_path(namespace, key);
61
62        if !path.exists() {
63            return Ok(None);
64        }
65
66        let content = fs::read_to_string(&path)
67            .with_context(|| format!("Failed to read cache file: {}", path.display()))?;
68
69        let mut entry: CacheEntry = serde_json::from_str(&content)
70            .with_context(|| format!("Failed to parse cache entry: {}", path.display()))?;
71
72        entry.record_access();
73
74        let updated_content = serde_json::to_string_pretty(&entry)?;
75        fs::write(&path, updated_content)
76            .with_context(|| format!("Failed to update cache metadata: {}", path.display()))?;
77
78        Ok(Some(entry))
79    }
80
81    /// Set a cache entry
82    pub fn set(&self, entry: &CacheEntry) -> Result<()> {
83        let path = self.get_cache_path(&entry.namespace, &entry.key);
84
85        if let Some(parent) = path.parent() {
86            fs::create_dir_all(parent).with_context(|| {
87                format!("Failed to create cache subdirectory: {}", parent.display())
88            })?;
89        }
90
91        let content =
92            serde_json::to_string_pretty(entry).context("Failed to serialize cache entry")?;
93
94        fs::write(&path, content)
95            .with_context(|| format!("Failed to write cache file: {}", path.display()))?;
96
97        log::debug!("Cache entry saved: {}", path.display());
98
99        Ok(())
100    }
101
102    /// Delete a cache entry
103    pub fn delete(&self, namespace: &str, key: &str) -> Result<()> {
104        let path = self.get_cache_path(namespace, key);
105
106        if path.exists() {
107            fs::remove_file(&path)
108                .with_context(|| format!("Failed to delete cache file: {}", path.display()))?;
109            log::debug!("Cache entry deleted: {}", path.display());
110        }
111
112        Ok(())
113    }
114
115    /// Get the cache directory path
116    pub fn cache_dir(&self) -> &Path {
117        &self.cache_dir
118    }
119
120    /// Calculate total cache size in bytes
121    pub fn total_size(&self) -> Result<u64> {
122        let mut total = 0u64;
123
124        for entry in walkdir::WalkDir::new(&self.cache_dir)
125            .into_iter()
126            .filter_map(|e| e.ok())
127        {
128            if entry.file_type().is_file() {
129                if let Ok(metadata) = entry.metadata() {
130                    total += metadata.len();
131                }
132            }
133        }
134
135        Ok(total)
136    }
137
138    /// Count total cache entries
139    pub fn entry_count(&self) -> Result<usize> {
140        let mut count = 0;
141
142        for entry in walkdir::WalkDir::new(&self.cache_dir)
143            .into_iter()
144            .filter_map(|e| e.ok())
145        {
146            if entry.file_type().is_file()
147                && entry.path().extension().map_or(false, |ext| ext == "json")
148            {
149                count += 1;
150            }
151        }
152
153        Ok(count)
154    }
155
156    /// Clear all cache entries
157    pub fn clear_all(&self) -> Result<usize> {
158        let mut removed = 0;
159
160        for entry in walkdir::WalkDir::new(&self.cache_dir)
161            .into_iter()
162            .filter_map(|e| e.ok())
163        {
164            if entry.file_type().is_file()
165                && entry.path().extension().map_or(false, |ext| ext == "json")
166            {
167                fs::remove_file(entry.path())?;
168                removed += 1;
169            }
170        }
171
172        Ok(removed)
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use crate::entry::CacheEntry;
180    use tempfile::TempDir;
181
182    #[test]
183    fn test_storage_creation() {
184        let temp_dir = TempDir::new().unwrap();
185        let storage = CacheStorage::new(temp_dir.path()).unwrap();
186
187        assert!(storage.cache_dir().exists());
188    }
189
190    #[test]
191    fn test_set_and_get() {
192        let temp_dir = TempDir::new().unwrap();
193        let storage = CacheStorage::new(temp_dir.path()).unwrap();
194
195        let entry = CacheEntry::new(
196            "1.0.0".to_string(),
197            "my-ns".to_string(),
198            "abc123".to_string(),
199            "test value".to_string(),
200            100,
201        );
202
203        storage.set(&entry).unwrap();
204
205        let retrieved = storage.get("my-ns", "abc123").unwrap();
206        assert!(retrieved.is_some());
207
208        let retrieved_entry = retrieved.unwrap();
209        assert_eq!(retrieved_entry.value, "test value");
210        assert_eq!(retrieved_entry.metadata.access_count, 1);
211    }
212
213    #[test]
214    fn test_exists() {
215        let temp_dir = TempDir::new().unwrap();
216        let storage = CacheStorage::new(temp_dir.path()).unwrap();
217
218        assert!(!storage.exists("ns", "nonexistent"));
219
220        let entry = CacheEntry::new(
221            "1.0.0".to_string(),
222            "ns".to_string(),
223            "abc123".to_string(),
224            "test".to_string(),
225            10,
226        );
227
228        storage.set(&entry).unwrap();
229
230        assert!(storage.exists("ns", "abc123"));
231    }
232
233    #[test]
234    fn test_delete() {
235        let temp_dir = TempDir::new().unwrap();
236        let storage = CacheStorage::new(temp_dir.path()).unwrap();
237
238        let entry = CacheEntry::new(
239            "1.0.0".to_string(),
240            "ns".to_string(),
241            "abc123".to_string(),
242            "test".to_string(),
243            10,
244        );
245
246        storage.set(&entry).unwrap();
247        assert!(storage.exists("ns", "abc123"));
248
249        storage.delete("ns", "abc123").unwrap();
250        assert!(!storage.exists("ns", "abc123"));
251    }
252
253    #[test]
254    fn test_total_size() {
255        let temp_dir = TempDir::new().unwrap();
256        let storage = CacheStorage::new(temp_dir.path()).unwrap();
257
258        let initial_size = storage.total_size().unwrap();
259
260        let entry = CacheEntry::new(
261            "1.0.0".to_string(),
262            "ns".to_string(),
263            "abc123".to_string(),
264            "test value".to_string(),
265            100,
266        );
267
268        storage.set(&entry).unwrap();
269
270        let new_size = storage.total_size().unwrap();
271        assert!(new_size > initial_size);
272    }
273
274    #[test]
275    fn test_entry_count() {
276        let temp_dir = TempDir::new().unwrap();
277        let storage = CacheStorage::new(temp_dir.path()).unwrap();
278
279        assert_eq!(storage.entry_count().unwrap(), 0);
280
281        let entry1 = CacheEntry::new(
282            "1.0.0".to_string(),
283            "ns".to_string(),
284            "abc123".to_string(),
285            "test1".to_string(),
286            10,
287        );
288
289        storage.set(&entry1).unwrap();
290        assert_eq!(storage.entry_count().unwrap(), 1);
291
292        let entry2 = CacheEntry::new(
293            "1.0.0".to_string(),
294            "ns".to_string(),
295            "def456".to_string(),
296            "test2".to_string(),
297            10,
298        );
299
300        storage.set(&entry2).unwrap();
301        assert_eq!(storage.entry_count().unwrap(), 2);
302    }
303
304    #[test]
305    fn test_clear_all() {
306        let temp_dir = TempDir::new().unwrap();
307        let storage = CacheStorage::new(temp_dir.path()).unwrap();
308
309        let entry1 = CacheEntry::new(
310            "1.0.0".to_string(),
311            "ns".to_string(),
312            "abc123".to_string(),
313            "test1".to_string(),
314            10,
315        );
316
317        let entry2 = CacheEntry::new(
318            "1.0.0".to_string(),
319            "ns".to_string(),
320            "def456".to_string(),
321            "test2".to_string(),
322            10,
323        );
324
325        storage.set(&entry1).unwrap();
326        storage.set(&entry2).unwrap();
327
328        assert_eq!(storage.entry_count().unwrap(), 2);
329
330        let removed = storage.clear_all().unwrap();
331        assert_eq!(removed, 2);
332        assert_eq!(storage.entry_count().unwrap(), 0);
333    }
334
335    #[test]
336    fn test_get_cache_path_short_key() {
337        let temp_dir = TempDir::new().unwrap();
338        let storage = CacheStorage::new(temp_dir.path()).unwrap();
339
340        let entry = CacheEntry::new(
341            "1.0.0".to_string(),
342            "ns".to_string(),
343            "a".to_string(),
344            "test".to_string(),
345            10,
346        );
347
348        storage.set(&entry).unwrap();
349        assert!(storage.exists("ns", "a"));
350    }
351
352    #[test]
353    fn test_get_cache_path_exact_2_char_key() {
354        let temp_dir = TempDir::new().unwrap();
355        let storage = CacheStorage::new(temp_dir.path()).unwrap();
356
357        let entry = CacheEntry::new(
358            "1.0.0".to_string(),
359            "ns".to_string(),
360            "ab".to_string(),
361            "test".to_string(),
362            10,
363        );
364
365        storage.set(&entry).unwrap();
366        assert!(storage.exists("ns", "ab"));
367    }
368
369    #[test]
370    fn test_entry_count_ignores_non_json_files() {
371        let temp_dir = TempDir::new().unwrap();
372        let storage = CacheStorage::new(temp_dir.path()).unwrap();
373
374        fs::write(temp_dir.path().join("not_a_cache.txt"), "hello").unwrap();
375        assert_eq!(storage.entry_count().unwrap(), 0);
376
377        let entry = CacheEntry::new(
378            "1.0.0".to_string(),
379            "ns".to_string(),
380            "abc123".to_string(),
381            "test".to_string(),
382            10,
383        );
384        storage.set(&entry).unwrap();
385        assert_eq!(storage.entry_count().unwrap(), 1);
386    }
387
388    #[test]
389    fn test_clear_all_ignores_non_json_files() {
390        let temp_dir = TempDir::new().unwrap();
391        let storage = CacheStorage::new(temp_dir.path()).unwrap();
392
393        let txt_path = temp_dir.path().join("not_a_cache.txt");
394        fs::write(&txt_path, "hello").unwrap();
395
396        let entry = CacheEntry::new(
397            "1.0.0".to_string(),
398            "ns".to_string(),
399            "abc123".to_string(),
400            "test".to_string(),
401            10,
402        );
403        storage.set(&entry).unwrap();
404
405        let removed = storage.clear_all().unwrap();
406        assert_eq!(removed, 1);
407        assert!(txt_path.exists());
408    }
409
410    #[test]
411    fn test_clear_all_returns_exact_count() {
412        let temp_dir = TempDir::new().unwrap();
413        let storage = CacheStorage::new(temp_dir.path()).unwrap();
414
415        assert_eq!(storage.clear_all().unwrap(), 0);
416
417        for i in 0..3 {
418            let entry = CacheEntry::new(
419                "1.0.0".to_string(),
420                "ns".to_string(),
421                format!("hash{:03}", i),
422                "test".to_string(),
423                10,
424            );
425            storage.set(&entry).unwrap();
426        }
427
428        assert_eq!(storage.clear_all().unwrap(), 3);
429    }
430}