Skip to main content

romm_api/core/
cache.rs

1//! Persistent ROM cache — survives across program restarts.
2//!
3//! Stores `RomList` per platform/collection on disk as JSON. On load, entries
4//! are validated against the live `rom_count` from the API; stale entries are
5//! silently discarded so only changed platforms trigger a re-fetch.
6//!
7//! Disk-backed cache for ROM metadata and collection lists.
8//!
9//! This module provides a fast, persistent cache to reduce API calls
10//! and improve the responsiveness of the TUI and CLI.
11
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14
15use serde::{Deserialize, Serialize};
16
17use crate::types::RomList;
18
19/// Cache file name used by both default and legacy paths.
20const DEFAULT_CACHE_FILE: &str = "romm-cache.json";
21
22// ---------------------------------------------------------------------------
23// Cache key
24// ---------------------------------------------------------------------------
25
26#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
27pub enum RomCacheKey {
28    Platform(u64),
29    /// Manual (user) collection from `GET /api/collections`.
30    Collection(u64),
31    /// Smart collection from `GET /api/collections/smart` (distinct ID space from manual).
32    SmartCollection(u64),
33    /// Virtual (autogenerated) collection from `GET /api/collections/virtual` (string id).
34    VirtualCollection(String),
35}
36
37// ---------------------------------------------------------------------------
38// On-disk format
39// ---------------------------------------------------------------------------
40
41#[derive(Serialize, Deserialize)]
42struct CacheFile {
43    version: u32,
44    entries: Vec<CacheEntry>,
45}
46
47#[derive(Serialize, Deserialize)]
48struct CacheEntry {
49    key: RomCacheKey,
50    /// The `platform.rom_count` (or `collection.rom_count`) at the time we
51    /// cached this data.  On lookup we compare this against the *current*
52    /// platform count, NOT against `data.total` (which can legitimately differ).
53    expected_count: u64,
54    data: RomList,
55}
56
57// ---------------------------------------------------------------------------
58// RomCache
59// ---------------------------------------------------------------------------
60
61/// In-memory view of the persisted ROM cache.
62///
63/// Internally this is just a `HashMap` keyed by [`RomCacheKey`], plus the
64/// path of the JSON file on disk. All callers go through [`RomCache::load`]
65/// so they never touch the filesystem directly.
66pub struct RomCache {
67    entries: HashMap<RomCacheKey, (u64, RomList)>, // (expected_count, data)
68    path: PathBuf,
69}
70
71/// Basic diagnostics for the ROM cache file on disk.
72#[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    /// Load cache from disk (or start empty if the file is missing / corrupt).
84    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    /// Effective ROM cache path (`ROMM_CACHE_PATH` override wins over OS-local default).
93    pub fn effective_path() -> PathBuf {
94        cache_path_with_override().0
95    }
96
97    /// Remove the cache file from disk if it exists.
98    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    /// Read best-effort metadata and parse information for the current cache file.
108    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    /// Persist current cache to disk (best-effort; errors are silently ignored).
165    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    /// Return cached data **only** if the platform's `rom_count` hasn't changed
214    /// since we cached it.  We compare the stored count (from the platforms
215    /// endpoint at cache time) against the current count — NOT `RomList.total`,
216    /// which can legitimately differ from `rom_count`.
217    ///
218    /// Also rejects incomplete paginated lists (`items.len() < total`) so a
219    /// mid-fetch abort cannot poison later cache hits.
220    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    /// Insert (or replace) an entry, then persist to disk.
230    /// `expected_count` is the platform/collection `rom_count` at this moment.
231    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    /// Drop one cache entry if present, then persist.
237    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    /// Remove every [`RomCacheKey::Platform`] entry (e.g. after a full `scan_library`).
246    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}