minecli 0.1.0

A CLI for managing Minecraft server mods, datapacks, and plugins.
use std::fs;
use std::io::Write;
use std::path::Path;
use std::process::{Command, Output};
use std::time::Duration;

use reqwest::blocking::Client;
use serde_json::Value;

const MINECRAFT_VERSION: &str = "1.21.5";

#[derive(Debug, Clone, Copy)]
enum ServerFlavor {
    Fabric,
    Purpur,
    Forge,
    NeoForge,
}

impl ServerFlavor {
    fn expected_type(self) -> &'static str {
        match self {
            Self::Fabric => "fabric",
            Self::Purpur => "purpur",
            Self::Forge => "forge",
            Self::NeoForge => "neoforge",
        }
    }

    fn package(self) -> (&'static str, &'static str, &'static str) {
        match self {
            Self::Fabric => ("fabric-api", "mod", "mods"),
            Self::Purpur => ("simple-voice-chat", "plugin", "plugins"),
            Self::Forge | Self::NeoForge => ("simple-voice-chat", "mod", "mods"),
        }
    }
}

fn minecli(server_dir: &Path, cache_dir: &Path, args: &[&str]) -> Output {
    Command::new(env!("CARGO_BIN_EXE_minecli"))
        .env("MINECLI_CACHE_DIR", cache_dir)
        .arg("--path")
        .arg(server_dir)
        .args(args)
        .output()
        .expect("failed to run minecli")
}

fn stdout(output: &Output) -> String {
    String::from_utf8_lossy(&output.stdout).to_string()
}

fn stderr(output: &Output) -> String {
    String::from_utf8_lossy(&output.stderr).to_string()
}

fn assert_success(output: &Output) {
    assert!(
        output.status.success(),
        "stdout:\n{}\nstderr:\n{}",
        stdout(output),
        stderr(output)
    );
}

#[ignore = "downloads real server jars and packages from external sources"]
#[test]
fn live_fabric_server_flow() {
    run_live_flow(ServerFlavor::Fabric);
}

#[ignore = "downloads real server jars and packages from external sources"]
#[test]
fn live_purpur_server_flow() {
    run_live_flow(ServerFlavor::Purpur);
}

#[ignore = "downloads real server jars and packages from external sources"]
#[test]
fn live_forge_server_flow() {
    run_live_flow(ServerFlavor::Forge);
}

#[ignore = "downloads real server jars and packages from external sources"]
#[test]
fn live_neoforge_server_flow() {
    run_live_flow(ServerFlavor::NeoForge);
}

fn run_live_flow(flavor: ServerFlavor) {
    let workspace = tempfile::tempdir().unwrap();
    let server_dir = workspace.path().join("server");
    let cache_dir = workspace.path().join("cache");
    fs::create_dir_all(&server_dir).unwrap();

    log_step(flavor, format!("workspace: {}", workspace.path().display()));
    log_step(flavor, format!("server dir: {}", server_dir.display()));
    log_step(flavor, format!("cache dir: {}", cache_dir.display()));

    let client = http_client();
    log_step(flavor, "downloading server artifact");
    download_server_jar(&client, flavor, &server_dir);
    log_directory(flavor, "server files after download", &server_dir);

    log_step(flavor, "running minecli init with auto-detection");
    let init = minecli(
        &server_dir,
        &cache_dir,
        &["init", "--minecraft", MINECRAFT_VERSION, "--name", "live"],
    );
    assert_success(&init);
    log_output(flavor, "init stdout", &stdout(&init));

    log_step(flavor, "checking detected server type with minecli status");
    let status = minecli(&server_dir, &cache_dir, &["status"]);
    assert_success(&status);
    log_output(flavor, "status stdout", &stdout(&status));
    assert!(
        stdout(&status).contains(&format!("Type: {}", flavor.expected_type())),
        "{}",
        stdout(&status)
    );

    let (project, kind, target_dir) = flavor.package();
    log_step(
        flavor,
        format!("installing package `{project}` as `{kind}`"),
    );
    let install = minecli(
        &server_dir,
        &cache_dir,
        &["install", project, "--kind", kind],
    );
    assert_success(&install);
    log_output(flavor, "install stdout", &stdout(&install));
    log_directory(
        flavor,
        format!("target directory after install: {target_dir}"),
        &server_dir.join(target_dir),
    );
    log_directory(flavor, "download cache after install", &cache_dir);

    log_step(flavor, "checking lockfile list");
    let list = minecli(&server_dir, &cache_dir, &["list"]);
    assert_success(&list);
    log_output(flavor, "list stdout", &stdout(&list));
    assert!(stdout(&list).contains(project), "{}", stdout(&list));
    assert!(
        installed_files(&server_dir.join(target_dir)) > 0,
        "expected at least one file in {target_dir}"
    );

    log_step(flavor, format!("removing `{project}`"));
    let remove = minecli(
        &server_dir,
        &cache_dir,
        &["remove", project, "--remove-orphans"],
    );
    assert_success(&remove);
    log_output(flavor, "remove stdout", &stdout(&remove));
    log_directory(
        flavor,
        format!("target directory after remove: {target_dir}"),
        &server_dir.join(target_dir),
    );

    let list_after_remove = minecli(&server_dir, &cache_dir, &["list", "--json"]);
    assert_success(&list_after_remove);
    log_output(
        flavor,
        "list --json stdout after remove",
        &stdout(&list_after_remove),
    );
    assert_eq!(stdout(&list_after_remove).trim(), "[]");
    assert_eq!(installed_files(&server_dir.join(target_dir)), 0);
    log_step(flavor, "flow completed");
}

