Skip to main content

romm_cli/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
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10use serde::{Deserialize, Serialize};
11
12use crate::types::RomList;
13
14/// Cache file name used by both default and legacy paths.
15const DEFAULT_CACHE_FILE: &str = "romm-cache.json";
16
17// ---------------------------------------------------------------------------
18// Cache key
19// ---------------------------------------------------------------------------
20
21#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
22pub enum RomCacheKey {
23    Platform(u64),
24    /// Manual (user) collection from `GET /api/collections`.
25    Collection(u64),
26    /// Smart collection from `GET /api/collections/smart` (distinct ID space from manual).
27    SmartCollection(u64),
28    /// Virtual (autogenerated) collection from `GET /api/collections/virtual` (string id).
29    VirtualCollection(String),
30}
31
32// ---------------------------------------------------------------------------
33// On-disk format
34// ---------------------------------------------------------------------------
35
36#[derive(Serialize, Deserialize)]
37struct CacheFile {
38    version: u32,
39    entries: Vec<CacheEntry>,
40}
41
42#[derive(Serialize, Deserialize)]
43struct CacheEntry {
44    key: RomCacheKey,
45    /// The `platform.rom_count` (or `collection.rom_count`) at the time we
46    /// cached this data.  On lookup we compare this against the *current*
47    /// platform count, NOT against `data.total` (which can legitimately differ).
48    expected_count: u64,
49    data: RomList,
50}
51
52// ---------------------------------------------------------------------------
53// RomCache
54// ---------------------------------------------------------------------------
55
56/// In-memory view of the persisted ROM cache.
57///
58/// Internally this is just a `HashMap` keyed by [`RomCacheKey`], plus the
59/// path of the JSON file on disk. All callers go through [`RomCache::load`]
60/// so they never touch the filesystem directly.
61pub struct RomCache {
62    entries: HashMap<RomCacheKey, (u64, RomList)>, // (expected_count, data)
63    path: PathBuf,
64}
65
66/// Basic diagnostics for the ROM cache file on disk.
67#[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    /// Load cache from disk (or start empty if the file is missing / corrupt).
79    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    /// Effective ROM cache path (`ROMM_CACHE_PATH` override wins over OS-local default).
88    pub fn effective_path() -> PathBuf {
89        cache_path_with_override().0
90    }
91
92    /// Remove the cache file from disk if it exists.
93    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    /// Read best-effort metadata and parse information for the current cache file.
103    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    /// Persist current cache to disk (best-effort; errors are silently ignored).
160    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    /// Return cached data **only** if the platform's `rom_count` hasn't changed
209    /// since we cached it.  We compare the stored count (from the platforms
210    /// endpoint at cache time) against the current count — NOT `RomList.total`,
211    /// which can legitimately differ from `rom_count`.
212    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    /// Insert (or replace) an entry, then persist to disk.
220    /// `expected_count` is the platform/collection `rom_count` at this moment.
221    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    /// Drop one cache entry if present, then persist.
227    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    /// Remove every [`RomCacheKey::Platform`] entry (e.g. after a full `scan_library`).
236    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}