diff --git a/Cargo.toml b/Cargo.toml index e670b3d..988cc74 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ - "mpt", "addonscript", + "mpt", + "twitch", ] diff --git a/addonscript/src/manifest/mod.rs b/addonscript/src/manifest/mod.rs index 6e04ffb..e304851 100644 --- a/addonscript/src/manifest/mod.rs +++ b/addonscript/src/manifest/mod.rs @@ -13,6 +13,19 @@ pub struct Manifest { pub manifest_type: ManifestType, pub versions: Vec, pub repositories: Vec, + pub meta: Meta, +} + +#[derive(Deserialize, Serialize, Debug)] +pub struct Meta { + pub name: String, + pub contributors: Vec, +} + +#[derive(Deserialize, Serialize, Debug)] +pub struct Contributor { + pub name: String, + pub roles: Vec, } #[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, diff --git a/mpt/Cargo.toml b/mpt/Cargo.toml index ac86ed6..8bd0c64 100644 --- a/mpt/Cargo.toml +++ b/mpt/Cargo.toml @@ -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" diff --git a/mpt/src/commands/buildtwitch.rs b/mpt/src/commands/buildtwitch.rs new file mode 100644 index 0000000..df9a7f0 --- /dev/null +++ b/mpt/src/commands/buildtwitch.rs @@ -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, + ) -> CallbackStatus { + 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::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::>(), + 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, + 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(()) +} diff --git a/mpt/src/commands/downloadmods.rs b/mpt/src/commands/downloadmods.rs index c048242..8b0c44c 100644 --- a/mpt/src/commands/downloadmods.rs +++ b/mpt/src/commands/downloadmods.rs @@ -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::::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", diff --git a/mpt/src/commands/init.rs b/mpt/src/commands/init.rs index c8934cf..1798edb 100644 --- a/mpt/src/commands/init.rs +++ b/mpt/src/commands/init.rs @@ -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?; diff --git a/mpt/src/commands/mod.rs b/mpt/src/commands/mod.rs index d28e804..a9917b5 100644 --- a/mpt/src/commands/mod.rs +++ b/mpt/src/commands/mod.rs @@ -1,2 +1,3 @@ +pub mod buildtwitch; pub mod downloadmods; pub mod init; diff --git a/mpt/src/config.rs b/mpt/src/config.rs index 95765dd..0fcb305 100644 --- a/mpt/src/config.rs +++ b/mpt/src/config.rs @@ -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)] diff --git a/mpt/src/downloader.rs b/mpt/src/downloader.rs index 626ad24..6c59e2b 100644 --- a/mpt/src/downloader.rs +++ b/mpt/src/downloader.rs @@ -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 { + /// 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, + /// 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, } impl Downloader { + /// 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 Downloader { callback.on_completed(stop_info).await } + /// Downloads a single file. Used intenally by the Downloader async fn download_one( client: Arc, url: Url, @@ -69,17 +80,37 @@ impl Downloader { } } +/// 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 { 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, ) -> CallbackStatus; + /// 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::EndRes; } diff --git a/mpt/src/forge.rs b/mpt/src/forge.rs index 6d4fbd2..2f984ea 100644 --- a/mpt/src/forge.rs +++ b/mpt/src/forge.rs @@ -8,6 +8,8 @@ struct ForgeVersionResponse { promos: HashMap, } +/// 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")); + } } diff --git a/mpt/src/main.rs b/mpt/src/main.rs index 7a932a2..121244e 100644 --- a/mpt/src/main.rs +++ b/mpt/src/main.rs @@ -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(()) diff --git a/mpt/src/util.rs b/mpt/src/util.rs index 4f42485..b635640 100644 --- a/mpt/src/util.rs +++ b/mpt/src/util.rs @@ -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 { 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 { +/// 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 { 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 Self::Styled; +} + +impl CliStyle for T +where + T::Styled: AsRef + AsMut, +{ + 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) -> HashMap { + 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 { + 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::>(); + 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 CliStyle for T -where - T::Styled: AsRef + AsMut, -{ - 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("█▇▆▅▄▃▂▁ ") -} diff --git a/twitch/Cargo.toml b/twitch/Cargo.toml new file mode 100644 index 0000000..b56108f --- /dev/null +++ b/twitch/Cargo.toml @@ -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" diff --git a/twitch/src/lib.rs b/twitch/src/lib.rs new file mode 100644 index 0000000..640fc64 --- /dev/null +++ b/twitch/src/lib.rs @@ -0,0 +1 @@ +pub mod manifest; diff --git a/twitch/src/manifest.rs b/twitch/src/manifest.rs new file mode 100644 index 0000000..7ac1b60 --- /dev/null +++ b/twitch/src/manifest.rs @@ -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, + 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, + ) -> 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, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct ModLoader { + pub id: String, + pub primary: bool, +}