neser 0.3.1

NESER - NES Emulator in Rust. Desktop (SDL) and WebAssembly frontends.
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('"', "\\\"")
}