Skip to main content

rfc/cache/
storage.rs

1use std::collections::HashSet;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result};
6use directories::ProjectDirs;
7
8use crate::cache::CacheMetadata;
9use crate::models::{DocumentType, Format};
10
11/// A cached document with optional metadata
12#[derive(Debug, Clone)]
13pub struct CachedDocument {
14    pub doc_type: DocumentType,
15    pub metadata: Option<CacheMetadata>,
16}
17
18/// Manages local document caching
19pub struct CacheManager {
20    cache_dir: PathBuf,
21}
22
23impl CacheManager {
24    /// Create a new cache manager
25    pub fn new() -> Result<Self> {
26        let cache_dir = Self::default_cache_dir()?;
27        fs::create_dir_all(&cache_dir).context("Failed to create cache directory")?;
28        Ok(Self { cache_dir })
29    }
30
31    /// Create a cache manager with a custom directory
32    pub fn with_dir(cache_dir: PathBuf) -> Result<Self> {
33        fs::create_dir_all(&cache_dir).context("Failed to create cache directory")?;
34        Ok(Self { cache_dir })
35    }
36
37    /// Get the default cache directory
38    pub fn default_cache_dir() -> Result<PathBuf> {
39        if let Some(proj_dirs) = ProjectDirs::from("", "", "rfc") {
40            Ok(proj_dirs.cache_dir().to_path_buf())
41        } else {
42            // Fallback to home directory
43            let home = std::env::var("HOME").context("HOME not set")?;
44            Ok(PathBuf::from(home).join(".cache").join("rfc"))
45        }
46    }
47
48    /// Get cached document content
49    pub fn get_document(&self, doc: &DocumentType, format: Format) -> Option<String> {
50        let path = self.document_path(doc, format);
51        fs::read_to_string(path).ok()
52    }
53
54    /// Store document content in cache
55    pub fn store_document(&self, doc: &DocumentType, format: Format, content: &str) -> Result<()> {
56        let path = self.document_path(doc, format);
57
58        // Ensure parent directory exists
59        if let Some(parent) = path.parent() {
60            fs::create_dir_all(parent).context("Failed to create document cache directory")?;
61        }
62
63        fs::write(&path, content).context("Failed to write document to cache")?;
64        Ok(())
65    }
66
67    /// Clear all cached documents
68    pub fn clear_cache(&self) -> Result<()> {
69        if self.cache_dir.exists() {
70            fs::remove_dir_all(&self.cache_dir).context("Failed to clear cache")?;
71            fs::create_dir_all(&self.cache_dir).context("Failed to recreate cache directory")?;
72        }
73        Ok(())
74    }
75
76    /// Remove a specific document from cache
77    /// Removes document content and associated metadata
78    /// Returns true if the document was found and removed
79    pub fn remove(&self, doc: &DocumentType) -> Result<bool> {
80        let html_path = self.document_path(doc, Format::Html);
81        let text_path = self.document_path(doc, Format::Text);
82        let meta_path = self.metadata_path(doc);
83
84        let mut removed = false;
85
86        if html_path.exists() {
87            fs::remove_file(&html_path).context("Failed to remove cached HTML file")?;
88            removed = true;
89        }
90
91        if text_path.exists() {
92            fs::remove_file(&text_path).context("Failed to remove cached text file")?;
93            removed = true;
94        }
95
96        if meta_path.exists() {
97            fs::remove_file(&meta_path).context("Failed to remove cached metadata file")?;
98        }
99
100        Ok(removed)
101    }
102
103    /// List all cached documents
104    pub fn list_cached(&self) -> Vec<DocumentType> {
105        let docs_dir = self.cache_dir.join("documents");
106        if !docs_dir.exists() {
107            return Vec::new();
108        }
109
110        let mut seen = HashSet::new();
111        let mut documents = Vec::new();
112
113        if let Ok(entries) = fs::read_dir(&docs_dir) {
114            for entry in entries.flatten() {
115                let path = entry.path();
116                if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
117                    let doc_type = DocumentType::from_canonical_name(stem);
118                    if seen.insert(doc_type.clone()) {
119                        documents.push(doc_type);
120                    }
121                }
122            }
123        }
124
125        documents
126    }
127
128    /// Get the cache directory path
129    pub fn cache_dir(&self) -> &Path {
130        &self.cache_dir
131    }
132
133    /// Get the path for a cached document
134    fn document_path(&self, doc: &DocumentType, format: Format) -> PathBuf {
135        self.cache_dir
136            .join("documents")
137            .join(format!("{}.{}", doc.name(), format.extension()))
138    }
139
140    /// Get the path for metadata file
141    fn metadata_path(&self, doc: &DocumentType) -> PathBuf {
142        self.cache_dir
143            .join("documents")
144            .join(format!("{}.meta", doc.name()))
145    }
146
147    /// Get cached metadata for a document
148    pub fn get_metadata(&self, doc: &DocumentType) -> Option<CacheMetadata> {
149        let path = self.metadata_path(doc);
150        let content = fs::read_to_string(path).ok()?;
151        serde_json::from_str(&content).ok()
152    }
153
154    /// Store metadata for a document
155    pub fn store_metadata(&self, doc: &DocumentType, meta: &CacheMetadata) -> Result<()> {
156        let path = self.metadata_path(doc);
157        if let Some(parent) = path.parent() {
158            fs::create_dir_all(parent).context("Failed to create metadata directory")?;
159        }
160        let content = serde_json::to_string_pretty(meta).context("Failed to serialize metadata")?;
161        fs::write(path, content).context("Failed to write metadata file")?;
162        Ok(())
163    }
164
165    /// List cached documents with their metadata
166    pub fn list_cached_with_metadata(&self) -> Vec<CachedDocument> {
167        self.list_cached()
168            .into_iter()
169            .map(|doc_type| {
170                let metadata = self.get_metadata(&doc_type);
171                CachedDocument { doc_type, metadata }
172            })
173            .collect()
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use chrono::Utc;
181    use tempfile::TempDir;
182
183    fn test_cache() -> (CacheManager, TempDir) {
184        let temp_dir = TempDir::new().unwrap();
185        let cache = CacheManager::with_dir(temp_dir.path().to_path_buf()).unwrap();
186        (cache, temp_dir)
187    }
188
189    #[test]
190    fn test_store_and_retrieve() {
191        let (cache, _temp) = test_cache();
192        let doc = DocumentType::Rfc(9000);
193        let content = "<html>Test content</html>";
194
195        cache.store_document(&doc, Format::Html, content).unwrap();
196
197        let retrieved = cache.get_document(&doc, Format::Html);
198        assert_eq!(retrieved, Some(content.to_string()));
199    }
200
201    #[test]
202    fn test_list_cached() {
203        let (cache, _temp) = test_cache();
204
205        cache
206            .store_document(&DocumentType::Rfc(9000), Format::Html, "test")
207            .unwrap();
208        cache
209            .store_document(&DocumentType::Rfc(8200), Format::Text, "test")
210            .unwrap();
211
212        let cached = cache.list_cached();
213        assert_eq!(cached.len(), 2);
214    }
215
216    #[test]
217    fn test_clear_cache() {
218        let (cache, _temp) = test_cache();
219        let doc = DocumentType::Rfc(9000);
220
221        cache.store_document(&doc, Format::Html, "test").unwrap();
222        assert!(cache.get_document(&doc, Format::Html).is_some());
223
224        cache.clear_cache().unwrap();
225        assert!(cache.get_document(&doc, Format::Html).is_none());
226    }
227
228    #[test]
229    fn test_remove_document() {
230        let (cache, _temp) = test_cache();
231        let doc = DocumentType::Rfc(9000);
232
233        // Remove non-existent returns false
234        assert!(!cache.remove(&doc).unwrap());
235
236        // Store both formats and metadata, then remove
237        cache
238            .store_document(&doc, Format::Html, "html content")
239            .unwrap();
240        cache
241            .store_document(&doc, Format::Text, "text content")
242            .unwrap();
243
244        let meta = CacheMetadata {
245            title: "Test Title".to_string(),
246            cached_at: Utc::now(),
247        };
248        cache.store_metadata(&doc, &meta).unwrap();
249
250        assert!(cache.remove(&doc).unwrap());
251
252        // Verify both formats and metadata are gone
253        assert!(cache.get_document(&doc, Format::Html).is_none());
254        assert!(cache.get_document(&doc, Format::Text).is_none());
255        assert!(cache.get_metadata(&doc).is_none());
256
257        // Second remove returns false
258        assert!(!cache.remove(&doc).unwrap());
259    }
260
261    #[test]
262    fn test_remove_partial_formats() {
263        let (cache, _temp) = test_cache();
264        let doc = DocumentType::Rfc(8000);
265
266        // Store only HTML
267        cache
268            .store_document(&doc, Format::Html, "html only")
269            .unwrap();
270
271        // Remove should succeed and return true
272        assert!(cache.remove(&doc).unwrap());
273        assert!(cache.get_document(&doc, Format::Html).is_none());
274    }
275
276    #[test]
277    fn test_list_cached_with_drafts() {
278        let (cache, _temp) = test_cache();
279
280        let draft = DocumentType::Draft("draft-ietf-quic-transport-34".to_string());
281        cache.store_document(&draft, Format::Text, "test").unwrap();
282
283        let cached = cache.list_cached();
284        assert_eq!(cached.len(), 1);
285        assert!(cached.contains(&draft));
286    }
287
288    #[test]
289    fn test_store_and_retrieve_metadata() {
290        let (cache, _temp) = test_cache();
291        let doc = DocumentType::Rfc(9000);
292        let meta = CacheMetadata {
293            title: "QUIC: A UDP-Based Multiplexed and Secure Transport".to_string(),
294            cached_at: Utc::now(),
295        };
296
297        cache.store_metadata(&doc, &meta).unwrap();
298
299        let retrieved = cache.get_metadata(&doc);
300        assert!(retrieved.is_some());
301        let retrieved = retrieved.unwrap();
302        assert_eq!(retrieved.title, meta.title);
303    }
304
305    #[test]
306    fn test_list_cached_with_metadata() {
307        let (cache, _temp) = test_cache();
308
309        let doc1 = DocumentType::Rfc(9000);
310        let doc2 = DocumentType::Rfc(8200);
311
312        cache.store_document(&doc1, Format::Text, "test").unwrap();
313        cache.store_document(&doc2, Format::Text, "test").unwrap();
314
315        let meta1 = CacheMetadata {
316            title: "QUIC Transport".to_string(),
317            cached_at: Utc::now(),
318        };
319        cache.store_metadata(&doc1, &meta1).unwrap();
320
321        let cached = cache.list_cached_with_metadata();
322        assert_eq!(cached.len(), 2);
323
324        // doc1 has metadata
325        let cached_doc1 = cached.iter().find(|cd| cd.doc_type == doc1).unwrap();
326        assert!(cached_doc1.metadata.is_some());
327
328        // doc2 doesn't have metadata
329        let cached_doc2 = cached.iter().find(|cd| cd.doc_type == doc2).unwrap();
330        assert!(cached_doc2.metadata.is_none());
331    }
332
333    #[test]
334    fn test_metadata_missing() {
335        let (cache, _temp) = test_cache();
336        let doc = DocumentType::Rfc(9000);
337
338        cache.store_document(&doc, Format::Text, "test").unwrap();
339
340        // Should return None for missing metadata
341        assert!(cache.get_metadata(&doc).is_none());
342    }
343}