diff --git a/Cargo.lock b/Cargo.lock index 2502eb0..98bb935 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 3 [[package]] name = "addr2line" -version = "0.21.0" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" +checksum = "6e4503c46a5c0c7844e948c9a4d6acd9f50cccb4de1c48eb9e291ea17470c678" dependencies = [ "gimli", ] @@ -66,6 +66,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "anyhow" +version = "1.0.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" + [[package]] name = "atomic-waker" version = "1.1.2" @@ -80,9 +86,9 @@ checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" [[package]] name = "backtrace" -version = "0.3.71" +version = "0.3.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b05800d2e817c8b3b4b54abd461726265fa9789ae34330622f2db9ee696f9d" +checksum = "17c6a35df3749d2e8bb1b7b21a976d82b15548788d2735b9d82f329268f71a11" dependencies = [ "addr2line", "cc", @@ -169,7 +175,7 @@ version = "4.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "528131438037fd55894f62d6e9f068b8f45ac57ffa77517819645d10aed04f64" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn", @@ -197,6 +203,18 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "comfy-table" +version = "7.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b34115915337defe99b2aff5c2ce6771e5fbc4079f4b506301f5cf394c8452f7" +dependencies = [ + "crossterm 0.27.0", + "strum", + "strum_macros", + "unicode-width", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -229,6 +247,19 @@ dependencies = [ "winapi", ] +[[package]] +name = "crossterm" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" +dependencies = [ + "bitflags 2.5.0", + "crossterm_winapi", + "libc", + "parking_lot", + "winapi", +] + [[package]] name = "crossterm_winapi" version = "0.9.1" @@ -364,9 +395,9 @@ dependencies = [ [[package]] name = "gimli" -version = "0.28.1" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" +checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd" [[package]] name = "h2" @@ -393,6 +424,12 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "heck" version = "0.5.0" @@ -483,9 +520,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d8d52be92d09acc2e01dddb7fde3ad983fc6489c7db4837e605bc3fca4cb63e" +checksum = "7b875924a60b96e5d7b9ae7b066540b1dd1cbd90d1828f54c92e02a283351c56" dependencies = [ "bytes", "futures-channel", @@ -528,7 +565,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fddf93031af70e75410a2511ec04d49e758ed2f26dad3404a934e0fb45cc12a" dependencies = [ "bitflags 2.5.0", - "crossterm", + "crossterm 0.25.0", "dyn-clone", "fuzzy-matcher", "fxhash", @@ -670,9 +707,9 @@ dependencies = [ [[package]] name = "object" -version = "0.32.2" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" +checksum = "b8ec7ab813848ba4522158d5517a6093db1ded27575b070f4177b8d12b41db5e" dependencies = [ "memchr", ] @@ -731,13 +768,17 @@ dependencies = [ name = "packium" version = "0.1.0" dependencies = [ + "anyhow", "clap", "colored", + "comfy-table", "inquire", "reqwest", "serde", + "serde_json", "tokio", "toml", + "url", ] [[package]] @@ -911,6 +952,12 @@ version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "976295e77ce332211c0d24d92c0e83e50f5c5f046d11082cea19f3df13a3562d" +[[package]] +name = "rustversion" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6" + [[package]] name = "ryu" version = "1.0.18" @@ -1068,6 +1115,25 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d8cec3501a5194c432b2b7976db6b7d10ec95c253208b45f83f7136aa985e29" + +[[package]] +name = "strum_macros" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6cf59daf282c0a494ba14fd21610a0325f9f90ec9d1231dea26bcb1d696c946" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "syn" version = "2.0.66" @@ -1324,6 +1390,7 @@ dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] [[package]] @@ -1592,9 +1659,9 @@ checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" [[package]] name = "winnow" -version = "0.6.8" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3c52e9c97a68071b23e836c9380edae937f17b9c4667bd021973efc689f618d" +checksum = "86c949fede1d13936a99f14fafd3e76fd642b556dd2ce96287fbe2e0151bfac6" dependencies = [ "memchr", ] diff --git a/Cargo.toml b/Cargo.toml index 149a255..6f76e1a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,10 @@ clap = { version = "4.5.4", features = [ "derive" ]} toml = "0.8.13" inquire = "0.7.5" serde = { version = "1.0.203", features = [ "derive" ]} +serde_json = "1.0.117" colored = "2.1.0" reqwest = { version = "0.12.4", features = [ "json" ]} tokio = { version = "1.37.0", features = [ "full" ] } +comfy-table = "7.1.1" +url = { version = "2.5.0", features = [ "serde" ] } +anyhow = "1.0.86" diff --git a/src/cli.rs b/src/cli.rs index a4b9e4b..ac7a75b 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -20,10 +20,7 @@ pub enum Commands { Info, /// Add a mod to the modpack - Add { - /// The name of the mod to add - name: String, - }, + Add, /// Remove a mod from the modpack Remove, diff --git a/src/commands/add.rs b/src/commands/add.rs new file mode 100644 index 0000000..8737aa9 --- /dev/null +++ b/src/commands/add.rs @@ -0,0 +1,24 @@ +use crate::config; +use crate::modrinth::project::get_project_versions; +use crate::modrinth::search::search; +use inquire::{Select, Text}; + +pub async fn add() { + let config = config::Config::get().unwrap(); + + let query = Text::new("Search").prompt().unwrap(); + + let response = search(&query, &config.game_version, &config.loader) + .await + .unwrap(); + + let project = Select::new("Select a mod", response.hits).prompt().unwrap(); + + let project_versions = get_project_versions(&project.project_id, &config.game_version) + .await + .unwrap(); + + let project_version = project_versions.get(0).unwrap(); + + config::Config::add(project.project_id, project_version.id.to_owned()).unwrap(); +} diff --git a/src/commands/init.rs b/src/commands/init.rs new file mode 100644 index 0000000..c80f52e --- /dev/null +++ b/src/commands/init.rs @@ -0,0 +1,33 @@ +use crate::config; +use crate::modrinth::tags; +use inquire::Select; + +pub async fn init(snaphots: bool) { + let mut game_versions = tags::get_minecraft_versions().await.unwrap(); + + if snaphots { + game_versions = game_versions + .into_iter() + .filter(|v| (&v.version_type == "snapshot" || &v.version_type == "release")) + .collect() + } else { + game_versions = game_versions + .into_iter() + .filter(|v| &v.version_type == "release") + .collect() + } + + let options = game_versions.iter().map(|v| &v.version).collect(); + + let game_version = Select::new("Select a Minecraft version", options) + .prompt() + .unwrap(); + + let options = vec!["fabric", "quilt", "forge", "neoforge"]; + + let loader = Select::new("Select a mod loader", options) + .prompt() + .unwrap(); + + config::Config::init(game_version.to_string(), loader.to_string()).unwrap(); +} diff --git a/src/commands/list.rs b/src/commands/list.rs new file mode 100644 index 0000000..41c8a66 --- /dev/null +++ b/src/commands/list.rs @@ -0,0 +1,21 @@ +use crate::{config, modrinth::project::get_multiple_projects}; +use colored::*; +use comfy_table::{presets::NOTHING, Table}; + +pub async fn list() { + let config = config::Config::get().unwrap(); + + let projects = get_multiple_projects(config.projects.keys().to_owned().collect()) + .await + .unwrap(); + + let rows: Vec> = projects + .iter() + .map(|p| vec![p.title.bold(), p.id.dimmed()]) + .collect(); + + let mut table = Table::new(); + table.load_preset(NOTHING).add_rows(rows); + + println!("{table}"); +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 0000000..f256382 --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1,3 @@ +pub mod add; +pub mod init; +pub mod list; diff --git a/src/config.rs b/src/config.rs index 94a6363..30e81a7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,139 +1,50 @@ -use crate::modrinth; -use crate::pack; -use inquire::Select; +use anyhow::Result; use serde::{Deserialize, Serialize}; -use std::{ - fmt::{Display, Formatter}, - fs, -}; +use std::collections::HashMap; +use std::fs; -#[derive(Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize)] pub struct Config { - pub info: Info, - pub mods: Vec, -} - -#[derive(Serialize, Deserialize)] -pub struct Mod { - pub id: String, - pub version_id: String, -} - -#[derive(Serialize, Deserialize)] -pub struct Info { - loader: Modloader, - minecraft_version: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy)] -pub enum Modloader { - Fabric, - Quilt, - Forge, - Neoforge, -} - -impl Modloader { - const VARIANTS: &'static [Modloader] = - &[Self::Fabric, Self::Quilt, Self::Forge, Self::Neoforge]; -} - -impl Display for Modloader { - fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), std::fmt::Error> { - write!(f, "{self:?}") - } + pub game_version: String, + pub loader: String, + pub projects: HashMap, } impl Config { - pub fn get() -> Self { - let pack_string = fs::read_to_string(".packium.toml").unwrap(); + pub fn get() -> Result { + let config_raw = fs::read_to_string(".packium.toml")?; - toml::from_str(&pack_string).unwrap() + let config = toml::from_str(&config_raw)?; + + Ok(config) } - pub async fn mods(&self) -> Vec { - modrinth::get_multiple_project(self.mods.iter().map(|x| x.id.clone()).collect()) - .await - .unwrap() + pub fn save(&self) -> Result<()> { + let config_toml = toml::to_string_pretty(&self)?; + + fs::write(".packium.toml", &config_toml)?; + + Ok(()) } - pub async fn init() { - println!("Fetching Minecraft information..."); - - let versions = modrinth::get_minecraft_versions().await.unwrap(); - - let versions = versions - .iter() - .filter(|x| x.version_type == "release".to_string()) - .map(|x| &x.version) - .collect(); - - let modloader = Select::new("Modloader?", Modloader::VARIANTS.to_vec()) - .prompt() - .unwrap(); - - let version = Select::new("Minecraft version?", versions) - .prompt() - .unwrap(); - - let pack = Config { - info: Info { - minecraft_version: version.to_owned(), - loader: modloader, - }, - mods: vec![], + pub fn init(game_version: String, loader: String) -> Result { + let config = Self { + game_version, + loader, + projects: HashMap::new(), }; - let pack_toml = toml::to_string_pretty(&pack).unwrap(); + config.save()?; - std::fs::write(".packium.toml", pack_toml).unwrap(); + Ok(config) } - pub async fn add(name: &String) { - let mut config = Self::get(); + pub fn add(project_id: String, version_id: String) -> Result { + let mut config = Self::get()?; - let mods = modrinth::search_projects( - name.to_owned(), - &config.info.minecraft_version, - &config.info.loader.to_string().to_lowercase(), - ) - .await - .unwrap(); + config.projects.insert(project_id, version_id); + config.save()?; - let project = Select::new("Choose a mod", mods).prompt().unwrap(); - - let versions = modrinth::get_project_versions( - &project.project_id, - &config.info.minecraft_version, - &config.info.loader.to_string().to_lowercase(), - ) - .await - .unwrap(); - - let version = versions.get(0).unwrap(); - - config.mods.push(Mod { - id: project.project_id.clone(), - version_id: version.id.clone(), - }); - - let config_toml = toml::to_string_pretty(&config).unwrap(); - - std::fs::write(".packium.toml", config_toml).unwrap(); - } - - pub async fn remove() { - let mut config = Self::get(); - let mods = config.mods().await; - - let project = Select::new("Choose a mod to remove", mods) - .prompt() - .unwrap(); - - config.mods.retain(|x| x.id != project.id); - - let config_toml = toml::to_string_pretty(&config).unwrap(); - - std::fs::write(".packium.toml", config_toml).unwrap(); + Ok(config) } } diff --git a/src/main.rs b/src/main.rs index e77fc12..c54ac06 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ mod cli; +mod commands; mod config; mod modrinth; -mod pack; use clap::Parser; @@ -11,13 +11,16 @@ async fn main() { match &args.command { cli::Commands::Init => { - config::Config::init().await; + commands::init::init(false).await; } - cli::Commands::Add { name } => { - config::Config::add(name).await; + cli::Commands::Add => { + commands::add::add().await; } cli::Commands::Remove => { - config::Config::remove().await; + // config::Config::remove().await; + } + cli::Commands::List => { + commands::list::list().await; } _ => (), }; diff --git a/src/modrinth.rs b/src/modrinth.rs deleted file mode 100644 index 6a57401..0000000 --- a/src/modrinth.rs +++ /dev/null @@ -1,101 +0,0 @@ -use crate::config; -use core::fmt; -use serde::Deserialize; - -#[derive(Deserialize, Debug)] -pub struct MinecraftVersion { - pub version: String, - pub version_type: String, - pub date: String, - pub major: bool, -} - -#[derive(Deserialize, Debug)] -pub struct SearchProject { - pub title: String, - pub description: String, - pub project_id: String, - pub game_versions: Vec, - pub versions: Vec, -} - -impl fmt::Display for SearchProject { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.title) - } -} - -#[derive(Deserialize, Debug)] -pub struct Project { - pub title: String, - pub description: String, - pub id: String, - pub game_versions: Vec, - pub versions: Vec, -} - -impl fmt::Display for Project { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.title) - } -} - -#[derive(Deserialize, Debug)] -pub struct Search { - hits: Vec, -} - -#[derive(Deserialize, Debug)] -pub struct ProjectVersion { - pub name: String, - pub game_versions: Vec, - pub version_type: String, - pub id: String, - pub project_id: String, -} - -pub async fn get_minecraft_versions() -> Result, reqwest::Error> { - reqwest::get("https://api.modrinth.com/v2/tag/game_version") - .await? - .json::>() - .await -} - -pub async fn search_projects( - query: String, - version: &String, - loader: &String, -) -> Result, reqwest::Error> { - let url = format!("https://api.modrinth.com/v2/search?query={query}&facets=[[\"project_type:mod\"],[\"versions:{version}\"],[\"categories:{loader}\"]]"); - - let response = reqwest::get(url).await?.json::().await?; - - Ok(response.hits) -} - -pub async fn get_project_versions( - id: &String, - version: &String, - loader: &String, -) -> Result, reqwest::Error> { - let url = format!("https://api.modrinth.com/v2/project/{id}/version?loaders=[\"{loader}\"]&game_versions=[\"{version}\"]"); - - let response = reqwest::get(url) - .await? - .json::>() - .await?; - - Ok(response) -} - -pub async fn get_multiple_project( - project_ids: Vec, -) -> Result, reqwest::Error> { - let ids = project_ids.join("\", \""); - - let url = format!("https://api.modrinth.com/v2/projects?ids=[\"{ids}\"]"); - - let response = reqwest::get(url).await?.json::>().await?; - - Ok(response) -} diff --git a/src/modrinth/mod.rs b/src/modrinth/mod.rs new file mode 100644 index 0000000..b6bf021 --- /dev/null +++ b/src/modrinth/mod.rs @@ -0,0 +1,6 @@ +pub mod project; +pub mod search; +pub mod tags; +pub mod versions; + +pub const API_BASE_URL: &str = "https://api.modrinth.com/v2"; diff --git a/src/modrinth/project.rs b/src/modrinth/project.rs new file mode 100644 index 0000000..14a8cb1 --- /dev/null +++ b/src/modrinth/project.rs @@ -0,0 +1,51 @@ +use super::versions::ProjectVersion; +use super::API_BASE_URL; +use anyhow::{bail, Result}; +use serde::Deserialize; + +type ProjectVersionResponse = Vec; + +pub async fn get_project_versions( + project_id: &str, + game_version: &str, +) -> Result { + let url = format!( + "{API_BASE_URL}/project/{}/version?game_versions=[\"{}\"]", + project_id, game_version + ); + + let response = reqwest::get(url) + .await? + .json::() + .await; + + match response { + Ok(r) => Ok(r), + Err(err) => bail!("Error with Modrinth API: {}", err.to_string()), + } +} + +type ProjectResponse = Vec; + +#[derive(Debug, Deserialize)] +pub struct Project { + slug: String, + pub title: String, + pub description: String, + pub downloads: i64, + pub id: String, + pub followers: i64, +} + +pub async fn get_multiple_projects(version_ids: Vec<&String>) -> Result { + let version_ids_json = serde_json::to_string(&version_ids)?; + + let url = format!("{API_BASE_URL}/projects?ids={}", version_ids_json); + + let response = reqwest::get(url).await?.json::().await; + + match response { + Ok(r) => Ok(r), + Err(err) => bail!("Error with Modrinth API: {}", err.to_string()), + } +} diff --git a/src/modrinth/search.rs b/src/modrinth/search.rs new file mode 100644 index 0000000..2c3d8d8 --- /dev/null +++ b/src/modrinth/search.rs @@ -0,0 +1,70 @@ +use super::API_BASE_URL; +use anyhow::{bail, Result}; +use serde::Deserialize; +use std::fmt; + +#[derive(Deserialize, Debug)] +pub struct Response { + pub hits: Vec, + offset: i64, + limit: i64, + total_hits: i64, +} + +#[derive(Deserialize, Debug)] +pub struct SearchHit { + pub slug: String, + pub title: String, + pub description: String, + pub client_side: String, + pub server_side: String, + pub project_type: String, + pub downloads: i64, + pub project_id: String, + pub author: String, + pub versions: Vec, + pub follows: i64, +} + +impl fmt::Display for SearchHit { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.title) + } +} + +pub async fn search(query: &str, game_version: &str, loader: &str) -> Result { + let game_version_facet = format!("versions:{game_version}"); + let loader_facet = format!("categories:{loader}"); + + let facets: Vec> = vec![ + vec!["project_type:mod"], + vec![&game_version_facet], + vec![&loader_facet], + ]; + + let facets_json = serde_json::to_string(&facets)?; + + let url = format!( + "{API_BASE_URL}/search?query={}&facets={}", + query, facets_json + ); + + let response = reqwest::get(url).await?.json::().await; + + match response { + Ok(r) => Ok(r), + Err(err) => bail!("Error with Modrinth API: {}", err.to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_search() { + let response = search("Fabric API", "1.20.4", "fabric").await; + + assert!(response.is_ok()) + } +} diff --git a/src/modrinth/tags.rs b/src/modrinth/tags.rs new file mode 100644 index 0000000..82dfe63 --- /dev/null +++ b/src/modrinth/tags.rs @@ -0,0 +1,36 @@ +use super::API_BASE_URL; +use anyhow::{bail, Result}; +use serde::Deserialize; + +type Response = Vec; + +#[derive(Debug, Deserialize)] +pub struct MinecraftVersion { + pub version: String, + pub version_type: String, + date: String, + major: bool, +} + +pub async fn get_minecraft_versions() -> Result { + let url = format!("{API_BASE_URL}/tag/game_version"); + + let response = reqwest::get(url).await?.json::().await; + + match response { + Ok(r) => Ok(r), + Err(err) => bail!("Error with Modrinth API: {}", err.to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_game_versions() { + let response = get_minecraft_versions().await; + + assert!(response.is_ok()) + } +} diff --git a/src/modrinth/versions.rs b/src/modrinth/versions.rs new file mode 100644 index 0000000..391001d --- /dev/null +++ b/src/modrinth/versions.rs @@ -0,0 +1,31 @@ +use super::API_BASE_URL; +use anyhow::{bail, Result}; +use serde::Deserialize; + +type Response = Vec; + +#[derive(Debug, Deserialize)] +pub struct ProjectVersion { + pub name: String, + pub version_number: String, + game_versions: Vec, + version_type: String, + loaders: Vec, + featured: bool, + pub id: String, + project_id: String, + downloads: i64, +} + +pub async fn get_multiple_versions(version_ids: Vec<&String>) -> Result { + let version_ids_json = serde_json::to_string(&version_ids)?; + + let url = format!("{API_BASE_URL}/versions?ids={}", version_ids_json); + + let response = reqwest::get(url).await?.json::().await; + + match response { + Ok(r) => Ok(r), + Err(err) => bail!("Error with Modrinth API: {}", err.to_string()), + } +}