use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::types::RomList;
const DEFAULT_CACHE_FILE: &str = "romm-cache.json";
#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
pub enum RomCacheKey {
Platform(u64),
Collection(u64),
SmartCollection(u64),
VirtualCollection(String),
}
#[derive(Serialize, Deserialize)]
struct CacheFile {
version: u32,
entries: Vec<CacheEntry>,
}
#[derive(Serialize, Deserialize)]
struct CacheEntry {
key: RomCacheKey,
expected_count: u64,
data: RomList,
}
pub struct RomCache {
entries: HashMap<RomCacheKey, (u64, RomList)>, path: PathBuf,
}
#[derive(Debug, Clone)]
pub struct RomCacheInfo {
pub path: PathBuf,
pub exists: bool,
pub size_bytes: Option<u64>,
pub version: Option<u32>,
pub entry_count: Option<usize>,
pub parse_error: Option<String>,
}
impl RomCache {
pub fn load() -> Self {
let (path, from_env_override) = cache_path_with_override();
if !from_env_override {
maybe_migrate_legacy_cache(&path);
}
Self::load_from(path)
}
pub fn effective_path() -> PathBuf {
cache_path_with_override().0
}
pub fn clear_file() -> std::io::Result<bool> {
let path = Self::effective_path();
if path.is_file() {
std::fs::remove_file(path)?;
return Ok(true);
}
Ok(false)
}
pub fn read_info() -> RomCacheInfo {
let path = Self::effective_path();
let meta = std::fs::metadata(&path).ok();
let exists = meta.is_some();
let size_bytes = meta.map(|m| m.len());
let mut info = RomCacheInfo {
path: path.clone(),
exists,
size_bytes,
version: None,
entry_count: None,
parse_error: None,
};
if !exists {
return info;
}
match std::fs::read_to_string(&path) {
Ok(data) => match serde_json::from_str::<CacheFile>(&data) {
Ok(file) => {
info.version = Some(file.version);
info.entry_count = Some(file.entries.len());
}
Err(err) => {
info.parse_error = Some(err.to_string());
}
},
Err(err) => {
info.parse_error = Some(err.to_string());
}
}
info
}
fn load_from(path: PathBuf) -> Self {
let entries = Self::read_file(&path).unwrap_or_default();
Self { entries, path }
}
fn read_file(path: &Path) -> Option<HashMap<RomCacheKey, (u64, RomList)>> {
let data = std::fs::read_to_string(path).ok()?;
let file: CacheFile = serde_json::from_str(&data).ok()?;
if file.version != 1 {
return None;
}
let map = file
.entries
.into_iter()
.map(|e| (e.key, (e.expected_count, e.data)))
.collect();
Some(map)
}
pub fn save(&self) {
let file = CacheFile {
version: 1,
entries: self
.entries
.iter()
.map(|(k, (ec, v))| CacheEntry {
key: k.clone(),
expected_count: *ec,
data: v.clone(),
})
.collect(),
};
let path = self.path.clone();
let write_fn = move || match serde_json::to_string(&file) {
Ok(json) => {
if let Some(parent) = path.parent() {
if let Err(err) = std::fs::create_dir_all(parent) {
eprintln!(
"warning: failed to create ROM cache directory {:?}: {}",
parent, err
);
return;
}
}
if let Err(err) = std::fs::write(&path, json) {
eprintln!(
"warning: failed to write ROM cache file {:?}: {}",
path, err
);
}
}
Err(err) => {
eprintln!(
"warning: failed to serialize ROM cache file {:?}: {}",
path, err
);
}
};
#[cfg(test)]
write_fn();
#[cfg(not(test))]
std::thread::spawn(write_fn);
}
pub fn get_valid(&self, key: &RomCacheKey, expected_count: u64) -> Option<&RomList> {
self.entries
.get(key)
.filter(|(stored_count, _)| *stored_count == expected_count)
.map(|(_, list)| list)
}
pub fn insert(&mut self, key: RomCacheKey, data: RomList, expected_count: u64) {
self.entries.insert(key, (expected_count, data));
self.save();
}
pub fn remove(&mut self, key: &RomCacheKey) -> bool {
let removed = self.entries.remove(key).is_some();
if removed {
self.save();
}
removed
}
pub fn remove_all_platform_entries(&mut self) -> usize {
let before = self.entries.len();
self.entries
.retain(|k, _| !matches!(k, RomCacheKey::Platform(_)));
let removed = before - self.entries.len();
if removed > 0 {
self.save();
}
removed
}
}
fn cache_path_with_override() -> (PathBuf, bool) {
if let Ok(path) = std::env::var("ROMM_CACHE_PATH") {
return (PathBuf::from(path), true);
}
(default_cache_path(), false)
}
fn default_cache_path() -> PathBuf {
if let Ok(dir) = std::env::var("ROMM_TEST_CACHE_DIR") {
return PathBuf::from(dir).join(DEFAULT_CACHE_FILE);
}
if let Some(dir) = dirs::cache_dir() {
return dir.join("romm-cli").join(DEFAULT_CACHE_FILE);
}
PathBuf::from(DEFAULT_CACHE_FILE)
}
fn legacy_cache_path() -> PathBuf {
PathBuf::from(DEFAULT_CACHE_FILE)
}
fn maybe_migrate_legacy_cache(destination: &Path) {
if destination.is_file() {
return;
}
let legacy = legacy_cache_path();
if !legacy.is_file() {
return;
}
if RomCache::read_file(&legacy).is_none() {
tracing::warn!(
"Skipping legacy ROM cache migration from {} because file is unreadable or invalid",
legacy.display()
);
return;
}
if let Some(parent) = destination.parent() {
if let Err(err) = std::fs::create_dir_all(parent) {
tracing::warn!(
"Failed to create ROM cache directory {} for migration: {}",
parent.display(),
err
);
return;
}
}
match std::fs::copy(&legacy, destination) {
Ok(_) => tracing::info!(
"Migrated ROM cache from {} to {}",
legacy.display(),
destination.display()
),
Err(err) => tracing::warn!(
"Failed to migrate ROM cache from {} to {}: {}",
legacy.display(),
destination.display(),
err
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::Rom;
use std::sync::{Mutex, MutexGuard, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
struct TestEnv {
_guard: MutexGuard<'static, ()>,
}
impl TestEnv {
fn new() -> Self {
let guard = env_lock().lock().expect("env lock");
std::env::remove_var("ROMM_CACHE_PATH");
std::env::remove_var("ROMM_TEST_CACHE_DIR");
Self { _guard: guard }
}
}
impl Drop for TestEnv {
fn drop(&mut self) {
std::env::remove_var("ROMM_CACHE_PATH");
std::env::remove_var("ROMM_TEST_CACHE_DIR");
}
}
fn sample_rom_list() -> RomList {
RomList {
items: vec![Rom {
id: 1,
platform_id: 10,
platform_slug: None,
platform_fs_slug: None,
platform_custom_name: Some("NES".to_string()),
platform_display_name: Some("NES".to_string()),
fs_name: "Mario (USA).zip".to_string(),
fs_name_no_tags: "Mario".to_string(),
fs_name_no_ext: "Mario".to_string(),
fs_extension: "zip".to_string(),
fs_path: "/roms/mario.zip".to_string(),
fs_size_bytes: 1234,
name: "Mario".to_string(),
slug: Some("mario".to_string()),
summary: Some("A platform game".to_string()),
path_cover_small: None,
path_cover_large: None,
url_cover: None,
is_unidentified: false,
is_identified: true,
}],
total: 1,
limit: 50,
offset: 0,
}
}
fn temp_cache_path() -> PathBuf {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos();
std::env::temp_dir().join(format!("romm-cache-test-{}.json", ts))
}
#[test]
fn returns_cache_only_for_matching_expected_count() {
let path = temp_cache_path();
let mut cache = RomCache::load_from(path.clone());
let key = RomCacheKey::Platform(42);
let list = sample_rom_list();
cache.insert(key.clone(), list.clone(), 7);
assert!(cache.get_valid(&key, 7).is_some());
assert!(cache.get_valid(&key, 8).is_none());
let _ = std::fs::remove_file(path);
}
#[test]
fn persists_and_reloads_entries_from_disk() {
let path = temp_cache_path();
let mut cache = RomCache::load_from(path.clone());
let key = RomCacheKey::Collection(9);
let list = sample_rom_list();
cache.insert(key.clone(), list.clone(), 3);
let loaded = RomCache::load_from(path.clone());
let cached = loaded.get_valid(&key, 3).expect("cached value");
assert_eq!(cached.items.len(), 1);
assert_eq!(cached.items[0].name, "Mario");
let _ = std::fs::remove_file(path);
}
#[test]
fn persists_virtual_collection_key() {
let path = temp_cache_path();
let mut cache = RomCache::load_from(path.clone());
let key = RomCacheKey::VirtualCollection("recent".to_string());
let list = sample_rom_list();
cache.insert(key.clone(), list.clone(), 5);
let loaded = RomCache::load_from(path.clone());
let cached = loaded.get_valid(&key, 5).expect("cached value");
assert_eq!(cached.items.len(), 1);
let _ = std::fs::remove_file(path);
}
#[test]
fn remove_and_remove_all_platform_entries() {
let path = temp_cache_path();
let mut cache = RomCache::load_from(path.clone());
cache.insert(RomCacheKey::Platform(1), sample_rom_list(), 1);
cache.insert(RomCacheKey::Platform(2), sample_rom_list(), 1);
cache.insert(RomCacheKey::Collection(9), sample_rom_list(), 1);
assert!(cache.remove(&RomCacheKey::Platform(1)));
assert!(cache.get_valid(&RomCacheKey::Platform(1), 1).is_none());
assert!(cache.get_valid(&RomCacheKey::Platform(2), 1).is_some());
assert_eq!(cache.remove_all_platform_entries(), 1);
assert!(cache.get_valid(&RomCacheKey::Platform(2), 1).is_none());
assert!(cache.get_valid(&RomCacheKey::Collection(9), 1).is_some());
let _ = std::fs::remove_file(path);
}
#[test]
fn effective_path_uses_env_override_when_set() {
let _env = TestEnv::new();
let path = std::env::temp_dir().join("romm-cache-env-override.json");
std::env::set_var("ROMM_CACHE_PATH", &path);
assert_eq!(RomCache::effective_path(), path);
}
#[test]
fn effective_path_uses_test_cache_dir_without_override() {
let _env = TestEnv::new();
let dir = std::env::temp_dir().join("romm-cache-default-dir-test");
std::env::set_var("ROMM_TEST_CACHE_DIR", &dir);
assert_eq!(RomCache::effective_path(), dir.join(DEFAULT_CACHE_FILE));
}
#[test]
fn migrates_legacy_cache_once_for_default_path() {
let _env = TestEnv::new();
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos();
let work_dir = std::env::temp_dir().join(format!("romm-cache-migrate-cwd-{ts}"));
let cache_dir = std::env::temp_dir().join(format!("romm-cache-migrate-dest-{ts}"));
std::fs::create_dir_all(&work_dir).expect("create work dir");
std::env::set_var("ROMM_TEST_CACHE_DIR", &cache_dir);
let prev_cwd = std::env::current_dir().expect("cwd");
std::env::set_current_dir(&work_dir).expect("set cwd");
let mut legacy = RomCache::load_from(PathBuf::from(DEFAULT_CACHE_FILE));
let key = RomCacheKey::Platform(7);
legacy.insert(key.clone(), sample_rom_list(), 1);
let migrated = RomCache::load();
assert!(migrated.get_valid(&key, 1).is_some());
std::env::set_current_dir(prev_cwd).expect("restore cwd");
let _ = std::fs::remove_dir_all(&work_dir);
let _ = std::fs::remove_dir_all(&cache_dir);
}
#[test]
fn clear_file_removes_existing_cache_file() {
let _env = TestEnv::new();
let path = temp_cache_path();
std::env::set_var("ROMM_CACHE_PATH", &path);
let mut cache = RomCache::load();
cache.insert(RomCacheKey::Platform(1), sample_rom_list(), 1);
assert!(path.is_file());
assert!(RomCache::clear_file().expect("clear should work"));
assert!(!path.exists());
}
}