1use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10use serde::{Deserialize, Serialize};
11
12use crate::types::RomList;
13
14const DEFAULT_CACHE_FILE: &str = "romm-cache.json";
16
17#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
22pub enum RomCacheKey {
23 Platform(u64),
24 Collection(u64),
26 SmartCollection(u64),
28 VirtualCollection(String),
30}
31
32#[derive(Serialize, Deserialize)]
37struct CacheFile {
38 version: u32,
39 entries: Vec<CacheEntry>,
40}
41
42#[derive(Serialize, Deserialize)]
43struct CacheEntry {
44 key: RomCacheKey,
45 expected_count: u64,
49 data: RomList,
50}
51
52pub struct RomCache {
62 entries: HashMap<RomCacheKey, (u64, RomList)>, path: PathBuf,
64}
65
66#[derive(Debug, Clone)]
68pub struct RomCacheInfo {
69 pub path: PathBuf,
70 pub exists: bool,
71 pub size_bytes: Option<u64>,
72 pub version: Option<u32>,
73 pub entry_count: Option<usize>,
74 pub parse_error: Option<String>,
75}
76
77impl RomCache {
78 pub fn load() -> Self {
80 let (path, from_env_override) = cache_path_with_override();
81 if !from_env_override {
82 maybe_migrate_legacy_cache(&path);
83 }
84 Self::load_from(path)
85 }
86
87 pub fn effective_path() -> PathBuf {
89 cache_path_with_override().0
90 }
91
92 pub fn clear_file() -> std::io::Result<bool> {
94 let path = Self::effective_path();
95 if path.is_file() {
96 std::fs::remove_file(path)?;
97 return Ok(true);
98 }
99 Ok(false)
100 }
101
102 pub fn read_info() -> RomCacheInfo {
104 let path = Self::effective_path();
105 let meta = std::fs::metadata(&path).ok();
106 let exists = meta.is_some();
107 let size_bytes = meta.map(|m| m.len());
108
109 let mut info = RomCacheInfo {
110 path: path.clone(),
111 exists,
112 size_bytes,
113 version: None,
114 entry_count: None,
115 parse_error: None,
116 };
117
118 if !exists {
119 return info;
120 }
121
122 match std::fs::read_to_string(&path) {
123 Ok(data) => match serde_json::from_str::<CacheFile>(&data) {
124 Ok(file) => {
125 info.version = Some(file.version);
126 info.entry_count = Some(file.entries.len());
127 }
128 Err(err) => {
129 info.parse_error = Some(err.to_string());
130 }
131 },
132 Err(err) => {
133 info.parse_error = Some(err.to_string());
134 }
135 }
136
137 info
138 }
139
140 fn load_from(path: PathBuf) -> Self {
141 let entries = Self::read_file(&path).unwrap_or_default();
142 Self { entries, path }
143 }
144
145 fn read_file(path: &Path) -> Option<HashMap<RomCacheKey, (u64, RomList)>> {
146 let data = std::fs::read_to_string(path).ok()?;
147 let file: CacheFile = serde_json::from_str(&data).ok()?;
148 if file.version != 1 {
149 return None;
150 }
151 let map = file
152 .entries
153 .into_iter()
154 .map(|e| (e.key, (e.expected_count, e.data)))
155 .collect();
156 Some(map)
157 }
158
159 pub fn save(&self) {
161 let file = CacheFile {
162 version: 1,
163 entries: self
164 .entries
165 .iter()
166 .map(|(k, (ec, v))| CacheEntry {
167 key: k.clone(),
168 expected_count: *ec,
169 data: v.clone(),
170 })
171 .collect(),
172 };
173 let path = self.path.clone();
174
175 let write_fn = move || match serde_json::to_string(&file) {
176 Ok(json) => {
177 if let Some(parent) = path.parent() {
178 if let Err(err) = std::fs::create_dir_all(parent) {
179 eprintln!(
180 "warning: failed to create ROM cache directory {:?}: {}",
181 parent, err
182 );
183 return;
184 }
185 }
186 if let Err(err) = std::fs::write(&path, json) {
187 eprintln!(
188 "warning: failed to write ROM cache file {:?}: {}",
189 path, err
190 );
191 }
192 }
193 Err(err) => {
194 eprintln!(
195 "warning: failed to serialize ROM cache file {:?}: {}",
196 path, err
197 );
198 }
199 };
200
201 #[cfg(test)]
202 write_fn();
203
204 #[cfg(not(test))]
205 std::thread::spawn(write_fn);
206 }
207
208 pub fn get_valid(&self, key: &RomCacheKey, expected_count: u64) -> Option<&RomList> {
213 self.entries
214 .get(key)
215 .filter(|(stored_count, _)| *stored_count == expected_count)
216 .map(|(_, list)| list)
217 }
218
219 pub fn insert(&mut self, key: RomCacheKey, data: RomList, expected_count: u64) {
222 self.entries.insert(key, (expected_count, data));
223 self.save();
224 }
225
226 pub fn remove(&mut self, key: &RomCacheKey) -> bool {
228 let removed = self.entries.remove(key).is_some();
229 if removed {
230 self.save();
231 }
232 removed
233 }
234
235 pub fn remove_all_platform_entries(&mut self) -> usize {
237 let before = self.entries.len();
238 self.entries
239 .retain(|k, _| !matches!(k, RomCacheKey::Platform(_)));
240 let removed = before - self.entries.len();
241 if removed > 0 {
242 self.save();
243 }
244 removed
245 }
246}
247
248fn cache_path_with_override() -> (PathBuf, bool) {
249 if let Ok(path) = std::env::var("ROMM_CACHE_PATH") {
250 return (PathBuf::from(path), true);
251 }
252 (default_cache_path(), false)
253}
254
255fn default_cache_path() -> PathBuf {
256 if let Ok(dir) = std::env::var("ROMM_TEST_CACHE_DIR") {
257 return PathBuf::from(dir).join(DEFAULT_CACHE_FILE);
258 }
259 if let Some(dir) = dirs::cache_dir() {
260 return dir.join("romm-cli").join(DEFAULT_CACHE_FILE);
261 }
262 PathBuf::from(DEFAULT_CACHE_FILE)
263}
264
265fn legacy_cache_path() -> PathBuf {
266 PathBuf::from(DEFAULT_CACHE_FILE)
267}
268
269fn maybe_migrate_legacy_cache(destination: &Path) {
270 if destination.is_file() {
271 return;
272 }
273
274 let legacy = legacy_cache_path();
275 if !legacy.is_file() {
276 return;
277 }
278 if RomCache::read_file(&legacy).is_none() {
279 tracing::warn!(
280 "Skipping legacy ROM cache migration from {} because file is unreadable or invalid",
281 legacy.display()
282 );
283 return;
284 }
285 if let Some(parent) = destination.parent() {
286 if let Err(err) = std::fs::create_dir_all(parent) {
287 tracing::warn!(
288 "Failed to create ROM cache directory {} for migration: {}",
289 parent.display(),
290 err
291 );
292 return;
293 }
294 }
295 match std::fs::copy(&legacy, destination) {
296 Ok(_) => tracing::info!(
297 "Migrated ROM cache from {} to {}",
298 legacy.display(),
299 destination.display()
300 ),
301 Err(err) => tracing::warn!(
302 "Failed to migrate ROM cache from {} to {}: {}",
303 legacy.display(),
304 destination.display(),
305 err
306 ),
307 }
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313 use crate::types::Rom;
314 use std::sync::{Mutex, MutexGuard, OnceLock};
315 use std::time::{SystemTime, UNIX_EPOCH};
316
317 fn env_lock() -> &'static Mutex<()> {
318 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
319 LOCK.get_or_init(|| Mutex::new(()))
320 }
321
322 struct TestEnv {
323 _guard: MutexGuard<'static, ()>,
324 }
325
326 impl TestEnv {
327 fn new() -> Self {
328 let guard = env_lock().lock().expect("env lock");
329 std::env::remove_var("ROMM_CACHE_PATH");
330 std::env::remove_var("ROMM_TEST_CACHE_DIR");
331 Self { _guard: guard }
332 }
333 }
334
335 impl Drop for TestEnv {
336 fn drop(&mut self) {
337 std::env::remove_var("ROMM_CACHE_PATH");
338 std::env::remove_var("ROMM_TEST_CACHE_DIR");
339 }
340 }
341
342 fn sample_rom_list() -> RomList {
343 RomList {
344 items: vec![Rom {
345 id: 1,
346 platform_id: 10,
347 platform_slug: None,
348 platform_fs_slug: None,
349 platform_custom_name: Some("NES".to_string()),
350 platform_display_name: Some("NES".to_string()),
351 fs_name: "Mario (USA).zip".to_string(),
352 fs_name_no_tags: "Mario".to_string(),
353 fs_name_no_ext: "Mario".to_string(),
354 fs_extension: "zip".to_string(),
355 fs_path: "/roms/mario.zip".to_string(),
356 fs_size_bytes: 1234,
357 name: "Mario".to_string(),
358 slug: Some("mario".to_string()),
359 summary: Some("A platform game".to_string()),
360 path_cover_small: None,
361 path_cover_large: None,
362 url_cover: None,
363 is_unidentified: false,
364 is_identified: true,
365 }],
366 total: 1,
367 limit: 50,
368 offset: 0,
369 }
370 }
371
372 fn temp_cache_path() -> PathBuf {
373 let ts = SystemTime::now()
374 .duration_since(UNIX_EPOCH)
375 .expect("time")
376 .as_nanos();
377 std::env::temp_dir().join(format!("romm-cache-test-{}.json", ts))
378 }
379
380 #[test]
381 fn returns_cache_only_for_matching_expected_count() {
382 let path = temp_cache_path();
383 let mut cache = RomCache::load_from(path.clone());
384 let key = RomCacheKey::Platform(42);
385 let list = sample_rom_list();
386 cache.insert(key.clone(), list.clone(), 7);
387
388 assert!(cache.get_valid(&key, 7).is_some());
389 assert!(cache.get_valid(&key, 8).is_none());
390
391 let _ = std::fs::remove_file(path);
392 }
393
394 #[test]
395 fn persists_and_reloads_entries_from_disk() {
396 let path = temp_cache_path();
397 let mut cache = RomCache::load_from(path.clone());
398 let key = RomCacheKey::Collection(9);
399 let list = sample_rom_list();
400 cache.insert(key.clone(), list.clone(), 3);
401
402 let loaded = RomCache::load_from(path.clone());
403 let cached = loaded.get_valid(&key, 3).expect("cached value");
404 assert_eq!(cached.items.len(), 1);
405 assert_eq!(cached.items[0].name, "Mario");
406
407 let _ = std::fs::remove_file(path);
408 }
409
410 #[test]
411 fn persists_virtual_collection_key() {
412 let path = temp_cache_path();
413 let mut cache = RomCache::load_from(path.clone());
414 let key = RomCacheKey::VirtualCollection("recent".to_string());
415 let list = sample_rom_list();
416 cache.insert(key.clone(), list.clone(), 5);
417
418 let loaded = RomCache::load_from(path.clone());
419 let cached = loaded.get_valid(&key, 5).expect("cached value");
420 assert_eq!(cached.items.len(), 1);
421
422 let _ = std::fs::remove_file(path);
423 }
424
425 #[test]
426 fn remove_and_remove_all_platform_entries() {
427 let path = temp_cache_path();
428 let mut cache = RomCache::load_from(path.clone());
429 cache.insert(RomCacheKey::Platform(1), sample_rom_list(), 1);
430 cache.insert(RomCacheKey::Platform(2), sample_rom_list(), 1);
431 cache.insert(RomCacheKey::Collection(9), sample_rom_list(), 1);
432
433 assert!(cache.remove(&RomCacheKey::Platform(1)));
434 assert!(cache.get_valid(&RomCacheKey::Platform(1), 1).is_none());
435 assert!(cache.get_valid(&RomCacheKey::Platform(2), 1).is_some());
436
437 assert_eq!(cache.remove_all_platform_entries(), 1);
438 assert!(cache.get_valid(&RomCacheKey::Platform(2), 1).is_none());
439 assert!(cache.get_valid(&RomCacheKey::Collection(9), 1).is_some());
440
441 let _ = std::fs::remove_file(path);
442 }
443
444 #[test]
445 fn effective_path_uses_env_override_when_set() {
446 let _env = TestEnv::new();
447 let path = std::env::temp_dir().join("romm-cache-env-override.json");
448 std::env::set_var("ROMM_CACHE_PATH", &path);
449 assert_eq!(RomCache::effective_path(), path);
450 }
451
452 #[test]
453 fn effective_path_uses_test_cache_dir_without_override() {
454 let _env = TestEnv::new();
455 let dir = std::env::temp_dir().join("romm-cache-default-dir-test");
456 std::env::set_var("ROMM_TEST_CACHE_DIR", &dir);
457 assert_eq!(RomCache::effective_path(), dir.join(DEFAULT_CACHE_FILE));
458 }
459
460 #[test]
461 fn migrates_legacy_cache_once_for_default_path() {
462 let _env = TestEnv::new();
463 let ts = SystemTime::now()
464 .duration_since(UNIX_EPOCH)
465 .expect("time")
466 .as_nanos();
467 let work_dir = std::env::temp_dir().join(format!("romm-cache-migrate-cwd-{ts}"));
468 let cache_dir = std::env::temp_dir().join(format!("romm-cache-migrate-dest-{ts}"));
469 std::fs::create_dir_all(&work_dir).expect("create work dir");
470 std::env::set_var("ROMM_TEST_CACHE_DIR", &cache_dir);
471
472 let prev_cwd = std::env::current_dir().expect("cwd");
473 std::env::set_current_dir(&work_dir).expect("set cwd");
474 let mut legacy = RomCache::load_from(PathBuf::from(DEFAULT_CACHE_FILE));
475 let key = RomCacheKey::Platform(7);
476 legacy.insert(key.clone(), sample_rom_list(), 1);
477
478 let migrated = RomCache::load();
479 assert!(migrated.get_valid(&key, 1).is_some());
480
481 std::env::set_current_dir(prev_cwd).expect("restore cwd");
482 let _ = std::fs::remove_dir_all(&work_dir);
483 let _ = std::fs::remove_dir_all(&cache_dir);
484 }
485
486 #[test]
487 fn clear_file_removes_existing_cache_file() {
488 let _env = TestEnv::new();
489 let path = temp_cache_path();
490 std::env::set_var("ROMM_CACHE_PATH", &path);
491 let mut cache = RomCache::load();
492 cache.insert(RomCacheKey::Platform(1), sample_rom_list(), 1);
493 assert!(path.is_file());
494 assert!(RomCache::clear_file().expect("clear should work"));
495 assert!(!path.exists());
496 }
497}