rustzx 0.12.0

ZX Spectum emulator
mod frame_buffer;
mod io;

use anyhow::{anyhow, bail, Context};
use frame_buffer::{FrameBufferContext, RgbaFrameBuffer};
use io::FileAsset;
use rustzx_core::{
    host::{FrameBuffer, Host, HostContext, RomFormat, RomSet, Snapshot, Tape},
    zx::machine::ZXMachine,
};
use std::{collections::VecDeque, fs::File, path::Path};

const SUPPORTED_SNAPSHOT_FORMATS: [&str; 1] = ["sna"];
const SUPPORTED_TAPE_FORMATS: [&str; 1] = ["tap"];

pub struct AppHost;

impl Host for AppHost {
    type Context = AppHostContext;
    type FrameBuffer = RgbaFrameBuffer;
    type RomSet = FileRomSet;
    type SnapshotAsset = FileAsset;
    type TapeAsset = FileAsset;
}

pub struct AppHostContext;

impl HostContext<AppHost> for AppHostContext {
    fn frame_buffer_context(&self) -> <<AppHost as Host>::FrameBuffer as FrameBuffer>::Context {
        FrameBufferContext
    }
}

pub struct FileRomSet {
    pages: VecDeque<FileAsset>,
}

impl RomSet for FileRomSet {
    type Asset = FileAsset;

    fn format(&self) -> RomFormat {
        RomFormat::Binary16KPages
    }

    fn next_asset(&mut self) -> Option<Self::Asset> {
        self.pages.pop_front()
    }
}

pub enum DetectedFileKind {
    Tape,
    Snapshot,
}

pub fn load_tape(path: &Path) -> anyhow::Result<Tape<FileAsset>> {
    if !file_extension_matches_one_of(path, &SUPPORTED_TAPE_FORMATS) {
        bail!("Invalid tape format");
    }

    if !path.exists() {
        bail!("Provided tape file does not exist");
    }

    File::open(path)
        .with_context(|| "Failed to open tape file")
        .map(|file| Tape::Tap(file.into()))
}

pub fn load_snapshot(path: &Path) -> anyhow::Result<Snapshot<FileAsset>> {
    if !file_extension_matches_one_of(path, &SUPPORTED_SNAPSHOT_FORMATS) {
        bail!("Invalid snapshot format");
    }

    if !path.exists() {
        bail!("Provided snapshot file does not exist");
    }

    File::open(path)
        .with_context(|| "Failed to open snapshot file")
        .map(|file| Snapshot::Sna(file.into()))
}

fn load_rom_asset(path: &Path) -> anyhow::Result<FileAsset> {
    File::open(path)
        .with_context(|| "Failed to load rom asset")
        .map(|file| file.into())
}

pub fn load_rom(path: &Path, machine: ZXMachine) -> anyhow::Result<FileRomSet> {
    match machine {
        ZXMachine::Sinclair48K => {
            if !path.exists() {
                bail!("Provided 48K ROM file does not exist")
            }

            Ok(FileRomSet {
                pages: VecDeque::from(vec![
                    load_rom_asset(path).with_context(|| "48K ROM load failed")?
                ]),
            })
        }
        ZXMachine::Sinclair128K => {
            let rom0_path = path;
            if !file_extension_matches(rom0_path, "0") {
                bail!("128K ROM filename should end with '.0' extension");
            }
            if !rom0_path.exists() {
                bail!("Provided 128K ROM0 file does not exist");
            }
            let rom1_path = rom0_path.to_owned().with_extension("1");
            if !rom1_path.exists() {
                bail!("Provided 128K ROM1 file does not exist");
            }

            Ok(FileRomSet {
                pages: VecDeque::from(vec![
                    load_rom_asset(&rom0_path).with_context(|| "128K ROM0 load failed")?,
                    load_rom_asset(&rom1_path).with_context(|| "128K ROM1 load failed")?,
                ]),
            })
        }
    }
}

pub fn detect_file_type(path: &Path) -> anyhow::Result<DetectedFileKind> {
    if file_extension_matches_one_of(path, &SUPPORTED_TAPE_FORMATS) {
        load_tape(path)?;
        Ok(DetectedFileKind::Tape)
    } else if file_extension_matches_one_of(path, &SUPPORTED_SNAPSHOT_FORMATS) {
        load_snapshot(path)?;
        Ok(DetectedFileKind::Snapshot)
    } else {
        Err(anyhow!("Not supported file format"))
    }
}

fn file_extension_matches(path: &Path, expected: &str) -> bool {
    let actual = path
        .extension()
        .unwrap_or_default()
        .to_str()
        .unwrap_or_default()
        .to_lowercase();

    actual == expected
}

fn file_extension_matches_one_of(path: &Path, extensions: &[&str]) -> bool {
    extensions
        .iter()
        .copied()
        .any(|ext| file_extension_matches(path, ext))
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::Path;

    #[test]
    fn file_extension_matches_returns_true() {
        assert!(file_extension_matches(&Path::new("test.tap"), "tap"));
        assert!(file_extension_matches(&Path::new("test.TAP"), "tap"));
        assert!(file_extension_matches(&Path::new("test.tAp"), "tap"));
    }

    #[test]
    fn file_extension_matches_returns_false() {
        assert!(!file_extension_matches(&Path::new("test.tap"), "sna"));
    }
}