neser 0.1.0

NESER - NES Emulator in Rust - is a NES emulator written in Rust. It aims to be a high-quality, hardware-accurate emulator that is also easy to use and extend. It supports a wide range of NES games and features, including various mappers, audio processing, and input handling. NESER is designed to be modular and extensible, allowing developers to easily add new features or support for additional hardware. It can be run using one of two frontends: a native desktop application using SDL2, or a web application using WebAssembly. The desktop application provides a high-performance, feature-rich experience with support for various input devices and display options, while the web application allows users to play NES games directly in their browsers without needing to install any software in a BYOR manner (Bring Your Own Roms).
Documentation
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_nes_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_nes_files(search_paths: &[PathBuf]) -> io::Result<BTreeSet<PathBuf>> {
    let mut files = BTreeSet::new();
    for root in search_paths {
        collect_nes_files_under(root, &mut files)?;
    }
    Ok(files)
}

fn collect_nes_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_nes_files_under(&path, out)?;
        } else if file_type.is_file() && is_nes_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_nes_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"));
    }
}