arg types implemented
This commit is contained in:
parent
132d82680b
commit
6dd9c4871d
|
@ -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,
|
File diff suppressed because it is too large
Load Diff
16
Cargo.toml
16
Cargo.toml
|
@ -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"
|
||||
|
|
156
src/api.rs
156
src/api.rs
|
@ -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 {}
|
|
@ -0,0 +1,156 @@
|
|||
/*
|
||||
* Copyright (c) 2022–2023 Emma Tebibyte <emma@tebibyte.media>
|
||||
* Copyright (c) 2021–2022 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)
|
||||
))
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
166
src/client.rs
166
src/client.rs
|
@ -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(), ¶ms)?;
|
||||
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(())
|
||||
}
|
||||
}
|
196
src/config.rs
196
src/config.rs
|
@ -1,119 +1,91 @@
|
|||
use clap::{Parser, Subcommand, ValueEnum};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
/*
|
||||
* Copyright (c) 2022–2023 Emma Tebibyte <emma@tebibyte.media>
|
||||
* Copyright (c) 2021–2022 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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
137
src/main.rs
137
src/main.rs
|
@ -1,121 +1,36 @@
|
|||
/*
|
||||
* Copyright (c) 2022–2023 Emma Tebibyte <emma@tebibyte.media>
|
||||
* Copyright (c) 2021–2022 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();
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue