arg types implemented

This commit is contained in:
Emma Tebibyte 2023-03-22 18:24:25 -04:00
parent 132d82680b
commit 6dd9c4871d
Signed by: emma
GPG Key ID: 6D661C738815E7DD
8 changed files with 609 additions and 1227 deletions

View File

@ -633,8 +633,8 @@ the "copyright" line and a pointer to where the full notice is found.
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,

1005
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -3,18 +3,22 @@ name = "hopper"
version = "0.1.0"
license = "AGPL-3.0-or-later"
edition = "2021"
authors = [
"Emma Tebibyte <emma@tebibyte.media>",
"Marceline Cramer <mars@tebibyte.media>"
]
[dependencies]
anyhow = "1.0"
confy = "0.4"
arg = "0.4.1"
c-main = "1.0.1"
console = "0.15.0"
curl = "0.4.44"
dialoguer = "0.9.0"
env_logger = "0.9.0"
futures-util = "0.3.18"
indicatif = "0.15.0"
log = "0.4.14"
reqwest = { version = "0.11", features = ["json", "stream"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
clap = { version = "3.2.20", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
toml = "0.7.3"
xdg = "2.4.1"
yacexits = "0.1.3"

View File

@ -1,156 +0,0 @@
use console::style;
use serde::Deserialize;
use std::{collections::HashMap, fmt};
#[derive(Deserialize, Debug)]
pub struct SearchResponse {
pub hits: Vec<ModResult>,
pub offset: isize,
pub limit: isize,
pub total_hits: isize,
}
#[derive(Deserialize, Debug)]
pub struct ModResult {
pub slug: String,
pub title: String,
pub description: String,
pub categories: Vec<String>,
pub display_categories: Vec<String>, // NOTE this is not in the OpenAPI docs
pub client_side: String,
pub server_side: String,
pub project_type: String, // NOTE this isn't in all search results?
pub downloads: isize,
pub icon_url: String,
pub project_id: String, // TODO parse to 'local-xxxx' with reegex
pub author: String,
pub versions: Vec<String>,
pub follows: isize,
pub date_created: String,
pub date_modified: String,
pub latest_version: String,
pub license: String,
pub gallery: Vec<String>,
}
impl ModResult {
pub fn format_info(&self) -> String {
let title = style(self.title.clone()).bold();
let downloads = style(self.downloads.clone()).bold().green();
if let Some(latest_release) = self.versions.last() {
// TODO fetch version numbers to display
let latest_release = style(latest_release).bold().blue();
format!("{} [{}] ({} downloads)", title, latest_release, downloads)
} else {
format!("{} [no releases]", title)
}
}
pub fn format_description(&self) -> String {
self.description.to_owned()
}
pub fn display(&self, index: usize) {
let index = style(index).magenta();
let info = self.format_info();
let description = self.format_description();
println!("{:>2} {}\n {}", index, info, description);
}
}
#[derive(Deserialize, Debug)]
pub struct ModInfo {
pub slug: String,
pub title: String,
pub description: String,
pub categories: Vec<String>,
pub additional_categories: Vec<String>, // NOTE not listed in OpenAPI docs
pub client_side: String, // TODO serialize as enum
pub server_side: String, // TODO serialize as enum
pub body: String,
pub issues_url: Option<String>,
pub source_url: Option<String>,
pub wiki_url: Option<String>,
pub discord_url: Option<String>,
pub donation_urls: Option<Vec<DonationLink>>,
pub project_type: String,
pub downloads: isize,
pub icon_url: Option<String>,
pub id: String, // TODO serialize mod id?
pub team: String, // TODO serialize team id?
pub body_url: Option<String>, // NOTE deprecated
pub moderator_message: Option<String>,
pub published: String, // TODO serialize as datetime
pub updated: String, // TODO serialize as datetime
pub approved: Option<String>, // NOTE not listed in OpenAPI docs, TODO serialize as datetime
pub followers: isize,
pub status: String,
pub license: License,
pub versions: Vec<String>,
pub gallery: Option<Vec<GalleryEntry>>,
}
#[derive(Deserialize, Debug)]
pub struct GalleryEntry {
pub url: String,
pub featured: bool,
pub title: String,
pub description: String,
pub created: String,
}
#[derive(Deserialize, Debug)]
pub struct License {
pub id: String,
pub name: String,
pub url: String,
}
#[derive(Deserialize, Debug)]
pub struct DonationLink {
pub id: String,
pub platform: String,
pub url: String,
}
#[derive(Deserialize, Debug)]
pub struct ModVersion {
pub name: String,
pub version_number: String,
pub changelog: Option<String>,
// pub dependencies: Option<Vec<String>>, // TODO dependency wrangling, thank you modrinth, very cool
pub game_versions: Vec<String>,
pub version_type: String, // TODO {alpha | beta | release}
pub loaders: Vec<String>,
pub featured: bool,
pub id: String, // version id
pub project_id: String, // mod id
pub author_id: String, // user id
pub date_published: String, // TODO serialize datetime
pub downloads: isize,
pub changelog_url: Option<String>, // NOTE deprecated
pub files: Vec<ModVersionFile>,
}
#[derive(Deserialize, Debug)]
pub struct ModVersionFile {
pub hashes: HashMap<String, String>,
pub url: String,
pub filename: String,
pub primary: bool,
pub size: isize,
}
#[derive(Deserialize, Debug)]
pub struct Error {
pub error: String,
pub description: String,
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {}", self.error, self.description)
}
}
impl std::error::Error for Error {}

156
src/args.rs Normal file
View File

@ -0,0 +1,156 @@
/*
* Copyright (c) 20222023 Emma Tebibyte <emma@tebibyte.media>
* Copyright (c) 20212022 Marceline Cramer <mars@tebibyte.media>
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* This file is part of Hopper.
*
* Hopper is free software: you can redistribute it and/or modify it under the
* terms of the GNU General Public License as published by the Free Software
* Foundation, either version 3 of the License, or (at your option) any later
* version.
*
* Hopper is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU General Public License for more details.
* You should have received a copy of the GNU General Public License along with
* Hopper. If not, see <https://www.gnu.org/licenses/>.
*/
use core::str::FromStr;
use arg::Args;
#[derive(Args, Debug)]
struct Arguments {
#[arg(short = "v")]
v: bool,
#[arg(sub)]
sub: Command,
}
#[derive(Args, Debug)]
struct InitArgs {
#[arg(short = "d")]
dir: Option<String>,
#[arg(short = "f")]
template: Option<String>,
#[arg(short = "m")]
mc_version: Vec<String>,
#[arg(short = "t", required)]
package_type: PackageType,
}
#[derive(Args, Debug)]
struct HopArgs {
#[arg(short = "f")]
hopfile: Option<String>,
#[arg(short = "m")]
mc_version: Vec<String>,
#[arg(short = "t")]
package_type: Option<PackageType>,
}
#[derive(Args, Debug)]
struct SearchArgs {
package_name: String,
/// Overrides the download directory
#[arg(short = "d")]
dir: Option<String>,
/// Restricts the target Minecraft version
#[arg(short = "m")]
mc_version: Vec<String>,
/// Type of package to use
#[arg(short = "t")]
package_type: Option<PackageType>,
}
#[derive(Args, Debug)]
enum Command {
Add(SearchArgs),
Get(SearchArgs),
Init(InitArgs),
List(HopArgs),
Remove(HopArgs),
Update(HopArgs),
}
#[derive(Debug)]
enum PackageType {
Mod(Loader),
Pack(Loader),
Plugin(Server),
ResourcePack,
}
#[derive(Debug)]
enum Loader {
Fabric,
Forge,
Quilt,
}
#[derive(Debug)]
enum Server {
Bukkit,
Paper,
Purpur,
Spigot,
Sponge,
}
#[derive(Debug)]
enum PackageParseError {
Invalid(String),
}
impl FromStr for PackageType {
type Err = PackageParseError;
fn from_str(s: &str) -> Result<PackageType, PackageParseError> {
let pieces: Vec<&str> = s.split("-").collect();
if pieces.len() > 2 || pieces.len() == 1 {
return Err(PackageParseError::Invalid(
format!("{}: Invalid package name.", s)
));
}
let (prefix, postfix) = (pieces[0], pieces[1]);
let loader = match prefix {
"bukkit" => return Ok(PackageType::Plugin(Server::Bukkit)),
"fabric" => Loader::Fabric,
"forge" => Loader::Forge,
"paper" => return Ok(PackageType::Plugin(Server::Paper)),
"purpur" => return Ok(PackageType::Plugin(Server::Purpur)),
"quilt" => Loader::Quilt,
"resource" => return Ok(PackageType::ResourcePack),
"spigot" => return Ok(PackageType::Plugin(Server::Spigot)),
"sponge" => return Ok(PackageType::Plugin(Server::Sponge)),
_ => {
return Err(PackageParseError::Invalid(
format!("{}: Invalid package type.", prefix)
))
},
};
match postfix {
"mod" => Ok(PackageType::Mod(loader)),
"pack" => Ok(PackageType::Pack(loader)),
_ => {
Err(PackageParseError::Invalid(
format!("{}: Invalid package type.", postfix)
))
},
}
}
}

View File

@ -1,166 +0,0 @@
use crate::api::{ModInfo, ModResult, ModVersion, ModVersionFile, SearchResponse, Error as APIError};
use crate::config::{Args, Config, PackageType, SearchArgs};
use futures_util::StreamExt;
use log::*;
use std::cmp::min;
use std::io::Write;
pub struct HopperClient {
config: Config,
client: reqwest::Client,
}
impl HopperClient {
pub fn new(config: Config) -> Self {
Self {
config: config,
client: reqwest::ClientBuilder::new()
.user_agent(format!("tebibytemedia/hopper/{} (tebibyte.media)", env!("CARGO_PKG_VERSION")))
.build()
.unwrap(),
}
}
pub async fn search_mods(&self, search_args: &SearchArgs) -> anyhow::Result<SearchResponse> {
println!("Searching with query \"{}\"...", search_args.package_name);
let url = format!("https://{}/v2/search", self.config.upstream.server_address);
let mut params = vec![("query", search_args.package_name.to_owned())];
let mut facets: Vec<String> = Vec::new();
if let Some(versions) = &search_args.version {
let versions_facets = versions
.iter()
.map(|e| format!("[\"versions:{}\"]", e))
.collect::<Vec<String>>()
.join(",");
facets.push(format!("{}", versions_facets));
}
if let Some(package_type) = &search_args.package_type {
let package_type_facet = match package_type {
PackageType::Fabric => "[\"categories:fabric\"],[\"project_type:mod\"]",
PackageType::Forge => "[\"categories:forge\"],[\"project_type:mod\"]",
PackageType::Quilt => "[\"categories:quilt\"],[\"project_type:mod\"]",
PackageType::Resource => "[\"project_type:resourcepack\"]",
PackageType::FabricPack => "[\"project_type:modpack\"],[\"categories:fabric\"]",
PackageType::ForgePack => "[\"project_type:modpack\"],[\"categories:forge\"]",
PackageType::QuiltPack => "[\"project_type:modpack\"],[\"categories:quilt\"]",
PackageType::BukkitPlugin => "[\"project_type:mod\"],[\"categories:bukkit\"]",
PackageType::PaperPlugin => "[\"project_type:mod\"],[\"categories:paper\"]",
PackageType::PurpurPlugin => "[\"project_type:mod\"],[\"categories:purpur\"]",
PackageType::SpigotPlugin => "[\"project_type:mod\"],[\"categories:spigot\"]",
PackageType::SpongePlugin => "[\"project_type:mod\"],[\"categories:sponge\"]",
}
.to_string();
facets.push(package_type_facet);
}
if !facets.is_empty() {
params.push(("facets", format!("[{}]", facets.join(","))));
}
let url = reqwest::Url::parse_with_params(url.as_str(), &params)?;
info!("GET {}", url);
let response = self.client.get(url).send().await?;
if response.status().is_success() {
Ok(response.json::<SearchResponse>().await?)
} else {
Err(response.json::<APIError>().await?.into())
}
}
pub async fn fetch_mod_info(&self, mod_result: &ModResult) -> anyhow::Result<ModInfo> {
let mod_id = &mod_result.project_id;
println!(
"Fetching mod info for {} (ID: {})...",
mod_result.title, mod_id
);
let url = format!(
"https://{}/v2/project/{}",
self.config.upstream.server_address, mod_id
);
info!("GET {}", url);
let response = self.client.get(url).send().await?;
if response.status().is_success() {
Ok(response.json::<ModInfo>().await?)
} else {
Err(response.json::<APIError>().await?.into())
}
}
pub async fn fetch_mod_version(&self, version_id: &String) -> anyhow::Result<ModVersion> {
println!("Fetching mod version {}...", version_id);
let url = format!(
"https://{}/v2/version/{}",
self.config.upstream.server_address, version_id
);
info!("GET {}", url);
let response = self.client.get(url).send().await?;
if response.status().is_success() {
Ok(response.json::<ModVersion>().await?)
} else {
Err(response.json::<APIError>().await?.into())
}
}
pub async fn download_version_file(
&self,
args: &Args,
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
if !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 url = &file.url;
info!("GET {}", url);
let response = self.client.get(url).send().await?;
if !response.status().is_success() {
return Err(response.json::<APIError>().await?.into())
}
let total_size = response.content_length().unwrap();
// TODO better colors and styling!
// TODO square colored creeper face progress indicator (from top-left clockwise spiral in)
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(())
}
}

View File

@ -1,119 +1,91 @@
use clap::{Parser, Subcommand, ValueEnum};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/*
* Copyright (c) 20222023 Emma Tebibyte <emma@tebibyte.media>
* Copyright (c) 20212022 Marceline Cramer <mars@tebibyte.media>
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* This file is part of Hopper.
*
* Hopper is free software: you can redistribute it and/or modify it under the
* terms of the GNU General Public License as published by the Free Software
* Foundation, either version 3 of the License, or (at your option) any later
* version.
*
* Hopper is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU General Public License for more details.
* You should have received a copy of the GNU General Public License along with
* Hopper. If not, see <https://www.gnu.org/licenses/>.
*/
// TODO parameter to restrict target Minecraft version
#[derive(clap::Args, Clone, Debug)]
pub struct SearchArgs {
pub package_name: String,
use std::{
collections::HashMap,
fs::File,
io::Read,
};
/// Type of package to use
#[clap(short, long, value_enum)]
pub package_type: Option<PackageType>,
use serde::Deserialize;
use toml::de::ValueDeserializer;
use yacexits::{
EX_DATAERR,
EX_UNAVAILABLE,
};
/// Restricts the target Minecraft version
#[clap(short, long)]
pub version: Option<Vec<String>>,
}
// TODO use ColoredHelp by default?
#[derive(Subcommand, Clone, Debug)]
pub enum Command {
/// Adds a mod to the current instance
Add(SearchArgs),
/// Removes a mod
Remove {
package_name: String,
},
Get(SearchArgs),
Update,
Clean,
}
#[derive(ValueEnum, Clone, Debug)]
pub enum PackageType {
Fabric,
Forge,
Quilt,
Resource,
FabricPack,
ForgePack,
QuiltPack,
BukkitPlugin,
PaperPlugin,
PurpurPlugin,
SpigotPlugin,
SpongePlugin,
}
// TODO move main body argument fields to substruct for ease of moving?
#[derive(Parser, Clone, Debug)]
#[clap(name = "hopper")]
pub struct Args {
/// Path to configuration file
#[clap(short, long, value_parser)]
pub config: Option<PathBuf>,
/// Path to mod lockfile
#[clap(short, long, value_parser)]
pub lockfile: Option<PathBuf>,
/// Auto-accept confirmation dialogues
#[clap(short = 'y', long = "yes")]
pub auto_accept: bool,
#[clap(subcommand)]
pub command: Command,
}
impl Args {
pub fn load_config(&self) -> Result<Config, confy::ConfyError> {
if let Some(config_path) = &self.config {
confy::load_path(config_path)
} else {
confy::load("hopper")
}
}
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct Upstream {
/// Modrinth main server address
pub server_address: String,
}
impl Default for Upstream {
fn default() -> Self {
Self {
server_address: "api.modrinth.com".into(),
}
}
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct Options {
/// Whether to reverse search results
pub reverse_search: bool,
}
impl Default for Options {
fn default() -> Self {
Self {
reverse_search: true,
}
}
}
#[derive(Deserialize, Serialize, Debug, Default, Clone)]
#[derive(Deserialize)]
pub struct Config {
/// General settings
pub options: Options,
/// Configuration for the upstream Modrinth server
pub upstream: Upstream,
hopfiles: Vec<String>,
sources: HashMap<String, String>,
}
pub struct AppContext {
pub args: Args,
pub config: Config,
pub fn get_config() -> Result<(), (String, u32)> {
let xdg_dirs = match xdg::BaseDirectories::with_prefix("hopper") {
Ok(dirs) => dirs,
Err(err) => {
return Err((
format!("{:?}", err),
EX_UNAVAILABLE,
));
},
};
Ok(())
}
impl Config {
pub fn read_config(config_path: String) -> Result<Self, (String, u32)> {
let mut buf: Vec<u8> = Vec::new();
let mut config_file = match File::open(&config_path) {
Ok(file) => file,
Err(_) => {
return Err((
format!("{}: Permission denied.", &config_path),
EX_UNAVAILABLE,
));
},
};
match config_file.read_to_end(&mut buf) {
Ok(_) => {},
Err(err) => {
return Err((
format!("{:?}", err),
EX_DATAERR,
));
},
};
let toml = match String::from_utf8(buf) {
Ok(contents) => contents,
Err(_) => {
return Err((
format!("Invalid configuration file."),
EX_DATAERR,
));
},
};
match Config::deserialize(ValueDeserializer::new(&toml)) {
Ok(val) => Ok(val),
Err(err) => Err((format!("{:?}", err), EX_DATAERR)),
}
}
}

View File

@ -1,121 +1,36 @@
/*
* Copyright (c) 20222023 Emma Tebibyte <emma@tebibyte.media>
* Copyright (c) 20212022 Marceline Cramer <mars@tebibyte.media>
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* This file is part of Hopper.
*
* Hopper is free software: you can redistribute it and/or modify it under the
* terms of the GNU General Public License as published by the Free Software
* Foundation, either version 3 of the License, or (at your option) any later
* version.
*
* Hopper is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU General Public License for more details.
* You should have received a copy of the GNU General Public License along with
* Hopper. If not, see <https://www.gnu.org/licenses/>.
*/
#![no_main]
mod api;
mod args;
mod client;
mod config;
use api::*;
use clap::Parser;
use args::*;
use client::*;
use config::*;
fn display_search_results(ctx: &AppContext, response: &SearchResponse) {
let iter = response.hits.iter().enumerate();
if ctx.config.options.reverse_search {
for (i, result) in iter.rev() {
result.display(i + 1);
}
} else {
for (i, result) in iter {
result.display(i + 1);
}
}
}
// TODO implement enum for more graceful exiting
async fn select_from_results(
_ctx: &AppContext,
response: &SearchResponse,
) -> anyhow::Result<Vec<usize>> {
let input: String = dialoguer::Input::new()
.with_prompt("Mods to install (eg: 1 2 3-5)")
.interact_text()?;
let mut selected: Vec<usize> = Vec::new();
for token in input.split(" ") {
let terms: Vec<&str> = token.split("-").collect();
match terms.len() {
1 => selected.push(terms[0].parse().expect("Token must be an integer")),
2 => {
let terms: Vec<usize> = terms
.iter()
.map(|term| term.parse().expect("Term must be an integer"))
.collect();
let from = terms[0];
let to = terms[1];
for index in from..=to {
selected.push(index);
}
}
_ => panic!("Invalid selection token {}", token),
}
}
selected.dedup();
let selected = selected
.iter()
.map(|index| {
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;
index
})
.collect();
Ok(selected)
}
async fn cmd_get(ctx: &AppContext, search_args: SearchArgs) -> anyhow::Result<()> {
let client = HopperClient::new(ctx.config.clone());
let response = client.search_mods(&search_args).await?;
if response.hits.is_empty() {
// TODO formatting
println!("No results; nothing to do...");
return Ok(());
}
display_search_results(ctx, &response);
let selected = select_from_results(ctx, &response).await?;
if selected.is_empty() {
// TODO formatting
println!("No packages selected; nothing to do...");
return Ok(());
}
for selection in selected.iter() {
let to_get = &response.hits[*selection];
let mod_info = client.fetch_mod_info(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 = client.fetch_mod_version(version_id).await?;
for file in version.files.iter() {
client.download_version_file(&ctx.args, file).await?;
}
}
}
Ok(())
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
env_logger::init();
let args = Args::parse();
let config = args.load_config()?;
let ctx = AppContext { args, config };
match ctx.args.to_owned().command {
Command::Get(search_args) => cmd_get(&ctx, search_args).await,
_ => unimplemented!("unimplemented subcommand"),
}
#[no_mangle]
async fn rust_main(args: c_main::Args) {
let argv: Vec<&str> = args.into_iter().collect();
}