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())?;
let raw = super::read_pack_manifest(entry)?;
serde_json::from_slice(&raw).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 = super::override_files(path, &[&manifest.overrides])?.len();
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,
config: &InstanceConfig,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
crate::instance::manager::validate_name(&config.name)?;
let minecraft_dir = manager
.instances_dir
.join(&config.name)
.join(crate::storage::MINECRAFT_DIR_NAME);
let manifest = parse(&summary.archive_path)?;
download_files(&manifest, &minecraft_dir).await?;
extract_overrides(&summary.archive_path, &minecraft_dir, &manifest.overrides)?;
Ok(())
}
async fn download_files(
manifest: &Manifest,
minecraft_dir: &Path,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let ids = manifest
.files
.iter()
.filter(|file| file.required)
.map(|file| file.file_id)
.collect::<Vec<_>>();
if ids.is_empty() {
return Ok(());
}
let api_key =
crate::net::curseforge::api_key().ok_or("CurseForge API key is not configured")?;
let client = crate::net::HttpClient::new();
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 overrides = super::override_files(path, &[manifest.overrides.trim_matches('/')])?;
let ids = manifest
.files
.iter()
.filter(|file| file.required)
.map(|file| file.file_id)
.collect::<Vec<_>>();
if ids.is_empty() {
return Ok(overrides);
}
let api_key = crate::net::curseforge::api_key()
.ok_or_else(|| "CurseForge API key is not configured".to_owned())?;
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()
));
}
let mut files: Vec<_> = versions
.into_iter()
.map(|version| {
version
.files
.iter()
.find(|file| file.primary)
.or_else(|| version.files.first())
.ok_or_else(|| format!("CurseForge file '{}' has no artifact", version.id))
.and_then(|file| super::portable_pack_path(&format!("mods/{}", file.filename)))
.map(std::path::PathBuf::from)
})
.collect::<Result<_, _>>()?;
files.extend(overrides);
Ok(files)
}
fn extract_overrides(
path: &Path,
minecraft_dir: &Path,
root: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
progress::set_action("Extracting overrides...");
super::archive::extract_overrides(path, minecraft_dir, &[root])
}
#[cfg(test)]
#[path = "../tests/import/curseforge.rs"]
mod tests;