use crate::config::{BranchConfig, IncludeOrExclude, Loader, ProjectSettings};
use crate::{MRPACK_INDEX_FILE_NAME, PackrinthError, PackrinthResult};
use serde::{Deserialize, Serialize};
use std::io::{BufReader, Read};
use std::path::Path;
use std::{cmp, fs, io};
use zip::ZipArchive;
use zip::result::ZipResult;
const MODRINTH_API_BASE_URL: &str = "https://api.modrinth.com/v2";
pub fn extract_mrpack_overrides(mrpack_path: &Path, output_directory: &Path) -> ZipResult<()> {
let zip_file = fs::File::open(mrpack_path)?;
let mut archive = ZipArchive::new(zip_file)?;
for i in 0..archive.len() {
let mut file = archive.by_index(i)?;
let output_path = Path::new(output_directory).join(file.name());
if file.name().ends_with('/') {
fs::create_dir_all(&output_path)?;
} else if file.name() != MRPACK_INDEX_FILE_NAME {
if let Some(parent) = output_path.parent()
&& !parent.exists()
{
fs::create_dir_all(parent)?;
}
let mut output_file = fs::File::create(&output_path)?;
io::copy(&mut file, &mut output_file)?;
}
}
Ok(())
}
fn request_text<T: ToString>(api_endpoint: &T) -> PackrinthResult<String> {
let full_url = MODRINTH_API_BASE_URL.to_string() + api_endpoint.to_string().as_str();
crate::request_text(&full_url)
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Project {
pub id: String,
pub slug: String,
pub title: String,
pub server_side: SideSupport,
pub client_side: SideSupport,
pub project_type: ProjectType,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ProjectType {
#[serde(rename = "mod")]
Mod,
#[serde(rename = "modpack")]
Modpack,
#[serde(rename = "resourcepack")]
ResourcePack,
#[serde(rename = "shader")]
Shader,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SideSupport {
#[serde(rename = "required")]
Required,
#[serde(rename = "optional")]
Optional,
#[serde(rename = "unsupported")]
Unsupported,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Version {
pub id: String,
pub project_id: String,
pub version_type: VersionType,
pub game_versions: Vec<String>,
pub files: Vec<VersionFile>,
pub dependencies: Vec<VersionDependency>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum VersionType {
#[serde(rename = "release")]
Release,
#[serde(rename = "beta")]
Beta,
#[serde(rename = "alpha")]
Alpha,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct VersionFile {
pub url: String,
pub filename: String,
pub primary: bool,
pub size: u64,
pub hashes: FileHashes,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct FileHashes {
pub sha1: String,
pub sha512: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct VersionDependency {
pub project_id: Option<String>,
pub dependency_type: VersionDependencyType,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum VersionDependencyType {
#[serde(rename = "required")]
Required,
#[serde(rename = "optional")]
Optional,
#[serde(rename = "incompatible")]
Incompatible,
#[serde(rename = "embedded")]
Embedded,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MrPack {
pub format_version: u16,
pub game: String,
pub version_id: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
pub files: Vec<File>,
pub dependencies: MrPackDependencies,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct File {
#[serde(skip_serializing)]
#[serde(skip_deserializing)]
pub project_name: String,
pub path: String,
pub hashes: FileHashes,
#[serde(skip_serializing_if = "Option::is_none")]
pub env: Option<Env>,
pub downloads: Vec<String>,
pub file_size: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Env {
pub client: SideSupport,
pub server: SideSupport,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MrPackDependencies {
pub minecraft: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub forge: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub neoforge: Option<String>,
#[serde(rename = "fabric-loader")]
#[serde(skip_serializing_if = "Option::is_none")]
pub fabric_loader: Option<String>,
#[serde(rename = "quilt-loader")]
#[serde(skip_serializing_if = "Option::is_none")]
pub quilt_loader: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum FileResult {
Ok {
file: File,
dependencies: Vec<VersionDependency>,
project_id: String,
},
Skipped,
NotFound,
Err(PackrinthError),
}
impl Project {
pub fn from_id(id: &str) -> PackrinthResult<Self> {
let api_endpoint = format!("/project/{id}");
let modrinth_project_response = request_text(&api_endpoint)?;
match serde_json::from_str::<Self>(&modrinth_project_response) {
Ok(versions) => Ok(versions),
Err(error) => Err(PackrinthError::FailedToParseModrinthResponseJson {
modrinth_endpoint: api_endpoint,
error_message: error.to_string(),
}),
}
}
}
impl ProjectType {
pub fn directory(&self) -> PackrinthResult<&str> {
match self {
ProjectType::Mod => Ok("mods"),
ProjectType::Modpack => Err(PackrinthError::AttemptedToAddOtherModpack),
ProjectType::ResourcePack => Ok("resourcepacks"),
ProjectType::Shader => Ok("shaderpacks"),
}
}
}
impl Version {
pub fn from_sha512_hash(hash: &str) -> PackrinthResult<Self> {
let api_endpoint = format!("/version_file/{hash}?algorithm=sha512");
let api_response = request_text(&api_endpoint)?;
match serde_json::from_str::<Self>(&api_response) {
Ok(versions) => Ok(versions),
Err(error) => Err(PackrinthError::FailedToParseModrinthResponseJson {
modrinth_endpoint: api_endpoint,
error_message: error.to_string(),
}),
}
}
}
impl MrPack {
pub fn from_mrpack(mrpack_path: &Path) -> PackrinthResult<Self> {
let mrpack_file = match fs::File::open(mrpack_path) {
Ok(mrpack_file) => mrpack_file,
Err(error) => {
return Err(PackrinthError::FailedToInitializeFileType {
file_to_create: mrpack_path.display().to_string(),
error_message: error.to_string(),
});
}
};
let mut zip_archive = match ZipArchive::new(BufReader::new(mrpack_file)) {
Ok(zip_archive) => zip_archive,
Err(error) => {
return Err(PackrinthError::FailedToCreateZipArchive {
zip_path: mrpack_path.display().to_string(),
error_message: error.to_string(),
});
}
};
let mut mrpack_config_file = match zip_archive.by_name(MRPACK_INDEX_FILE_NAME) {
Ok(mrpack_config_file_name) => mrpack_config_file_name,
Err(error) => {
return Err(PackrinthError::InvalidMrPack {
mrpack_path: mrpack_path.display().to_string(),
error_message: error.to_string(),
});
}
};
let mut mrpack = String::new();
if let Err(error) = mrpack_config_file.read_to_string(&mut mrpack) {
return Err(PackrinthError::FailedToReadToString {
path_to_read: mrpack_config_file.name().to_string(),
error_message: error.to_string(),
});
}
match serde_json::from_str(&mrpack) {
Ok(mrpack) => Ok(mrpack),
Err(error) => Err(PackrinthError::InvalidMrPack {
mrpack_path: mrpack_path.display().to_string(),
error_message: error.to_string(),
}),
}
}
}
impl File {
#[must_use]
pub fn from_project(
branch_name: &str,
branch_config: &BranchConfig,
project_id: &str,
project_settings: &ProjectSettings,
no_alpha: bool,
no_beta: bool,
) -> FileResult {
if let Some(include_or_exclude) = &project_settings.include_or_exclude {
match include_or_exclude {
IncludeOrExclude::Include(inclusions) => {
if !inclusions.contains(&branch_name.to_string()) {
return FileResult::Skipped;
}
}
IncludeOrExclude::Exclude(exclusions) => {
if exclusions.contains(&branch_name.to_string()) {
return FileResult::Skipped;
}
}
}
}
let mut loaders = Loader::modrinth_value_vec(&branch_config.acceptable_loaders);
if let Some(mod_loader) = &branch_config.mod_loader {
loaders.push(mod_loader.modrinth_value());
}
loaders.push(Loader::Minecraft.modrinth_value());
loaders.push(Loader::VanillaShader.modrinth_value());
let mut game_versions = vec![branch_config.minecraft_version.clone()];
game_versions.extend(branch_config.acceptable_minecraft_versions.clone());
let mut api_endpoint = format!(
"/project/{project_id}/version?loaders={loaders:?}&game_versions={game_versions:?}"
);
let mut is_version_override = false;
if let Some(version_overrides) = &project_settings.version_overrides
&& let Some(version_override) = version_overrides.get(branch_name)
{
api_endpoint = format!("/version/{version_override}");
is_version_override = true;
}
let api_response = match request_text(&api_endpoint) {
Ok(response) => response,
Err(error) => return FileResult::Err(error),
};
let mut modrinth_versions: Vec<Version> = if is_version_override {
match serde_json::from_str::<Version>(&api_response) {
Ok(version) => vec![version],
Err(error) => {
return FileResult::Err(PackrinthError::FailedToParseModrinthResponseJson {
modrinth_endpoint: api_endpoint,
error_message: error.to_string(),
});
}
}
} else {
match serde_json::from_str(&api_response) {
Ok(versions) => versions,
Err(error) => {
return FileResult::Err(PackrinthError::FailedToParseModrinthResponseJson {
modrinth_endpoint: api_endpoint,
error_message: error.to_string(),
});
}
}
};
#[allow(clippy::items_after_statements)]
fn max_semver(versions: &[String]) -> Option<semver::Version> {
versions
.iter()
.filter_map(|s| s.parse::<semver::Version>().ok())
.max()
}
modrinth_versions.sort_by(|a, b| {
let ma = max_semver(&a.game_versions);
let mb = max_semver(&b.game_versions);
match (ma, mb) {
(Some(va), Some(vb)) => cmp::Reverse(va).cmp(&cmp::Reverse(vb)),
(Some(_), None) => cmp::Ordering::Less,
(None, Some(_)) => cmp::Ordering::Greater,
(None, None) => cmp::Ordering::Equal,
}
});
for modrinth_version in modrinth_versions {
match modrinth_version.version_type {
VersionType::Release => return Self::from_modrinth_version(&modrinth_version),
VersionType::Beta => {
if !no_beta {
return Self::from_modrinth_version(&modrinth_version);
}
}
VersionType::Alpha => {
if !no_alpha {
return Self::from_modrinth_version(&modrinth_version);
}
}
}
}
FileResult::NotFound
}
fn from_modrinth_version(modrinth_version: &Version) -> FileResult {
let modrinth_project: Project = match Project::from_id(&modrinth_version.project_id) {
Ok(versions) => versions,
Err(error) => {
return FileResult::Err(error);
}
};
let mut primary_file_url = None;
let mut primary_file_name = None;
let mut primary_file_hashes = None;
let mut primary_file_size = None;
for version_file in &modrinth_version.files {
if version_file.primary {
primary_file_url = Some(&version_file.url);
primary_file_name = Some(&version_file.filename);
primary_file_hashes = Some(&version_file.hashes);
primary_file_size = Some(&version_file.size);
break;
}
}
if primary_file_url.is_none() {
primary_file_url = Some(&modrinth_version.files[0].url);
}
if primary_file_name.is_none() {
primary_file_name = Some(&modrinth_version.files[0].filename);
}
if primary_file_hashes.is_none() {
primary_file_hashes = Some(&modrinth_version.files[0].hashes);
}
if primary_file_size.is_none() {
primary_file_size = Some(&modrinth_version.files[0].size);
}
let directory = match modrinth_project.project_type.directory() {
Ok(directory) => directory,
Err(error) => return FileResult::Err(error),
};
let path =
String::from(directory) + "/" + primary_file_name.expect("No Modrinth file found");
FileResult::Ok {
file: Self {
project_name: modrinth_project.title,
path,
hashes: primary_file_hashes.expect("No Modrinth file found").clone(),
env: Some(Env {
client: modrinth_project.client_side,
server: modrinth_project.server_side,
}),
downloads: vec![primary_file_url.expect("No Modrinth file found").clone()],
file_size: *primary_file_size.expect("No Modrinth file found"),
},
dependencies: modrinth_version.dependencies.clone(),
project_id: modrinth_version.project_id.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::MainLoader;
use pretty_assertions::assert_eq;
#[test]
fn project_from_id() {
let project = Project::from_id("fabric-api");
assert_eq!(
Ok(Project {
id: "P7dR8mSH".to_string(),
slug: "fabric-api".to_string(),
title: "Fabric API".to_string(),
server_side: SideSupport::Optional,
client_side: SideSupport::Optional,
project_type: ProjectType::Mod,
}),
project
);
}
#[test]
fn test_version_from_sha512_hash() {
let version = Version::from_sha512_hash(
"f0ecb1e1c8f1471437c83f4f58e549efecc0ed3f275baa2a64bbb9a26fd8c14365431bf92cf68d8f8055f6ef103fcc863cd75adbbe8be80f7b752fe1c0c3a305",
);
println!("{:#?}", version);
assert_eq!(Ok(Version {
id: "9xIK4e8l".to_string(),
project_id: "P7dR8mSH".to_string(),
version_type: VersionType::Release,
game_versions: vec!["1.21.1".to_string()],
files: vec![VersionFile {
url: "https://cdn.modrinth.com/data/P7dR8mSH/versions/9xIK4e8l/fabric-api-0.116.6%2B1.21.1.jar".to_string(),
filename: "fabric-api-0.116.6+1.21.1.jar".to_string(),
primary: true,
size: 2424827,
hashes: FileHashes { sha1: "10d5c7cf5fb309513b4f68b85b1e0d9dccbec9ac".to_string(), sha512: "f0ecb1e1c8f1471437c83f4f58e549efecc0ed3f275baa2a64bbb9a26fd8c14365431bf92cf68d8f8055f6ef103fcc863cd75adbbe8be80f7b752fe1c0c3a305".to_string() },
}],
dependencies: vec![],
}), version);
}
#[test]
fn test_file_from_project() {
let branch_config = BranchConfig {
version: "1.0.0".to_string(),
minecraft_version: "1.14".to_string(),
acceptable_minecraft_versions: vec![],
mod_loader: Some(MainLoader::Fabric),
loader_version: Some("0.17.2".to_string()),
acceptable_loaders: vec![],
manual_files: vec![],
};
let project_settings = ProjectSettings {
version_overrides: None,
include_or_exclude: None,
};
let file = File::from_project(
"test",
&branch_config,
"fabric-api",
&project_settings,
false,
false,
);
assert_eq!(FileResult::Ok {
file: File {
project_name: "Fabric API".to_string(),
path: "mods/fabric-0.2.7%2Bbuild.127.jar".to_string(),
hashes: FileHashes { sha1: "554edd4ffb7c05585acc8b7700f523e4b1fc0cde".to_string(), sha512: "6fc41a2adaa4d3b254cb378ec2fbe589d178d998516cf6e7abed8ad4a3fa9da7e75201dcf843698917f1f3b2fb5458587426b79ae22bc642774fc74ff38e79d6".to_string() },
env: Some(Env {
client: SideSupport::Optional,
server: SideSupport::Optional,
}),
downloads: vec!["https://cdn.modrinth.com/data/P7dR8mSH/versions/0.2.7%2Bbuild.127/fabric-0.2.7%2Bbuild.127.jar".to_string(),],
file_size: 253237,
},
dependencies: vec![],
project_id: "P7dR8mSH".to_string(),
}, file);
}
#[test]
fn test_file_from_modrinth_version() {
let modrinth_version = Version {
id: "X2hTodix".to_string(),
project_id: "P7dR8mSH".to_string(),
version_type: VersionType::Release,
game_versions: vec!["1.21.8".to_string()],
files: vec![VersionFile {
url: "https://cdn.modrinth.com/data/P7dR8mSH/versions/X2hTodix/fabric-api-0.129.0%2B1.21.8.jar".to_string(),
filename: "fabric-api-0.129.0+1.21.8.jar".to_string(),
primary: true,
size: 2_212_412,
hashes: FileHashes { sha1: "9be74f9c3120ffb9f38df8f4164392d69e6ba84e".to_string(), sha512: "471babff84b36bd0f5051051bc192a97136ba733df6a49f222cb67a231d857eb4b1c5ec8dea605e146f49f75f800709f8836540a472fe8032f9fbd3f6690ec3d".to_string() },
}],
dependencies: vec![],
};
let file = File::from_modrinth_version(&modrinth_version);
assert_eq!(FileResult::Ok {
file: File {
project_name: "Fabric API".to_string(),
path: "mods/fabric-api-0.129.0+1.21.8.jar".to_string(),
hashes: FileHashes { sha1: "9be74f9c3120ffb9f38df8f4164392d69e6ba84e".to_string(), sha512: "471babff84b36bd0f5051051bc192a97136ba733df6a49f222cb67a231d857eb4b1c5ec8dea605e146f49f75f800709f8836540a472fe8032f9fbd3f6690ec3d".to_string() },
env: Some(Env {
client: SideSupport::Optional,
server: SideSupport::Optional,
}),
downloads: vec!["https://cdn.modrinth.com/data/P7dR8mSH/versions/X2hTodix/fabric-api-0.129.0%2B1.21.8.jar".to_string()],
file_size: 2_212_412,
},
dependencies: vec![],
project_id: "P7dR8mSH".to_string(),
}, file);
}
}