manx_cli/
cache.rs

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        // Check version
75        if entry.version != CACHE_VERSION {
76            fs::remove_file(&path).ok();
77            return Ok(None);
78        }
79
80        // Check TTL
81        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        // Create category directory if needed
101        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        // Check cache size and clean if needed
116        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            // Remove oldest files until under limit
216            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            // Sort by modification time (oldest first)
235            files.sort_by_key(|(_, time)| *time);
236
237            // Remove oldest files
238            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}