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> {
218 self.entries
219 .get(key)
220 .filter(|(stored_count, _)| *stored_count == expected_count)
221 .map(|(_, list)| list)
222 }
223
224 pub fn insert(&mut self, key: RomCacheKey, data: RomList, expected_count: u64) {
227 self.entries.insert(key, (expected_count, data));
228 self.save();
229 }
230
231 pub fn remove(&mut self, key: &RomCacheKey) -> bool {
233 let removed = self.entries.remove(key).is_some();
234 if removed {
235 self.save();
236 }
237 removed
238 }
239
240 pub fn remove_all_platform_entries(&mut self) -> usize {
242 let before = self.entries.len();
243 self.entries
244 .retain(|k, _| !matches!(k, RomCacheKey::Platform(_)));
245 let removed = before - self.entries.len();
246 if removed > 0 {
247 self.save();
248 }
249 removed
250 }
251}
252
253fn cache_path_with_override() -> (PathBuf, bool) {
254 if let Ok(path) = std::env::var("ROMM_CACHE_PATH") {
255 return (PathBuf::from(path), true);
256 }
257 (default_cache_path(), false)
258}
259
260fn default_cache_path() -> PathBuf {
261 if let Ok(dir) = std::env::var("ROMM_TEST_CACHE_DIR") {
262 return PathBuf::from(dir).join(DEFAULT_CACHE_FILE);
263 }
264 if let Some(dir) = dirs::cache_dir() {
265 return dir.join("romm-cli").join(DEFAULT_CACHE_FILE);
266 }
267 PathBuf::from(DEFAULT_CACHE_FILE)
268}
269
270fn legacy_cache_path() -> PathBuf {
271 PathBuf::from(DEFAULT_CACHE_FILE)
272}
273
274fn maybe_migrate_legacy_cache(destination: &Path) {
275 if destination.is_file() {
276 return;
277 }
278
279 let legacy = legacy_cache_path();
280 if !legacy.is_file() {
281 return;
282 }
283 if RomCache::read_file(&legacy).is_none() {
284 tracing::warn!(
285 "Skipping legacy ROM cache migration from {} because file is unreadable or invalid",
286 legacy.display()
287 );
288 return;
289 }
290 if let Some(parent) = destination.parent() {
291 if let Err(err) = std::fs::create_dir_all(parent) {
292 tracing::warn!(
293 "Failed to create ROM cache directory {} for migration: {}",
294 parent.display(),
295 err
296 );
297 return;
298 }
299 }
300 match std::fs::copy(&legacy, destination) {
301 Ok(_) => tracing::info!(
302 "Migrated ROM cache from {} to {}",
303 legacy.display(),
304 destination.display()
305 ),
306 Err(err) => tracing::warn!(
307 "Failed to migrate ROM cache from {} to {}: {}",
308 legacy.display(),
309 destination.display(),
310 err
311 ),
312 }
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318 use crate::types::Rom;
319 use std::sync::{Mutex, MutexGuard, OnceLock};
320 use std::time::{SystemTime, UNIX_EPOCH};
321
322 fn env_lock() -> &'static Mutex<()> {
323 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
324 LOCK.get_or_init(|| Mutex::new(()))
325 }
326
327 struct TestEnv {
328 _guard: MutexGuard<'static, ()>,
329 }
330
331 impl TestEnv {
332 fn new() -> Self {
333 let guard = env_lock().lock().expect("env lock");
334 std::env::remove_var("ROMM_CACHE_PATH");
335 std::env::remove_var("ROMM_TEST_CACHE_DIR");
336 Self { _guard: guard }
337 }
338 }
339
340 impl Drop for TestEnv {
341 fn drop(&mut self) {
342 std::env::remove_var("ROMM_CACHE_PATH");
343 std::env::remove_var("ROMM_TEST_CACHE_DIR");
344 }
345 }
346
347 fn sample_rom_list() -> RomList {
348 RomList {
349 items: vec![Rom {
350 id: 1,
351 platform_id: 10,
352 platform_slug: None,
353 platform_fs_slug: None,
354 platform_custom_name: Some("NES".to_string()),
355 platform_display_name: Some("NES".to_string()),
356 fs_name: "Mario (USA).zip".to_string(),
357 fs_name_no_tags: "Mario".to_string(),
358 fs_name_no_ext: "Mario".to_string(),
359 fs_extension: "zip".to_string(),
360 fs_path: "/roms/mario.zip".to_string(),
361 fs_size_bytes: 1234,
362 name: "Mario".to_string(),
363 slug: Some("mario".to_string()),
364 summary: Some("A platform game".to_string()),
365 path_cover_small: None,
366 path_cover_large: None,
367 url_cover: None,
368 has_manual: false,
369 path_manual: None,
370 url_manual: None,
371 is_unidentified: false,
372 is_identified: true,
373 files: Vec::new(),
374 }],
375 total: 1,
376 limit: 50,
377 offset: 0,
378 }
379 }
380
381 fn temp_cache_path() -> PathBuf {
382 let ts = SystemTime::now()
383 .duration_since(UNIX_EPOCH)
384 .expect("time")
385 .as_nanos();
386 std::env::temp_dir().join(format!("romm-cache-test-{}.json", ts))
387 }
388
389 #[test]
390 fn returns_cache_only_for_matching_expected_count() {
391 let path = temp_cache_path();
392 let mut cache = RomCache::load_from(path.clone());
393 let key = RomCacheKey::Platform(42);
394 let list = sample_rom_list();
395 cache.insert(key.clone(), list.clone(), 7);
396
397 assert!(cache.get_valid(&key, 7).is_some());
398 assert!(cache.get_valid(&key, 8).is_none());
399
400 let _ = std::fs::remove_file(path);
401 }
402
403 #[test]
404 fn persists_and_reloads_entries_from_disk() {
405 let path = temp_cache_path();
406 let mut cache = RomCache::load_from(path.clone());
407 let key = RomCacheKey::Collection(9);
408 let list = sample_rom_list();
409 cache.insert(key.clone(), list.clone(), 3);
410
411 let loaded = RomCache::load_from(path.clone());
412 let cached = loaded.get_valid(&key, 3).expect("cached value");
413 assert_eq!(cached.items.len(), 1);
414 assert_eq!(cached.items[0].name, "Mario");
415
416 let _ = std::fs::remove_file(path);
417 }
418
419 #[test]
420 fn persists_virtual_collection_key() {
421 let path = temp_cache_path();
422 let mut cache = RomCache::load_from(path.clone());
423 let key = RomCacheKey::VirtualCollection("recent".to_string());
424 let list = sample_rom_list();
425 cache.insert(key.clone(), list.clone(), 5);
426
427 let loaded = RomCache::load_from(path.clone());
428 let cached = loaded.get_valid(&key, 5).expect("cached value");
429 assert_eq!(cached.items.len(), 1);
430
431 let _ = std::fs::remove_file(path);
432 }
433
434 #[test]
435 fn remove_and_remove_all_platform_entries() {
436 let path = temp_cache_path();
437 let mut cache = RomCache::load_from(path.clone());
438 cache.insert(RomCacheKey::Platform(1), sample_rom_list(), 1);
439 cache.insert(RomCacheKey::Platform(2), sample_rom_list(), 1);
440 cache.insert(RomCacheKey::Collection(9), sample_rom_list(), 1);
441
442 assert!(cache.remove(&RomCacheKey::Platform(1)));
443 assert!(cache.get_valid(&RomCacheKey::Platform(1), 1).is_none());
444 assert!(cache.get_valid(&RomCacheKey::Platform(2), 1).is_some());
445
446 assert_eq!(cache.remove_all_platform_entries(), 1);
447 assert!(cache.get_valid(&RomCacheKey::Platform(2), 1).is_none());
448 assert!(cache.get_valid(&RomCacheKey::Collection(9), 1).is_some());
449
450 let _ = std::fs::remove_file(path);
451 }
452
453 #[test]
454 fn effective_path_uses_env_override_when_set() {
455 let _env = TestEnv::new();
456 let path = std::env::temp_dir().join("romm-cache-env-override.json");
457 std::env::set_var("ROMM_CACHE_PATH", &path);
458 assert_eq!(RomCache::effective_path(), path);
459 }
460
461 #[test]
462 fn effective_path_uses_test_cache_dir_without_override() {
463 let _env = TestEnv::new();
464 let dir = std::env::temp_dir().join("romm-cache-default-dir-test");
465 std::env::set_var("ROMM_TEST_CACHE_DIR", &dir);
466 assert_eq!(RomCache::effective_path(), dir.join(DEFAULT_CACHE_FILE));
467 }
468
469 #[test]
470 fn migrates_legacy_cache_once_for_default_path() {
471 let _env = TestEnv::new();
472 let ts = SystemTime::now()
473 .duration_since(UNIX_EPOCH)
474 .expect("time")
475 .as_nanos();
476 let work_dir = std::env::temp_dir().join(format!("romm-cache-migrate-cwd-{ts}"));
477 let cache_dir = std::env::temp_dir().join(format!("romm-cache-migrate-dest-{ts}"));
478 std::fs::create_dir_all(&work_dir).expect("create work dir");
479 std::env::set_var("ROMM_TEST_CACHE_DIR", &cache_dir);
480
481 let prev_cwd = std::env::current_dir().expect("cwd");
482 std::env::set_current_dir(&work_dir).expect("set cwd");
483 let mut legacy = RomCache::load_from(PathBuf::from(DEFAULT_CACHE_FILE));
484 let key = RomCacheKey::Platform(7);
485 legacy.insert(key.clone(), sample_rom_list(), 1);
486
487 let migrated = RomCache::load();
488 assert!(migrated.get_valid(&key, 1).is_some());
489
490 std::env::set_current_dir(prev_cwd).expect("restore cwd");
491 let _ = std::fs::remove_dir_all(&work_dir);
492 let _ = std::fs::remove_dir_all(&cache_dir);
493 }
494
495 #[test]
496 fn clear_file_removes_existing_cache_file() {
497 let _env = TestEnv::new();
498 let path = temp_cache_path();
499 std::env::set_var("ROMM_CACHE_PATH", &path);
500 let mut cache = RomCache::load();
501 cache.insert(RomCacheKey::Platform(1), sample_rom_list(), 1);
502 assert!(path.is_file());
503 assert!(RomCache::clear_file().expect("clear should work"));
504 assert!(!path.exists());
505 }
506}