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