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::collections::HashSet;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};

fn main() {
    let root = Path::new("roms/games/mappers");
    emit_rerun_hints(root);

    let mut autorun_files = Vec::new();
    collect_autorun_files(root, &mut autorun_files);
    autorun_files.sort();

    let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR is set by Cargo"));
    let generated_path = out_dir.join("autorun_generated_tests.rs");

    let mut code = String::new();
    code.push_str("// @generated by build.rs\n");
    code.push_str("// Do not edit manually.\n\n");

    if autorun_files.is_empty() {
        code.push_str("#[test]\n");
        code.push_str("fn test_no_mapper_autorun_files_found() {\n");
        code.push_str("    println!(\"[autorun verification] Skipping - no .autorun files found under roms/games/mappers\");\n");
        code.push_str("}\n");
    } else {
        let mut seen_names = HashSet::new();
        for path in autorun_files {
            let rel = path_to_unix_string(&path);
            let fn_name = unique_test_name(&path, &mut seen_names);
            let escaped_rel = escape_for_rust_string(&rel);

            code.push_str("#[test]\n");
            code.push_str(&format!("fn {fn_name}() {{\n"));
            code.push_str(&format!(
                "    verify_single_autorun(std::path::Path::new(\"{escaped_rel}\"))\n"
            ));
            code.push_str("        .unwrap_or_else(|e| panic!(\"{e}\"));\n");
            code.push_str("}\n\n");
        }
    }

    fs::write(&generated_path, code).expect("write generated autorun tests");
}

fn emit_rerun_hints(path: &Path) {
    println!("cargo:rerun-if-changed={}", path_to_unix_string(path));
    if let Ok(entries) = fs::read_dir(path) {
        for entry in entries.flatten() {
            emit_rerun_hints(&entry.path());
        }
    }
}

fn collect_autorun_files(dir: &Path, files: &mut Vec<PathBuf>) {
    let entries = match fs::read_dir(dir) {
        Ok(entries) => entries,
        Err(_) => return,
    };

    for entry in entries.flatten() {
        let path = entry.path();
        if path.is_dir() {
            collect_autorun_files(&path, files);
        } else if path
            .extension()
            .and_then(|ext| ext.to_str())
            .is_some_and(|ext| ext.eq_ignore_ascii_case("autorun"))
        {
            files.push(path);
        }
    }
}

fn unique_test_name(path: &Path, seen: &mut HashSet<String>) -> String {
    let mapper = mapper_from_path(path);
    let game_name = game_name_from_path(path);
    let candidate = format!("test_mapper_{mapper}_autorun_{game_name}");

    if seen.insert(candidate.clone()) {
        return candidate;
    }

    let mut i = 2usize;
    loop {
        let with_suffix = format!("{candidate}_{i}");
        if seen.insert(with_suffix.clone()) {
            return with_suffix;
        }
        i += 1;
    }
}

fn mapper_from_path(path: &Path) -> String {
    path.parent()
        .and_then(|p| p.file_name())
        .and_then(|n| n.to_str())
        .map(|s| sanitize_identifier(s, "unknown"))
        .filter(|s| !s.is_empty())
        .unwrap_or_else(|| "unknown".to_string())
}

fn game_name_from_path(path: &Path) -> String {
    let stem = path.file_stem().and_then(|n| n.to_str()).unwrap_or("game");
    let simplified = remove_trailing_metadata_groups(stem);
    sanitize_identifier(&simplified, "game")
}

fn remove_trailing_metadata_groups(input: &str) -> String {
    let mut result = input.trim().to_string();
    loop {
        let trimmed = result.trim_end();
        if trimmed.ends_with(')')
            && let Some(start) = trimmed.rfind('(')
            && trimmed[start..].find(')').is_some()
            && trimmed[start + 1..trimmed.len() - 1]
                .chars()
                .all(|c| c != '(')
        {
            result = trimmed[..start].trim_end().to_string();
            continue;
        }
        if trimmed.ends_with(']')
            && let Some(start) = trimmed.rfind('[')
            && trimmed[start..].find(']').is_some()
            && trimmed[start + 1..trimmed.len() - 1]
                .chars()
                .all(|c| c != '[')
        {
            result = trimmed[..start].trim_end().to_string();
            continue;
        }
        break;
    }

    if result.is_empty() {
        input.trim().to_string()
    } else {
        result
    }
}

fn sanitize_identifier(text: &str, fallback: &str) -> String {
    let mut out = String::new();
    for ch in text.chars() {
        if ch.is_ascii_alphanumeric() {
            out.push(ch.to_ascii_lowercase());
        } else {
            out.push('_');
        }
    }
    while out.contains("__") {
        out = out.replace("__", "_");
    }
    let out = out.trim_matches('_').to_string();
    if out.is_empty() {
        fallback.to_string()
    } else {
        out
    }
}

fn path_to_unix_string(path: &Path) -> String {
    path.to_string_lossy().replace('\\', "/")
}

fn escape_for_rust_string(text: &str) -> String {
    text.replace('\\', "\\\\").replace('"', "\\\"")
}