use crate::index::file::{Env, Hashes};
use crate::instance::{Instance, Loader};
use crate::local_storage;
use color_eyre::owo_colors::OwoColorize;
use serde::{Deserialize, Serialize};
use std::io::ErrorKind;
use std::path::PathBuf;
use std::str::FromStr;
use std::{fs, io};
use strum::Display;
use url::Url;
mod tag;
pub use tag::*;
pub mod modrinth;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Component {
pub slug: String,
pub category: Category,
pub tags: tag::TagInformation,
pub environment: Env,
pub version_id: String,
pub file_name: String,
pub file_size: usize,
pub download_url: Url,
pub hashes: Hashes,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display)]
#[serde(rename_all = "lowercase")]
pub enum Category {
Mod,
Resourcepack,
#[serde(alias = "shaderpack")]
Shader,
Datapack,
Config,
}
impl Component {
pub const LOCAL_STORAGE_SUFFIX: &'static str = ".invar.yaml";
#[tracing::instrument]
pub fn load_all() -> Result<Vec<Self>, local_storage::Error> {
let mut components = vec![];
for file in local_storage::metadata_files(".")? {
let path = file.path();
let yaml = fs::read_to_string(path).map_err(|source| local_storage::Error::Io {
source,
faulty_path: Some(path.to_path_buf()),
})?;
let component = serde_yml::from_str(&yaml)?;
components.push(component);
}
Ok(components)
}
pub fn remove(slug: &str) -> Result<(), local_storage::Error> {
let target_filename = format!("{slug}{}", Self::LOCAL_STORAGE_SUFFIX);
let candidate = local_storage::metadata_files(".")?.find(|dir_entry| {
dir_entry
.file_name()
.to_str()
.is_some_and(|name| name == target_filename)
});
match candidate {
Some(file) => {
fs::remove_file(file.path()).map_err(|source| local_storage::Error::Io {
source,
faulty_path: Some(file.path().to_path_buf()),
})?;
}
None => {
return Err(local_storage::Error::Io {
source: io::Error::new(ErrorKind::NotFound, "Failed to find file"),
faulty_path: None,
})
}
}
Ok(())
}
pub fn save_to_metadata_dir(&self) -> Result<(), local_storage::Error> {
let yaml = serde_yml::to_string(self)?;
let path = self.local_storage_path();
fs::create_dir_all(path.parent().unwrap()).map_err(|source| local_storage::Error::Io {
source,
faulty_path: Some(path.clone()),
})?;
fs::write(&path, yaml).map_err(|source| local_storage::Error::Io {
source,
faulty_path: Some(path.clone()),
})?;
Ok(())
}
#[must_use]
pub fn local_storage_path(&self) -> PathBuf {
let mut path = PathBuf::from(self.category);
if let Some(tag) = &self.tags.main {
path.push(tag.to_string());
}
path.push(format!("{}{}", self.slug, Self::LOCAL_STORAGE_SUFFIX));
path
}
#[must_use]
pub fn runtime_path(&self) -> PathBuf {
let mut path = PathBuf::from(self.category);
path.push(&self.file_name);
path
}
#[tracing::instrument]
pub fn fetch_from_modrinth(slug: &str, instance: &Instance) -> Result<Self, AddError> {
let metadata_url = format!("https://api.modrinth.com/v2/project/{slug}");
let versions_url = format!("https://api.modrinth.com/v2/project/{slug}/version");
let metadata: modrinth::Metadata = reqwest::blocking::get(metadata_url)?.json()?;
let mut versions: Vec<modrinth::Version> = reqwest::blocking::get(versions_url)?.json()?;
versions.retain(|v| {
let version_insensitive =
[Category::Resourcepack, Category::Shader].contains(&metadata.category);
let version_compatible = v.game_versions.iter().any(|v| {
semver::Version::from_str(v).is_ok_and(|v| v == instance.minecraft_version)
});
let version_compatible = version_insensitive || version_compatible;
let loader_compatible = v.loaders.iter().any(|l| {
*l == instance.loader
|| instance.allowed_foreign_loaders.contains(l)
|| *l == Loader::Other
});
loader_compatible && version_compatible
});
for version in &mut versions {
version.loaders.dedup();
}
versions.sort_unstable_by_key(|version| version.date_published);
versions.reverse();
let version = match versions.len() {
0 => return Err(AddError::Incompatible),
1 => versions.first().unwrap_or_else(|| unreachable!()),
count => {
let message = format!(
"{count} compatible versions of {} found, choose one:",
slug.magenta().bold()
);
let help = format!(
"NOTE: this component will be added as a '{}', so pick a version with the right loaders",
metadata.category
);
&inquire::Select::new(&message, versions)
.with_help_message(&help)
.prompt()?
}
};
let file = version.files.first().ok_or(AddError::NoFile)?;
let main_tag = self::tag::pick_main_tag()?;
let other_tags = self::tag::pick_secondary_tags(main_tag.as_ref())?;
let component = Self {
slug: slug.to_owned(),
category: metadata.category,
tags: tag::TagInformation {
main: main_tag,
others: other_tags,
},
environment: Env {
client: metadata.client_side,
server: metadata.server_side,
},
version_id: version.id.clone(),
file_name: file.filename.clone(),
file_size: file.size,
download_url: file.url.clone(),
hashes: file.hashes.clone(),
};
Ok(component)
}
}
impl From<Category> for PathBuf {
fn from(category: Category) -> Self {
Self::from(match category {
Category::Mod => "mods",
Category::Resourcepack => "resourcepacks",
Category::Datapack => "datapacks",
Category::Shader => "shaderpacks",
Category::Config => "config",
})
}
}
#[derive(thiserror::Error, Debug)]
pub enum AddError {
#[error("API error: {0:?}")]
Api(#[from] reqwest::Error),
#[error("Could not find a compatible version of this component")]
Incompatible,
#[error("The latest compatible version of this component has no files associated")]
NoFile,
#[error("Failed to get required input from user")]
User(#[from] inquire::error::InquireError),
}