Full rewrite #24

Open
emma wants to merge 30 commits from rewrite into main
14 changed files with 1597 additions and 1349 deletions

8
.editorconfig Normal file
View File

@ -0,0 +1,8 @@
root = true
[*]
charset = utf8
end_of_line = lf
indent_style = tab
indent_size = 4
insert_final_newline = true

12
.github/FUNDING.yml vendored
View File

@ -1,12 +0,0 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: tebibytemedia # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: tebibytemedia # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

104
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,104 @@
# Contributing
When contributing a pull request to the main branch, please sign your commits
with a PGP key and add your name and the year to the bottom of the list of
copyright holders for the file. For example, an existing copyright header might
read:
```
* Copyright (c) 20222023 Emma Tebibyte <emma@tebibyte.media>
```
You would add your name below it like this:
```
* Copyright (c) 20222023 Emma Tebibyte <emma@tebibyte.media>
* Copyright (c) 20XX Your Name <your e-mail address or website>
```
Only list years in which you worked on the source file. For example:
```
* Copyright (c) 20202021, 2023 Your Name <yourname@example.com>
```
This header shows that `Your Name` worked on this source file in 2020, 2021, and
2023. Please use the en dash (“–”) to separate the years in the copyright
notice.
If you are contributing a new file, please add the following license header text
to it, replacing the proper text on the copyright line:
```
/*
* Copyright (c) 20XX [Your Name] <yourname@example.com>
* 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/>.
*/
```
When including code provided under an AGPLv3-compatible license, please modify
the license notice. The following example contains an Expat (MIT) license
notice:
```
/*
* Copyright (c) 20XX [Your Name] <yourname@example.com>
* 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/>.
*
* This file incorporates work covered by the following copyright and permission
* notice:
*
* MIT License
*
* Copyright (c) <year> <copyright holders>
*
* Permission is hereby granted, free of charge, to any person obtaining a
* copy of this software and associated documentation files (the
* "Software"), to deal in the Software without restriction, including
* without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit
* persons to whom the Software is furnished to do so, subject to the
* following conditions:
*
* The above copyright notice and this permission notice (including the next
* paragraph) shall be included in all copies or substantial portions of the
* Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
* OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
* NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
* OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
* USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
```
When writing code, make sure lines never exceed 80 characters in width when
using four-character-wide tabs.

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,

1281
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -3,18 +3,23 @@ name = "hopper"
version = "0.1.0"
license = "AGPL-3.0-or-later"
edition = "2021"
authors = [
"[ ] <https://git.tebibyte.media/BlankParenthesis/>",
"Emma Tebibyte <emma@tebibyte.media>",
"Marceline Cramer <mars@tebibyte.media>",
"Spookdot <https://git.tebibyte.media/spookdot/>",
]
[dependencies]
anyhow = "1.0"
confy = "0.4"
arg = "0.4.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.5"

138
README.md
View File

