use std::{collections::HashSet, path::Path};
use serde::Serialize;
use crate::{
Error,
config::Config,
parser::{pack::Pack, packwiz::PackwizMod},
request::{Author, License, Mod},
};
pub const SCHEMA_VERSION: u32 = 1;
#[derive(Serialize, Debug)]
pub struct Document<'a> {
pub schema_version: u32,
pub pack: &'a Pack,
pub mods: &'a [PackwizMod],
pub projects: Vec<ProjectEntry<'a>>,
pub settings: Settings<'a>,
}
#[derive(Serialize, Debug)]
pub enum ProjectEntry<'a> {
Modrinth(ProjectView<'a>),
CurseForge(ProjectView<'a>),
}
#[derive(Serialize, Debug)]
pub struct ProjectView<'a> {
pub id: &'a str,
pub slug: &'a str,
pub title: &'a str,
pub description: &'a str,
pub mod_url: &'a str,
pub license: Option<&'a License>,
pub authors: &'a [Author],
pub icon_url: Option<&'a str>,
pub source_url: Option<&'a str>,
pub issues_url: Option<&'a str>,
pub wiki_url: Option<&'a str>,
}
impl<'a> From<&'a Mod> for ProjectView<'a> {
fn from(m: &'a Mod) -> Self {
Self {
id: &m.id,
slug: &m.slug,
title: &m.title,
description: &m.description,
mod_url: &m.mod_url,
license: m.license.as_ref(),
authors: &m.authors,
icon_url: m.icon_url.as_deref(),
source_url: m.source_url.as_deref(),
issues_url: m.issues_url.as_deref(),
wiki_url: m.wiki_url.as_deref(),
}
}
}
#[derive(Serialize, Debug)]
pub struct Settings<'a> {
pub format: Option<&'a str>,
pub output: Option<&'a Path>,
pub path: Option<&'a Path>,
}
impl<'a> Document<'a> {
pub fn new(
pack: &'a Pack,
config: &'a Config,
mods: &'a [PackwizMod],
projects: &'a [Mod],
) -> Self {
let modrinth_ids: HashSet<&str> = mods
.iter()
.filter_map(|m| m.update.modrinth.as_ref())
.map(|update| update.mod_id.as_str())
.collect();
let projects = projects
.iter()
.map(|m| {
if modrinth_ids.contains(m.id.as_str()) {
ProjectEntry::Modrinth(m.into())
} else {
ProjectEntry::CurseForge(m.into())
}
})
.collect();
Self {
schema_version: SCHEMA_VERSION,
pack,
mods,
projects,
settings: Settings {
format: config.format.as_deref(),
output: config.output.as_deref(),
path: config.path.as_deref(),
},
}
}
pub fn to_json_pretty(&self) -> Result<String, Error> {
Ok(serde_json::to_string_pretty(self)?)
}
}
pub fn generate_document(
pack: &Pack,
config: &Config,
mods: &[PackwizMod],
projects: &[Mod],
) -> Result<String, Error> {
Document::new(pack, config, mods, projects).to_json_pretty()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::packwiz::{
PackwizDownload, PackwizMod, PackwizModUpdate, PackwizModUpdateCurseforge,
PackwizModUpdateModrinth,
};
fn sample_pack() -> Pack {
toml::from_str(
r#"
name = "Create Prime"
author = "minecraft_steve"
version = "1.0.0"
pack-format = "packwiz:1.1.0"
[index]
file = "index.toml"
hash = "deadbeef"
[versions]
minecraft = "1.21.1"
neoforge = "21.1.249"
"#,
)
.expect("sample should parse")
}
fn modrinth_pw_mod() -> PackwizMod {
PackwizMod {
name: "Sodium".into(),
filename: "sodium-neoforge-0.8.13.jar".into(),
side: Some("client".into()),
download: PackwizDownload {
url: Some("https://cdn.modrinth.com/data/AANobbMI/x.jar".into()),
hash: "4f5376".into(),
hash_format: "sha512".into(),
mode: None,
},
update: PackwizModUpdate {
modrinth: Some(PackwizModUpdateModrinth {
mod_id: "AANobbMI".into(),
version: "uMOpc5uV".into(),
}),
curseforge: None,
},
category: Some("mods".into()),
path: "mods/sodium.pw.toml".into(),
}
}
fn curseforge_pw_mod() -> PackwizMod {
PackwizMod {
name: "FTB Teams".into(),
filename: "ftb-teams-neoforge.jar".into(),
side: Some("both".into()),
download: PackwizDownload {
url: None,
hash: "62b65f".into(),
hash_format: "sha1".into(),
mode: Some("metadata:curseforge".into()),
},
update: PackwizModUpdate {
modrinth: None,
curseforge: Some(PackwizModUpdateCurseforge {
file_id: 8724782,
project_id: 404468,
}),
},
category: Some("mods".into()),
path: "mods/ftb-teams.pw.toml".into(),
}
}
fn project(id: &str, slug: &str, title: &str) -> Mod {
Mod {
id: id.into(),
slug: slug.into(),
title: title.into(),
description: "A mod.".into(),
mod_url: format!("https://example.invalid/{slug}"),
license: None,
authors: vec![Author {
name: "someone".into(),
url: "https://example.invalid/u".into(),
}],
icon_url: None,
source_url: None,
issues_url: None,
wiki_url: None,
}
}
fn render(mods: &[PackwizMod], projects: &[Mod]) -> serde_json::Value {
let json = generate_document(&sample_pack(), &Config::default(), mods, projects)
.expect("should serialize");
serde_json::from_str(&json).expect("the export should be valid json")
}
#[test]
fn renders_the_document_shape() {
let doc = render(&[modrinth_pw_mod()], &[project(
"AANobbMI", "sodium", "Sodium",
)]);
assert_eq!(doc["schema_version"], 1);
assert_eq!(doc["pack"]["name"], "Create Prime");
assert_eq!(doc["pack"]["pack_format"], "packwiz:1.1.0");
assert_eq!(doc["pack"]["versions"]["minecraft"], "1.21.1");
assert_eq!(doc["pack"]["versions"]["neoforge"], "21.1.249");
assert_eq!(doc["mods"][0]["filename"], "sodium-neoforge-0.8.13.jar");
assert_eq!(doc["mods"][0]["side"], "client");
assert_eq!(doc["mods"][0]["download"]["hash_format"], "sha512");
assert_eq!(doc["mods"][0]["update"]["modrinth"]["mod_id"], "AANobbMI");
assert_eq!(doc["projects"][0]["Modrinth"]["slug"], "sodium");
assert_eq!(
doc["projects"][0]["Modrinth"]["mod_url"],
"https://example.invalid/sodium"
);
}
#[test]
fn unused_loaders_are_null_rather_than_missing() {
let doc = render(&[], &[]);
let versions = doc["pack"]["versions"]
.as_object()
.expect("versions should be an object");
assert!(versions["fabric"].is_null());
assert!(versions["forge"].is_null());
assert!(versions["quilt"].is_null());
assert_eq!(versions["neoforge"], "21.1.249");
let keys: Vec<&str> = versions.keys().map(String::as_str).collect();
assert_eq!(keys, ["fabric", "forge", "minecraft", "neoforge", "quilt"]);
}
#[test]
fn projects_are_tagged_by_source() {
let doc = render(&[modrinth_pw_mod(), curseforge_pw_mod()], &[
project("AANobbMI", "sodium", "Sodium"),
project("404468", "ftb-teams", "FTB Teams"),
]);
assert_eq!(doc["projects"][0]["Modrinth"]["slug"], "sodium");
assert!(doc["projects"][0]["CurseForge"].is_null());
assert_eq!(doc["projects"][1]["CurseForge"]["slug"], "ftb-teams");
assert!(doc["projects"][1]["Modrinth"].is_null());
}
#[test]
fn a_configured_api_key_never_reaches_the_export() {
let config: Config = toml::from_str(
r#"
format = "{NAME}"
[secrets]
cf-api-key = "super-secret-value"
"#,
)
.expect("config should parse");
let key = config
.secrets
.cf_api_key
.as_ref()
.expect("the sample key should have been loaded");
let json = generate_document(&sample_pack(), &config, &[], &[]).expect("should serialize");
assert!(
!json.contains(key.expose()),
"the raw key reached the export"
);
assert!(
!json.contains(&key.fingerprint()),
"a fingerprint of the key reached the export"
);
let doc: serde_json::Value = serde_json::from_str(&json).expect("valid json");
let fields: Vec<&str> = doc["settings"]
.as_object()
.expect("settings should be an object")
.keys()
.map(String::as_str)
.collect();
assert_eq!(fields, ["format", "output", "path"]);
}
}