ferrous_forge/rust_version/
file_cache.rs1use crate::{Error, Result};
9use serde::{Deserialize, Serialize};
10use std::fs;
11use std::path::{Path, PathBuf};
12use std::time::{Duration, SystemTime};
13
14pub const DEFAULT_CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct CacheEntry {
20 pub created_at: SystemTime,
22 pub data: Vec<u8>,
24 pub content_type: String,
26}
27
28pub struct FileCache {
30 cache_dir: PathBuf,
31 ttl: Duration,
32}
33
34impl FileCache {
35 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 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 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 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 pub fn should_use_offline(&self) -> bool {
112 if std::env::var("FERROUS_FORGE_OFFLINE").is_ok() {
114 return true;
115 }
116
117 self.has_valid_cache()
120 }
121
122 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 fn cache_path(&self, key: &str) -> PathBuf {
141 let safe_key = key.replace(['/', '\\', ':', ' '], "_");
143 self.cache_dir.join(format!("{safe_key}.json"))
144 }
145
146 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 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 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 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#[derive(Debug, Clone)]
214pub struct CacheStats {
215 pub total_entries: usize,
217 pub valid_entries: usize,
219 pub expired_entries: usize,
221 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 cache
248 .set("test-key", b"test data".to_vec(), "text/plain")
249 .unwrap();
250
251 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 assert!(cache.get("test-key").is_some());
268
269 thread::sleep(Duration::from_millis(60));
271
272 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}