@ -25,36 +25,36 @@ Hopper can automatically search, download, and update Minecraft mods, modpacks,
resource packs, and plugins from [Modrinth](https://modrinth.com/) so that
keeping your mods up-to-date and compatible with each other is easy. With
Hopper, you won't have to manually visit [CurseForge](https://curseforge.com/)
and download each mod one-by-one every time you set up a new instance, or deal
and download each mod one by one every time you set up a new instance or deal
with the hassle of swapping out different mod versions for hours while trying to
get Minecraft to accept them all at once.
Hopper is still very early in development, but important features are coming
along smoothly, and we'll have lots of progress to show off in the coming weeks.
It's written in [Rust](https://www.rust-lang.org/) and released under the
[AGPLv3](LICENSE).
Its written in [Rust](https://www.rust-lang.org/) and released under the
[GNU AGPLv3](https://www.gnu.org/licenses/agpl-3.0.en.html).
We're looking for people to help contribute code and write documentation. Please
reach out to us in [our Discord "server"](https://discord.gg/jJutHQjsh9) if
you're interested in helping out. If you have a taste in CLI apps like Hopper,
Were looking for people to help contribute code and write documentation. Please
reach out to us in [our Discord “server”](https://discord.gg/jJutHQjsh9) if
youre interested in helping out. If you have a taste in CLI apps like Hopper,
your input is especially appreciated.
Inspired by applications like [paru](https://github.com/morganamilo/paru), a
feature-packed AUR helper and [topgrade](https://github.com/r-darwish/topgrade),
a tool to upgrade everything
a tool to upgrade everything.
[![Donate using
Liberapay](https://liberapay.com/assets/widgets/donate.svg)](https://liberapay.com/tebibytemedia/donate)
# High-level Goals
## High-level Goals
## Continuous
### Continuous
- Small binary size
- Minimal compile times
## Features
### Features
### High Priority:
High Priority:
- Modrinth package searching
- Modrinth package installation
- Parallel package downloading
@ -62,17 +62,17 @@ Liberapay](https://liberapay.com/assets/widgets/donate.svg)](https://liberapay.c
- Package updating
- Listing installed packages
### Medium Priority
Medium Priority
- CurseForge package searching
- CurseForge package installation
- A `man(1)` entry
### Low Priority:
Low Priority:
- Shell autocomplete
- Configurable search result display like [Starship](https://starship.rs)
- Version-control system repository package management & compilation
### External-Dependent:
External-Dependent:
- Conflict resolution
- Dependency resolution
- Integration into [Prism Launcher](https://prismlauncher.org/) and/or
@ -81,26 +81,26 @@ Liberapay](https://liberapay.com/assets/widgets/donate.svg)](https://liberapay.c
- Graphical frontend with notifications
[Modrinth REST API
docs](https://docs.modrinth.com/api-spec/)
documentation](https://docs.modrinth.com/api-spec/)
# File Structure
```
├── "$XDG_CONFIG_HOME"/hopper.toml
├── "$XDG_CACHE_HOME"/hopper/
├── "$XDG_DATA_HOME"/hopper/
│ ├── 1.19.1/
│ │ └── fabric/
│ └── 1.18.2/
│ ├── forge/
│ └── plugin/
└── "XDG_DATA_HOME"/templates/
└── "$XDG_DATA_HOME"/templates/
└── example-template.hop -> ~/.minecraft/mods/example-template.hop
```
# Hopfile Structure
Hopfiles will contain a Minecraft version number, a list of packages, and any
references to other hopfiles on which it's based, or "templates". If a hopfile
references to other hopfiles on which its based, or “templates”. If a hopfile
is based on other template hopfiles, it inherits the packages from them. A
hopfile does not inherit the package or Minecraft version from a template.
@ -115,7 +115,7 @@ resource = "alacrity"
# Hopper Configuration File Structure
Hopper's configuration will be maintained with a list of all hopfiles known to
Hoppers configuration will be maintained with a list of all hopfiles known to
hopper. Its config file will also contain a list of mod hosting sites like
Modrinth and CurseForge and a list of (remote or local) version-control
repositories from which to compile mods. The latter will use a (potentially
@ -136,46 +136,64 @@ git = [
]
```
# Docs
## Documentation
## Types
### Types
There are multiple types of packages hopper can manage.
### Mods
#### Mods
- `fabric-mod`
- `forge-mod`
- `quilt-mod`
### Plugins
#### Modpacks
- `fabric-pack`
- `forge-pack`
- `quilt-pack`
#### Plugins
- `bukkit-plugin`
- `paper-plugin`
- `purpur-plugin`
- `spigot-plugin`
- `sponge-plugin`
### Other
- `data-pack`
- `fabric-pack`
- `forge-pack`
#### Other
- `data-pack` (planned)
- `resource-pack`
- `quilt-pack`
These types are specified in various hopper subcommands and in its configuration.
These types are specified in various hopper subcommands and in its
configuration.
## Usage
### Usage
`hopper [options...] [subcommand...]`
## OPTIONS
### Options
`-v`, `--verbose`
&emsp;Includes debug information in the output of `hopper` subcommands.
## SUBCOMMANDS
## Subcommands
`get [options...] [targets...]`
### `add [options...] [packages...]`
&emsp;Adds packages to the current hopfile, symlinking them to its directory. If
the package cannot be found in the package cache, `hopper get` is run first.
OPTIONS
&emsp; `-f`, `--hopfile [hopfiles...]`
&emsp;&emsp;Specifies hopfiles to which mods will be added.
&emsp;`-m`, `--mc-version [version...]`
&emsp;&emsp;Overrides the version of Minecraft added packages will be for.
### `get [options...] [targets...]`
&emsp;Searches for packages, displays the results, and downloads any selected
packages to the local cache. If multiple targets are specified, results are
@ -186,7 +204,7 @@ OPTIONS
&emsp;`-d`, `--dir [directory...]`
&emsp;&emsp;Specifies the directory to download to (default is
"$XDG_CACHE_HOME"/hopper/).
`"$XDG_DATA_HOME"/hopper/`).
&emsp;`-m`, `--mc-version [version...]`
@ -202,7 +220,7 @@ cache. Requires `--mc-version` and `--type` be specified.
&emsp;&emsp;Specifies what types of packages are being queried.
`init [options...]`
### `init [options...]`
&emsp;Creates a hopfile in the current directory and adds it to the global known
hopfiles list.
@ -217,26 +235,16 @@ OPTIONS
&emsp;&emsp;Specifies templates upon which to base the new hopfile.
&emsp;`-m`, `--mc-version [version]`
&emsp;`-m`, `--mc-version [version...]`
&emsp;&emsp;Specifies for what version of Minecraft packages are being managed.
&emsp;&emsp;Specifies the version of Minecraft packages are being managed for.
&emsp;`-t`, `--type [type...]`
&emsp;&emsp;Specifies what type of packages will be listed in this hopfile.
`install [options...] [packages...]`
&emsp;Adds packages to the current hopfile, symlinking them to its directory. If
the package cannot be found in the package cache, `hopper get` is run first.
OPTIONS
&emsp; `-f`, `--hopfile [hopfiles...]`
&emsp;&emsp;Specifies hopfiles to which mods will be added.
`list [options...]`
### `list [options...]`
&emsp;Lists all installed packages.
@ -246,7 +254,7 @@ OPTIONS
&emsp;&emsp;Lists packages installed in a specified hopfile.
&emsp;`-m`, `--mc-version [version]`
&emsp;`-m`, `--mc-version [version...]`
&emsp;&emsp;Specifies for what version of Minecraft packages are being managed.
@ -254,22 +262,40 @@ OPTIONS
&emsp;&emsp;List all packages of a specified type.
`update [options...]`
### `remove [options...] [packages...]`
&emsp;Updates installed packages and adds mods if they're missing to directories
with known hopfiles.
&emsp;Uninstalls packages.
OPTIONS
&emsp;`-f`, `--hopfile [hopfiles...]`
&emsp;&emsp;Updates only packages in the specified hopfile. Note that this
option creates a new file and symlink as it does not update the packages for
other hopfiles.
&emsp;&emsp;Removes only packages in the specified hopfile.
&emsp;`-m`, `--mc-version [version]`
&emsp;&emsp;Specifies for what version of Minecraft packages are being updated.
&emsp;&emsp;Specifies the version of Minecraft the packages are being
uninstalled for.
&emsp;`-t`, `--type [types...] [packages...]`
&emsp;&emsp;Removes only packages of a specified type. Optionally takes a list
of packages as an argument.
### `update [options...]`
&emsp;Updates installed packages. This command also adds mods to directories
with known hopfiles if the hopfile lists a mod which is not present.
OPTIONS
&emsp;`-f`, `--hopfile [hopfiles...]`
&emsp;&emsp;Updates only packages in the specified hopfile.
&emsp;`-m`, `--mc-version [version...]`
&emsp;&emsp;Specifies the version of Minecraft packages are being updated for.
&emsp;`-t`, `--type [types...] [packages...]`

View File

@ -1,156 +1,186 @@
/*
* Copyright (c) 20212022 Marceline Cramer <mars@tebibyte.media>
* Copyright (c) 20222023 Emma Tebibyte <emma@tebibyte.media>
* Copyright (c) 2022 Spookdot <https://git.tebibyte.media/spookdot/>
* 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 console::style;
use serde::Deserialize;
use std::{collections::HashMap, fmt};
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,
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>,
pub slug: String,
pub title: String,
pub description: String,
pub categories: Vec<String>,
// NOTE this is not in the OpenAPI docs
pub display_categories: Vec<String>,
pub client_side: String,
pub server_side: String,
// NOTE this isn't in all search results?
pub project_type: String,
pub downloads: isize,
pub icon_url: String,
// TODO parse to 'local-xxxx' with regex
pub project_id: String,
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_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 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);
}
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>>,
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
// NOTE not listed in OpenAPI docs, TODO serialize as datetime
pub approved: Option<String>,
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,
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,
pub id: String,
pub name: String,
pub url: String,
}
#[derive(Deserialize, Debug)]
pub struct DonationLink {
pub id: String,
pub platform: String,
pub url: String,
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>,
pub name: String,
pub version_number: String,
pub changelog: Option<String>,
// TODO dependency wrangling, thank you modrinth, very cool
// pub dependencies: Option<Vec<String>>,
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,
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,
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)
}
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {}", self.error, self.description)
}
}
impl std::error::Error for Error {}

241
src/args.rs Normal file
View File

@ -0,0 +1,241 @@
/*
* Copyright (c) 20212022 Marceline Cramer <mars@tebibyte.media>
* Copyright (c) 20222023 Emma Tebibyte <emma@tebibyte.media>
* Copyright (c) 2022 Spookdot <https://git.tebibyte.media/spookdot/>
* 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::{
fmt,
str::FromStr,
};
pub use arg::Args;
use yacexits::{ EX_DATAERR, EX_USAGE };
#[derive(Args, Debug)]
pub struct Arguments {
pub argv0: String,
#[arg(short = "v")]
pub v: Option<bool>,
#[arg(sub)]
pub sub: Command,
}
#[derive(Args, Debug)]
pub enum Command {
Add(AddArgs),
Get(SearchArgs),
Init(InitArgs),
List(HopArgs),
Remove(RmArgs),
Update(HopArgs),
}
#[derive(Args, Debug)]
pub struct AddArgs {
#[arg(short = "m")]
pub mc_version: String,
#[arg(short = "f")]
pub hopfiles: Vec<String>,
pub package_names: Vec<String>,
}
#[derive(Args, Debug)]
pub struct HopArgs {
#[arg(short = "f")]
pub hopfile: Vec<String>,
#[arg(short = "m")]
pub mc_version: Vec<String>,
#[arg(short = "t")]
pub package_type: Option<PackageType>,
}
#[derive(Args, Debug)]
pub struct InitArgs {
#[arg(short = "f")]
pub template: Option<String>,
pub mc_version: String,
pub package_type: PackageType,
}
#[derive(Args, Debug)]
pub struct RmArgs {
#[arg(short = "f")]
pub hopfile: Option<String>,
pub package_type: PackageType,
pub mc_version: String,
pub package_names: Vec<String>,
}
#[derive(Args, Debug)]
pub struct SearchArgs {
#[arg(short = "n")]
pub no_confirm: bool,
/// Overrides the download directory
#[arg(short = "d")]
pub dir: Option<String>,
/// Restricts the target Minecraft version
#[arg(short = "m")]
pub mc_version: Vec<String>,
/// Type of package to use
#[arg(short = "t")]
pub package_type: PackageType,
pub package_name: String,
}
#[derive(Clone, Copy, Debug)]
pub enum PackageType {
Dummy,
Mod(Loader),
Pack(Loader),
Plugin(Server),
ResourcePack,
}
#[derive(Clone, Copy, Debug)]
pub enum Loader {
Fabric,
Forge,
Quilt,
}
#[derive(Clone, Copy, Debug)]
pub enum Server {
Bukkit,
Paper,
Purpur,
Spigot,
Sponge,
}
impl fmt::Display for Command {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self {
Command::Add(_) => write!(f, "add"),
Command::Get(_) => write!(f, "get"),
Command::Init(_) => write!(f, "init"),
Command::List(_) => write!(f, "list"),
Command::Remove(_) => write!(f, "remove"),
Command::Update(_) => write!(f, "update"),
}
}
}
#[derive(Clone, Debug)]
pub enum PackageParseError {
Invalid(String),
}
impl std::default::Default for PackageType { // TODO: Actually implement Default
fn default() -> Self { // for PackageType
PackageType::Dummy
}
}
impl From<PackageParseError> for (String, u32) {
fn from(error: PackageParseError) -> Self {
match error {
PackageParseError::Invalid(err) => (err, EX_DATAERR),
}
}
}
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 type.", 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)
))
},
}
}
}
// TODO: Make this an enum for this for matching specific error kinds
pub struct ArgsError {
message: String,
code: u32,
}
// TODO: More granular matching here with an enum
impl From<arg::ParseKind<'_>> for ArgsError {
fn from(_: arg::ParseKind) -> Self {
let message = format!(
"{}",
"[-v] add | get | init | list | remove | update\n\n".to_owned() +
"add [-m version] [-f hopfiles...] packages...\n" +
"get [-n] [-d directory] [-m versions...] [-t types...] packages\n" +
"init [-f hopfiles...] version type\n" +
"list [[-f hopfiles...] | [-m versions...] [-t types...]]\n" +
"remove [[-f hopfiles...] | type version]] packages...\n" +
"update [[-f hopfiles... | [-m versions...] [-t types...]]",
);
ArgsError { message, code: EX_USAGE }
}
}
impl From<ArgsError> for (String, u32) {
fn from(err: ArgsError) -> Self {
(err.message, err.code)
}
}

View File

@ -1,34 +1,79 @@
use crate::api::{ModInfo, ModResult, ModVersion, ModVersionFile, SearchResponse, Error as APIError};
use crate::config::{Args, Config, PackageType, SearchArgs};
use futures_util::StreamExt;
use log::*;
/*
* Copyright (c) 20212022 Marceline Cramer <mars@tebibyte.media>
* Copyright (c) 20222023 Emma Tebibyte <emma@tebibyte.media>
* Copyright (c) 2022 Spookdot <https://git.tebibyte.media/spookdot/>
* 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 crate::{
api::{
ModInfo,
ModResult,
ModVersion,
ModVersionFile,
SearchResponse,
Error as APIError,
},
config::{
Config,
},
args::{
Arguments,
Loader,
PackageType,
Server,
SearchArgs,
},
};
use std::cmp::min;
use std::io::Write;
use curl::easy::{ Easy };
use futures_util::StreamExt;
pub struct HopperClient {
config: Config,
client: reqwest::Client,
client: Easy,
}
impl HopperClient {
pub fn new(config: Config) -> Self {
curl::init();
Self {
config: config,
client: reqwest::ClientBuilder::new()
.user_agent(format!("tebibytemedia/hopper/{} (tebibyte.media)", env!("CARGO_PKG_VERSION")))
.build()
.unwrap(),
client: Easy::new(),
}
}
pub async fn search_mods(&self, search_args: &SearchArgs) -> anyhow::Result<SearchResponse> {
println!("Searching with query \"{}\"...", search_args.package_name);
pub async fn search_mods(
&mut self,
search_args: &SearchArgs,
) /*-> Result<SearchResponse, (String, u32)>*/ {
println!("Searching with query “{}”...", search_args.package_name);
let url = format!("https://{}/v2/search", self.config.upstream.server_address);
let mut urls = Vec::new();
for entry in self.config.sources.modrinth.iter() {
urls.push(format!("{}/v2/search", entry));
}
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 {
if let versions = &search_args.mc_version {
let versions_facets = versions
.iter()
.map(|e| format!("[\"versions:{}\"]", e))
@ -36,131 +81,47 @@ impl HopperClient {
.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();
if let package_type = search_args.package_type {
let project_type = match package_type {
PackageType::Dummy => "",
PackageType::Mod(_) => "[\"project_type:mod\"]",
PackageType::Pack(_) => "[\"project_type:modpack\"]",
PackageType::Plugin(_) => "[\"project_type:mod\"]",
PackageType::ResourcePack => "[\"project_type:resourcepack\"]",
};
let project_category = match package_type {
PackageType::Dummy => "",
PackageType::Mod(kind) | PackageType::Pack(kind) => {
match kind {
Loader::Fabric => "[\"categories:fabric\"]",
Loader::Forge => "[\"categories:forge\"]",
Loader::Quilt => "[\"categories:quilt\"]",
}
},
PackageType::Plugin(kind) => {
match kind {
Server::Bukkit => "[\"categories:bukkit\"]",
Server::Paper => "[\"categories:paper\"]",
Server::Purpur => "[\"categories:purpur\"]",
Server::Spigot => "[\"categories:spigot\"]",
Server::Sponge => "[\"categories:sponge\"]",
}
},
PackageType::ResourcePack => "",
};
let package_type_facet = format!(
"{},{}",
project_type,
project_category,
);
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(())
}
}

148
src/commands.rs Normal file
View File

@ -0,0 +1,148 @@
/*
* Copyright (c) 20212022 Marceline Cramer <mars@tebibyte.media>
* Copyright (c) 2022 [ ] <https://git.tebibyte.media/BlankParenthesis/>
* Copyright (c) 2023 Emma Tebibyte <emma@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 tokio::fs::File;
use tokio::io::AsyncWriteExt;
pub 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(())
}
pub async fn cmd_init(args: HopfileArgs) -> anyhow::Result<()> {
let mut path = args.dir.unwrap_or_default();
path.push("info.hop");
if path.try_exists().expect("Invalid dir") {
let message = format!(
"hopfile already exists: {}",
path.to_str().unwrap()
);
Err(anyhow::Error::msg(message))
} else {
let mut file = File::create(&path).await?;
let doc = Hopfile::new(args.template, args.version);
let output = toml_edit::easy::to_string_pretty(&doc).unwrap();
file.write_all(output.as_bytes()).await?;
println!("Saved {}", path.to_str().unwrap());
Ok(())
}
}
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)
}

View File

@ -1,119 +1,104 @@
use clap::{Parser, Subcommand, ValueEnum};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/*
* Copyright (c) 20212022 Marceline Cramer <mars@tebibyte.media>
* Copyright (c) 20222023 Emma Tebibyte <emma@tebibyte.media>
* Copyright (c) 2022 Spookdot <https://git.tebibyte.media/spookdot/>
* Copyright (c) 2023 [ ] <https://git.tebibyte.media/BlankParenthesis/>
* 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::{
fs::File,
io::{ Read, self },
path::PathBuf,
};
/// Type of package to use
#[clap(short, long, value_enum)]
pub package_type: Option<PackageType>,
use serde::Deserialize;
use xdg::BaseDirectories;
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,
pub hopfiles: Vec<String>,
pub sources: Sources,
}
pub struct AppContext {
pub args: Args,
pub config: Config,
#[derive(Deserialize)]
pub struct Sources {
pub modrinth: Vec<String>,
}
pub enum ConfigError {
CreateError(io::Error),
OpenError(io::Error),
ReadError(io::Error),
FormatError(std::string::FromUtf8Error),
ParseError(toml::de::Error),
}
impl From<ConfigError> for (String, u32) {
fn from(error: ConfigError) -> Self {
let (message, code) = match error {
// TODO: More precise matching inside these arms
ConfigError::CreateError(_) => {
("Unable to create configuration file.", EX_UNAVAILABLE)
},
ConfigError::OpenError(_) => {
("Unable to open configuration file.", EX_UNAVAILABLE)
},
ConfigError::ReadError(_) => {
("Error while reading configuration file.", EX_DATAERR)
},
ConfigError::FormatError(_) => {
("Configuration file is not valid UTF-8.", EX_DATAERR)
},
ConfigError::ParseError(_) => {
("Unable to parse configuration file.", EX_DATAERR)
},
};
(message.to_string(), code)
}
}
impl From<xdg::BaseDirectoriesError> for ConfigError {
fn from(err: xdg::BaseDirectoriesError) -> Self {
ConfigError::CreateError(io::Error::from(err))
}
}
impl Config {
pub fn read_config() -> Result<Self, ConfigError> {
let config_path = BaseDirectories::with_prefix("hopper")?
.place_config_file("config.toml")
.map_err(ConfigError::CreateError)?;
let mut buf: Vec<u8> = Vec::new();
let mut config_file = File::open(&config_path)
.map_err(ConfigError::OpenError)?;
config_file.read_to_end(&mut buf)
.map_err(ConfigError::ReadError)?;
let toml = String::from_utf8(buf)
.map_err(ConfigError::FormatError)?;
toml::from_str(&toml).map_err(ConfigError::ParseError)
}
}

137
src/hopfile.rs Normal file
View File

@ -0,0 +1,137 @@
/*
* Copyright (c) 2022 [ ] <https://git.tebibyte.media/BlankParenthesis/>
* 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 std::str::FromStr;
use serde::{Deserialize, Serialize, de::Visitor};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct Hopfile {
pub template: Option<String>,
// TODO: possibly parse this into a more specific format (enum maybe?)
pub mc_version: String,
pub packages: Packages,
}
impl Hopfile {
pub fn new(template: Option<String>, version: Option<String>) -> Self {
Self {
template,
mc_version: version.unwrap_or_else(|| String::from("1.19.1")),
packages: Packages::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Packages {
pub mods: Vec<Resource>,
pub resources: Vec<Resource>,
}
#[derive(Debug, Clone, Copy, Default)]
enum Provider {
#[default]
Modrinth,
}
impl FromStr for Provider {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"modrinth" => Ok(Self::Modrinth),
_ => Err(()),
}
}
}
impl ToString for Provider {
fn to_string(&self) -> String {
String::from(match self {
Self::Modrinth => "modrinth",
})
}
}
#[derive(Debug, Clone)]
pub struct Resource {
provider: Provider,
name: String,
}
impl FromStr for Resource {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
if let Some((provider, name)) = s.split_once(':') {
Ok(Resource {
provider: Provider::from_str(provider)?,
name: name.to_string(),
})
} else if !s.is_empty() {
Ok(Resource {
provider: Provider::default(),
name: s.to_string(),
})
} else {
Err(())
}
}
}
impl ToString for Resource {
fn to_string(&self) -> String {
[self.provider.to_string().as_str(), self.name.as_str()].join(":")
}
}
impl Serialize for Resource {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where S: serde::Serializer {
serializer.serialize_str(&self.to_string())
}
}
struct V;
impl<'de> Visitor<'de> for V {
type Value = Resource;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(formatter, "a string")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where E: serde::de::Error, {
Resource::from_str(v).map_err(|_| serde::de::Error::custom(
format!("Failed to parse mod/resource: '{}'", v)
))
}
}
impl<'de> Deserialize<'de> for Resource {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where D: serde::Deserializer<'de> {
deserializer.deserialize_str(V)
}
}

View File

@ -1,121 +1,83 @@
/*
* Copyright (c) 20212022 Marceline Cramer <mars@tebibyte.media>
* Copyright (c) 20222023 Emma Tebibyte <emma@tebibyte.media>
* Copyright (c) 2022 Spookdot <https://git.tebibyte.media/spookdot/>
* Copyright (c) 20222023 [ ] <https://git.tebibyte.media/BlankParenthesis/>
* 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/>.
*/
mod api;
mod args;
mod client;
mod config;
mod hopfile;
use api::*;
use clap::Parser;
use args::*;
use client::*;
use config::*;
use hopfile::*;
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);
}
}
use std::env::args;
use yacexits::{
exit,
EX_OSERR,
EX_SOFTWARE,
EX_USAGE,
};
struct AppContext {
args: Arguments,
config: Config,
}
// 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(())
fn main() {
let argv = args().collect::<Vec<String>>();
match rust_main(argv.clone()) {
Ok(code) => exit(code),
Err((message, code)) => {
if code == EX_USAGE {
eprintln!("Usage: {} {}", argv[0], message);
} else {
eprintln!("{}: {}", argv[0], message);
}
exit(code);
},
};
}
#[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"),
}
async fn rust_main(argv: Vec<String>) -> Result<u32, (String, u32)> {
let args = Arguments::from_args(argv.clone().iter().map(|s| s.as_str()))
.map_err(|err| { ArgsError::from(err) })?;
let config = Config::read_config()?;
let ctx = AppContext { args, config };
match ctx.args.sub {
// Command::Get(search_args) => cmd_get(&ctx, search_args).await,
// Command::Init(hopfile_args) => cmd_init(hopfile_args).await,
_ => {
let message = format!(
"{}: Unimplemented subcommand.", ctx.args.sub
);
Err((message, EX_SOFTWARE))
},
}?
}