diff --git a/Cargo.lock b/Cargo.lock index a5782bb..abdaccd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -204,6 +204,15 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "combine" version = "4.6.8" @@ -876,12 +885,15 @@ name = "mcsm" version = "0.1.0" dependencies = [ "clap", + "colored", "dialoguer", "futures-util", "indicatif", "reqwest", "serde", "sha1", + "terminal_size", + "textwrap", "tokio", "toml", ] @@ -1128,6 +1140,7 @@ dependencies = [ "rustls-platform-verifier", "serde", "serde_json", + "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls", @@ -1265,6 +1278,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -1370,6 +1389,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "sha1" version = "0.11.0" @@ -1431,6 +1462,12 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "smawk" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" + [[package]] name = "socket2" version = "0.6.5" @@ -1535,6 +1572,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width", +] + [[package]] name = "thiserror" version = "2.0.20" @@ -1762,6 +1820,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-width" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index 4dad678..e5df991 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,11 +5,14 @@ edition = "2024" [dependencies] clap = { version = "4.6.6", features = ["derive"] } +colored = "3.1.1" dialoguer = { version = "0.12.0", features = ["fuzzy-select"] } futures-util = "0.3.34" indicatif = "0.18.6" -reqwest = { version = "0.13.4", features = ["json", "stream"] } +reqwest = { version = "0.13.4", features = ["json", "stream", "query"] } serde = { version = "1.0.229", features = ["derive"] } sha1 = "0.11.0" +terminal_size = "0.4.4" +textwrap = "0.16.2" tokio = { version = "1.53.1", features = ["full"] } toml = "1.1.4" diff --git a/src/commands/create.rs b/src/commands/create.rs index 5b39f73..91a0a85 100644 --- a/src/commands/create.rs +++ b/src/commands/create.rs @@ -43,7 +43,7 @@ impl Create { std::env::set_current_dir(&project_name)?; let config_file = tokio::fs::File::create("mcsm.toml").await?; Ok(Create { - /*project_name,*/ + /*project_name,*/ config_file, version, loader, @@ -101,21 +101,22 @@ impl LoaderDownloader for VanillaDownloader { .await?; if let Some(version) = create.version.clone() { - let version_exists = res - .versions - .iter() - .any(|v| v.id == version); - if !version_exists { return Err("Invalid minecraft version specified".into()) } - } - else { + let version_exists = res.versions.iter().any(|v| v.id == version); + if !version_exists { + return Err("Invalid minecraft version specified".into()); + } + } else { let versions = res .versions .iter() .filter(|version| { - version.version_type == VanillaVersionType::Release || - (create.show_snapshots && version.version_type == VanillaVersionType::Snapshot) || - (create.show_betas && version.version_type == VanillaVersionType::OldBeta) || - (create.show_alphas && version.version_type == VanillaVersionType::OldAlpha) + version.version_type == VanillaVersionType::Release + || (create.show_snapshots + && version.version_type == VanillaVersionType::Snapshot) + || (create.show_betas + && version.version_type == VanillaVersionType::OldBeta) + || (create.show_alphas + && version.version_type == VanillaVersionType::OldAlpha) }) .collect::>(); let version_names = versions @@ -140,10 +141,7 @@ impl LoaderDownloader for VanillaDownloader { let version_detail: VanillaVersionDetail = with_spinner("", "", || async { let version_detail: VanillaVersionDetail = - reqwest::get(version.url.clone()) - .await? - .json() - .await?; + reqwest::get(version.url.clone()).await?.json().await?; Ok(version_detail) }) .await?; @@ -215,13 +213,11 @@ impl LoaderDownloader for FabricDownloader { .await?; if let Some(version) = create.version.clone() { - let exists = res - .game - .iter() - .any(|game| game.version == version); - if !exists { return Err("Invalid minecraft version specified".into()) } - } - else { + let exists = res.game.iter().any(|game| game.version == version); + if !exists { + return Err("Invalid minecraft version specified".into()); + } + } else { let game_version_names = res .game .iter() @@ -243,9 +239,10 @@ impl LoaderDownloader for FabricDownloader { .loader .iter() .any(|loader| loader.version == loader_version); - if !exists { return Err(format!("Invalid {} loader version specified", Self::name()).into()) } - } - else { + if !exists { + return Err(format!("Invalid {} loader version specified", Self::name()).into()); + } + } else { let loader_version_names = res .loader .iter() @@ -283,7 +280,10 @@ impl LoaderDownloader for FabricDownloader { with_spinner(&start_msg, "download finished", || async { let download_url = format!( "{}/loader/{}/{}/{}/server/jar", - URL, create.version.as_ref().unwrap(), create.loader_version.as_ref().unwrap(), installer_version + URL, + create.version.as_ref().unwrap(), + create.loader_version.as_ref().unwrap(), + installer_version ); let res = reqwest::get(download_url).await?; let mut file = tokio::fs::File::create("server.jar").await?; diff --git a/src/commands/mod.rs b/src/commands/mod.rs index c5fb369..eede39f 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1 +1,2 @@ pub mod create; +pub mod search; diff --git a/src/commands/search.rs b/src/commands/search.rs new file mode 100644 index 0000000..f4c94a7 --- /dev/null +++ b/src/commands/search.rs @@ -0,0 +1,97 @@ +use std::error::Error; + +use colored::Colorize; +use serde::Serialize; +use terminal_size; +use textwrap::{Options, fill}; +use terminal_size::{terminal_size as get_terminal_size, Width}; + +use crate::models::{config::Config, search::SearchResult}; + +#[derive(Serialize)] +struct SearchQueryParams { + query: String, + facets: String, + offset: Option, + limit: Option, +} + +pub struct Search { + query: String, +} + +impl Search { + const BASE_URL: &str = "https://api.modrinth.com/v2"; + + pub fn new(query: String) -> Self { + Search { query } + } + + pub async fn run(&self) -> Result<(), Box> { + let file_res = tokio::fs::read_to_string("mcsm.toml").await; + if file_res.is_err() { + return Err("current directory is not a mcsm project".into()); + } + let config: Config = toml::from_str(&file_res.unwrap())?; + + if config.server.loader == "Vanilla" { + return Err("cannot search for mods on Vanilla server".into()); + } + + let client = reqwest::Client::new(); + let search = format!("{}/search", Self::BASE_URL); + + let facets = format!( + r#"[["project_type:mod"],["environment!=client_only"],["environment!=singleplayer_only"],["environment!=unknown"],["categories:{}"],["versions:{}"]]"#, + config.server.loader.to_lowercase(), + config.server.version, + ); + let params = SearchQueryParams { + query: self.query.clone(), + facets: facets.into(), + offset: None, + limit: None, + }; + + let res: SearchResult = client + .get(search) + .query(¶ms) + .send() + .await? + .json() + .await?; + + self.print_results(&res); + + Ok(()) + } + + fn print_results(&self, res: &SearchResult) { + println!( + "{}\n", + format!("Found {} result{}", res.total_hits, if res.total_hits == 1 { "" } else { "s" }) + .green() + .bold() + ); + + let width = get_terminal_size().map(|(Width(w), _)| w as usize).unwrap_or(80); + + for (i, hit) in res.hits.iter().enumerate() { + let wrapped = fill( + &hit.description, + Options::new(width.saturating_sub(3)) + .initial_indent(" ") + .subsequent_indent(" "), + ); + + println!( + "{} {} {}\n by {}\n{}\n", + format!("{}.", i + 1).bold(), + hit.title.bold(), + format!("({})", hit.slug).cyan(), + hit.author.yellow(), + wrapped, + ); + } + } +} diff --git a/src/main.rs b/src/main.rs index 4beb546..eb6700d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,7 @@ mod commands; mod models; mod utils; -use crate::commands::create::Create; +use crate::commands::{create::Create, search::Search}; use clap::{ Parser, Subcommand, builder::styling::{self}, @@ -53,6 +53,10 @@ enum Commands { #[arg(long)] show_alphas: bool, }, + + Search { + query: String, + }, } #[tokio::main] @@ -77,12 +81,19 @@ async fn main() -> Result<(), Box> { loader_version, show_snapshots, show_betas, - show_alphas - ).await?; - if loader_is_unset { create.init_loader().await? } + show_alphas, + ) + .await?; + if loader_is_unset { + create.init_loader().await? + } let config = create.download_server().await?; create.save_config(config).await?; } + Commands::Search { query } => { + let search = Search::new(query); + search.run().await?; + } } Ok(()) diff --git a/src/models/mod.rs b/src/models/mod.rs index 4120088..6a75d69 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,3 +1,4 @@ pub mod config; pub mod fabric; +pub mod search; pub mod vanilla; diff --git a/src/models/search.rs b/src/models/search.rs new file mode 100644 index 0000000..90c61f8 --- /dev/null +++ b/src/models/search.rs @@ -0,0 +1,17 @@ +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct SearchResult { + pub hits: Vec, + pub offset: u32, + pub limit: u32, + pub total_hits: u32, +} + +#[derive(Deserialize)] +pub struct SearchHit { + pub slug: String, + pub author: String, + pub title: String, + pub description: String, +}