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, Copy, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
22pub enum RomCacheKey {
23    Platform(u64),
24    Collection(u64),
25}
26
27// ---------------------------------------------------------------------------
28// On-disk format
29// ---------------------------------------------------------------------------
30
31#[derive(Serialize, Deserialize)]
32struct CacheFile {
33    version: u32,
34    entries: Vec<CacheEntry>,
35}
36
37#[derive(Serialize, Deserialize)]
38struct CacheEntry {
39    key: RomCacheKey,
40    /// The `platform.rom_count` (or `collection.rom_count`) at the time we
41    /// cached this data.  On lookup we compare this against the *current*
42    /// platform count, NOT against `data.total` (which can legitimately differ).
43    expected_count: u64,
44    data: RomList,
45}
46
47// ---------------------------------------------------------------------------
48// RomCache
49// ---------------------------------------------------------------------------
50
51/// In-memory view of the persisted ROM cache.
52///
53/// Internally this is just a `HashMap` keyed by [`RomCacheKey`], plus the
54/// path of the JSON file on disk. All callers go through [`RomCache::load`]
55/// so they never touch the filesystem directly.
56pub struct RomCache {
57    entries: HashMap<RomCacheKey, (u64, RomList)>, // (expected_count, data)
58    path: PathBuf,
59}
60
61impl RomCache {
62    /// Load cache from disk (or start empty if the file is missing / corrupt).
63    pub fn load() -> Self {
64        let path = PathBuf::from(
65            std::env::var("ROMM_CACHE_PATH").unwrap_or_else(|_| DEFAULT_CACHE_FILE.to_string()),
66        );
67        Self::load_from(path)
68    }
69
70    fn load_from(path: PathBuf) -> Self {
71        let entries = Self::read_file(&path).unwrap_or_default();
72        Self { entries, path }
73    }
74
75    fn read_file(path: &Path) -> Option<HashMap<RomCacheKey, (u64, RomList)>> {
76        let data = std::fs::read_to_string(path).ok()?;
77        let file: CacheFile = serde_json::from_str(&data).ok()?;
78        if file.version != 1 {
79            return None;
80        }
81        let map = file
82            .entries
83            .into_iter()
84            .map(|e| (e.key, (e.expected_count, e.data)))
85            .collect();
86        Some(map)
87    }
88
89    /// Persist current cache to disk (best-effort; errors are silently ignored).
90    pub fn save(&self) {
91        let file = CacheFile {
92            version: 1,
93            entries: self
94                .entries
95                .iter()
96                .map(|(k, (ec, v))| CacheEntry {
97                    key: *k,
98                    expected_count: *ec,
99                    data: v.clone(),
100                })
101                .collect(),
102        };
103        match serde_json::to_string(&file) {
104            Ok(json) => {
105                if let Err(err) = std::fs::write(&self.path, json) {
106                    eprintln!(
107                        "warning: failed to write ROM cache file {:?}: {}",
108                        self.path, err
109                    );
110                }
111            }
112            Err(err) => {
113                eprintln!(
114                    "warning: failed to serialize ROM cache file {:?}: {}",
115                    self.path, err
116                );
117            }
118        }
119    }
120
121    /// Return cached data **only** if the platform's `rom_count` hasn't changed
122    /// since we cached it.  We compare the stored count (from the platforms
123    /// endpoint at cache time) against the current count — NOT `RomList.total`,
124    /// which can legitimately differ from `rom_count`.
125    pub fn get_valid(&self, key: &RomCacheKey, expected_count: u64) -> Option<&RomList> {
126        self.entries
127            .get(key)
128            .filter(|(stored_count, _)| *stored_count == expected_count)
129            .map(|(_, list)| list)
130    }
131
132    /// Insert (or replace) an entry, then persist to disk.
133    /// `expected_count` is the platform/collection `rom_count` at this moment.
134    pub fn insert(&mut self, key: RomCacheKey, data: RomList, expected_count: u64) {
135        self.entries.insert(key, (expected_count, data));
136        self.save();
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use crate::types::Rom;
144    use std::time::{SystemTime, UNIX_EPOCH};
145
146    fn sample_rom_list() -> RomList {
147        RomList {
148            items: vec![Rom {
149                id: 1,
150                platform_id: 10,
151                platform_slug: None,
152                platform_fs_slug: None,
153                platform_custom_name: Some("NES".to_string()),
154                platform_display_name: Some("NES".to_string()),
155                fs_name: "Mario (USA).zip".to_string(),
156                fs_name_no_tags: "Mario".to_string(),
157                fs_name_no_ext: "Mario".to_string(),
158                fs_extension: "zip".to_string(),
159                fs_path: "/roms/mario.zip".to_string(),
160                fs_size_bytes: 1234,
161                name: "Mario".to_string(),
162                slug: Some("mario".to_string()),
163                summary: Some("A platform game".to_string()),
164                path_cover_small: None,
165                path_cover_large: None,
166                url_cover: None,
167                is_unidentified: false,
168                is_identified: true,
169            }],
170            total: 1,
171            limit: 50,
172            offset: 0,
173        }
174    }
175
176    fn temp_cache_path() -> PathBuf {
177        let ts = SystemTime::now()
178            .duration_since(UNIX_EPOCH)
179            .expect("time")
180            .as_nanos();
181        std::env::temp_dir().join(format!("romm-cache-test-{}.json", ts))
182    }
183
184    #[test]
185    fn returns_cache_only_for_matching_expected_count() {
186        let path = temp_cache_path();
187        let mut cache = RomCache::load_from(path.clone());
188        let key = RomCacheKey::Platform(42);
189        let list = sample_rom_list();
190        cache.insert(key, list.clone(), 7);
191
192        assert!(cache.get_valid(&key, 7).is_some());
193        assert!(cache.get_valid(&key, 8).is_none());
194
195        let _ = std::fs::remove_file(path);
196    }
197
198    #[test]
199    fn persists_and_reloads_entries_from_disk() {
200        let path = temp_cache_path();
201        let mut cache = RomCache::load_from(path.clone());
202        let key = RomCacheKey::Collection(9);
203        let list = sample_rom_list();
204        cache.insert(key, list.clone(), 3);
205
206        let loaded = RomCache::load_from(path.clone());
207        let cached = loaded.get_valid(&key, 3).expect("cached value");
208        assert_eq!(cached.items.len(), 1);
209        assert_eq!(cached.items[0].name, "Mario");
210
211        let _ = std::fs::remove_file(path);
212    }
213}