Skip to main content

ferrous_forge/rust_version/
file_cache.rs

1//! Local file cache manager for GitHub API responses
2//!
3//! Provides persistent caching with 24-hour TTL for offline support.
4//!
5//! @task T024
6//! @epic T014
7
8use crate::{Error, Result};
9use serde::{Deserialize, Serialize};
10use std::fs;
11use std::path::{Path, PathBuf};
12use std::time::{Duration, SystemTime};
13
14/// Default cache TTL (24 hours)
15pub const DEFAULT_CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
16
17/// Cache file metadata
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct CacheEntry {
20    /// When the cache entry was created
21    pub created_at: SystemTime,
22    /// Data stored in the cache
23    pub data: Vec<u8>,
24    /// Content type (e.g., "application/json")
25    pub content_type: String,
26}
27
28/// File-based cache manager for GitHub API responses
29pub struct FileCache {
30    cache_dir: PathBuf,
31    ttl: Duration,
32}
33
34impl FileCache {
35    /// Create a new file cache
36    ///
37    /// # Errors
38    ///
39    /// Returns an error if the cache directory cannot be created.
40    pub fn new(cache_dir: impl AsRef<Path>, ttl: Duration) -> Result<Self> {
41        let cache_dir = cache_dir.as_ref().to_path_buf();
42        fs::create_dir_all(&cache_dir)
43            .map_err(|e| Error::io(format!("Failed to create cache directory: {e}")))?;
44
45        Ok(Self { cache_dir, ttl })
46    }
47
48    /// Create a default cache in the user's cache directory
49    ///
50    /// # Errors
51    ///
52    /// Returns an error if the cache directory cannot be determined or created.
53    pub fn default() -> Result<Self> {
54        let cache_dir = dirs::cache_dir()
55            .ok_or_else(|| Error::config("Could not determine cache directory"))?
56            .join("ferrous-forge")
57            .join("github");
58
59        Self::new(cache_dir, DEFAULT_CACHE_TTL)
60    }
61
62    /// Get a cached entry
63    ///
64    /// Returns `None` if the entry doesn't exist or has expired.
65    pub fn get(&self, key: &str) -> Option<CacheEntry> {
66        let path = self.cache_path(key);
67
68        if !path.exists() {
69            return None;
70        }
71
72        let entry = match self.read_entry(&path) {
73            Ok(e) => e,
74            Err(_) => return None,
75        };
76
77        if self.is_expired(&entry) {
78            let _ = fs::remove_file(&path);
79            return None;
80        }
81
82        Some(entry)
83    }
84
85    /// Store data in the cache
86    ///
87    /// # Errors
88    ///
89    /// Returns an error if the cache file cannot be written.
90    pub fn set(&self, key: &str, data: Vec<u8>, content_type: &str) -> Result<()> {
91        let path = self.cache_path(key);
92        let entry = CacheEntry {
93            created_at: SystemTime::now(),
94            data,
95            content_type: content_type.to_string(),
96        };
97
98        let json = serde_json::to_vec(&entry)
99            .map_err(|e| Error::Validation(format!("Failed to serialize cache entry: {e}")))?;
100
101        fs::write(&path, json)
102            .map_err(|e| Error::io(format!("Failed to write cache file: {e}")))?;
103
104        Ok(())
105    }
106
107    /// Check if offline mode should be used (cache-only)
108    ///
109    /// Returns true if we should operate in offline mode due to
110    /// network unavailability or explicit offline flag.
111    pub fn should_use_offline(&self) -> bool {
112        // Check for environment variable override
113        if std::env::var("FERROUS_FORGE_OFFLINE").is_ok() {
114            return true;
115        }
116
117        // Check if we have recent cached data
118        // If we have valid cache, we can work offline
119        self.has_valid_cache()
120    }
121
122    /// Check if any valid cached data exists
123    fn has_valid_cache(&self) -> bool {
124        let Ok(entries) = fs::read_dir(&self.cache_dir) else {
125            return false;
126        };
127
128        for entry in entries.flatten() {
129            if let Ok(cache_entry) = self.read_entry(&entry.path()) {
130                if !self.is_expired(&cache_entry) {
131                    return true;
132                }
133            }
134        }
135
136        false
137    }
138
139    /// Get the cache file path for a key
140    fn cache_path(&self, key: &str) -> PathBuf {
141        // Sanitize key to create safe filename
142        let safe_key = key.replace(['/', '\\', ':', ' '], "_");
143        self.cache_dir.join(format!("{safe_key}.json"))
144    }
145
146    /// Read a cache entry from disk
147    fn read_entry(&self, path: &Path) -> Result<CacheEntry> {
148        let data =
149            fs::read(path).map_err(|e| Error::io(format!("Failed to read cache file: {e}")))?;
150
151        serde_json::from_slice(&data)
152            .map_err(|e| Error::parse(format!("Failed to parse cache entry: {e}")))
153    }
154
155    /// Check if a cache entry has expired
156    fn is_expired(&self, entry: &CacheEntry) -> bool {
157        SystemTime::now()
158            .duration_since(entry.created_at)
159            .map(|elapsed| elapsed > self.ttl)
160            .unwrap_or(true)
161    }
162
163    /// Clear all cached entries
164    ///
165    /// # Errors
166    ///
167    /// Returns an error if the cache directory cannot be cleared.
168    pub fn clear(&self) -> Result<()> {
169        let entries = fs::read_dir(&self.cache_dir)
170            .map_err(|e| Error::io(format!("Failed to read cache directory: {e}")))?;
171
172        for entry in entries.flatten() {
173            let _ = fs::remove_file(entry.path());
174        }
175
176        Ok(())
177    }
178
179    /// Get cache statistics
180    pub fn stats(&self) -> CacheStats {
181        let mut stats = CacheStats {
182            total_entries: 0,
183            valid_entries: 0,
184            expired_entries: 0,
185            total_size: 0,
186        };
187
188        let Ok(entries) = fs::read_dir(&self.cache_dir) else {
189            return stats;
190        };
191
192        for entry in entries.flatten() {
193            stats.total_entries += 1;
194
195            if let Ok(metadata) = entry.metadata() {
196                stats.total_size += metadata.len();
197            }
198
199            if let Ok(cache_entry) = self.read_entry(&entry.path()) {
200                if self.is_expired(&cache_entry) {
201                    stats.expired_entries += 1;
202                } else {
203                    stats.valid_entries += 1;
204                }
205            }
206        }
207
208        stats
209    }
210}
211
212/// Cache statistics
213#[derive(Debug, Clone)]
214pub struct CacheStats {
215    /// Total number of cache entries
216    pub total_entries: usize,
217    /// Number of valid (non-expired) entries
218    pub valid_entries: usize,
219    /// Number of expired entries
220    pub expired_entries: usize,
221    /// Total size in bytes
222    pub total_size: u64,
223}
224
225impl std::fmt::Display for CacheStats {
226    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
227        write!(
228            f,
229            "Cache: {} total, {} valid, {} expired, {} bytes",
230            self.total_entries, self.valid_entries, self.expired_entries, self.total_size
231        )
232    }
233}
234
235#[cfg(test)]
236#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
237mod tests {
238    use super::*;
239    use std::thread;
240
241    #[test]
242    fn test_cache_basic() {
243        let temp_dir = tempfile::tempdir().unwrap();
244        let cache = FileCache::new(temp_dir.path(), Duration::from_secs(60)).unwrap();
245
246        // Store data
247        cache
248            .set("test-key", b"test data".to_vec(), "text/plain")
249            .unwrap();
250
251        // Retrieve data
252        let entry = cache.get("test-key").unwrap();
253        assert_eq!(entry.data, b"test data");
254        assert_eq!(entry.content_type, "text/plain");
255    }
256
257    #[test]
258    fn test_cache_expiration() {
259        let temp_dir = tempfile::tempdir().unwrap();
260        let cache = FileCache::new(temp_dir.path(), Duration::from_millis(50)).unwrap();
261
262        cache
263            .set("test-key", b"test data".to_vec(), "text/plain")
264            .unwrap();
265
266        // Should exist immediately
267        assert!(cache.get("test-key").is_some());
268
269        // Wait for expiration
270        thread::sleep(Duration::from_millis(60));
271
272        // Should be expired
273        assert!(cache.get("test-key").is_none());
274    }
275
276    #[test]
277    fn test_cache_stats() {
278        let temp_dir = tempfile::tempdir().unwrap();
279        let cache = FileCache::new(temp_dir.path(), Duration::from_secs(60)).unwrap();
280
281        cache.set("key1", b"data1".to_vec(), "text/plain").unwrap();
282        cache.set("key2", b"data2".to_vec(), "text/plain").unwrap();
283
284        let stats = cache.stats();
285        assert_eq!(stats.total_entries, 2);
286        assert_eq!(stats.valid_entries, 2);
287        assert_eq!(stats.expired_entries, 0);
288    }
289}