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