use std::io;
use std::path::{Path, PathBuf};
use std::{collections::BTreeSet, fs};
const CATALOG_HEADER: &str = "path";
#[derive(Debug, Clone)]
pub struct CartridgeCatalogOptions {
pub search_paths: Vec<PathBuf>,
pub catalog_csv_path: PathBuf,
pub scan_enabled: bool,
pub rebuild_catalog: bool,
}
impl CartridgeCatalogOptions {
pub fn new(search_paths: Vec<PathBuf>, catalog_csv_path: PathBuf) -> Self {
Self {
search_paths,
catalog_csv_path,
scan_enabled: true,
rebuild_catalog: false,
}
}
}
pub fn refresh_cartridge_catalog(options: &CartridgeCatalogOptions) -> io::Result<Vec<PathBuf>> {
let mut catalog_entries = existing_catalog_entries(options)?;
if options.scan_enabled {
let discovered = collect_rom_files(&options.search_paths)?;
catalog_entries.extend(discovered);
}
write_catalog_entries(&options.catalog_csv_path, &catalog_entries)?;
Ok(catalog_entries.into_iter().collect())
}
pub fn default_catalog_csv_path(home_dir: &Path) -> PathBuf {
home_dir.join(".neser").join("cartridges.csv")
}
fn existing_catalog_entries(options: &CartridgeCatalogOptions) -> io::Result<BTreeSet<PathBuf>> {
if options.rebuild_catalog {
return Ok(BTreeSet::new());
}
read_catalog_entries(&options.catalog_csv_path).map(|entries| entries.into_iter().collect())
}
fn collect_rom_files(search_paths: &[PathBuf]) -> io::Result<BTreeSet<PathBuf>> {
let mut files = BTreeSet::new();
for root in search_paths {
collect_rom_files_under(root, &mut files)?;
}
Ok(files)
}
fn collect_rom_files_under(root: &Path, out: &mut BTreeSet<PathBuf>) -> io::Result<()> {
let Some(entries) = read_dir_if_exists(root)? else {
return Ok(());
};
for entry in entries {
let entry = entry?;
let path = entry.path();
let metadata = match fs::symlink_metadata(&path) {
Ok(metadata) => metadata,
Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
Err(err) => return Err(err),
};
let file_type = metadata.file_type();
if file_type.is_symlink() {
continue;
}
if file_type.is_dir() {
collect_rom_files_under(&path, out)?;
} else if file_type.is_file() && is_rom_file(&path) {
out.insert(path);
}
}
Ok(())
}
fn read_dir_if_exists(path: &Path) -> io::Result<Option<fs::ReadDir>> {
match fs::read_dir(path) {
Ok(entries) => Ok(Some(entries)),
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(err),
}
}
fn is_rom_file(path: &Path) -> bool {
path.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("nes"))
}
fn read_catalog_entries(path: &Path) -> io::Result<Vec<PathBuf>> {
let content = match fs::read_to_string(path) {
Ok(content) => content,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(err) => return Err(err),
};
let entries = content
.lines()
.skip(1)
.map(str::trim)
.filter(|line| !line.is_empty())
.map(PathBuf::from)
.collect();
Ok(entries)
}
fn write_catalog_entries(path: &Path, entries: &BTreeSet<PathBuf>) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let content = serialize_catalog_entries(entries);
fs::write(path, content)
}
fn serialize_catalog_entries(entries: &BTreeSet<PathBuf>) -> String {
let mut content = String::from(CATALOG_HEADER);
content.push('\n');
for entry in entries {
content.push_str(&entry.to_string_lossy());
content.push('\n');
}
content
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn write_file(path: &Path) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create parent dir");
}
fs::write(path, b"NES\x1A").expect("write test file");
}
fn read_csv_lines(path: &Path) -> Vec<String> {
fs::read_to_string(path)
.expect("catalog CSV should exist")
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(ToString::to_string)
.collect()
}
#[test]
fn given_nested_search_paths_when_refreshing_then_catalog_contains_only_nes_files() {
let temp = tempdir().expect("tempdir");
let root = temp.path().join("roms");
write_file(&root.join("games/a.nes"));
write_file(&root.join("games/sub/b.NES"));
write_file(&root.join("games/notes.txt"));
let catalog_path = temp.path().join("cartridges.csv");
let options = CartridgeCatalogOptions::new(vec![root], catalog_path.clone());
let entries = refresh_cartridge_catalog(&options).expect("refresh should succeed");
let mut csv_lines = read_csv_lines(&catalog_path);
csv_lines.sort();
assert_eq!(entries.len(), 2);
assert!(csv_lines.iter().any(|line| line == "path"));
assert!(csv_lines.iter().any(|line| line.ends_with("games/a.nes")));
assert!(
csv_lines
.iter()
.any(|line| line.ends_with("games/sub/b.NES"))
);
assert!(!csv_lines.iter().any(|line| line.ends_with("notes.txt")));
}
#[test]
fn given_existing_catalog_when_refreshing_then_new_files_are_added_without_duplicates() {
let temp = tempdir().expect("tempdir");
let root = temp.path().join("roms");
write_file(&root.join("old.nes"));
write_file(&root.join("new.nes"));
let old_path = root.join("old.nes");
let catalog_path = temp.path().join("cartridges.csv");
fs::write(&catalog_path, format!("path\n{}\n", old_path.display())).expect("seed csv");
let options = CartridgeCatalogOptions::new(vec![root], catalog_path.clone());
let _ = refresh_cartridge_catalog(&options).expect("refresh should succeed");
let csv_lines = read_csv_lines(&catalog_path);
let old_count = csv_lines
.iter()
.filter(|line| line.ends_with("old.nes"))
.count();
let new_count = csv_lines
.iter()
.filter(|line| line.ends_with("new.nes"))
.count();
assert_eq!(old_count, 1);
assert_eq!(new_count, 1);
}
#[test]
fn given_rebuild_enabled_when_refreshing_then_existing_catalog_is_recreated() {
let temp = tempdir().expect("tempdir");
let root = temp.path().join("roms");
write_file(&root.join("fresh.nes"));
let stale_path = root.join("stale.nes");
let catalog_path = temp.path().join("cartridges.csv");
fs::write(&catalog_path, format!("path\n{}\n", stale_path.display())).expect("seed csv");
let mut options = CartridgeCatalogOptions::new(vec![root], catalog_path.clone());
options.rebuild_catalog = true;
let _ = refresh_cartridge_catalog(&options).expect("refresh should succeed");
let csv_lines = read_csv_lines(&catalog_path);
assert!(csv_lines.iter().any(|line| line.ends_with("fresh.nes")));
assert!(!csv_lines.iter().any(|line| line.ends_with("stale.nes")));
}
#[test]
fn given_scan_disabled_when_refreshing_then_catalog_keeps_existing_entries() {
let temp = tempdir().expect("tempdir");
let root = temp.path().join("roms");
write_file(&root.join("newly_found.nes"));
let existing = root.join("existing.nes");
let catalog_path = temp.path().join("cartridges.csv");
fs::write(&catalog_path, format!("path\n{}\n", existing.display())).expect("seed csv");
let mut options = CartridgeCatalogOptions::new(vec![root], catalog_path.clone());
options.scan_enabled = false;
let entries = refresh_cartridge_catalog(&options).expect("refresh should succeed");
assert_eq!(entries, vec![existing]);
let csv_lines = read_csv_lines(&catalog_path);
assert_eq!(csv_lines.len(), 2);
assert!(csv_lines.iter().any(|line| line.ends_with("existing.nes")));
assert!(
!csv_lines
.iter()
.any(|line| line.ends_with("newly_found.nes"))
);
}
#[test]
#[cfg(unix)]
fn given_symlinked_directory_loop_when_refreshing_then_scan_skips_symlink() {
use std::os::unix::fs::symlink;
let temp = tempdir().expect("tempdir");
let root = temp.path().join("roms");
write_file(&root.join("games/a.nes"));
fs::create_dir_all(root.join("games/sub")).expect("create nested directory");
symlink(&root, root.join("games/sub/loop")).expect("create symlink loop");
let catalog_path = temp.path().join("cartridges.csv");
let options = CartridgeCatalogOptions::new(vec![root], catalog_path);
let entries = refresh_cartridge_catalog(&options).expect("refresh should succeed");
assert_eq!(entries.len(), 1);
assert!(entries[0].ends_with("games/a.nes"));
}
#[test]
fn default_catalog_path_points_to_dot_neser_cartridges_csv() {
let home = PathBuf::from("/tmp/demo-home");
let path = default_catalog_csv_path(&home);
assert_eq!(path, home.join(".neser").join("cartridges.csv"));
}
#[test]
fn is_rom_file_accepts_only_nes_extensions() {
assert!(is_rom_file(Path::new("game.nes")));
assert!(is_rom_file(Path::new("game.NES")));
assert!(!is_rom_file(Path::new("game.gb")));
assert!(!is_rom_file(Path::new("game.gbc")));
assert!(!is_rom_file(Path::new("game.txt")));
assert!(!is_rom_file(Path::new("game.zip")));
}
#[test]
fn scan_discovers_only_nes_files() {
let temp = tempdir().expect("tempdir");
let root = temp.path().join("roms");
write_file(&root.join("nes_game.nes"));
write_file(&root.join("gb_game.gb"));
write_file(&root.join("gbc_game.gbc"));
write_file(&root.join("notes.txt"));
let catalog_path = temp.path().join("cartridges.csv");
let options = CartridgeCatalogOptions::new(vec![root], catalog_path);
let entries = refresh_cartridge_catalog(&options).expect("refresh should succeed");
assert_eq!(entries.len(), 1);
assert!(entries.iter().any(|p| p.ends_with("nes_game.nes")));
}
}