1use anyhow::{Context, Result};
2use directories::ProjectDirs;
3use serde::{Deserialize, Serialize};
4use std::fs;
5use std::path::PathBuf;
6use std::time::{Duration, SystemTime, UNIX_EPOCH};
7
8const CACHE_VERSION: u32 = 1;
9const DEFAULT_TTL_HOURS: u64 = 24;
10const MAX_CACHE_SIZE_MB: u64 = 100;
11
12#[derive(Debug, Serialize, Deserialize)]
13struct CacheEntry<T> {
14 version: u32,
15 data: T,
16 timestamp: u64,
17 ttl_hours: u64,
18}
19
20pub struct CacheManager {
21 cache_dir: PathBuf,
22 ttl: Duration,
23}
24
25impl CacheManager {
26 pub fn new() -> Result<Self> {
27 let cache_dir = Self::get_cache_dir()?;
28 fs::create_dir_all(&cache_dir)?;
29
30 Ok(Self {
31 cache_dir,
32 ttl: Duration::from_secs(DEFAULT_TTL_HOURS * 3600),
33 })
34 }
35
36 pub fn with_custom_dir(dir: PathBuf) -> Result<Self> {
37 fs::create_dir_all(&dir)?;
38 Ok(Self {
39 cache_dir: dir,
40 ttl: Duration::from_secs(DEFAULT_TTL_HOURS * 3600),
41 })
42 }
43
44 fn get_cache_dir() -> Result<PathBuf> {
45 Ok(ProjectDirs::from("", "", "manx")
46 .context("Failed to determine cache directory")?
47 .cache_dir()
48 .to_path_buf())
49 }
50
51 pub fn cache_key(&self, category: &str, key: &str) -> PathBuf {
52 let safe_key = key.replace('/', "_").replace('@', "_v_").replace(' ', "_");
53
54 self.cache_dir
55 .join(category)
56 .join(format!("{}.json", safe_key))
57 }
58
59 pub async fn get<T>(&self, category: &str, key: &str) -> Result<Option<T>>
60 where
61 T: for<'de> Deserialize<'de>,
62 {
63 let path = self.cache_key(category, key);
64
65 if !path.exists() {
66 return Ok(None);
67 }
68
69 let data = fs::read_to_string(&path).context("Failed to read cache file")?;
70
71 let entry: CacheEntry<T> =
72 serde_json::from_str(&data).context("Failed to parse cache entry")?;
73
74 if entry.version != CACHE_VERSION {
76 fs::remove_file(&path).ok();
77 return Ok(None);
78 }
79
80 let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
82
83 let age = now.saturating_sub(entry.timestamp);
84 let ttl_secs = self.ttl.as_secs();
85
86 if age > ttl_secs {
87 fs::remove_file(&path).ok();
88 return Ok(None);
89 }
90
91 Ok(Some(entry.data))
92 }
93
94 pub async fn set<T>(&self, category: &str, key: &str, data: T) -> Result<()>
95 where
96 T: Serialize,
97 {
98 let path = self.cache_key(category, key);
99
100 if let Some(parent) = path.parent() {
102 fs::create_dir_all(parent)?;
103 }
104
105 let entry = CacheEntry {
106 version: CACHE_VERSION,
107 data,
108 timestamp: SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(),
109 ttl_hours: DEFAULT_TTL_HOURS,
110 };
111
112 let json = serde_json::to_string_pretty(&entry)?;
113 fs::write(&path, json)?;
114
115 self.clean_if_needed().await?;
117
118 Ok(())
119 }
120
121 pub async fn clear(&self) -> Result<()> {
122 if self.cache_dir.exists() {
123 fs::remove_dir_all(&self.cache_dir)?;
124 fs::create_dir_all(&self.cache_dir)?;
125 }
126 Ok(())
127 }
128
129 pub async fn stats(&self) -> Result<CacheStats> {
130 let mut total_size = 0u64;
131 let mut file_count = 0u32;
132 let mut categories = Vec::new();
133
134 if !self.cache_dir.exists() {
135 return Ok(CacheStats {
136 total_size_mb: 0.0,
137 file_count: 0,
138 categories,
139 });
140 }
141
142 for entry in fs::read_dir(&self.cache_dir)? {
143 let entry = entry?;
144 let path = entry.path();
145
146 if path.is_dir() {
147 categories.push(entry.file_name().to_string_lossy().to_string());
148
149 for file in fs::read_dir(&path)? {
150 let file = file?;
151 if file.path().is_file() {
152 let metadata = file.metadata()?;
153 total_size += metadata.len();
154 file_count += 1;
155 }
156 }
157 }
158 }
159
160 Ok(CacheStats {
161 total_size_mb: total_size as f64 / 1_048_576.0,
162 file_count,
163 categories,
164 })
165 }
166
167 pub async fn list_cached(&self) -> Result<Vec<CachedItem>> {
168 let mut items = Vec::new();
169
170 if !self.cache_dir.exists() {
171 return Ok(items);
172 }
173
174 for category_entry in fs::read_dir(&self.cache_dir)? {
175 let category_entry = category_entry?;
176 let category_path = category_entry.path();
177
178 if category_path.is_dir() {
179 let category = category_entry.file_name().to_string_lossy().to_string();
180
181 for file_entry in fs::read_dir(&category_path)? {
182 let file_entry = file_entry?;
183 let file_path = file_entry.path();
184
185 if file_path.is_file()
186 && file_path.extension() == Some(std::ffi::OsStr::new("json"))
187 {
188 let name = file_path
189 .file_stem()
190 .and_then(|s| s.to_str())
191 .unwrap_or("unknown")
192 .to_string();
193
194 let metadata = file_entry.metadata()?;
195 let size_kb = metadata.len() as f64 / 1024.0;
196
197 items.push(CachedItem {
198 category: category.clone(),
199 name,
200 size_kb,
201 });
202 }
203 }
204 }
205 }
206
207 items.sort_by(|a, b| a.category.cmp(&b.category).then(a.name.cmp(&b.name)));
208 Ok(items)
209 }
210
211 async fn clean_if_needed(&self) -> Result<()> {
212 let stats = self.stats().await?;
213
214 if stats.total_size_mb > MAX_CACHE_SIZE_MB as f64 {
215 let mut files: Vec<(PathBuf, SystemTime)> = Vec::new();
217
218 for entry in fs::read_dir(&self.cache_dir)? {
219 let entry = entry?;
220 let path = entry.path();
221
222 if path.is_dir() {
223 for file in fs::read_dir(&path)? {
224 let file = file?;
225 let file_path = file.path();
226 if file_path.is_file() {
227 let modified = file.metadata()?.modified()?;
228 files.push((file_path, modified));
229 }
230 }
231 }
232 }
233
234 files.sort_by_key(|(_, time)| *time);
236
237 let mut current_size = stats.total_size_mb;
239 for (file_path, _) in files {
240 if current_size <= MAX_CACHE_SIZE_MB as f64 * 0.8 {
241 break;
242 }
243
244 if let Ok(metadata) = fs::metadata(&file_path) {
245 let file_size_mb = metadata.len() as f64 / 1_048_576.0;
246 fs::remove_file(&file_path).ok();
247 current_size -= file_size_mb;
248 }
249 }
250 }
251
252 Ok(())
253 }
254}
255
256#[derive(Debug, Serialize)]
257pub struct CacheStats {
258 pub total_size_mb: f64,
259 pub file_count: u32,
260 pub categories: Vec<String>,
261}
262
263#[derive(Debug, Serialize)]
264pub struct CachedItem {
265 pub category: String,
266 pub name: String,
267 pub size_kb: f64,
268}