From f58d25b54be2a5d67c1d2bfcdb07350a16417bd3 Mon Sep 17 00:00:00 2001 From: marceline-cramer Date: Mon, 29 Nov 2021 12:30:30 -0700 Subject: [PATCH 1/5] Multiselect results --- Cargo.lock | 19 +++++++++++++++++++ Cargo.toml | 1 + src/main.rs | 32 ++++++++++++++++++++++++++------ 3 files changed, 46 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7c86944..8c95eba 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" @@ -301,6 +313,7 @@ dependencies = [ "anyhow", "confy", "console", + "dialoguer", "reqwest", "serde", "serde_json", @@ -1280,3 +1293,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..091ecb3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ edition = "2021" anyhow = "1.0" confy = "0.4" console = "0.15.0" +dialoguer = "0.9.0" reqwest = { version = "0.11", features = ["json"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/src/main.rs b/src/main.rs index 526e683..ab1b82c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -224,12 +224,32 @@ fn display_search_results(config: &Config, response: &SearchResponse) { async fn select_from_results<'a>( _config: &Config, 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 { @@ -282,7 +302,7 @@ async fn cmd_get(config: &Config, package_name: String) -> anyhow::Result<()> { } display_search_results(config, &response); - let selected = select_from_results(config, &response).await; + let selected = select_from_results(config, &response).await?; if selected.is_empty() { // TODO formatting From 2f396b5e126131d8d741053353cee53cc5e22c8a Mon Sep 17 00:00:00 2001 From: marceline-cramer Date: Tue, 30 Nov 2021 11:55:50 -0700 Subject: [PATCH 2/5] Add confirmation prompt to download --- src/main.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index ab1b82c..afef0d0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -279,11 +279,22 @@ async fn fetch_mod_version(config: &Config, version_id: &String) -> anyhow::Resu async fn download_version_file(_config: &Config, file: &ModVersionFile) -> anyhow::Result<()> { let client = reqwest::Client::new(); let response = client.get(&file.url).send().await?; + let filename = &file.filename; + + 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(()); + } // TODO stream from socket to cache with response.bytes_stream() // TODO check hashes while streaming - 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)?; From 9a91ac5510b27b0f1dec669954f09fb6a3375da8 Mon Sep 17 00:00:00 2001 From: marceline-cramer Date: Tue, 30 Nov 2021 12:17:55 -0700 Subject: [PATCH 3/5] Add download progress bar --- Cargo.lock | 46 +++++++++++++++++++++++++++++++++++++++------- Cargo.toml | 4 +++- src/main.rs | 43 ++++++++++++++++++++++++++++++++++++------- 3 files changed, 78 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8c95eba..07f23d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -223,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" @@ -235,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]] @@ -314,6 +326,8 @@ dependencies = [ "confy", "console", "dialoguer", + "futures-util", + "indicatif", "reqwest", "serde", "serde_json", @@ -413,6 +427,18 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "indicatif" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d207dc617c7a380ab07ff572a6e52fa202a2a8f355860ac9c38e23f8196be1b" +dependencies = [ + "console", + "lazy_static", + "number_prefix", + "regex", +] + [[package]] name = "instant" version = "0.1.12" @@ -550,6 +576,12 @@ dependencies = [ "libc", ] +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + [[package]] name = "once_cell" version = "1.8.0" diff --git a/Cargo.toml b/Cargo.toml index 091ecb3..4b50691 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,9 @@ anyhow = "1.0" confy = "0.4" console = "0.15.0" dialoguer = "0.9.0" -reqwest = { version = "0.11", features = ["json"] } +futures-util = "0.3.18" +indicatif = "0.16.2" +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 afef0d0..29bfe7f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,9 @@ 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; @@ -277,10 +280,10 @@ async fn fetch_mod_version(config: &Config, version_id: &String) -> anyhow::Resu } 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 replace all uses of .unwrap() with proper error codes let filename = &file.filename; + // TODO make confirmation skippable with flag argument use dialoguer::Confirm; let prompt = format!("Download to {}?", filename); let confirm = Confirm::new() @@ -292,13 +295,39 @@ async fn download_version_file(_config: &Config, file: &ModVersionFile) -> anyho return Ok(()); } - // TODO stream from socket to cache with response.bytes_stream() // TODO check hashes while streaming - 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."); + let client = reqwest::Client::new(); + let url = &file.url; + let response = client.get(url).send().await?; + let total_size = response.content_length().unwrap(); + + 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("#>-")); + + // TODO find a way to into -> impl Into> + // let message = format!("Downloading {}", url); + let message = "Downloading file"; + pb.set_message(message); + + let filename = &file.filename; + let mut file = std::fs::File::create(filename)?; + let mut downloaded: u64 = 0; + let mut stream = response.bytes_stream(); + + 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); + } + + // TODO find a way to into -> impl Into> + // let message = format!("Downloaded {} to {}", url, filename); + let message = "Download complete"; + pb.finish_with_message(message); Ok(()) } From 1fc350d26956bc19846e231cf56f5d93cd33c18c Mon Sep 17 00:00:00 2001 From: marceline-cramer Date: Tue, 30 Nov 2021 12:31:47 -0700 Subject: [PATCH 4/5] Add -y flag Refactor using AppContext (needs more work) --- src/main.rs | 72 +++++++++++++++++++++++++++++++---------------------- 1 file changed, 42 insertions(+), 30 deletions(-) diff --git a/src/main.rs b/src/main.rs index 29bfe7f..b9f3f89 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,7 @@ use std::path::PathBuf; use structopt::StructOpt; // TODO use ColoredHelp by default? -#[derive(StructOpt, Debug)] +#[derive(StructOpt, Clone, Debug)] enum Command { /// Adds a mod to the current instance #[structopt(setting = structopt::clap::AppSettings::ColoredHelp)] @@ -24,7 +24,7 @@ enum Command { Clean, } -#[derive(StructOpt, Debug)] +#[derive(StructOpt, Clone, Debug)] #[structopt(name = "hopper", setting = structopt::clap::AppSettings::ColoredHelp)] struct Args { /// Path to configuration file @@ -35,6 +35,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, } @@ -86,6 +90,11 @@ struct Config { upstream: Upstream, } +struct AppContext { + pub args: Args, + pub config: Config, +} + #[derive(Deserialize, Debug)] struct SearchResponse { hits: Vec, @@ -195,9 +204,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 @@ -210,9 +219,9 @@ async fn search_mods(config: &Config, query: String) -> anyhow::Result( - _config: &Config, + _ctx: &AppContext, response: &'a SearchResponse, ) -> anyhow::Result> { let input: String = dialoguer::Input::new() @@ -255,44 +264,46 @@ async fn select_from_results<'a>( 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<()> { +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; // TODO make confirmation skippable with flag argument - 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(()); + 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(()); + } } // TODO check hashes while streaming @@ -332,8 +343,8 @@ async fn download_version_file(_config: &Config, file: &ModVersionFile) -> anyho 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 @@ -341,8 +352,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 @@ -351,18 +362,18 @@ 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?; + let mod_info = fetch_mod_info(ctx, to_get).await?; println!("mod: {:#?}", mod_info); // 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?; + let version = fetch_mod_version(ctx, version_id).await?; println!("version: {:#?}", version); for file in version.files.iter() { - download_version_file(config, file).await?; + download_version_file(ctx, file).await?; } } } @@ -374,8 +385,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"), } } From 814cf8ed3659b5e8246454011588e0a177cf2a6c Mon Sep 17 00:00:00 2001 From: marceline-cramer Date: Tue, 30 Nov 2021 16:09:38 -0700 Subject: [PATCH 5/5] Dynamic progress messages + TODOs --- Cargo.lock | 8 ++++---- Cargo.toml | 2 +- src/main.rs | 21 ++++++--------------- 3 files changed, 11 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 07f23d4..0190247 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -429,9 +429,9 @@ dependencies = [ [[package]] name = "indicatif" -version = "0.16.2" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d207dc617c7a380ab07ff572a6e52fa202a2a8f355860ac9c38e23f8196be1b" +checksum = "7baab56125e25686df467fe470785512329883aab42696d661247aca2a2896e4" dependencies = [ "console", "lazy_static", @@ -578,9 +578,9 @@ dependencies = [ [[package]] name = "number_prefix" -version = "0.4.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" +checksum = "17b02fc0ff9a9e4b35b3342880f48e896ebf69f2967921fe8646bf5b7125956a" [[package]] name = "once_cell" diff --git a/Cargo.toml b/Cargo.toml index 4b50691..dab2667 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ confy = "0.4" console = "0.15.0" dialoguer = "0.9.0" futures-util = "0.3.18" -indicatif = "0.16.2" +indicatif = "0.15.0" reqwest = { version = "0.11", features = ["json", "stream"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/src/main.rs b/src/main.rs index b9f3f89..eb8dc72 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ use std::path::PathBuf; use structopt::StructOpt; // TODO use ColoredHelp by default? +// TODO move each enum value to a dedicated struct #[derive(StructOpt, Clone, Debug)] enum Command { /// Adds a mod to the current instance @@ -24,6 +25,7 @@ enum Command { Clean, } +// 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 { @@ -306,27 +308,23 @@ async fn download_version_file(ctx: &AppContext, file: &ModVersionFile) -> anyho } } - // TODO check hashes while streaming - 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("#>-")); - - // TODO find a way to into -> impl Into> - // let message = format!("Downloading {}", url); - let message = "Downloading file"; - pb.set_message(message); + 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)?; @@ -335,11 +333,7 @@ async fn download_version_file(ctx: &AppContext, file: &ModVersionFile) -> anyho pb.set_position(new); } - // TODO find a way to into -> impl Into> - // let message = format!("Downloaded {} to {}", url, filename); - let message = "Download complete"; - pb.finish_with_message(message); - + pb.finish_with_message(&format!("Downloaded {} to {}", url, filename)); Ok(()) } @@ -363,15 +357,12 @@ async fn cmd_get(ctx: &AppContext, package_name: String) -> anyhow::Result<()> { for to_get in selected.iter() { let mod_info = fetch_mod_info(ctx, to_get).await?; - println!("mod: {:#?}", mod_info); // 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(ctx, version_id).await?; - println!("version: {:#?}", version); - for file in version.files.iter() { download_version_file(ctx, file).await?; }