kr580 1.0.0

Desktop KR580VM80 / Intel 8080 emulator.
Documentation
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};

#[derive(Debug, PartialEq)]
enum CliAction {
    OpenFile(Option<PathBuf>),
    RegisterFileType,
    UnregisterFileType,
    Install,
    Help,
    Version,
}

fn main() {
    let action = match parse_cli_args(&mut std::env::args().skip(1)) {
        Ok(action) => action,
        Err(message) => {
            eprintln!("error: {message}");
            std::process::exit(1);
        }
    };

    match action {
        CliAction::Help => print_usage(),
        CliAction::Version => print_version(),
        CliAction::RegisterFileType => {
            run_assoc(
                k580_ui::file_assoc::register,
                "Ассоциация .580 зарегистрирована",
            );
        }
        CliAction::UnregisterFileType => {
            run_assoc(k580_ui::file_assoc::unregister, "Ассоциация .580 удалена");
        }
        CliAction::Install => {
            if let Err(error) = spawn_installer() {
                eprintln!("error: {error}");
                std::process::exit(1);
            }
        }
        CliAction::OpenFile(path) => {
            if let Err(error) = spawn_k580(path.as_deref()) {
                eprintln!("error: {error}");
                std::process::exit(1);
            }
        }
    }
}

fn parse_cli_args(args: &mut impl Iterator<Item = String>) -> Result<CliAction, String> {
    let Some(arg) = args.next() else {
        return Ok(CliAction::OpenFile(None));
    };
    match arg.as_str() {
        "--help" | "-h" => Ok(CliAction::Help),
        "--version" | "-V" => Ok(CliAction::Version),
        "--register-file-type" | "-r" => Ok(CliAction::RegisterFileType),
        "--unregister-file-type" | "-u" => Ok(CliAction::UnregisterFileType),
        "--install" | "-i" => Ok(CliAction::Install),
        path => {
            if path.starts_with('-') {
                return Err(format!("unknown option: {path}"));
            }
            if args.next().is_some() {
                return Err("too many arguments".to_owned());
            }
            Ok(CliAction::OpenFile(Some(PathBuf::from(path))))
        }
    }
}

fn print_usage() {
    println!(
        "kr [ПАРАМЕТР] [ФАЙЛ]

Аргументы:
  ФАЙЛ  снимок .580 для открытия

Параметры:
  -h, --help                  Показать справку
  -V, --version               Показать версию
  -r, --register-file-type    Зарегистрировать ассоциацию .580
  -u, --unregister-file-type  Удалить ассоциацию .580
  -i, --install               Открыть установщик KR580"
    );
}

fn print_version() {
    println!("kr {}", env!("CARGO_PKG_VERSION"));
}

fn run_assoc(action: fn() -> Result<(), String>, success: &str) {
    match action() {
        Ok(()) => println!("{success}"),
        Err(error) => {
            eprintln!("{error}");
            std::process::exit(1);
        }
    }
}

fn spawn_k580(file: Option<&Path>) -> std::io::Result<()> {
    let k580 = match k580_executable() {
        Ok(path) => path,
        Err(error) => {
            #[cfg(debug_assertions)]
            build_k580()?;
            k580_executable().map_err(|_| error)?
        }
    };
    let mut cmd = Command::new(&k580);
    cmd.stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null());
    if let Some(path) = file {
        cmd.arg(path);
    }
    let _child = cmd.spawn()?;
    Ok(())
}

fn spawn_installer() -> std::io::Result<()> {
    let installer = installer_executable()?;
    let mut command = Command::new(&installer);
    if is_uninstaller_binary(&installer) {
        command.arg("--setup");
    }
    command
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .spawn()?;
    Ok(())
}

#[cfg(debug_assertions)]
fn build_k580() -> std::io::Result<()> {
    let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_owned());
    let status = std::process::Command::new(cargo)
        .args(["build", "-p", "kr580", "--bin", "k580"])
        .status()
        .map_err(|e| {
            std::io::Error::new(
                std::io::ErrorKind::NotFound,
                format!("failed to run cargo: {e}"),
            )
        })?;
    if status.success() {
        Ok(())
    } else {
        Err(std::io::Error::other(
            "cargo build -p kr580 --bin k580 failed",
        ))
    }
}

