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    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    /// Insert (or replace) an entry, then persist to disk.
225    /// `expected_count` is the platform/collection `rom_count` at this moment.
226    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    /// Drop one cache entry if present, then persist.
232    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    /// Remove every [`RomCacheKey::Platform`] entry (e.g. after a full `scan_library`).
241    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}