fn http_client() -> Client {
    Client::builder()
        .timeout(Duration::from_secs(120))
        .user_agent(format!(
            "minecli-live-tests/{} (github.com/vincentsaluzzo/mine-cli)",
            env!("CARGO_PKG_VERSION")
        ))
        .build()
        .unwrap()
}

fn download_server_jar(client: &Client, flavor: ServerFlavor, server_dir: &Path) {
    match flavor {
        ServerFlavor::Fabric => download_fabric_server(client, server_dir),
        ServerFlavor::Purpur => download_purpur_server(client, server_dir),
        ServerFlavor::Forge => download_forge_installer(client, server_dir),
        ServerFlavor::NeoForge => download_neoforge_installer(client, server_dir),
    }
}

fn download_fabric_server(client: &Client, server_dir: &Path) {
    let loader_version = latest_fabric_loader(client);
    let installer_version = latest_fabric_installer(client);
    let url = format!(
        "https://meta.fabricmc.net/v2/versions/loader/{MINECRAFT_VERSION}/{loader_version}/{installer_version}/server/jar"
    );
    println!("[fabric] loader={loader_version}, installer={installer_version}");
    download_to(client, &url, &server_dir.join("fabric-server-launch.jar"));
}

fn latest_fabric_loader(client: &Client) -> String {
    let versions = client
        .get(format!(
            "https://meta.fabricmc.net/v2/versions/loader/{MINECRAFT_VERSION}"
        ))
        .send()
        .unwrap()
        .error_for_status()
        .unwrap()
        .json::<Vec<Value>>()
        .unwrap();

    versions
        .iter()
        .find(|version| version["loader"]["stable"].as_bool().unwrap_or(false))
        .or_else(|| versions.first())
        .and_then(|version| version["loader"]["version"].as_str())
        .expect("fabric loader version")
        .to_owned()
}

fn latest_fabric_installer(client: &Client) -> String {
    let versions = client
        .get("https://meta.fabricmc.net/v2/versions/installer")
        .send()
        .unwrap()
        .error_for_status()
        .unwrap()
        .json::<Vec<Value>>()
        .unwrap();

    versions
        .iter()
        .find(|version| version["stable"].as_bool().unwrap_or(false))
        .or_else(|| versions.first())
        .and_then(|version| version["version"].as_str())
        .expect("fabric installer version")
        .to_owned()
}

fn download_purpur_server(client: &Client, server_dir: &Path) {
    let latest = client
        .get(format!(
            "https://api.purpurmc.org/v2/purpur/{MINECRAFT_VERSION}/latest"
        ))
        .send()
        .unwrap()
        .error_for_status()
        .unwrap()
        .json::<Value>()
        .unwrap();
    let build = latest["build"].as_str().expect("purpur build");
    let url = format!("https://api.purpurmc.org/v2/purpur/{MINECRAFT_VERSION}/{build}/download");
    println!("[purpur] build={build}");
    download_to(
        client,
        &url,
        &server_dir.join(format!("purpur-{MINECRAFT_VERSION}.jar")),
    );
}

fn download_forge_installer(client: &Client, server_dir: &Path) {
    let metadata = download_text(
        client,
        "https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml",
    );
    let version = latest_version_from_maven_metadata(&metadata, &format!("{MINECRAFT_VERSION}-"));
    let url = format!(
        "https://maven.minecraftforge.net/net/minecraftforge/forge/{version}/forge-{version}-installer.jar"
    );
    println!("[forge] installer version={version}");
    download_to(
        client,
        &url,
        &server_dir.join(format!("forge-{version}-installer.jar")),
    );
}

fn download_neoforge_installer(client: &Client, server_dir: &Path) {
    let metadata = download_text(
        client,
        "https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml",
    );
    let version = latest_version_from_maven_metadata(&metadata, "21.5.");
    let url = format!(
        "https://maven.neoforged.net/releases/net/neoforged/neoforge/{version}/neoforge-{version}-installer.jar"
    );
    println!("[neoforge] installer version={version}");
    download_to(
        client,
        &url,
        &server_dir.join(format!("neoforge-{version}-installer.jar")),
    );
}

fn latest_version_from_maven_metadata(metadata: &str, prefix: &str) -> String {
    metadata
        .lines()
        .filter_map(|line| {
            line.trim()
                .strip_prefix("<version>")
                .and_then(|value| value.strip_suffix("</version>"))
        })
        .rfind(|version| version.starts_with(prefix))
        .expect("matching maven version")
        .to_owned()
}

fn download_text(client: &Client, url: &str) -> String {
    client
        .get(url)
        .send()
        .unwrap()
        .error_for_status()
        .unwrap()
        .text()
        .unwrap()
}

fn download_to(client: &Client, url: &str, path: &Path) {
    println!("  download: {url}");
    println!("       to: {}", path.display());
    let mut response = client.get(url).send().unwrap().error_for_status().unwrap();
    let mut file = fs::File::create(path).unwrap();
    let bytes = response.copy_to(&mut file).unwrap();
    file.flush().unwrap();
    println!("  wrote {bytes} bytes");
}

fn installed_files(path: &Path) -> usize {
    match fs::read_dir(path) {
        Ok(entries) => entries
            .filter_map(Result::ok)
            .filter(|entry| entry.path().is_file())
            .count(),
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => 0,
        Err(error) => panic!("failed to read {}: {error}", path.display()),
    }
}

fn log_step(flavor: ServerFlavor, message: impl AsRef<str>) {
    println!("[{}] {}", flavor.expected_type(), message.as_ref());
}

fn log_output(flavor: ServerFlavor, title: &str, output: &str) {
    log_step(flavor, title);
    for line in output.trim().lines() {
        println!("  {line}");
    }
}

fn log_directory(flavor: ServerFlavor, title: impl AsRef<str>, path: &Path) {
    log_step(flavor, title);
    match fs::read_dir(path) {
        Ok(entries) => {
            let mut entries = entries
                .filter_map(Result::ok)
                .map(|entry| {
                    let path = entry.path();
                    let size = fs::metadata(&path)
                        .map(|metadata| metadata.len())
                        .unwrap_or(0);
                    let name = entry.file_name().to_string_lossy().to_string();
                    (name, size)
                })
                .collect::<Vec<_>>();
            entries.sort_by(|left, right| left.0.cmp(&right.0));

            if entries.is_empty() {
                println!("  <empty>");
            } else {
                for (name, size) in entries {
                    println!("  {name} ({size} bytes)");
                }
            }
        }
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
            println!("  <missing>");
        }
        Err(error) => panic!("failed to read {}: {error}", path.display()),
    }
}