fn k580_executable() -> std::io::Result<PathBuf> {
    let kr = std::env::current_exe()?;
    let dir = kr
        .parent()
        .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no parent directory"))?;
    let k580 = dir.join(k580_binary_name());
    if k580.is_file() {
        return Ok(k580);
    }
    if let Some(k580) = installed_app_binary_from_launcher(&kr, k580_binary_name())
        && k580.is_file()
    {
        return Ok(k580);
    }

    let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
    let profile = if cfg!(debug_assertions) {
        "debug"
    } else {
        "release"
    };
    let fallback = manifest_dir
        .join("..")
        .join("..")
        .join("target")
        .join(profile)
        .join(k580_binary_name());
    if fallback.is_file() {
        return Ok(fallback);
    }

    Err(std::io::Error::new(
        std::io::ErrorKind::NotFound,
        format!("k580 executable not found at {}", k580.display()),
    ))
}

fn installer_executable() -> std::io::Result<PathBuf> {
    let kr = std::env::current_exe()?;
    let dir = kr
        .parent()
        .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no parent directory"))?;
    let installer = dir.join(installer_binary_name());
    if installer.is_file() {
        return Ok(installer);
    }
    if let Some(installer) = installed_app_binary_from_launcher(&kr, installer_binary_name())
        && installer.is_file()
    {
        return Ok(installer);
    }
    if let Some(uninstaller) = installed_app_binary_from_launcher(&kr, uninstaller_binary_name())
        && uninstaller.is_file()
    {
        return Ok(uninstaller);
    }

    let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
    let profile = if cfg!(debug_assertions) {
        "debug"
    } else {
        "release"
    };
    let fallback = manifest_dir
        .join("..")
        .join("..")
        .join("target")
        .join(profile)
        .join(installer_binary_name());
    if fallback.is_file() {
        return Ok(fallback);
    }

    Err(std::io::Error::new(
        std::io::ErrorKind::NotFound,
        format!("installer executable not found at {}", installer.display()),
    ))
}

fn installed_app_binary_from_launcher(launcher: &Path, binary_name: &str) -> Option<PathBuf> {
    let dir = launcher.parent()?;
    if !dir
        .file_name()
        .and_then(|name| name.to_str())
        .is_some_and(|name| name.eq_ignore_ascii_case("bin"))
    {
        return None;
    }
    Some(dir.parent()?.join("app").join(binary_name))
}

fn is_uninstaller_binary(path: &Path) -> bool {
    path.file_name()
        .and_then(|name| name.to_str())
        .is_some_and(|name| name.eq_ignore_ascii_case(uninstaller_binary_name()))
}

#[cfg(target_os = "windows")]
fn k580_binary_name() -> &'static str {
    "k580.exe"
}

#[cfg(not(target_os = "windows"))]
fn k580_binary_name() -> &'static str {
    "k580"
}

#[cfg(target_os = "windows")]
fn installer_binary_name() -> &'static str {
    "k580-installer.exe"
}

#[cfg(not(target_os = "windows"))]
fn installer_binary_name() -> &'static str {
    "k580-installer"
}

#[cfg(target_os = "windows")]
fn uninstaller_binary_name() -> &'static str {
    "uninstaller.exe"
}

#[cfg(not(target_os = "windows"))]
fn uninstaller_binary_name() -> &'static str {
    "uninstaller"
}

#[cfg(test)]
mod tests {
    use super::{CliAction, installed_app_binary_from_launcher, parse_cli_args};
    use std::path::PathBuf;

    #[test]
    fn no_args_opens_empty_file() {
        assert!(matches!(
            parse_cli_args(&mut [].into_iter()),
            Ok(CliAction::OpenFile(None))
        ));
    }

    #[test]
    fn single_path_arg_opens_file() {
        let action = parse_cli_args(&mut ["snapshot.580".to_owned()].into_iter()).unwrap();
        assert_eq!(
            action,
            CliAction::OpenFile(Some(PathBuf::from("snapshot.580")))
        );
    }

    #[test]
    fn known_flags_are_recognized() {
        for flag in [
            "--help",
            "-h",
            "--version",
            "-V",
            "--register-file-type",
            "-r",
            "--unregister-file-type",
            "-u",
            "--install",
            "-i",
        ] {
            assert!(
                parse_cli_args(&mut [flag.to_owned()].into_iter()).is_ok(),
                "flag {flag} should parse"
            );
        }
    }

    #[test]
    fn unknown_flag_errors() {
        assert!(parse_cli_args(&mut ["--unknown".to_owned()].into_iter()).is_err());
    }

    #[test]
    fn too_many_arguments_errors() {
        assert!(parse_cli_args(&mut ["a.580".to_owned(), "b.580".to_owned()].into_iter()).is_err());
    }

    #[test]
    fn installed_bin_launcher_resolves_gui_under_app() {
        let root = PathBuf::from("kr580-root");
        let launcher = root.join("bin").join("kr");
        assert_eq!(
            installed_app_binary_from_launcher(&launcher, "k580"),
            Some(root.join("app").join("k580"))
        );
    }
}