add buildtwitch command and docs

This commit is contained in:
LordMZTE 2021-09-01 15:06:49 +02:00
parent 86560f8e19
commit 3de0fc53b9
15 changed files with 670 additions and 58 deletions

View File

@ -1,5 +1,6 @@
[workspace]
members = [
"mpt",
"addonscript",
"mpt",
"twitch",
]

View File

@ -13,6 +13,19 @@ pub struct Manifest {
pub manifest_type: ManifestType,
pub versions: Vec<Version>,
pub repositories: Vec<Repository>,
pub meta: Meta,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct Meta {
pub name: String,
pub contributors: Vec<Contributor>,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct Contributor {
pub name: String,
pub roles: Vec<String>,
}
#[derive(Deserialize, Serialize, Debug)]
@ -23,7 +36,7 @@ pub struct Repository {
pub url: Url,
}
#[derive(Deserialize, Serialize, Debug)]
#[derive(Deserialize, Serialize, Debug, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum RepositoryType {
Maven,

View File

@ -10,7 +10,7 @@ path = "src/main.rs"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
addonscript = { path = "../addonscript"}
addonscript = { path = "../addonscript" }
anyhow = "1.0.43"
async-trait = "0.1.51"
crossterm = "0.21.0"
@ -25,4 +25,7 @@ structopt = "0.3.22"
thiserror = "1.0.28"
tokio = { version = "1.10.1", features = ["rt-multi-thread", "macros", "fs"] }
toml = "0.5.8"
twitch = { path = "../twitch" }
url = "2.2.2"
walkdir = "2.3.2"
zip = "0.5.13"

View File

@ -0,0 +1,281 @@
use crate::{
config::Config,
downloader::{
Callback,
CallbackStatus,
DownloadError,
DownloadInfo,
Downloader,
FileToDownload,
},
forge,
util::{self, CliStyle},
};
use addonscript::manifest::{
installer::Installer,
link::Link,
File,
FileOpt,
Manifest,
RelationType,
Repository,
RepositoryType,
};
use anyhow::{bail, Context};
use async_trait::async_trait;
use indicatif::ProgressBar;
use reqwest::Client;
use std::{collections::HashMap, io::Write, path::Path, sync::Arc};
use tokio::io::AsyncReadExt;
use twitch::manifest::Manifest as TwManifest;
use walkdir::WalkDir;
use zip::{CompressionMethod, ZipWriter};
pub async fn run((config, mut manifest): (Config, Manifest), all: bool) -> anyhow::Result<()> {
util::make_tmp_dir(&config).await?;
let twitch_dir = config.locations.temp_dir.join("twitch");
let mut version = manifest
.versions
.pop()
.context("Manifest has no versions!")?;
let repos = util::repo_map(manifest.repositories);
let mut modloader = None;
let mut cf_rels = vec![];
let mut link_rels = vec![];
for rel in version.relations {
if !all && !rel.options.contains(&FileOpt::Included) {
println!(
"{}",
format!("Skipping non-included relation {:?}", &rel).info()
);
continue;
}
if rel.relation_type == RelationType::Modloader {
if modloader.is_some() {
bail!("Found multiple modloaders in manifest! Only one is supported!");
}
let version = rel
.versions
.context("Modloader is missing `versions` field!")?;
let version =
forge::parse_version(&version).context("Couldn't parse forge version!")?;
modloader = Some(format!("forge-{}", version));
continue;
}
let file = if let Some(file) = rel.file {
file
} else {
println!(
"{}",
format!("Skipping relation {:?} with no files", &rel).info()
);
continue;
};
sort_file(file, &repos, &mut link_rels, &mut cf_rels)?;
}
for file in version.files {
sort_file(file, &repos, &mut link_rels, &mut cf_rels)?;
}
let mut to_download = vec![];
for (installer, link) in link_rels {
match link {
Link::Http(url) => {
let file_name = util::url_file_name(&url)?;
let dir = if let Installer::Dir(p) = installer {
p
} else {
bail!("Relation uses non-dir installer over http!")
};
to_download.push(FileToDownload {
url,
target: twitch_dir.join("overrides").join(dir).join(file_name),
});
},
Link::File(path) => {
println!(
"{}",
format!("Copying local file {}", path.to_string_lossy()).info()
);
match installer {
Installer::Dir(dir) => {
tokio::fs::copy(config.locations.src.join(path), twitch_dir.join(dir))
.await?;
},
Installer::Override => {
let path = config.locations.src.join(path);
if !path.is_dir() {
bail!("File with override installer is not directory!");
}
util::copy_dir(path, twitch_dir.join("overrides")).await?;
},
}
},
}
}
struct Cb {
pb: ProgressBar,
}
#[async_trait]
impl Callback for Cb {
type EndRes = Result<(), DownloadError>;
type StopInfo = DownloadError;
async fn on_download_complete(
&mut self,
res: Result<DownloadInfo, DownloadError>,
) -> CallbackStatus<Self::StopInfo> {
self.pb.inc(1);
match res {
Ok(i) => {
self.pb.println(i.to_colored_text());
CallbackStatus::Continue
},
Err(e) => CallbackStatus::Stop(e),
}
}
async fn on_completed(self, stop_info: Option<Self::StopInfo>) -> Self::EndRes {
self.pb.finish();
match stop_info {
None => Ok(()),
Some(e) => Err(e),
}
}
}
println!("{}", "Downloading remote files.".info());
Downloader {
callback: Cb {
pb: ProgressBar::new(to_download.len() as u64)
.with_prefix("Downloading")
.with_style(util::progress_style()),
},
files: to_download,
parellel_count: config.downloads.max_threads,
client: Arc::new(Client::new()),
}
.download()
.await?;
println!("{}", "Creating manifest.".info());
let tw_manifest = TwManifest::create(
cf_rels,
&manifest
.meta
.contributors
.into_iter()
.map(|c| c.name)
.collect::<Vec<_>>(),
manifest.meta.name.clone(),
version.version.clone(),
version.mcversion.pop().context("mcversion is empty!")?,
modloader,
);
let json = serde_json::to_vec(&tw_manifest).context("Failed to serialize twitch manifest")?;
tokio::fs::write(twitch_dir.join("manifest.json"), json).await?;
tokio::fs::create_dir_all("build").await?;
println!("{}", "Zipping pack.".info());
let mut zip = ZipWriter::new(
// I don't think we can make this async :(
std::fs::File::create(Path::new(&format!(
"build/{}-{}-twitch.zip",
manifest.meta.name, version.version
)))
.context("Failed to open zip file!")?,
);
let options =
zip::write::FileOptions::default().compression_method(CompressionMethod::Deflated);
let mut buf = vec![];
for entry in WalkDir::new(&twitch_dir) {
let entry = entry?;
let path = entry.path();
let to = path.strip_prefix(&twitch_dir)?;
if path.is_file() {
zip.start_file(to.to_string_lossy(), options)?;
tokio::fs::File::open(path)
.await?
.read_to_end(&mut buf)
.await?;
zip.write_all(&buf)?;
buf.clear();
} else if !to.as_os_str().is_empty() {
zip.add_directory(to.to_string_lossy(), options)?;
}
}
zip.finish()?;
Ok(())
}
fn sort_file(
file: File,
repos: &HashMap<String, Repository>,
link_rels: &mut Vec<(Installer, Link)>,
cf_rels: &mut Vec<(u32, u32)>,
) -> anyhow::Result<()> {
match file {
File::Link {
installer, link, ..
} => link_rels.push((installer, link)),
File::Maven {
installer,
artifact,
repository,
} => {
let repo = repos
.get(&repository)
.with_context(|| format!("File references unknown repository {}", &repository))?;
match repo.repo_type {
RepositoryType::Maven => {
let url = util::mvn_artifact_to_url(&artifact, &repo)
.context("Failed to convert maven artifact to url")?;
link_rels.push((installer, Link::Http(url)));
},
RepositoryType::Curseforge => {
let (p_id, f_id) = util::parse_curseforge_artifact(&artifact)?;
cf_rels.push((
p_id.parse()
.context("Couldn't parse curseforge project ID!")?,
f_id.parse().context("Couldn't parse curseforge file ID!")?,
));
},
}
},
}
Ok(())
}

View File

@ -8,7 +8,7 @@ use crate::{
Downloader,
FileToDownload,
},
util::{mvn_artifact_to_url, progress_style, CliStyle},
util::{self, mvn_artifact_to_url, progress_style, CliStyle},
};
use addonscript::manifest::{
installer::Installer,
@ -47,11 +47,7 @@ pub async fn run(
} = manifest;
let version = versions.pop().context("Manifest has no Versions!")?;
let mut repos = HashMap::new();
for repo in repositories {
repos.insert(repo.id.clone(), repo);
}
let repos = util::repo_map(repositories);
let pb = ProgressBar::new(version.relations.len() as u64)
.with_prefix("Resolving")
@ -131,7 +127,9 @@ pub async fn run(
.context("File uses empty URL!")?;
let file = percent_decode(file.as_bytes()).decode_utf8_lossy();
to.push(Borrow::<str>::borrow(&file));
files.push(FileToDownload { url, target: to });
if !to.exists() {
files.push(FileToDownload { url, target: to });
}
},
}
}
@ -152,12 +150,7 @@ pub async fn run(
self.pb.inc(1);
match res {
Ok(i) => {
self.pb.println(format!(
"{} {} => {}",
i.status,
i.from.as_str().cyan().bold(),
i.to.to_string_lossy().cyan().bold()
));
self.pb.println(i.to_colored_text());
CallbackStatus::Continue
},
@ -220,13 +213,7 @@ async fn process_file(
url
},
RepositoryType::Curseforge => {
let mut splits = artifact.split(':').skip(1);
let p_id = splits
.next()
.context("Couldn't parse curseforge artifact!")?;
let f_id = splits
.next()
.context("Couldn't parse curseforge artifact!")?;
let (p_id, f_id) = util::parse_curseforge_artifact(&artifact)?;
let url = format!(
"https://addons-ecs.forgesvc.net/api/v2/addon/{}/file/{}/download-url",

View File

@ -3,9 +3,11 @@ use addonscript::{
manifest::{
installer::Installer,
link::Link,
Contributor,
File,
Manifest,
ManifestType,
Meta,
Relation,
RelationType,
Repository,
@ -37,7 +39,11 @@ use crate::config::Config;
const DEFAULT_CONFIG: &[u8] = include_bytes!("../../assets/modpacktoolsconfig.toml");
pub async fn run(modpack_name: String, mcversion: String) -> anyhow::Result<()> {
pub async fn run(
modpack_name: String,
author_name: String,
mcversion: String,
) -> anyhow::Result<()> {
let mut stdout = std::io::stdout();
execute!(
@ -135,7 +141,8 @@ pub async fn run(modpack_name: String, mcversion: String) -> anyhow::Result<()>
tokio::fs::create_dir_all(path).await?;
let data = serde_json::to_vec_pretty(&Manifest {
id: modpack_name,
// TODO rename this to snake_case
id: modpack_name.clone(),
manifest_type: ManifestType::Modpack,
versions: vec![Version {
version: "1.0".into(),
@ -153,6 +160,13 @@ pub async fn run(modpack_name: String, mcversion: String) -> anyhow::Result<()>
repo_type: RepositoryType::Curseforge,
url: Url::parse("https://cursemaven.com").unwrap(), // unwrap is ok on fixed value
}],
meta: Meta {
name: modpack_name,
contributors: vec![Contributor {
roles: vec!["Owner".to_owned()],
name: author_name,
}],
},
})?;
tokio::fs::write(path.join("modpack.json5"), data).await?;

View File

@ -1,2 +1,3 @@
pub mod buildtwitch;
pub mod downloadmods;
pub mod init;

View File

@ -1,4 +1,5 @@
use serde::Deserialize;
use std::path::PathBuf;
#[derive(Deserialize)]
#[serde(rename_all = "PascalCase")]
@ -10,8 +11,8 @@ pub struct Config {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Locations {
pub src: String,
pub temp_dir: String,
pub src: PathBuf,
pub temp_dir: PathBuf,
}
#[derive(Deserialize)]

View File

@ -1,4 +1,5 @@
use async_trait::async_trait;
use crossterm::style::Stylize;
use futures::{stream, StreamExt};
use reqwest::{Client, StatusCode};
use std::{path::PathBuf, sync::Arc};
@ -6,14 +7,23 @@ use thiserror::Error;
use tokio::{fs::File, io::AsyncWriteExt};
use url::Url;
/// A struct that can download multiple files over http parallely using tokio
/// and reqwest
pub struct Downloader<C: Callback> {
/// A callback object defined by the user containing methods that will be
/// called when progress is made or the process completes
pub callback: C,
/// The files the downloader will download
pub files: Vec<FileToDownload>,
/// The maximum amount of files the downloader will download at once
pub parellel_count: usize,
/// The HTTP client to use to download files
pub client: Arc<Client>,
}
impl<C: Callback> Downloader<C> {
/// Starts the downloading process and returns the value that is returned
/// from the callback in the on_completed method
pub async fn download(self) -> C::EndRes {
let Self {
mut callback,
@ -42,6 +52,7 @@ impl<C: Callback> Downloader<C> {
callback.on_completed(stop_info).await
}
/// Downloads a single file. Used intenally by the Downloader
async fn download_one(
client: Arc<Client>,
url: Url,
@ -69,17 +80,37 @@ impl<C: Callback> Downloader<C> {
}
}
/// A file to be downloaded by a Downloader
pub struct FileToDownload {
/// The url to HTTP GET the file from
pub url: Url,
/// The file to save to
pub target: PathBuf,
}
/// A struct containing information about a file that is passed to the callback
/// once it has finished downloading
pub struct DownloadInfo {
/// the URL the file has been downloaded from
pub from: Url,
/// The path the file has been saved to
pub to: PathBuf,
/// The HTTP status code returned by the server
pub status: StatusCode,
}
impl DownloadInfo {
pub fn to_colored_text(&self) -> String {
format!(
"{} {} => {}",
self.status.as_str().red(),
self.from.as_str().cyan().bold(),
self.to.to_string_lossy().cyan().bold()
)
}
}
/// An error that can occur while a file is being downloaded
#[derive(Debug, Error)]
pub enum DownloadError {
#[error("HTTP Error: {0}")]
@ -88,20 +119,39 @@ pub enum DownloadError {
FilesystemError(#[from] std::io::Error),
}
/// Returned by the on_download_complete method in the callback to either
/// continue or stop the downloader. If the downloader is stopped, a StopInfo
/// object is also sent which will be received by the on_completed function if
/// the download was stopped.
pub enum CallbackStatus<I> {
Stop(I),
Continue,
}
/// A callback driven by a Downloader
#[async_trait]
pub trait Callback {
/// The type returned by the file downloader from the on_completed callback
/// function.
type EndRes;
/// The type sent to the on_completed function when the downloader was
/// interrupted by the on_download_complete function.
type StopInfo;
/// Called by the downloader once a file has been downloaded or failed to
/// download
///
/// * `res` - The result of the file download operation
async fn on_download_complete(
&mut self,
res: Result<DownloadInfo, DownloadError>,
) -> CallbackStatus<Self::StopInfo>;
/// Called by the Downloader once it has completed. The return value of this
/// function will also be returned by the file downloader.
///
/// * `stop_info` - Normally `None`, unless the on_download_complete
/// function stopped the
/// downloader, in which case the value returned by it will be provided.
async fn on_completed(self, stop_info: Option<Self::StopInfo>) -> Self::EndRes;
}

View File

@ -8,6 +8,8 @@ struct ForgeVersionResponse {
promos: HashMap<String, String>,
}
/// Queries the newest version of forge for a given minecraft version from the
/// forge api
pub async fn newest_forge_version(
http: &Client,
mcversion: &str,
@ -24,7 +26,21 @@ pub async fn newest_forge_version(
Ok(resp.promos.remove(&format!("{}-latest", mcversion)))
}
/// Parses the strange forge version format found in modpack.json files to the
/// actual forge version number which is used in other places such as the twitch
/// manifest.
#[inline]
pub fn parse_version(version: &str) -> Option<&str> {
version.split('-').nth(1)
version.split('-').nth(1).map(|s| s.trim_end_matches(']'))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_version_valid() {
assert_eq!(parse_version("[1.16.5-420.69-1.16.5]"), Some("420.69"));
assert_eq!(parse_version("[1.16.5-420.69]"), Some("420.69"));
}
}

View File

@ -18,6 +18,8 @@ enum Command {
Init {
#[structopt(help = "The name of the modpack")]
modpack_name: String,
#[structopt(help = "Name of the modpack author")]
name: String,
#[structopt(help = "The minecraft version of the modpack")]
mcversion: String,
},
@ -29,6 +31,16 @@ enum Command {
#[structopt(short, long, help = "Download all relations and not just mods")]
all: bool,
},
#[structopt(name = "buildtwitch")]
BuildTwitch {
#[structopt(
short,
long,
help = "Downloads all relations instead of just required ones"
)]
all: bool,
},
}
#[tokio::main]
@ -38,12 +50,17 @@ async fn main() -> anyhow::Result<()> {
match cmd {
Command::Init {
modpack_name,
name,
mcversion,
} => commands::init::run(modpack_name, mcversion).await?,
} => commands::init::run(modpack_name, name, mcversion).await?,
Command::DownloadMods { dir, all } => {
commands::downloadmods::run(util::parse_config_and_manifest().await?, dir, all).await?
},
Command::BuildTwitch { all } => {
commands::buildtwitch::run(util::parse_config_and_manifest().await?, all).await?
},
}
Ok(())

View File

@ -1,17 +1,29 @@
use addonscript::manifest::{Manifest, Repository};
use anyhow::Context;
use crossterm::style::{Attribute, Attributes, Color, ContentStyle, Stylize};
use indicatif::ProgressStyle;
use std::path::Path;
use percent_encoding::percent_decode;
use std::{
collections::HashMap,
io,
path::{Path, PathBuf},
string::FromUtf8Error,
};
use thiserror::Error;
use url::Url;
use walkdir::WalkDir;
use crate::config::Config;
/// reads and parses the config from the current working directory
pub async fn parse_config() -> anyhow::Result<Config> {
let conf = tokio::fs::read("modpacktoolsconfig.toml").await?;
Ok(toml::from_slice(&conf)?)
}
/// parses the config from the current working directory, reads the location of
/// the manifest file from it, parses the manifest and returns both the conig
/// and the manifest.
pub async fn parse_config_and_manifest() -> anyhow::Result<(Config, Manifest)> {
let config = parse_config().await?;
let src = Path::new(&config.locations.src);
@ -29,20 +41,26 @@ pub async fn parse_config_and_manifest() -> anyhow::Result<(Config, Manifest)> {
Ok((config, manifest))
}
#[derive(Debug, Error)]
pub enum MvnArtifactUrlError {
#[derive(Debug, Error, PartialEq, Eq)]
pub enum MavenParseError {
#[error("Maven Artifact specifier has invalid format!")]
InvalidFormat,
#[error("Url parse error while processing maven artifact: {0}")]
UrlParseError(#[from] url::ParseError),
}
pub fn mvn_artifact_to_url(art: &str, repo: &Repository) -> Result<Url, MvnArtifactUrlError> {
/// parses a maven artifact specifier and generates a URL to the artifact in a
/// given maven repo.
pub fn mvn_artifact_to_url(art: &str, repo: &Repository) -> Result<Url, MavenParseError> {
let mut splits = art.split(':');
let group_id = splits.next().ok_or(MvnArtifactUrlError::InvalidFormat)?;
let artifact_id = splits.next().ok_or(MvnArtifactUrlError::InvalidFormat)?;
let version = splits.next().ok_or(MvnArtifactUrlError::InvalidFormat)?;
let group_id = splits.next().ok_or(MavenParseError::InvalidFormat)?;
let artifact_id = splits.next().ok_or(MavenParseError::InvalidFormat)?;
let version = splits.next().ok_or(MavenParseError::InvalidFormat)?;
if splits.count() != 0 {
return Err(MavenParseError::InvalidFormat);
}
let mut url = repo.url.clone();
if !url.path().ends_with('/') {
@ -59,6 +77,108 @@ pub fn mvn_artifact_to_url(art: &str, repo: &Repository) -> Result<Url, MvnArtif
Ok(url)
}
/// convenience trait to color strings with given preset styles for the
/// modpacktools CLI
pub trait CliStyle: Stylize {
/// formats the object with the style used for information messages
fn info(self) -> Self::Styled;
}
impl<T: Stylize> CliStyle for T
where
T::Styled: AsRef<ContentStyle> + AsMut<ContentStyle>,
{
fn info(self) -> T::Styled {
let mut s = self.stylize();
s.as_mut().foreground_color = Some(Color::Cyan);
s.as_mut().attributes = Attributes::from(Attribute::Italic);
s
}
}
/// the default style for progress bars
pub fn progress_style() -> ProgressStyle {
ProgressStyle::default_bar()
.template("{prefix:.bold} [{wide_bar:.green}] {pos}/{len}")
.progress_chars("█▇▆▅▄▃▂▁ ")
}
/// creates the modpacktools temporary directory (set in the config)
pub async fn make_tmp_dir(config: &Config) -> anyhow::Result<()> {
tokio::fs::create_dir_all(&config.locations.temp_dir)
.await
.context("Failed to create temporary directory")?;
Ok(())
}
/// Converts a vec of addonscript repositories into a map of the ID and the
/// repo.
#[inline]
pub fn repo_map(repos: Vec<Repository>) -> HashMap<String, Repository> {
repos.into_iter().map(|r| (r.id.clone(), r)).collect()
}
/// Parses a curseforge artifact found in modpack.json files into the project id
/// and file id.
pub fn parse_curseforge_artifact(artifact: &str) -> Result<(&str, &str), MavenParseError> {
let mut splits = artifact.split(':').skip(1);
let p_id = splits.next().ok_or(MavenParseError::InvalidFormat)?;
let f_id = splits.next().ok_or(MavenParseError::InvalidFormat)?;
if splits.count() != 0 {
return Err(MavenParseError::InvalidFormat);
}
Ok((p_id, f_id))
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum UrlFileNameError {
#[error("URL is base URL without file name!")]
BaseUrl,
#[error("URL file name is invalid utf8: {0}")]
InvalidUtf8(#[from] FromUtf8Error),
}
/// Gets the last path segment (file name) of a URL
pub fn url_file_name(url: &Url) -> Result<String, UrlFileNameError> {
let file_name = url
.path_segments()
.map(Iterator::last)
.flatten()
.ok_or(UrlFileNameError::BaseUrl)?;
if file_name.is_empty() {
return Err(UrlFileNameError::BaseUrl);
}
let bytes = percent_decode(file_name.as_bytes()).collect::<Vec<_>>();
Ok(String::from_utf8(bytes)?)
}
/// Copies a directory inclding all files
pub async fn copy_dir(from: PathBuf, to: PathBuf) -> io::Result<()> {
for file in WalkDir::new(&from) {
let file = file?;
let is_dir = file.path().is_dir();
let target = to.join(
file.path()
.strip_prefix(&from)
// this should be safe
.expect("Couldn't strip base path"),
);
if is_dir {
tokio::fs::create_dir_all(target).await?;
} else {
tokio::fs::copy(file.path(), target).await?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use addonscript::manifest::RepositoryType;
@ -75,32 +195,50 @@ mod tests {
#[test]
fn artifact_to_url_valid() {
let res = mvn_artifact_to_url("de.mzte:test:0.1", &repo()).unwrap();
assert_eq!(
res,
Url::parse("https://example.com/maven/de/mzte/test/0.1/test-0.1.jar").unwrap()
mvn_artifact_to_url("de.mzte:test:0.1", &repo()).unwrap(),
Url::parse("https://example.com/maven/de/mzte/test/0.1/test-0.1.jar").unwrap(),
);
assert_eq!(
mvn_artifact_to_url("test:test:test", &repo()).unwrap(),
Url::parse("https://example.com/maven/test/test/test/test-test.jar").unwrap(),
);
}
}
pub trait CliStyle: Stylize {
fn info(self) -> Self::Styled;
}
#[test]
fn artifact_to_url_invalid() {
assert!(mvn_artifact_to_url("test", &repo()).is_err());
assert!(mvn_artifact_to_url("test:test", &repo()).is_err());
assert!(mvn_artifact_to_url("test:test:test:test", &repo()).is_err());
}
impl<T: Stylize> CliStyle for T
where
T::Styled: AsRef<ContentStyle> + AsMut<ContentStyle>,
{
fn info(self) -> T::Styled {
let mut s = self.stylize();
s.as_mut().foreground_color = Some(Color::Cyan);
s.as_mut().attributes = Attributes::from(Attribute::Italic);
s
#[test]
fn url_file_name_valid() {
assert_eq!(
url_file_name(&Url::parse("https://example.com/test.txt").unwrap()),
Ok("test.txt".to_string())
);
assert_eq!(
url_file_name(&Url::parse("https://example.com/test").unwrap()),
Ok("test".to_string())
);
}
#[test]
fn url_file_name_invalid() {
assert!(url_file_name(&Url::parse("https://example.com").unwrap()).is_err());
}
#[test]
fn curse_artifact_valid() {
assert_eq!(parse_curseforge_artifact("x:a:b"), Ok(("a", "b")));
}
#[test]
fn curse_artifact_invalid() {
assert!(parse_curseforge_artifact("x").is_err());
assert!(parse_curseforge_artifact("x:x:x:x").is_err());
}
}
pub fn progress_style() -> ProgressStyle {
ProgressStyle::default_bar()
.template("{prefix:.bold} [{wide_bar:.green}] {pos}/{len}")
.progress_chars("█▇▆▅▄▃▂▁ ")
}

11
twitch/Cargo.toml Normal file
View File

@ -0,0 +1,11 @@
[package]
name = "twitch"
version = "0.1.0"
edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
addonscript = { path = "../addonscript" }
serde = { version = "1.0.130", features = ["derive"] }
thiserror = "1.0.28"

1
twitch/src/lib.rs Normal file
View File

@ -0,0 +1 @@
pub mod manifest;

78
twitch/src/manifest.rs Normal file
View File

@ -0,0 +1,78 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Manifest {
pub author: String,
pub files: Vec<File>,
pub manifest_type: ManifestType,
pub manifest_version: u8,
pub minecraft: Minecraft,
pub name: String,
pub overrides: String,
pub version: String,
}
impl Manifest {
/// Creates a twitch manifest with the given data.
/// Useful for converting from addonscript manifests
pub fn create(
files: Vec<(u32, u32)>,
contributors: &[String],
name: String,
version: String,
mcversion: String,
modloader: Option<String>,
) -> Self {
Self {
overrides: "overrides".to_owned(),
author: contributors.join(", "),
files: files
.into_iter()
.map(|f| File {
project_id: f.0,
file_id: f.1,
required: true,
})
.collect(),
manifest_type: ManifestType::MinecraftModpack,
manifest_version: 1,
minecraft: Minecraft {
version: mcversion,
mod_loaders: modloader
.map(|id| vec![ModLoader { id, primary: true }])
.unwrap_or_default(),
},
name,
version,
}
}
}
#[derive(Serialize, Deserialize, Debug)]
pub struct File {
#[serde(rename = "projectID")]
pub project_id: u32,
#[serde(rename = "fileID")]
pub file_id: u32,
pub required: bool,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub enum ManifestType {
MinecraftModpack,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Minecraft {
version: String,
mod_loaders: Vec<ModLoader>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct ModLoader {
pub id: String,
pub primary: bool,
}