rmcl 0.4.0

A fully featured Minecraft TUI launcher
// curseforge modpack archives: manifest.json references provider file ids,
// while the overrides directory contains configs and other bundled files.

use std::path::Path;

use serde::Deserialize;

use crate::feedback::progress;
use crate::instance::{InstanceConfig, InstanceManager, ModLoader};

use super::{ImportSummary, PackFormat};

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Manifest {
    name: String,
    version: String,
    minecraft: Minecraft,
    #[serde(default)]
    files: Vec<ManifestFile>,
    #[serde(default = "default_overrides")]
    overrides: String,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Minecraft {
    version: String,
    #[serde(default)]
    mod_loaders: Vec<ManifestLoader>,
}

#[derive(Debug, Deserialize)]
struct ManifestLoader {
    id: String,
    #[serde(default)]
    primary: bool,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ManifestFile {
    #[serde(rename = "projectID")]
    _project_id: u64,
    #[serde(rename = "fileID")]
    file_id: u64,
    #[serde(default = "required_by_default")]
    required: bool,
}

fn default_overrides() -> String {
    "overrides".to_owned()
}

fn required_by_default() -> bool {
    true
}

fn parse(path: &Path) -> Result<Manifest, String> {
    let file = std::fs::File::open(path).map_err(|error| error.to_string())?;
    let mut archive = zip::ZipArchive::new(file).map_err(|error| error.to_string())?;
    let entry = archive
        .by_name("manifest.json")
        .map_err(|_| "Missing manifest.json in CurseForge pack".to_owned())?;
    serde_json::from_reader(entry).map_err(|error| format!("Invalid CurseForge manifest: {error}"))
}

fn loader(manifest: &Manifest) -> (ModLoader, Option<String>) {
    let id = manifest
        .minecraft
        .mod_loaders
        .iter()
        .find(|loader| loader.primary)
        .or_else(|| manifest.minecraft.mod_loaders.first())
        .map(|loader| loader.id.as_str());
    let Some(id) = id else {
        return (ModLoader::Vanilla, None);
    };
    let (name, version) = id.split_once('-').unwrap_or((id, ""));
    let loader = match name.to_ascii_lowercase().as_str() {
        "fabric" => ModLoader::Fabric,
        "forge" => ModLoader::Forge,
        "neoforge" => ModLoader::NeoForge,
        "quilt" => ModLoader::Quilt,
        _ => ModLoader::Vanilla,
    };
    (
        loader,
        (!version.is_empty() && loader != ModLoader::Vanilla).then(|| version.to_owned()),
    )
}

pub fn build_summary(path: &Path) -> Result<ImportSummary, String> {
    let manifest = parse(path)?;
    let (loader, loader_version) = loader(&manifest);
    let override_count = count_overrides(path, &manifest.overrides)?;
    Ok(ImportSummary {
        name: manifest.name,
        pack_version: manifest.version,
        game_version: manifest.minecraft.version,
        loader,
        loader_version,
        mod_count: manifest.files.iter().filter(|file| file.required).count(),
        override_count,
        format: PackFormat::CurseForge,
        archive_path: path.to_owned(),
        source: None,
    })
}

pub async fn execute_import(
    summary: &ImportSummary,
    manager: &InstanceManager,
) -> Result<InstanceConfig, Box<dyn std::error::Error + Send + Sync>> {
    let name = super::unique_instance_name(&summary.name, &manager.instances_dir);
    progress::set_action(format!("Importing '{name}'..."));
    progress::set_sub_action(format!("{} {}", summary.game_version, summary.loader));
    let config = manager
        .create(
            &name,
            &summary.game_version,
            summary.loader,
            summary.loader_version.as_deref(),
        )
        .await
        .map_err(|error| -> Box<dyn std::error::Error + Send + Sync> { Box::new(error) })?;
    let minecraft_dir = manager
        .instances_dir
        .join(&name)
        .join(crate::storage::MINECRAFT_DIR_NAME);
    let result = async {
        let manifest = parse(&summary.archive_path)?;
        download_files(&manifest, &minecraft_dir).await?;
        extract_overrides(&summary.archive_path, &minecraft_dir, &manifest.overrides)?;
        Ok::<(), Box<dyn std::error::Error + Send + Sync>>(())
    }
    .await;
    if let Err(error) = result {
        super::cleanup_failed_import(manager, &name);
        return Err(error);
    }
    progress::clear();
    Ok(config)
}

async fn download_files(
    manifest: &Manifest,
    minecraft_dir: &Path,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    let api_key =
        crate::net::curseforge::api_key().ok_or("CurseForge API key is not configured")?;
    let client = crate::net::HttpClient::new();
    let ids = manifest
        .files
        .iter()
        .filter(|file| file.required)
        .map(|file| file.file_id)
        .collect::<Vec<_>>();
    let versions = crate::net::curseforge::fetch_file_versions(&client, api_key, &ids).await?;
    if versions.len() != ids.len() {
        return Err(format!(
            "CurseForge returned {} of {} required pack files",
            versions.len(),
            ids.len()
        )
        .into());
    }
    let mods_dir = minecraft_dir.join("mods");
    tokio::fs::create_dir_all(&mods_dir).await?;
    let total = versions.len();
    progress::set_action(format!("Downloading mods... 0/{total}"));
    let completed = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
    let slots = std::sync::Arc::new(tokio::sync::Semaphore::new(10));
    let mut tasks = tokio::task::JoinSet::new();
    for mut version in versions {
        let client = client.clone();
        let api_key = api_key.to_owned();
        let mods_dir = mods_dir.clone();
        let completed = completed.clone();
        let slots = slots.clone();
        tasks.spawn(async move {
            let _permit = slots.acquire_owned().await?;
            crate::net::curseforge::ensure_download_url(&client, &api_key, &mut version).await?;
            crate::net::modrinth::download_version_file(&client, &version, &mods_dir).await?;
            let finished = completed.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
            progress::set_action(format!("Downloading mods... {finished}/{total}"));
            Ok::<(), Box<dyn std::error::Error + Send + Sync>>(())
        });
    }
    while let Some(result) = tasks.join_next().await {
        result??;
    }
    Ok(())
}

pub(super) async fn owned_files(path: &Path) -> Result<Vec<std::path::PathBuf>, String> {
    let manifest = parse(path)?;
    let api_key = crate::net::curseforge::api_key()
        .ok_or_else(|| "CurseForge API key is not configured".to_owned())?;
    let ids = manifest
        .files
        .iter()
        .filter(|file| file.required)
        .map(|file| file.file_id)
        .collect::<Vec<_>>();
    let versions =
        crate::net::curseforge::fetch_file_versions(&crate::net::HttpClient::new(), api_key, &ids)
            .await
            .map_err(|error| error.to_string())?;
    if versions.len() != ids.len() {
        return Err(format!(
            "CurseForge returned {} of {} required pack files",
            versions.len(),
            ids.len()
        ));
    }
    versions
        .into_iter()
        .map(|version| {
            version
                .files
                .iter()
                .find(|file| file.primary)
                .or_else(|| version.files.first())
                .map(|file| std::path::PathBuf::from("mods").join(&file.filename))
                .ok_or_else(|| format!("CurseForge file '{}' has no artifact", version.id))
        })
        .collect()
}

fn count_overrides(path: &Path, root: &str) -> Result<usize, String> {
    let root = root.trim_matches('/');
    if root.is_empty() {
        return Ok(0);
    }
    let file = std::fs::File::open(path).map_err(|error| error.to_string())?;
    let archive = zip::ZipArchive::new(file).map_err(|error| error.to_string())?;
    let prefix = format!("{root}/");
    Ok(archive
        .file_names()
        .filter(|name| name.starts_with(&prefix) && !name.ends_with('/'))
        .count())
}

fn extract_overrides(
    path: &Path,
    minecraft_dir: &Path,
    root: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    progress::set_action("Extracting overrides...");
    let file = std::fs::File::open(path)?;
    let mut archive = zip::ZipArchive::new(file)?;
    let root = root.trim_matches('/');
    if root.is_empty() {
        return Ok(());
    }
    for index in 0..archive.len() {
        let mut entry = archive.by_index(index)?;
        let enclosed = entry.enclosed_name().ok_or("Unsafe override path")?;
        let Ok(relative) = enclosed.strip_prefix(root) else {
            continue;
        };
        if relative.as_os_str().is_empty() {
            continue;
        }
        let destination = minecraft_dir.join(relative);
        if entry.is_dir() {
            std::fs::create_dir_all(destination)?;
            continue;
        }
        if let Some(parent) = destination.parent() {
            std::fs::create_dir_all(parent)?;
        }
        let mut destination = std::fs::File::create(destination)?;
        std::io::copy(&mut entry, &mut destination)?;
    }
    Ok(())
}

#[cfg(test)]
#[path = "../tests/import/curseforge.rs"]
mod tests;