diff --git a/Cargo.lock b/Cargo.lock index 7c86944..0190247 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -133,6 +133,18 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc" +[[package]] +name = "dialoguer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61579ada4ec0c6031cfac3f86fdba0d195a7ebeb5e36693bd53cb5999a25beeb" +dependencies = [ + "console", + "lazy_static", + "tempfile", + "zeroize", +] + [[package]] name = "directories" version = "2.0.2" @@ -211,9 +223,20 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.17" +version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88d1c26957f23603395cd326b0ffe64124b818f4449552f960d815cfba83a53d" +checksum = "629316e42fe7c2a0b9a65b47d159ceaa5453ab14e8f0a3c5eedbb8cd55b4a445" + +[[package]] +name = "futures-macro" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89f17b21645bc4ed773c69af9c9a0effd4a3f1a3876eadd453469f8854e7fdd" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "futures-sink" @@ -223,21 +246,22 @@ checksum = "36ea153c13024fe480590b3e3d4cad89a0cfacecc24577b68f86c6ced9c2bc11" [[package]] name = "futures-task" -version = "0.3.17" +version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d3d00f4eddb73e498a54394f228cd55853bdf059259e8e7bc6e69d408892e99" +checksum = "dabf1872aaab32c886832f2276d2f5399887e2bd613698a02359e4ea83f8de12" [[package]] name = "futures-util" -version = "0.3.17" +version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36568465210a3a6ee45e1f165136d68671471a501e632e9a98d96872222b5481" +checksum = "41d22213122356472061ac0f1ab2cee28d2bac8491410fd68c2af53d1cedb83e" dependencies = [ - "autocfg", "futures-core", + "futures-macro", "futures-task", "pin-project-lite", "pin-utils", + "slab", ] [[package]] @@ -301,6 +325,9 @@ dependencies = [ "anyhow", "confy", "console", + "dialoguer", + "futures-util", + "indicatif", "reqwest", "serde", "serde_json", @@ -400,6 +427,18 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "indicatif" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7baab56125e25686df467fe470785512329883aab42696d661247aca2a2896e4" +dependencies = [ + "console", + "lazy_static", + "number_prefix", + "regex", +] + [[package]] name = "instant" version = "0.1.12" @@ -537,6 +576,12 @@ dependencies = [ "libc", ] +[[package]] +name = "number_prefix" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17b02fc0ff9a9e4b35b3342880f48e896ebf69f2967921fe8646bf5b7125956a" + [[package]] name = "once_cell" version = "1.8.0" @@ -1280,3 +1325,9 @@ checksum = "0120db82e8a1e0b9fb3345a539c478767c0048d842860994d96113d5b667bd69" dependencies = [ "winapi", ] + +[[package]] +name = "zeroize" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68d9dcec5f9b43a30d38c49f91dfedfaac384cb8f085faca366c26207dd1619" diff --git a/Cargo.toml b/Cargo.toml index 2a1e5db..dab2667 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,10 @@ edition = "2021" anyhow = "1.0" confy = "0.4" console = "0.15.0" -reqwest = { version = "0.11", features = ["json"] } +dialoguer = "0.9.0" +futures-util = "0.3.18" +indicatif = "0.15.0" +reqwest = { version = "0.11", features = ["json", "stream"] } serde = { version = "1", features = ["derive"] } serde_json = "1" structopt = "0.3" diff --git a/src/main.rs b/src/main.rs index 526e683..eb8dc72 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,15 @@ use console::style; +use futures_util::StreamExt; use serde::{Deserialize, Serialize}; +use std::cmp::min; use std::collections::HashMap; +use std::io::Write; use std::path::PathBuf; use structopt::StructOpt; // TODO use ColoredHelp by default? -#[derive(StructOpt, Debug)] +// TODO move each enum value to a dedicated struct +#[derive(StructOpt, Clone, Debug)] enum Command { /// Adds a mod to the current instance #[structopt(setting = structopt::clap::AppSettings::ColoredHelp)] @@ -21,7 +25,8 @@ enum Command { Clean, } -#[derive(StructOpt, Debug)] +// TODO move main body argument fields to substruct for ease of moving? +#[derive(StructOpt, Clone, Debug)] #[structopt(name = "hopper", setting = structopt::clap::AppSettings::ColoredHelp)] struct Args { /// Path to configuration file @@ -32,6 +37,10 @@ struct Args { #[structopt(short, long, parse(from_os_str))] lockfile: Option, + /// Auto-accept confirmation dialogues + #[structopt(short = "y", long = "yes")] + auto_accept: bool, + #[structopt(subcommand)] command: Command, } @@ -83,6 +92,11 @@ struct Config { upstream: Upstream, } +struct AppContext { + pub args: Args, + pub config: Config, +} + #[derive(Deserialize, Debug)] struct SearchResponse { hits: Vec, @@ -192,9 +206,9 @@ struct ModVersionFile { filename: String, } -async fn search_mods(config: &Config, query: String) -> anyhow::Result { +async fn search_mods(ctx: &AppContext, query: String) -> anyhow::Result { let client = reqwest::Client::new(); - let url = format!("https://{}/api/v1/mod", config.upstream.server_address); + let url = format!("https://{}/api/v1/mod", ctx.config.upstream.server_address); let params = [("query", query.as_str())]; let url = reqwest::Url::parse_with_params(url.as_str(), ¶ms)?; let response = client @@ -207,9 +221,9 @@ async fn search_mods(config: &Config, query: String) -> anyhow::Result( - _config: &Config, + _ctx: &AppContext, response: &'a SearchResponse, -) -> Vec<&'a ModResult> { - // TODO actually select with a dialogue - match response.hits.first() { - Some(first) => vec![first], - None => Vec::new(), +) -> anyhow::Result> { + let input: String = dialoguer::Input::new() + .with_prompt("Mods to install (eg: 1 2 3)") + .interact_text()?; + + let mut selected: Vec = Vec::new(); + for token in input.split(" ") { + // TODO range input (eg: 1-3) + let index: usize = token.parse().expect("Token must be an integer"); + if index < 1 || index > response.hits.len() { + // TODO return useful error instead of panicking + panic!("Index {} is out of bounds", index); + } + + // input is indexed from 1, but results are indexed from 0 + let index = index - 1; + + if !selected.contains(&index) { + selected.push(index); + } else { + // TODO make this a proper warning log message + println!("warning: repeated index {}", index); + } } + + Ok(selected.iter().map(|i| &response.hits[*i]).collect()) } -async fn fetch_mod_info(config: &Config, mod_result: &ModResult) -> anyhow::Result { +async fn fetch_mod_info(ctx: &AppContext, mod_result: &ModResult) -> anyhow::Result { let client = reqwest::Client::new(); let mod_id = &mod_result.mod_id; let mod_id = mod_id[6..].to_owned(); // Remove "local-" prefix let url = format!( "https://{}/api/v1/mod/{}", - config.upstream.server_address, mod_id + ctx.config.upstream.server_address, mod_id ); let response = client.get(url).send().await?; let response = response.json::().await?; Ok(response) } -async fn fetch_mod_version(config: &Config, version_id: &String) -> anyhow::Result { +async fn fetch_mod_version(ctx: &AppContext, version_id: &String) -> anyhow::Result { let client = reqwest::Client::new(); let url = format!( "https://{}/api/v1/version/{}", - config.upstream.server_address, version_id + ctx.config.upstream.server_address, version_id ); let response = client.get(url).send().await?; let response = response.json::().await?; Ok(response) } -async fn download_version_file(_config: &Config, file: &ModVersionFile) -> anyhow::Result<()> { - let client = reqwest::Client::new(); - let response = client.get(&file.url).send().await?; - - // TODO stream from socket to cache with response.bytes_stream() - // TODO check hashes while streaming +async fn download_version_file(ctx: &AppContext, file: &ModVersionFile) -> anyhow::Result<()> { + // TODO replace all uses of .unwrap() with proper error codes let filename = &file.filename; - println!("downloading to {}...", filename); - let mut file = std::fs::File::create(&file.filename)?; - let mut content = std::io::Cursor::new(response.bytes().await?); - std::io::copy(&mut content, &mut file)?; - println!("done downloading."); + // TODO make confirmation skippable with flag argument + if !ctx.args.auto_accept { + use dialoguer::Confirm; + let prompt = format!("Download to {}?", filename); + let confirm = Confirm::new() + .with_prompt(prompt) + .default(true) + .interact()?; + if !confirm { + println!("Skipping downloading {}...", filename); + return Ok(()); + } + } + + let client = reqwest::Client::new(); + let url = &file.url; + let response = client.get(url).send().await?; + let total_size = response.content_length().unwrap(); + + // TODO better colors and styling! + use indicatif::{ProgressBar, ProgressStyle}; + let pb = ProgressBar::new(total_size); + pb.set_style(ProgressStyle::default_bar().template("{msg}\n{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})").progress_chars("#>-")); + pb.set_message(&format!("Downloading {}", url)); + + let filename = &file.filename; + let mut file = std::fs::File::create(filename)?; + let mut downloaded: u64 = 0; + let mut stream = response.bytes_stream(); + + // TODO check hashes while streaming + while let Some(item) = stream.next().await { + let chunk = &item.unwrap(); + file.write(&chunk)?; + let new = min(downloaded + (chunk.len() as u64), total_size); + downloaded = new; + pb.set_position(new); + } + + pb.finish_with_message(&format!("Downloaded {} to {}", url, filename)); Ok(()) } -async fn cmd_get(config: &Config, package_name: String) -> anyhow::Result<()> { - let response = search_mods(config, package_name).await?; +async fn cmd_get(ctx: &AppContext, package_name: String) -> anyhow::Result<()> { + let response = search_mods(ctx, package_name).await?; if response.hits.is_empty() { // TODO formatting @@ -281,8 +346,8 @@ async fn cmd_get(config: &Config, package_name: String) -> anyhow::Result<()> { return Ok(()); } - display_search_results(config, &response); - let selected = select_from_results(config, &response).await; + display_search_results(ctx, &response); + let selected = select_from_results(ctx, &response).await?; if selected.is_empty() { // TODO formatting @@ -291,18 +356,15 @@ async fn cmd_get(config: &Config, package_name: String) -> anyhow::Result<()> { } for to_get in selected.iter() { - let mod_info = fetch_mod_info(config, to_get).await?; - println!("mod: {:#?}", mod_info); + let mod_info = fetch_mod_info(ctx, to_get).await?; // TODO allow the user to select multiple versions if let Some(version_id) = mod_info.versions.first() { println!("fetching version {}", version_id); - let version = fetch_mod_version(config, version_id).await?; - println!("version: {:#?}", version); - + let version = fetch_mod_version(ctx, version_id).await?; for file in version.files.iter() { - download_version_file(config, file).await?; + download_version_file(ctx, file).await?; } } } @@ -314,8 +376,9 @@ async fn cmd_get(config: &Config, package_name: String) -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> { let args = Args::from_args(); let config = args.load_config()?; - match args.command { - Command::Get { package_name } => cmd_get(&config, package_name).await, + let ctx = AppContext { args, config }; + match ctx.args.to_owned().command { + Command::Get { package_name } => cmd_get(&ctx, package_name).await, _ => unimplemented!("unimplemented subcommand"), } }