pub mod enrichment_cache;
pub mod favorites;
pub mod rom_entry;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use crate::nes::cartridge::{HardwareType, ParsedRom, RomDb};
use crate::nes::console::{
CartridgeCatalogOptions, default_catalog_csv_path, refresh_cartridge_catalog,
};
pub use rom_entry::Platform;
pub use rom_entry::RomEntry;
pub fn load_catalog(
search_paths: &[String],
rebuild: bool,
include_unofficial: bool,
) -> Result<Vec<RomEntry>, String> {
let rom_paths = read_catalog_paths(search_paths, rebuild)?;
let rom_db = RomDb::new().map_err(|e| format!("Failed to load ROM DB: {e}"))?;
let entries: Vec<RomEntry> = rom_paths
.iter()
.map(|p| build_rom_entry(p, &rom_db))
.collect();
if include_unofficial {
let mut result = entries;
dedup_by_crc(&mut result);
Ok(result)
} else {
let mut result: Vec<RomEntry> = entries
.into_iter()
.filter(|e| !is_unofficial_rom(e))
.collect();
dedup_by_crc(&mut result);
Ok(result)
}
}
fn read_catalog_paths(search_paths: &[String], rebuild: bool) -> Result<Vec<PathBuf>, String> {
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.ok_or_else(|| "HOME environment variable not set".to_string())?;
let catalog_path = default_catalog_csv_path(&home);
let mut paths: Vec<PathBuf> = search_paths.iter().map(PathBuf::from).collect();
if paths.is_empty() {
paths.push(home.join(".neser").join("roms"));
}
let mut opts = CartridgeCatalogOptions::new(paths, catalog_path);
opts.scan_enabled = true;
opts.rebuild_catalog = rebuild;
refresh_cartridge_catalog(&opts).map_err(|e| format!("Catalog refresh failed: {e}"))
}
pub fn dedup_by_crc(entries: &mut Vec<RomEntry>) {
let mut seen = HashSet::new();
entries.retain(|e| match &e.crc {
Some(crc) => seen.insert(crc.clone()),
None => true,
});
}
pub fn build_rom_entry(path: &Path, rom_db: &RomDb) -> RomEntry {
let platform = platform_from_path(path);
if platform == Platform::Gb || platform == Platform::Gbc {
return build_gb_rom_entry(path, platform);
}
let bytes = match std::fs::read(path) {
Ok(b) => b,
Err(_) => return unreadable_entry(path),
};
let parsed = match ParsedRom::parse(&bytes, Some(rom_db)) {
Ok(p) => p,
Err(_) => return invalid_entry(path),
};
let crc = parsed.crc32;
let db_entry = rom_db.get_by_crc(crc);
let display_name = db_entry
.and_then(|e| e.name.as_deref())
.map(str::to_string)
.unwrap_or_else(|| file_stem(path));
let mapper = Some(parsed.header.mapper);
let hardware = db_entry.and_then(|e| e.hardware).unwrap_or_else(|| {
HardwareType::from_console_type_and_timing(
parsed.header.console_type,
parsed.header.timing_mode,
)
});
RomEntry {
path: path.to_path_buf(),
search_key: display_name.to_lowercase(),
mapper_label: mapper.map_or_else(|| "-".to_string(), |m| m.to_string()),
display_name,
mapper,
hardware: Some(hardware_label(hardware)),
crc: Some(format!("{crc:08X}")),
recording_duration: read_recording_duration(path),
metadata_game_id: None,
genres: Vec::new(),
overview: None,
release_date: None,
players: None,
rating: None,
boxart_path: None,
screenshot_paths: Vec::new(),
is_favorite: false,
platform,
}
}
fn build_gb_rom_entry(path: &Path, platform: Platform) -> RomEntry {
stub_entry(path, platform)
}
fn unreadable_entry(path: &Path) -> RomEntry {
stub_entry(path, platform_from_path(path))
}
fn invalid_entry(path: &Path) -> RomEntry {
stub_entry(path, platform_from_path(path))
}
fn stub_entry(path: &Path, platform: Platform) -> RomEntry {
let display_name = file_stem(path);
RomEntry {
path: path.to_path_buf(),
search_key: display_name.to_lowercase(),
mapper_label: "-".to_string(),
display_name,
mapper: None,
hardware: None,
crc: None,
recording_duration: read_recording_duration(path),
metadata_game_id: None,
genres: Vec::new(),
overview: None,
release_date: None,
players: None,
rating: None,
boxart_path: None,
screenshot_paths: Vec::new(),
is_favorite: false,
platform,
}
}
fn platform_from_path(path: &Path) -> Platform {
match path
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.to_ascii_lowercase())
.as_deref()
{
Some("gb") => Platform::Gb,
Some("gbc") => Platform::Gbc,
_ => Platform::Nes,
}
}
fn file_stem(path: &Path) -> String {
path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("?")
.to_string()
}
const UNOFFICIAL_KEYWORDS: &[&str] = &[
"(hack",
"[hack",
" hack ",
" hack.",
"_hack_",
"_hack.",
"-hack-",
"-hack.",
"(pirate)",
"[pirate]",
"(bootleg)",
"[bootleg]",
"(homebrew)",
"[homebrew]",
"(unl)",
"[unl]",
"(unif)",
"[unif]",
"(pd)",
"[pd]",
"(beta)",
"[beta]",
"(proto)",
"[proto]",
"(prototype)",
"[prototype]",
"(sample)",
"[sample]",
"(demo)",
"[demo]",
"-demo.",
"-demo-",
"_demo_",
"_demo.",
" demo.",
"(bad)",
"[bad]",
"[b]",
"-bad.",
"-bad-",
"_bad_",
"_bad.",
" bad.",
"(overdump)",
"[o]",
"[!p]",
"[t]",
"(trainer)",
"[t+",
"(translation)",
"-menu.",
"-menu-",
"_menu_",
"_menu.",
" menu.",
"(menu)",
"[menu]",
"-test.",
"-test-",
"_test_",
"_test.",
" test.",
"(test)",
"[test]",
"-hm00.",
"-hm00-",
"_hm00_",
"_hm00.",
"[hm00]",
"-pd.",
"-pd-",
"_pd_",
"_pd.",
" pd.",
"zzz-",
"zzz_",
"zzz ",
];
fn has_numbered_dump_suffix(s: &str) -> bool {
let bytes = s.as_bytes();
let len = bytes.len();
for i in 0..len.saturating_sub(3) {
if bytes[i] == b'-' && (bytes[i + 1] == b'p' || bytes[i + 1] == b'h') {
let rest = &s[i + 2..];
if let Some(dot) = rest.find('.') {
let num_part = &rest[..dot];
if !num_part.is_empty() && num_part.bytes().all(|b| b.is_ascii_digit()) {
return true;
}
}
}
}
for i in 0..len.saturating_sub(4) {
if bytes[i] == b'['
&& (bytes[i + 1] == b'b' || bytes[i + 1] == b'p' || bytes[i + 1] == b'h')
{
let rest = &s[i + 2..];
if let Some(bracket) = rest.find(']') {
let num_part = &rest[..bracket];
if !num_part.is_empty() && num_part.bytes().all(|b| b.is_ascii_digit()) {
return true;
}
}
}
}
false
}
fn is_unofficial_rom(entry: &RomEntry) -> bool {
let filename = entry
.path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_lowercase();
let name = entry.display_name.to_lowercase();
UNOFFICIAL_KEYWORDS
.iter()
.any(|kw| filename.contains(kw) || name.contains(kw))
|| has_numbered_dump_suffix(&filename)
|| has_numbered_dump_suffix(&name)
}
pub fn read_recording_duration(rom_path: &Path) -> Option<std::time::Duration> {
let autorun_path = rom_path.with_extension("autorun");
let content = std::fs::read_to_string(&autorun_path).ok()?;
#[derive(serde::Deserialize)]
struct RleFrame {
#[serde(default = "one")]
repeat: u32,
}
fn one() -> u32 {
1
}
#[derive(serde::Deserialize)]
struct AutorunMeta {
frames: Vec<RleFrame>,
}
let meta: AutorunMeta = serde_json::from_str(&content).ok()?;
let frame_count: u64 = meta.frames.iter().map(|f| f.repeat as u64).sum();
Some(std::time::Duration::from_secs_f64(
frame_count as f64 / 60.0,
))
}
fn hardware_label(hw: HardwareType) -> String {
match hw {
HardwareType::NesNtsc => "NES NTSC",
HardwareType::NesPal => "NES PAL",
HardwareType::Famicom => "Famicom",
HardwareType::VsSystem => "VS System",
HardwareType::Dendy => "Dendy",
HardwareType::Playchoice10 => "PlayChoice-10",
HardwareType::NesMultiRegion => "Multi-region",
HardwareType::FamicomNetworkSystem => "Famicom Net",
_ => "NES NTSC",
}
.to_string()
}
#[cfg(feature = "native")]
#[derive(Debug, Clone)]
pub struct EnrichmentProgress {
pub current: usize,
pub total: usize,
pub game_title: String,
pub phase: EnrichmentPhase,
}
#[cfg(feature = "native")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EnrichmentPhase {
MatchingMetadata,
DownloadingImages,
}
#[cfg(feature = "native")]
pub fn enrich_catalog(
catalog: &mut [RomEntry],
metadata_db_path: &std::path::Path,
image_cache_path: &std::path::Path,
rebuild: bool,
mut progress_callback: impl FnMut(EnrichmentProgress),
) {
use crate::platform::image_cache::ImageCache;
use crate::platform::metadata::{MetadataDb, match_title};
use enrichment_cache::{CachedEnrichment, EnrichmentCache};
let cache_path = image_cache_path.join("enrichment_cache.json");
let mut ecache = if rebuild {
EnrichmentCache::load_empty(&cache_path)
} else {
EnrichmentCache::load(&cache_path)
};
let mut uncached_indices: Vec<usize> = Vec::new();
for (i, entry) in catalog.iter_mut().enumerate() {
if let Some(cached) = ecache.get(&entry.path) {
entry.metadata_game_id = cached.metadata_game_id;
if let Some(ref name) = cached.display_name {
entry.display_name.clone_from(name);
entry.search_key = entry.display_name.to_lowercase();
}
entry.genres.clone_from(&cached.genres);
entry.overview.clone_from(&cached.overview);
entry.release_date.clone_from(&cached.release_date);
entry.players = cached.players;
entry.rating.clone_from(&cached.rating);
entry.boxart_path.clone_from(&cached.boxart_path);
entry.screenshot_paths.clone_from(&cached.screenshot_paths);
} else {
uncached_indices.push(i);
}
}
if uncached_indices.is_empty() {
return;
}
let db = match MetadataDb::open(metadata_db_path) {
Some(db) => db,
None => return,
};
use std::collections::HashMap;
let mut platform_titles: HashMap<i64, Vec<(i64, String)>> = HashMap::new();
for &idx in &uncached_indices {
let pid = catalog[idx].platform.thegamesdb_id();
platform_titles
.entry(pid)
.or_insert_with(|| db.titles_for_platform(pid));
}
let base_url = db.medium_base_url().to_string();
let uncached_total = uncached_indices.len();
for (step, &idx) in uncached_indices.iter().enumerate() {
let entry = &mut catalog[idx];
progress_callback(EnrichmentProgress {
current: step + 1,
total: uncached_total,
game_title: entry.display_name.clone(),
phase: EnrichmentPhase::MatchingMetadata,
});
let pid = entry.platform.thegamesdb_id();
let titles = platform_titles
.get(&pid)
.map(|v| v.as_slice())
.unwrap_or(&[]);
if let Some(m) = match_title(&entry.display_name, titles) {
entry.metadata_game_id = Some(m.game_id);
if let Some(meta) = db.get_game(m.game_id) {
entry.display_name = meta.title;
entry.search_key = entry.display_name.to_lowercase();
entry.genres = meta.genres;
entry.overview = meta.overview;
entry.release_date = meta.release_date;
entry.players = meta.players;
entry.rating = meta.rating;
}
}
}
let image_cache = match ImageCache::new(image_cache_path.to_path_buf(), base_url) {
Ok(cache) => cache,
Err(e) => {
crate::platform::debugging::log_info(format!("Failed to create image cache: {e}"));
for &idx in &uncached_indices {
let entry = &catalog[idx];
ecache.insert(
entry.path.clone(),
CachedEnrichment {
metadata_game_id: entry.metadata_game_id,
display_name: entry.metadata_game_id.map(|_| entry.display_name.clone()),
genres: entry.genres.clone(),
overview: entry.overview.clone(),
release_date: entry.release_date.clone(),
players: entry.players,
rating: entry.rating.clone(),
boxart_path: None,
screenshot_paths: Vec::new(),
},
);
}
ecache.save();
return;
}
};
for (step, &idx) in uncached_indices.iter().enumerate() {
let entry = &mut catalog[idx];
progress_callback(EnrichmentProgress {
current: step + 1,
total: uncached_total,
game_title: entry.display_name.clone(),
phase: EnrichmentPhase::DownloadingImages,
});
if let Some(game_id) = entry.metadata_game_id
&& let Some(meta) = db.get_game(game_id)
{
let boxart_filename = meta.front_boxart();
let screenshot_filenames: Vec<&str> = meta.screenshots();
let cached_imgs =
image_cache.ensure_game_images(boxart_filename, &screenshot_filenames);
entry.boxart_path = cached_imgs.boxart_path;
entry.screenshot_paths = cached_imgs.screenshot_paths;
}
ecache.insert(
entry.path.clone(),
CachedEnrichment {
metadata_game_id: entry.metadata_game_id,
display_name: entry.metadata_game_id.map(|_| entry.display_name.clone()),
genres: entry.genres.clone(),
overview: entry.overview.clone(),
release_date: entry.release_date.clone(),
players: entry.players,
rating: entry.rating.clone(),
boxart_path: entry.boxart_path.clone(),
screenshot_paths: entry.screenshot_paths.clone(),
},
);
}
ecache.save();
}
#[cfg(test)]
mod tests {
use super::*;
use crate::nes::cartridge::RomDb;
use std::io::Write;
use tempfile::NamedTempFile;
fn minimal_nrom_rom() -> Vec<u8> {
let mut rom = Vec::new();
rom.extend_from_slice(b"NES\x1A"); rom.push(1); rom.push(1); rom.push(0); rom.push(0); rom.extend_from_slice(&[0u8; 8]); rom.extend_from_slice(&[0xAAu8; 16 * 1024]); rom.extend_from_slice(&[0x55u8; 8 * 1024]); rom
}
#[test]
fn test_build_rom_entry_valid_rom_has_mapper_and_crc() {
let mut tmp = NamedTempFile::new().unwrap();
tmp.write_all(&minimal_nrom_rom()).unwrap();
let db = RomDb::from_csv_content("");
let entry = build_rom_entry(tmp.path(), &db);
assert_eq!(entry.mapper, Some(0), "mapper should be 0 (NROM)");
assert!(entry.crc.is_some(), "CRC should be present");
let crc = entry.crc.as_deref().unwrap();
assert_eq!(crc.len(), 8, "CRC should be 8 hex chars");
}
#[test]
fn test_build_rom_entry_missing_file_returns_stub() {
let db = RomDb::from_csv_content("");
let entry = build_rom_entry(Path::new("/nonexistent/game.nes"), &db);
assert_eq!(entry.display_name, "game");
assert!(entry.crc.is_none());
assert!(entry.mapper.is_none());
}
#[test]
fn test_build_rom_entry_invalid_file_returns_stub() {
let mut tmp = NamedTempFile::new().unwrap();
tmp.write_all(b"NOT A NES ROM").unwrap();
let db = RomDb::from_csv_content("");
let entry = build_rom_entry(tmp.path(), &db);
assert!(entry.crc.is_none());
assert!(entry.mapper.is_none());
}
#[test]
fn test_build_rom_entry_uses_db_name_when_available() {
let rom_bytes = minimal_nrom_rom();
let prg = &rom_bytes[16..16 + 16 * 1024];
let chr = &rom_bytes[16 + 16 * 1024..];
let crc = crate::nes::cartridge::calculate_rom_crc32(prg, chr);
let csv = format!(
"rom_id,name,country,crc,hardware,rom_class,mapper,submapper,nametable_layout,\
prg_rom_size,prg_rom_crc,prg_nvram_size,prg_ram_size,chr_rom_size,chr_rom_crc,\
chr_nvram_size,chr_ram_size,battery,vs_hardware_type,vs_ppu_type,expansion_type\n\
1,My Test ROM,USA,{crc:08X},,,0,,,,,,,,,,,,,,"
);
let db = RomDb::from_csv_content(&csv);
let mut tmp = NamedTempFile::new().unwrap();
tmp.write_all(&rom_bytes).unwrap();
let entry = build_rom_entry(tmp.path(), &db);
assert_eq!(entry.display_name, "My Test ROM", "should use ROM DB name");
}
#[test]
fn test_build_rom_entry_falls_back_to_filename_when_not_in_db() {
let mut tmp = NamedTempFile::with_suffix(".nes").unwrap();
tmp.write_all(&minimal_nrom_rom()).unwrap();
let db = RomDb::from_csv_content("");
let entry = build_rom_entry(tmp.path(), &db);
let stem = tmp
.path()
.file_stem()
.unwrap()
.to_string_lossy()
.to_string();
assert_eq!(entry.display_name, stem);
}
#[test]
fn test_build_rom_entry_no_recording_duration_when_no_autorun_file() {
let mut tmp = NamedTempFile::with_suffix(".nes").unwrap();
tmp.write_all(&minimal_nrom_rom()).unwrap();
let db = RomDb::from_csv_content("");
let entry = build_rom_entry(tmp.path(), &db);
assert!(
entry.recording_duration.is_none(),
"no .autorun file should mean recording_duration=None"
);
}
#[test]
fn test_build_rom_entry_recording_duration_when_autorun_file_exists() {
let mut tmp = NamedTempFile::with_suffix(".nes").unwrap();
tmp.write_all(&minimal_nrom_rom()).unwrap();
let autorun_path = tmp.path().with_extension("autorun");
let frames_json: String = (0..360)
.map(|_| r#"{"player1":0,"player2":0}"#)
.collect::<Vec<_>>()
.join(",");
let json = format!(r#"{{"version":3,"frames":[{frames_json}],"checkpoints":[]}}"#);
std::fs::write(&autorun_path, json.as_bytes()).unwrap();
let db = RomDb::from_csv_content("");
let entry = build_rom_entry(tmp.path(), &db);
let _ = std::fs::remove_file(&autorun_path);
let dur = entry.recording_duration.expect("should have duration");
assert_eq!(dur.as_secs(), 6, "360 frames at 60fps = 6 seconds");
}
#[test]
fn test_read_recording_duration_returns_none_for_invalid_json() {
let mut tmp = NamedTempFile::with_suffix(".nes").unwrap();
tmp.write_all(&minimal_nrom_rom()).unwrap();
let autorun_path = tmp.path().with_extension("autorun");
std::fs::write(&autorun_path, b"not valid json").unwrap();
let dur = read_recording_duration(tmp.path());
let _ = std::fs::remove_file(&autorun_path);
assert!(dur.is_none(), "invalid JSON should return None");
}
#[test]
fn test_read_recording_duration_sums_rle_repeat_fields() {
let mut tmp = NamedTempFile::with_suffix(".nes").unwrap();
tmp.write_all(&minimal_nrom_rom()).unwrap();
let autorun_path = tmp.path().with_extension("autorun");
let json = r#"{"version":3,"frames":[{"player1":0,"player2":0,"repeat":3480},{"player1":1,"player2":0,"repeat":240}],"checkpoints":[]}"#;
std::fs::write(&autorun_path, json.as_bytes()).unwrap();
let dur = read_recording_duration(tmp.path()).expect("should have duration");
let _ = std::fs::remove_file(&autorun_path);
assert_eq!(dur.as_secs(), 62, "3720 frames at 60fps = 62 seconds");
}
#[test]
fn test_build_rom_entry_metadata_fields_are_empty_by_default() {
let mut tmp = NamedTempFile::new().unwrap();
tmp.write_all(&minimal_nrom_rom()).unwrap();
let db = RomDb::from_csv_content("");
let entry = build_rom_entry(tmp.path(), &db);
assert!(entry.metadata_game_id.is_none());
assert!(entry.genres.is_empty());
assert!(entry.overview.is_none());
assert!(entry.release_date.is_none());
assert!(entry.players.is_none());
assert!(entry.rating.is_none());
assert!(entry.boxart_path.is_none());
assert!(entry.screenshot_paths.is_empty());
assert!(!entry.is_favorite);
}
#[test]
fn test_platform_from_path_nes() {
assert_eq!(platform_from_path(Path::new("game.nes")), Platform::Nes);
assert_eq!(platform_from_path(Path::new("game.NES")), Platform::Nes);
}
#[test]
fn test_platform_from_path_gb() {
assert_eq!(platform_from_path(Path::new("game.gb")), Platform::Gb);
assert_eq!(platform_from_path(Path::new("game.GB")), Platform::Gb);
}
#[test]
fn test_platform_from_path_gbc() {
assert_eq!(platform_from_path(Path::new("game.gbc")), Platform::Gbc);
assert_eq!(platform_from_path(Path::new("game.GBC")), Platform::Gbc);
}
#[test]
fn test_build_rom_entry_gb_file_has_gb_platform() {
let tmp = NamedTempFile::with_suffix(".gb").unwrap();
std::fs::write(tmp.path(), [0u8; 256]).unwrap();
let db = RomDb::from_csv_content("");
let entry = build_rom_entry(tmp.path(), &db);
assert_eq!(entry.platform, Platform::Gb);
}
#[test]
fn test_build_rom_entry_gbc_file_has_gbc_platform() {
let tmp = NamedTempFile::with_suffix(".gbc").unwrap();
std::fs::write(tmp.path(), [0u8; 256]).unwrap();
let db = RomDb::from_csv_content("");
let entry = build_rom_entry(tmp.path(), &db);
assert_eq!(entry.platform, Platform::Gbc);
}
#[cfg(feature = "native")]
#[test]
fn test_enrich_catalog_with_metadata_db() {
let db_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("scripts")
.join("metadata-scraper")
.join("metadata.db");
if !db_path.exists() {
eprintln!("Skipping test: metadata.db not found at {db_path:?}");
return;
}
let cache_dir = tempfile::TempDir::new().unwrap();
let mut catalog = vec![RomEntry {
path: std::path::PathBuf::from("super_mario_bros_3.nes"),
display_name: "Super Mario Bros. 3".to_string(),
search_key: "super mario bros. 3".to_string(),
mapper_label: "4".to_string(),
mapper: Some(4),
hardware: Some("NES NTSC".to_string()),
crc: Some("AABBCCDD".to_string()),
recording_duration: None,
metadata_game_id: None,
genres: Vec::new(),
overview: None,
release_date: None,
players: None,
rating: None,
boxart_path: None,
screenshot_paths: Vec::new(),
is_favorite: false,
platform: Platform::Nes,
}];
let mut progress_count = 0;
enrich_catalog(&mut catalog, &db_path, cache_dir.path(), false, |_| {
progress_count += 1
});
assert!(
progress_count > 0,
"progress callback should have been called"
);
let entry = &catalog[0];
assert!(
entry.metadata_game_id.is_some(),
"SMB3 should match a metadata entry"
);
assert!(!entry.genres.is_empty(), "SMB3 should have genres");
}
#[cfg(feature = "native")]
#[test]
fn test_enrich_catalog_skips_missing_db() {
let mut catalog = vec![RomEntry {
path: std::path::PathBuf::from("game.nes"),
display_name: "Test Game".to_string(),
search_key: "test game".to_string(),
mapper_label: "-".to_string(),
mapper: None,
hardware: None,
crc: None,
recording_duration: None,
metadata_game_id: None,
genres: Vec::new(),
overview: None,
release_date: None,
players: None,
rating: None,
boxart_path: None,
screenshot_paths: Vec::new(),
is_favorite: false,
platform: Platform::Nes,
}];
enrich_catalog(
&mut catalog,
std::path::Path::new("/nonexistent/metadata.db"),
std::path::Path::new("/nonexistent/cache"),
false,
|_| {},
);
assert!(catalog[0].metadata_game_id.is_none());
}
fn make_entry_with_path(path: &str, name: &str) -> RomEntry {
RomEntry {
path: PathBuf::from(path),
display_name: name.to_string(),
search_key: name.to_lowercase(),
mapper_label: "-".to_string(),
mapper: None,
hardware: None,
crc: None,
recording_duration: None,
metadata_game_id: None,
genres: Vec::new(),
overview: None,
release_date: None,
players: None,
rating: None,
boxart_path: None,
screenshot_paths: Vec::new(),
is_favorite: false,
platform: Platform::Nes,
}
}
#[test]
fn is_unofficial_rom_detects_hack_in_filename() {
let entry = make_entry_with_path("Super Mario (hack).nes", "Super Mario");
assert!(is_unofficial_rom(&entry));
}
#[test]
fn is_unofficial_rom_detects_hack_in_display_name() {
let entry = make_entry_with_path("game.nes", "Super Mario (Hack)");
assert!(is_unofficial_rom(&entry));
}
#[test]
fn is_unofficial_rom_detects_pirate() {
let entry = make_entry_with_path("Game (Pirate).nes", "Game");
assert!(is_unofficial_rom(&entry));
}
#[test]
fn is_unofficial_rom_detects_homebrew() {
let entry = make_entry_with_path("Game [Homebrew].nes", "Game");
assert!(is_unofficial_rom(&entry));
}
#[test]
fn is_unofficial_rom_detects_prototype() {
let entry = make_entry_with_path("Game (Proto).nes", "Game");
assert!(is_unofficial_rom(&entry));
}
#[test]
fn is_unofficial_rom_detects_beta() {
let entry = make_entry_with_path("Game [Beta].nes", "Game");
assert!(is_unofficial_rom(&entry));
}
#[test]
fn is_unofficial_rom_detects_unl() {
let entry = make_entry_with_path("Game (Unl).nes", "Game");
assert!(is_unofficial_rom(&entry));
}
#[test]
fn is_unofficial_rom_allows_official_rom() {
let entry = make_entry_with_path("Super Mario Bros. 3 (USA).nes", "Super Mario Bros. 3");
assert!(!is_unofficial_rom(&entry));
}
#[test]
fn is_unofficial_rom_allows_region_variants() {
let entry = make_entry_with_path("Metroid (Europe).nes", "Metroid");
assert!(!is_unofficial_rom(&entry));
}
#[test]
fn is_unofficial_rom_detects_hack_suffix_in_filename() {
let entry =
make_entry_with_path("arkanoid-98-arkanoid-hack.nes", "Arkanoid 98 Arkanoid Hack");
assert!(is_unofficial_rom(&entry));
}
#[test]
fn is_unofficial_rom_detects_hack_with_spaces() {
let entry = make_entry_with_path("game hack version.nes", "Game Hack Version");
assert!(is_unofficial_rom(&entry));
}
#[test]
fn is_unofficial_rom_detects_hack_with_underscores() {
let entry = make_entry_with_path("game_hack_v2.nes", "Game");
assert!(is_unofficial_rom(&entry));
}
#[test]
fn is_unofficial_rom_detects_menu_in_filename() {
let entry = make_entry_with_path("super-game-menu.nes", "Super Game Menu");
assert!(is_unofficial_rom(&entry));
}
#[test]
fn is_unofficial_rom_detects_demo_in_filename() {
let entry = make_entry_with_path("cool-game-demo.nes", "Cool Game Demo");
assert!(is_unofficial_rom(&entry));
}
#[test]
fn is_unofficial_rom_detects_test_in_filename() {
let entry = make_entry_with_path("mapper-test.nes", "Mapper Test");
assert!(is_unofficial_rom(&entry));
}
#[test]
fn is_unofficial_rom_allows_game_with_test_in_title() {
let entry = make_entry_with_path("test-drive.nes", "Test Drive");
assert!(!is_unofficial_rom(&entry));
}
#[test]
fn is_unofficial_rom_detects_bad_suffix() {
let entry = make_entry_with_path("game-bad.nes", "Game");
assert!(is_unofficial_rom(&entry));
}
#[test]
fn is_unofficial_rom_detects_p_numbered_suffix() {
let entry = make_entry_with_path("game-p1.nes", "Game");
assert!(is_unofficial_rom(&entry));
}
#[test]
fn is_unofficial_rom_detects_p_multi_digit_suffix() {
let entry = make_entry_with_path("game-p12.nes", "Game");
assert!(is_unofficial_rom(&entry));
}
#[test]
fn is_unofficial_rom_detects_h_numbered_suffix() {
let entry = make_entry_with_path("game-h2.nes", "Game");
assert!(is_unofficial_rom(&entry));
}
#[test]
fn is_unofficial_rom_detects_hm00_suffix() {
let entry = make_entry_with_path("game-hm00.nes", "Game");
assert!(is_unofficial_rom(&entry));
}
#[test]
fn is_unofficial_rom_detects_bracketed_b_numbered() {
let entry = make_entry_with_path("Game [b3].nes", "Game");
assert!(is_unofficial_rom(&entry));
}
#[test]
fn is_unofficial_rom_detects_bracketed_p_numbered() {
let entry = make_entry_with_path("Game [p1].nes", "Game");
assert!(is_unofficial_rom(&entry));
}
#[test]
fn is_unofficial_rom_detects_bracketed_h_numbered() {
let entry = make_entry_with_path("Game [h1].nes", "Game");
assert!(is_unofficial_rom(&entry));
}
#[test]
fn is_unofficial_rom_allows_game_with_p_in_title() {
let entry = make_entry_with_path("p-47.nes", "P-47");
assert!(!is_unofficial_rom(&entry));
}
#[test]
fn has_numbered_dump_suffix_matches_dash_p_digit_dot() {
assert!(has_numbered_dump_suffix("game-p1.nes"));
assert!(has_numbered_dump_suffix("game-p99.nes"));
assert!(has_numbered_dump_suffix("game-h3.nes"));
}
#[test]
fn has_numbered_dump_suffix_matches_bracket_b_digit() {
assert!(has_numbered_dump_suffix("game [b1].nes"));
assert!(has_numbered_dump_suffix("game [p2].nes"));
assert!(has_numbered_dump_suffix("game [h3].nes"));
}
#[test]
fn has_numbered_dump_suffix_ignores_non_numbered() {
assert!(!has_numbered_dump_suffix("game-play.nes"));
assert!(!has_numbered_dump_suffix("game-help.nes"));
assert!(!has_numbered_dump_suffix("test-drive.nes"));
}
#[test]
fn is_unofficial_rom_detects_pd_suffix() {
let entry = make_entry_with_path("game-pd.nes", "Game");
assert!(is_unofficial_rom(&entry));
}
#[test]
fn is_unofficial_rom_detects_zzz_prefix() {
let entry = make_entry_with_path("zzz-multicart.nes", "ZZZ Multicart");
assert!(is_unofficial_rom(&entry));
}
#[test]
fn is_unofficial_rom_detects_zzz_with_underscore() {
let entry = make_entry_with_path("zzz_unknown_game.nes", "ZZZ Unknown Game");
assert!(is_unofficial_rom(&entry));
}
fn make_entry_with_crc(name: &str, crc: Option<&str>) -> RomEntry {
let mut entry = make_entry_with_path(&format!("{name}.nes"), name);
entry.crc = crc.map(str::to_string);
entry
}
#[test]
fn dedup_by_crc_removes_duplicate_crc() {
let mut entries = vec![
make_entry_with_crc("Mario A", Some("AABBCCDD")),
make_entry_with_crc("Mario B", Some("AABBCCDD")),
];
dedup_by_crc(&mut entries);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].display_name, "Mario A");
}
#[test]
fn dedup_by_crc_keeps_entries_without_crc() {
let mut entries = vec![
make_entry_with_crc("No CRC 1", None),
make_entry_with_crc("No CRC 2", None),
];
dedup_by_crc(&mut entries);
assert_eq!(entries.len(), 2);
}
#[test]
fn dedup_by_crc_keeps_distinct_crcs() {
let mut entries = vec![
make_entry_with_crc("Game A", Some("11111111")),
make_entry_with_crc("Game B", Some("22222222")),
];
dedup_by_crc(&mut entries);
assert_eq!(entries.len(), 2);
}
#[test]
fn dedup_by_crc_mixed_entries() {
let mut entries = vec![
make_entry_with_crc("Alpha", Some("AABBCCDD")),
make_entry_with_crc("Beta", None),
make_entry_with_crc("Gamma", Some("AABBCCDD")),
make_entry_with_crc("Delta", Some("11223344")),
make_entry_with_crc("Epsilon", None),
];
dedup_by_crc(&mut entries);
assert_eq!(entries.len(), 4);
let names: Vec<&str> = entries.iter().map(|e| e.display_name.as_str()).collect();
assert_eq!(names, vec!["Alpha", "Beta", "Delta", "Epsilon"]);
}
}