commit dd97213de98bc8e4bf051daf2398d094dc046872 Author: LordMZTE Date: Sun Aug 29 15:14:10 2021 +0200 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2c96eb1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +target/ +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..e670b3d --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,5 @@ +[workspace] +members = [ + "mpt", + "addonscript", +] diff --git a/addonscript/Cargo.toml b/addonscript/Cargo.toml new file mode 100644 index 0000000..a3801da --- /dev/null +++ b/addonscript/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "addonscript" +version = "0.1.0" +edition = "2018" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +serde = { version = "1.0.129", features = ["derive"] } +thiserror = "1.0.26" +url = { version = "2.2.2", features = ["serde"] } diff --git a/addonscript/src/lib.rs b/addonscript/src/lib.rs new file mode 100644 index 0000000..d8f53ea --- /dev/null +++ b/addonscript/src/lib.rs @@ -0,0 +1,2 @@ +pub mod manifest; +pub mod util; diff --git a/addonscript/src/manifest/installer.rs b/addonscript/src/manifest/installer.rs new file mode 100644 index 0000000..ddbf3ac --- /dev/null +++ b/addonscript/src/manifest/installer.rs @@ -0,0 +1,96 @@ +use serde::{de::Visitor, Deserializer}; + +use std::{path::PathBuf, str::FromStr}; +use thiserror::Error; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug)] +pub enum Installer { + Override, + Dir(PathBuf), +} + +#[derive(Debug, Error)] +pub enum InstallerParseError { + #[error("Unknown Installer type")] + UnknownType, + #[error("Invalid installer arguments")] + InvalidArguments, + #[error("Invalid installer syntax")] + InvalidSyntax, +} + +impl FromStr for Installer { + type Err = InstallerParseError; + + fn from_str(s: &str) -> Result { + let mut splits = s.split(':'); + let installer_type = splits.next().ok_or(InstallerParseError::InvalidSyntax)?; + + match installer_type { + "internal.override" => { + if splits.count() == 0 { + Ok(Installer::Override) + } else { + Err(InstallerParseError::InvalidArguments) + } + }, + "internal.dir" => { + let dir = splits.next().ok_or(InstallerParseError::InvalidArguments)?; + let dir = PathBuf::from(dir); + + if splits.count() == 0 { + Ok(Installer::Dir(dir)) + } else { + Err(InstallerParseError::InvalidArguments) + } + }, + _ => Err(InstallerParseError::UnknownType), + } + } +} + +impl ToString for Installer { + fn to_string(&self) -> String { + match self { + Installer::Override => String::from("internal.override"), + Installer::Dir(dir) => format!("internal.dir:{}", dir.to_string_lossy()), + } + } +} + +impl Serialize for Installer { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for Installer { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct Vis; + impl<'de> Visitor<'de> for Vis { + type Value = Installer; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("an AddonScript installer") + } + + fn visit_str(self, v: &str) -> Result + where + E: serde::de::Error, + { + v.parse::() + .map_err(|e| E::custom(format!("invalid installer: {}", e))) + } + } + + deserializer.deserialize_str(Vis) + } +} diff --git a/addonscript/src/manifest/link.rs b/addonscript/src/manifest/link.rs new file mode 100644 index 0000000..69f771a --- /dev/null +++ b/addonscript/src/manifest/link.rs @@ -0,0 +1,81 @@ +use serde::{de::Visitor, Deserialize, Serialize}; +use std::str::FromStr; +use thiserror::Error; +use url::Url; + +use std::path::PathBuf; + +#[derive(Debug)] +pub enum Link { + File(PathBuf), + Http(Url), +} + +#[derive(Debug, Error)] +pub enum LinkParseError { + #[error("Unknown protocol")] + UnkownProtocol, + #[error("Error parsing url: {0}")] + UrlParseError(url::ParseError), +} + +impl FromStr for Link { + type Err = LinkParseError; + + fn from_str(s: &str) -> Result { + if s.starts_with("file://") { + Ok(Link::File(PathBuf::from(s.trim_start_matches("file://")))) + } else if s.starts_with("http://") || s.starts_with("https://") { + s.parse::() + .map(Link::Http) + .map_err(LinkParseError::UrlParseError) + } else { + Err(LinkParseError::UnkownProtocol) + } + } +} + +impl ToString for Link { + fn to_string(&self) -> String { + match self { + Link::File(path) => format!("file://{}", path.to_string_lossy()), + Link::Http(url) => url.to_string(), + } + } +} + +impl Serialize for Link { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for Link { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct Vis; + impl<'de> Visitor<'de> for Vis { + type Value = Link; + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str( + "either a (somewhat invalid) relative file:// url or a http/https url", + ) + } + + fn visit_str(self, v: &str) -> Result + where + E: serde::de::Error, + { + v.parse::() + .map_err(|e| E::custom(format!("invalid link: {}", e))) + } + } + + deserializer.deserialize_str(Vis) + } +} diff --git a/addonscript/src/manifest/mod.rs b/addonscript/src/manifest/mod.rs new file mode 100644 index 0000000..a6c0b80 --- /dev/null +++ b/addonscript/src/manifest/mod.rs @@ -0,0 +1,100 @@ +use crate::manifest::{installer::Installer, link::Link}; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use url::Url; + +pub mod installer; +pub mod link; + +#[derive(Deserialize, Serialize, Debug)] +pub struct Manifest { + pub id: String, + #[serde(rename = "type")] + pub manifest_type: ManifestType, + pub versions: Vec, + pub repositories: Vec, +} + +#[derive(Deserialize, Serialize, Debug)] +pub struct Repository { + pub id: String, + #[serde(rename = "type")] + pub repo_type: RepositoryType, + pub url: Url, +} + +#[derive(Deserialize, Serialize, Debug)] +#[serde(rename_all = "lowercase")] +pub enum RepositoryType { + Maven, + Curseforge, +} + +#[derive(Deserialize, Serialize, Debug)] +pub struct Version { + pub version: String, + pub mcversion: Vec, + #[serde(default)] + pub files: Vec, + #[serde(default)] + pub relations: Vec, +} + +#[derive(Deserialize, Serialize, Debug)] +pub struct Relation { + pub id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub file: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub versions: Option, + #[serde(rename = "type")] + pub relation_type: RelationType, + pub options: HashSet, +} + +#[derive(Deserialize, Serialize, Debug)] +#[serde(rename_all = "lowercase")] +pub enum RelationType { + Mod, + Modloader, +} + +#[derive(Deserialize, Serialize, Debug, Hash, Eq, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum FileOpt { + Required, + Client, + Server, + Included, +} + +#[derive(Deserialize, Serialize, Debug)] +#[serde(untagged)] +pub enum File { + Link { + id: Option, + installer: Installer, + link: Link, + options: Option>, + }, + Maven { + installer: Installer, + artifact: String, + repository: String, + }, +} + +impl File { + pub fn installer(&self) -> &Installer { + match self { + File::Link { installer, .. } => installer, + File::Maven { installer, .. } => installer, + } + } +} + +#[derive(Deserialize, Serialize, Debug)] +#[serde(rename_all = "lowercase")] +pub enum ManifestType { + Modpack, +} diff --git a/addonscript/src/util.rs b/addonscript/src/util.rs new file mode 100644 index 0000000..d593c7b --- /dev/null +++ b/addonscript/src/util.rs @@ -0,0 +1,14 @@ +use std::collections::HashSet; + +use crate::manifest::FileOpt; + +pub fn default_file_opts() -> HashSet { + let mut set = HashSet::with_capacity(4); + + set.insert(FileOpt::Client); + set.insert(FileOpt::Server); + set.insert(FileOpt::Required); + set.insert(FileOpt::Included); + + set +} diff --git a/mpt/Cargo.toml b/mpt/Cargo.toml new file mode 100644 index 0000000..80278ab --- /dev/null +++ b/mpt/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "mpt" +version = "0.1.0" +edition = "2018" + +[[bin]] +name = "lmpt" +path = "src/main.rs" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +addonscript = { path = "../addonscript"} +anyhow = "1.0.43" +crossterm = "0.21.0" +futures = "0.3.16" +indicatif = "0.16.2" +json5 = "0.3.0" +reqwest = { version = "0.11.4", features = ["stream"] } +serde = { version = "1.0.129", features = ["derive"] } +serde_json = "1.0.67" +structopt = "0.3.22" +thiserror = "1.0.28" +tokio = { version = "1.10.1", features = ["rt-multi-thread", "macros", "fs"] } +toml = "0.5.8" +url = "2.2.2" diff --git a/mpt/assets/modpacktoolsconfig.toml b/mpt/assets/modpacktoolsconfig.toml new file mode 100644 index 0000000..e0d52f3 --- /dev/null +++ b/mpt/assets/modpacktoolsconfig.toml @@ -0,0 +1,11 @@ +[Locations] +# The location of the source +src = "src" +# The location used to store temporary files +tempDir = ".mpt" + +[Downloads] +# The maximum number of threads that will be used for downloads +maxThreads = 5 +# The timeout of http requests in ms +httpTimeout = 30000 diff --git a/mpt/src/commands/downloadmods.rs b/mpt/src/commands/downloadmods.rs new file mode 100644 index 0000000..7be016c --- /dev/null +++ b/mpt/src/commands/downloadmods.rs @@ -0,0 +1,201 @@ +use crate::{config::Config, util::mvn_artifact_to_url}; +use addonscript::manifest::{ + installer::Installer, + link::Link, + File, + FileOpt, + Manifest, + Relation, + Repository, + RepositoryType, +}; +use anyhow::{bail, Context}; +use crossterm::style::{Attribute, Color, Stylize}; +use futures::stream::{self, StreamExt}; +use indicatif::ProgressBar; +use reqwest::{Client, StatusCode}; +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; +use tokio::{fs::File as TokioFile, io::AsyncWriteExt}; +use url::Url; + +pub async fn run( + (config, manifest): (Config, Manifest), + dir: PathBuf, + all: bool, +) -> anyhow::Result<()> { + let http = Arc::new(Client::new()); + let Manifest { + mut versions, + repositories, + .. + } = manifest; + let repositories = Arc::new(repositories); + + let futs = versions + .pop() + .context("Manifest has no versions!")? + .relations + .into_iter() + .filter(|rel| { + rel.options.contains(&FileOpt::Client) + && rel + .file + .as_ref() + .map(|f| { + matches!(f.installer(), Installer::Dir(dir) if all || dir == Path::new("mods")) + }) + .unwrap_or(false) + }) + .map(|rel| download_file(Arc::clone(&http), Arc::clone(&repositories), dir.clone(), all, rel)) + .collect::>(); + + let pb = ProgressBar::new(futs.len() as u64); + let mut futs = stream::iter(futs).buffer_unordered(config.downloads.max_threads as usize); + + while let Some(res) = futs.next().await { + match res { + Ok(i) => match i { + DownloadInfo::Local { from, to } => pb.println(format!( + "{} {} {} {}", + "Copied local file".with(Color::Green), + from.with(Color::Cyan).attribute(Attribute::Bold), + "to".with(Color::Green), + to.with(Color::Cyan).attribute(Attribute::Bold), + )), + DownloadInfo::Http { status, from, to } => pb.println(format!( + "{} {} {} {} {} {}", + "Downloaded file".with(Color::Green), + from.with(Color::Cyan).attribute(Attribute::Bold), + "to".with(Color::Green), + to.with(Color::Cyan).attribute(Attribute::Bold), + "with status".with(Color::Green), + status + .to_string() + .with(Color::Cyan) + .attribute(Attribute::Bold), + )), + }, + Err(e) => { + pb.println(format!( + "{} {:?}", + "Error downloading file:" + .with(Color::Red) + .attribute(Attribute::Bold), + e, + )); + }, + } + pb.inc(1); + } + + Ok(()) +} + +enum DownloadInfo { + Local { + from: String, + to: String, + }, + Http { + status: StatusCode, + from: String, + to: String, + }, +} + +async fn download_file( + http: Arc, + repos: Arc>, + mut target_dir: PathBuf, + all: bool, + rel: Relation, +) -> anyhow::Result { + let link; + let link = match &rel.file { + Some(File::Link { link, .. }) => link, + Some(File::Maven { + artifact, + repository, + .. + }) => { + if let Some(repo) = repos.iter().find(|r| &r.id == repository) { + match repo.repo_type { + RepositoryType::Maven => { + link = Link::Http(mvn_artifact_to_url(&artifact, &repo)?); + &link + }, + 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 url = format!( + "https://addons-ecs.forgesvc.net/api/v2/addon/{}/file/{}/download-url", + p_id, f_id + ); + + link = Link::Http( + Url::parse(http.get(url).send().await?.text().await?.trim()) + .context("failed to parse curseforge URL")?, + ); + &link + }, + } + } else { + bail!("Rel {:?} references non-existant repository!") + } + }, + None => bail!("Rel {:?} has no file!", rel), + }; + + if let (true, Installer::Dir(d)) = (all, rel.file.as_ref().unwrap().installer()) { + target_dir.push(d); + } + + tokio::fs::create_dir_all(&target_dir).await?; + + match link { + Link::File(f) => { + let to = target_dir.join(f.file_name().context("File to copy is not a file!")?); + tokio::fs::copy( + f, + target_dir.join(f.file_name().context("File to copy is not a file!")?), + ) + .await + .context("Failed to install file with file link.")?; + + Ok(DownloadInfo::Local { + from: f.to_string_lossy().to_string(), + to: to.to_string_lossy().to_string(), + }) + }, + Link::Http(l) => { + let res = http.get(l.clone()).send().await?; + let p = target_dir.join( + Path::new(l.path()) + .file_name() + .context("HTTP File has no file name!")?, + ); + let mut file = TokioFile::create(&p).await?; + let status = res.status(); + let mut stream = res.bytes_stream(); + while let Some(b) = stream.next().await { + file.write_all_buf(&mut b?).await?; + } + + Ok(DownloadInfo::Http { + status, + from: l.to_string(), + to: p.to_string_lossy().to_string(), + }) + }, + } +} diff --git a/mpt/src/commands/init.rs b/mpt/src/commands/init.rs new file mode 100644 index 0000000..c8934cf --- /dev/null +++ b/mpt/src/commands/init.rs @@ -0,0 +1,168 @@ +use crate::{config::Locations, forge}; +use addonscript::{ + manifest::{ + installer::Installer, + link::Link, + File, + Manifest, + ManifestType, + Relation, + RelationType, + Repository, + RepositoryType, + Version, + }, + util::default_file_opts, +}; +use anyhow::Context; +use crossterm::{ + execute, + style::{ + Attribute, + Color, + Print, + PrintStyledContent, + ResetColor, + SetAttribute, + SetForegroundColor, + Stylize, + }, + ExecutableCommand, +}; +use reqwest::Client; +use std::path::Path; +use url::Url; + +use crate::config::Config; + +const DEFAULT_CONFIG: &[u8] = include_bytes!("../../assets/modpacktoolsconfig.toml"); + +pub async fn run(modpack_name: String, mcversion: String) -> anyhow::Result<()> { + let mut stdout = std::io::stdout(); + + execute!( + stdout, + SetForegroundColor(Color::Green), + Print("Creating modpack "), + SetForegroundColor(Color::Cyan), + SetAttribute(Attribute::Bold), + Print(&modpack_name), + ResetColor, + SetForegroundColor(Color::Green), + Print(" on minecraft version "), + SetForegroundColor(Color::Cyan), + SetAttribute(Attribute::Bold), + Print(&mcversion), + ResetColor, + Print('\n'), + )?; + + let config_path = Path::new("modpacktoolsconfig.toml"); + if !config_path.exists() { + tokio::fs::write(config_path, DEFAULT_CONFIG).await?; + + stdout.execute(PrintStyledContent( + "Created config!\n" + .with(Color::Green) + .attribute(Attribute::Bold), + ))?; + } else { + stdout.execute(PrintStyledContent( + "Config already exists, skipping...\n" + .with(Color::Red) + .attribute(Attribute::Italic), + ))?; + } + + let config = tokio::fs::read(config_path).await?; + let Config { + locations: Locations { src, .. }, + .. + } = toml::from_slice::(&config).context("failed to deserialize config")?; + + let path = Path::new(&src); + + if path.join("modpack.json").exists() || path.join("modpack.json5").exists() { + stdout.execute(PrintStyledContent( + "Manifest already exists, skipping...\n" + .with(Color::Red) + .attribute(Attribute::Italic), + ))?; + } else { + let mut relations = vec![]; + stdout.execute(PrintStyledContent( + "Trying to find newest forge version...\n".with(Color::Magenta), + ))?; + + if let Some(ver) = forge::newest_forge_version(&Client::new(), &mcversion).await? { + execute!( + stdout, + SetForegroundColor(Color::Green), + Print("Found newest forge version "), + SetForegroundColor(Color::Cyan), + SetAttribute(Attribute::Bold), + Print(&ver), + ResetColor, + Print('\n'), + )?; + + relations.push(Relation { + id: "forge".into(), + file: None, + relation_type: RelationType::Modloader, + options: default_file_opts(), + versions: Some(format!( + "[{mcver}-{forgever}-{mcver}]", + mcver = mcversion, + forgever = ver, + )), + }) + } else { + execute!( + stdout, + SetForegroundColor(Color::Red), + Print("Couldn't find forge version for minecraft "), + SetAttribute(Attribute::Bold), + SetForegroundColor(Color::Cyan), + Print(&mcversion), + ResetColor, + SetForegroundColor(Color::Red), + Print(" skipping forge...\n"), + ResetColor, + )?; + } + + tokio::fs::create_dir_all(path).await?; + + let data = serde_json::to_vec_pretty(&Manifest { + id: modpack_name, + manifest_type: ManifestType::Modpack, + versions: vec![Version { + version: "1.0".into(), + mcversion: vec![mcversion], + files: vec![File::Link { + id: Some("overrides".into()), + link: Link::File("overrides".into()), + installer: Installer::Override, + options: Some(default_file_opts()), + }], + relations, + }], + repositories: vec![Repository { + id: "curseforge".into(), + repo_type: RepositoryType::Curseforge, + url: Url::parse("https://cursemaven.com").unwrap(), // unwrap is ok on fixed value + }], + })?; + + tokio::fs::write(path.join("modpack.json5"), data).await?; + + stdout.execute(PrintStyledContent( + "Created manifest!\n" + .with(Color::Green) + .attribute(Attribute::Bold), + ))?; + } + + Ok(()) +} diff --git a/mpt/src/commands/mod.rs b/mpt/src/commands/mod.rs new file mode 100644 index 0000000..d28e804 --- /dev/null +++ b/mpt/src/commands/mod.rs @@ -0,0 +1,2 @@ +pub mod downloadmods; +pub mod init; diff --git a/mpt/src/config.rs b/mpt/src/config.rs new file mode 100644 index 0000000..ad81e79 --- /dev/null +++ b/mpt/src/config.rs @@ -0,0 +1,22 @@ +use serde::Deserialize; + +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct Config { + pub locations: Locations, + pub downloads: Downloads, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Locations { + pub src: String, + pub temp_dir: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Downloads { + pub max_threads: u16, + pub http_timeout: u32, +} diff --git a/mpt/src/forge.rs b/mpt/src/forge.rs new file mode 100644 index 0000000..6d4fbd2 --- /dev/null +++ b/mpt/src/forge.rs @@ -0,0 +1,30 @@ +use std::collections::HashMap; + +use reqwest::Client; +use serde::Deserialize; + +#[derive(Deserialize)] +struct ForgeVersionResponse { + promos: HashMap, +} + +pub async fn newest_forge_version( + http: &Client, + mcversion: &str, +) -> anyhow::Result> { + let resp = http + .get("https://files.minecraftforge.net/net/minecraftforge/forge/promotions_slim.json") + .send() + .await? + .bytes() + .await?; + + let mut resp = json5::from_str::(std::str::from_utf8(&resp)?)?; + + Ok(resp.promos.remove(&format!("{}-latest", mcversion))) +} + +#[inline] +pub fn parse_version(version: &str) -> Option<&str> { + version.split('-').nth(1) +} diff --git a/mpt/src/main.rs b/mpt/src/main.rs new file mode 100644 index 0000000..75836d1 --- /dev/null +++ b/mpt/src/main.rs @@ -0,0 +1,49 @@ +use std::path::PathBuf; +use structopt::StructOpt; + +mod commands; +mod config; +mod forge; +mod util; + +#[derive(StructOpt)] +struct Opt { + #[structopt(subcommand)] + cmd: Command, +} + +#[derive(StructOpt)] +enum Command { + Init { + #[structopt(help = "The name of the modpack")] + modpack_name: String, + #[structopt(help = "The minecraft version of the modpack")] + mcversion: String, + }, + + #[structopt(name = "downloadmods")] + DownloadMods { + #[structopt(help = "Directory to download mods to")] + dir: PathBuf, + #[structopt(short, long, help = "Download all relations and not just mods")] + all: bool, + }, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let Opt { cmd } = Opt::from_args(); + + match cmd { + Command::Init { + modpack_name, + mcversion, + } => commands::init::run(modpack_name, mcversion).await?, + + Command::DownloadMods { dir, all } => { + commands::downloadmods::run(util::parse_config_and_manifest().await?, dir, all).await? + }, + } + + Ok(()) +} diff --git a/mpt/src/util.rs b/mpt/src/util.rs new file mode 100644 index 0000000..eecacea --- /dev/null +++ b/mpt/src/util.rs @@ -0,0 +1,82 @@ +use addonscript::manifest::{Manifest, Repository}; +use std::path::Path; +use thiserror::Error; +use url::Url; + +use crate::config::Config; + +pub async fn parse_config() -> anyhow::Result { + let conf = tokio::fs::read("modpacktoolsconfig.toml").await?; + Ok(toml::from_slice(&conf)?) +} + +pub async fn parse_config_and_manifest() -> anyhow::Result<(Config, Manifest)> { + let config = parse_config().await?; + let src = Path::new(&config.locations.src); + + let path = if src.join("modpack.json5").exists() { + src.join("modpack.json5") + } else { + src.join("modpack.json") + }; + + let data = tokio::fs::read(path).await?; + let data = std::str::from_utf8(&data)?; + let manifest = json5::from_str::(data)?; + + Ok((config, manifest)) +} + +#[derive(Debug, Error)] +pub enum MvnArtifactUrlError { + #[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 { + 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 mut url = repo.url.clone(); + if !url.path().ends_with('/') { + url.set_path(&format!("{}/", repo.url.path())); + } + + let url = url.join(&format!( + "{gid}/{aid}/{v}/{aid}-{v}.jar", + gid = group_id.replace('.', "/"), + aid = artifact_id, + v = version + ))?; + + Ok(url) +} + +#[cfg(test)] +mod tests { + use addonscript::manifest::RepositoryType; + + use super::*; + + fn repo() -> Repository { + Repository { + id: "test".into(), + repo_type: RepositoryType::Maven, + url: Url::parse("https://example.com/maven").unwrap(), + } + } + + #[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() + ); + } +} diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..1059111 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,12 @@ +unstable_features = true +binop_separator = "Back" +format_code_in_doc_comments = true +format_macro_matchers = true +format_strings = true +imports_layout = "HorizontalVertical" +match_block_trailing_comma = true +merge_imports = true +normalize_comments = true +use_field_init_shorthand = true +use_try_shorthand = true +wrap_comments = true