Dialogue prompts #2
|
@ -133,6 +133,18 @@ version = "0.8.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc"
|
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]]
|
[[package]]
|
||||||
name = "directories"
|
name = "directories"
|
||||||
version = "2.0.2"
|
version = "2.0.2"
|
||||||
|
@ -211,9 +223,20 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-core"
|
name = "futures-core"
|
||||||
version = "0.3.17"
|
version = "0.3.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
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]]
|
[[package]]
|
||||||
name = "futures-sink"
|
name = "futures-sink"
|
||||||
|
@ -223,21 +246,22 @@ checksum = "36ea153c13024fe480590b3e3d4cad89a0cfacecc24577b68f86c6ced9c2bc11"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-task"
|
name = "futures-task"
|
||||||
version = "0.3.17"
|
version = "0.3.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1d3d00f4eddb73e498a54394f228cd55853bdf059259e8e7bc6e69d408892e99"
|
checksum = "dabf1872aaab32c886832f2276d2f5399887e2bd613698a02359e4ea83f8de12"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-util"
|
name = "futures-util"
|
||||||
version = "0.3.17"
|
version = "0.3.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "36568465210a3a6ee45e1f165136d68671471a501e632e9a98d96872222b5481"
|
checksum = "41d22213122356472061ac0f1ab2cee28d2bac8491410fd68c2af53d1cedb83e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"autocfg",
|
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-macro",
|
||||||
"futures-task",
|
"futures-task",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"pin-utils",
|
"pin-utils",
|
||||||
|
"slab",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -301,6 +325,9 @@ dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"confy",
|
"confy",
|
||||||
"console",
|
"console",
|
||||||
|
"dialoguer",
|
||||||
|
"futures-util",
|
||||||
|
"indicatif",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
@ -400,6 +427,18 @@ dependencies = [
|
||||||
"hashbrown",
|
"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]]
|
[[package]]
|
||||||
name = "instant"
|
name = "instant"
|
||||||
version = "0.1.12"
|
version = "0.1.12"
|
||||||
|
@ -537,6 +576,12 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "number_prefix"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "17b02fc0ff9a9e4b35b3342880f48e896ebf69f2967921fe8646bf5b7125956a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.8.0"
|
version = "1.8.0"
|
||||||
|
@ -1280,3 +1325,9 @@ checksum = "0120db82e8a1e0b9fb3345a539c478767c0048d842860994d96113d5b667bd69"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize"
|
||||||
|
version = "1.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d68d9dcec5f9b43a30d38c49f91dfedfaac384cb8f085faca366c26207dd1619"
|
||||||
|
|
|
@ -8,7 +8,10 @@ edition = "2021"
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
confy = "0.4"
|
confy = "0.4"
|
||||||
console = "0.15.0"
|
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 = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
structopt = "0.3"
|
structopt = "0.3"
|
||||||
|
|
141
src/main.rs
141
src/main.rs
|
@ -1,11 +1,15 @@
|
||||||
use console::style;
|
use console::style;
|
||||||
|
use futures_util::StreamExt;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::cmp::min;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::io::Write;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use structopt::StructOpt;
|
use structopt::StructOpt;
|
||||||
|
|
||||||
// TODO use ColoredHelp by default?
|
// TODO use ColoredHelp by default?
|
||||||
#[derive(StructOpt, Debug)]
|
// TODO move each enum value to a dedicated struct
|
||||||
|
#[derive(StructOpt, Clone, Debug)]
|
||||||
enum Command {
|
enum Command {
|
||||||
/// Adds a mod to the current instance
|
/// Adds a mod to the current instance
|
||||||
#[structopt(setting = structopt::clap::AppSettings::ColoredHelp)]
|
#[structopt(setting = structopt::clap::AppSettings::ColoredHelp)]
|
||||||
|
@ -21,7 +25,8 @@ enum Command {
|
||||||
Clean,
|
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)]
|
#[structopt(name = "hopper", setting = structopt::clap::AppSettings::ColoredHelp)]
|
||||||
struct Args {
|
struct Args {
|
||||||
/// Path to configuration file
|
/// Path to configuration file
|
||||||
|
@ -32,6 +37,10 @@ struct Args {
|
||||||
#[structopt(short, long, parse(from_os_str))]
|
#[structopt(short, long, parse(from_os_str))]
|
||||||
lockfile: Option<PathBuf>,
|
lockfile: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Auto-accept confirmation dialogues
|
||||||
|
#[structopt(short = "y", long = "yes")]
|
||||||
|
auto_accept: bool,
|
||||||
|
|
||||||
#[structopt(subcommand)]
|
#[structopt(subcommand)]
|
||||||
command: Command,
|
command: Command,
|
||||||
}
|
}
|
||||||
|
@ -83,6 +92,11 @@ struct Config {
|
||||||
upstream: Upstream,
|
upstream: Upstream,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct AppContext {
|
||||||
|
pub args: Args,
|
||||||
|
pub config: Config,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
#[derive(Deserialize, Debug)]
|
||||||
struct SearchResponse {
|
struct SearchResponse {
|
||||||
hits: Vec<ModResult>,
|
hits: Vec<ModResult>,
|
||||||
|
@ -192,9 +206,9 @@ struct ModVersionFile {
|
||||||
filename: String,
|
filename: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn search_mods(config: &Config, query: String) -> anyhow::Result<SearchResponse> {
|
async fn search_mods(ctx: &AppContext, query: String) -> anyhow::Result<SearchResponse> {
|
||||||
let client = reqwest::Client::new();
|
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 params = [("query", query.as_str())];
|
||||||
let url = reqwest::Url::parse_with_params(url.as_str(), ¶ms)?;
|
let url = reqwest::Url::parse_with_params(url.as_str(), ¶ms)?;
|
||||||
let response = client
|
let response = client
|
||||||
|
@ -207,9 +221,9 @@ async fn search_mods(config: &Config, query: String) -> anyhow::Result<SearchRes
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO config flag to reverse search results order
|
// TODO config flag to reverse search results order
|
||||||
fn display_search_results(config: &Config, response: &SearchResponse) {
|
fn display_search_results(ctx: &AppContext, response: &SearchResponse) {
|
||||||
let iter = response.hits.iter().enumerate();
|
let iter = response.hits.iter().enumerate();
|
||||||
if config.options.reverse_search {
|
if ctx.config.options.reverse_search {
|
||||||
for (i, result) in iter.rev() {
|
for (i, result) in iter.rev() {
|
||||||
result.display(i + 1);
|
result.display(i + 1);
|
||||||
}
|
}
|
||||||
|
@ -222,58 +236,109 @@ fn display_search_results(config: &Config, response: &SearchResponse) {
|
||||||
|
|
||||||
// TODO implement enum for more graceful exiting
|
// TODO implement enum for more graceful exiting
|
||||||
async fn select_from_results<'a>(
|
async fn select_from_results<'a>(
|
||||||
_config: &Config,
|
_ctx: &AppContext,
|
||||||
response: &'a SearchResponse,
|
response: &'a SearchResponse,
|
||||||
) -> Vec<&'a ModResult> {
|
) -> anyhow::Result<Vec<&'a ModResult>> {
|
||||||
// TODO actually select with a dialogue
|
let input: String = dialoguer::Input::new()
|
||||||
match response.hits.first() {
|
.with_prompt("Mods to install (eg: 1 2 3)")
|
||||||
Some(first) => vec![first],
|
.interact_text()?;
|
||||||
None => Vec::new(),
|
|
||||||
|
let mut selected: Vec<usize> = 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<ModInfo> {
|
async fn fetch_mod_info(ctx: &AppContext, mod_result: &ModResult) -> anyhow::Result<ModInfo> {
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let mod_id = &mod_result.mod_id;
|
let mod_id = &mod_result.mod_id;
|
||||||
let mod_id = mod_id[6..].to_owned(); // Remove "local-" prefix
|
let mod_id = mod_id[6..].to_owned(); // Remove "local-" prefix
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"https://{}/api/v1/mod/{}",
|
"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 = client.get(url).send().await?;
|
||||||
let response = response.json::<ModInfo>().await?;
|
let response = response.json::<ModInfo>().await?;
|
||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_mod_version(config: &Config, version_id: &String) -> anyhow::Result<ModVersion> {
|
async fn fetch_mod_version(ctx: &AppContext, version_id: &String) -> anyhow::Result<ModVersion> {
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"https://{}/api/v1/version/{}",
|
"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 = client.get(url).send().await?;
|
||||||
let response = response.json::<ModVersion>().await?;
|
let response = response.json::<ModVersion>().await?;
|
||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn download_version_file(_config: &Config, file: &ModVersionFile) -> anyhow::Result<()> {
|
async fn download_version_file(ctx: &AppContext, file: &ModVersionFile) -> anyhow::Result<()> {
|
||||||
let client = reqwest::Client::new();
|
// TODO replace all uses of .unwrap() with proper error codes
|
||||||
let response = client.get(&file.url).send().await?;
|
|
||||||
|
|
||||||
// TODO stream from socket to cache with response.bytes_stream()
|
|
||||||
// TODO check hashes while streaming
|
|
||||||
let filename = &file.filename;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cmd_get(config: &Config, package_name: String) -> anyhow::Result<()> {
|
async fn cmd_get(ctx: &AppContext, package_name: String) -> anyhow::Result<()> {
|
||||||
let response = search_mods(config, package_name).await?;
|
let response = search_mods(ctx, package_name).await?;
|
||||||
|
|
||||||
if response.hits.is_empty() {
|
if response.hits.is_empty() {
|
||||||
// TODO formatting
|
// TODO formatting
|
||||||
|
@ -281,8 +346,8 @@ async fn cmd_get(config: &Config, package_name: String) -> anyhow::Result<()> {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
display_search_results(config, &response);
|
display_search_results(ctx, &response);
|
||||||
let selected = select_from_results(config, &response).await;
|
let selected = select_from_results(ctx, &response).await?;
|
||||||
|
|
||||||
if selected.is_empty() {
|
if selected.is_empty() {
|
||||||
// TODO formatting
|
// TODO formatting
|
||||||
|
@ -291,18 +356,15 @@ async fn cmd_get(config: &Config, package_name: String) -> anyhow::Result<()> {
|
||||||
}
|
}
|
||||||
|
|
||||||
for to_get in selected.iter() {
|
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
|
// TODO allow the user to select multiple versions
|
||||||
if let Some(version_id) = mod_info.versions.first() {
|
if let Some(version_id) = mod_info.versions.first() {
|
||||||
println!("fetching version {}", version_id);
|
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() {
|
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<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
let args = Args::from_args();
|
let args = Args::from_args();
|
||||||
let config = args.load_config()?;
|
let config = args.load_config()?;
|
||||||
match args.command {
|
let ctx = AppContext { args, config };
|
||||||
Command::Get { package_name } => cmd_get(&config, package_name).await,
|
match ctx.args.to_owned().command {
|
||||||
|
Command::Get { package_name } => cmd_get(&ctx, package_name).await,
|
||||||
_ => unimplemented!("unimplemented subcommand"),
|
_ => unimplemented!("unimplemented subcommand"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue