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/// Default cache file path (next to the binary / CWD).
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
66impl RomCache {
67    /// Load cache from disk (or start empty if the file is missing / corrupt).
68    pub fn load() -> Self {
69        let path = PathBuf::from(
70            std::env::var("ROMM_CACHE_PATH").unwrap_or_else(|_| DEFAULT_CACHE_FILE.to_string()),
71        );
72        Self::load_from(path)
73    }
74
75    fn load_from(path: PathBuf) -> Self {
76        let entries = Self::read_file(&path).unwrap_or_default();
77        Self { entries, path }
78    }
79
80    fn read_file(path: &Path) -> Option<HashMap<RomCacheKey, (u64, RomList)>> {
81        let data = std::fs::read_to_string(path).ok()?;
82        let file: CacheFile = serde_json::from_str(&data).ok()?;
83        if file.version != 1 {
84            return None;
85        }
86        let map = file
87            .entries
88            .into_iter()
89            .map(|e| (e.key, (e.expected_count, e.data)))
90            .collect();
91        Some(map)
92    }
93
94    /// Persist current cache to disk (best-effort; errors are silently ignored).
95    pub fn save(&self) {
96        let file = CacheFile {
97            version: 1,
98            entries: self
99                .entries
100                .iter()
101                .map(|(k, (ec, v))| CacheEntry {
102                    key: k.clone(),
103                    expected_count: *ec,
104                    data: v.clone(),
105                })
106                .collect(),
107        };
108        match serde_json::to_string(&file) {
109            Ok(json) => {
110                if let Err(err) = std::fs::write(&self.path, json) {
111                    eprintln!(
112                        "warning: failed to write ROM cache file {:?}: {}",
113                        self.path, err
114                    );
115                }
116            }
117            Err(err) => {
118                eprintln!(
119                    "warning: failed to serialize ROM cache file {:?}: {}",
120                    self.path, err
121                );
122            }
123        }
124    }
125
126    /// Return cached data **only** if the platform's `rom_count` hasn't changed
127    /// since we cached it.  We compare the stored count (from the platforms
128    /// endpoint at cache time) against the current count — NOT `RomList.total`,
129    /// which can legitimately differ from `rom_count`.
130    pub fn get_valid(&self, key: &RomCacheKey, expected_count: u64) -> Option<&RomList> {
131        self.entries
132            .get(key)
133            .filter(|(stored_count, _)| *stored_count == expected_count)
134            .map(|(_, list)| list)
135    }
136
137    /// Insert (or replace) an entry, then persist to disk.
138    /// `expected_count` is the platform/collection `rom_count` at this moment.
139    pub fn insert(&mut self, key: RomCacheKey, data: RomList, expected_count: u64) {
140        self.entries.insert(key, (expected_count, data));
141        self.save();
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use crate::types::Rom;
149    use std::time::{SystemTime, UNIX_EPOCH};
150
151    fn sample_rom_list() -> RomList {
152        RomList {
153            items: vec![Rom {
154                id: 1,
155                platform_id: 10,
156                platform_slug: None,
157                platform_fs_slug: None,
158                platform_custom_name: Some("NES".to_string()),
159                platform_display_name: Some("NES".to_string()),
160                fs_name: "Mario (USA).zip".to_string(),
161                fs_name_no_tags: "Mario".to_string(),
162                fs_name_no_ext: "Mario".to_string(),
163                fs_extension: "zip".to_string(),
164                fs_path: "/roms/mario.zip".to_string(),
165                fs_size_bytes: 1234,
166                name: "Mario".to_string(),
167                slug: Some("mario".to_string()),
168                summary: Some("A platform game".to_string()),
169                path_cover_small: None,
170                path_cover_large: None,
171                url_cover: None,
172                is_unidentified: false,
173                is_identified: true,
174            }],
175            total: 1,
176            limit: 50,
177            offset: 0,
178        }
179    }
180
181    fn temp_cache_path() -> PathBuf {
182        let ts = SystemTime::now()
183            .duration_since(UNIX_EPOCH)
184            .expect("time")
185            .as_nanos();
186        std::env::temp_dir().join(format!("romm-cache-test-{}.json", ts))
187    }
188
189    #[test]
190    fn returns_cache_only_for_matching_expected_count() {
191        let path = temp_cache_path();
192        let mut cache = RomCache::load_from(path.clone());
193        let key = RomCacheKey::Platform(42);
194        let list = sample_rom_list();
195        cache.insert(key.clone(), list.clone(), 7);
196
197        assert!(cache.get_valid(&key, 7).is_some());
198        assert!(cache.get_valid(&key, 8).is_none());
199
200        let _ = std::fs::remove_file(path);
201    }
202
203    #[test]
204    fn persists_and_reloads_entries_from_disk() {
205        let path = temp_cache_path();
206        let mut cache = RomCache::load_from(path.clone());
207        let key = RomCacheKey::Collection(9);
208        let list = sample_rom_list();
209        cache.insert(key.clone(), list.clone(), 3);
210
211        let loaded = RomCache::load_from(path.clone());
212        let cached = loaded.get_valid(&key, 3).expect("cached value");
213        assert_eq!(cached.items.len(), 1);
214        assert_eq!(cached.items[0].name, "Mario");
215
216        let _ = std::fs::remove_file(path);
217    }
218
219    #[test]
220    fn persists_virtual_collection_key() {
221        let path = temp_cache_path();
222        let mut cache = RomCache::load_from(path.clone());
223        let key = RomCacheKey::VirtualCollection("recent".to_string());
224        let list = sample_rom_list();
225        cache.insert(key.clone(), list.clone(), 5);
226
227        let loaded = RomCache::load_from(path.clone());
228        let cached = loaded.get_valid(&key, 5).expect("cached value");
229        assert_eq!(cached.items.len(), 1);
230
231        let _ = std::fs::remove_file(path);
232    }
233}