use crate::{
chunk::{NO_LAYER_INDEX, NO_WAD_INDEX},
error::ModpkgError,
license::ModpkgLicense,
Modpkg,
};
use semver::Version;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io::{Cursor, Read, Seek, Write};
pub const METADATA_CHUNK_PATH: &str = "_meta_/info.msgpack";
impl<TSource: Read + Seek> Modpkg<TSource> {
pub fn load_metadata(&mut self) -> Result<ModpkgMetadata, ModpkgError> {
let chunk = *self.get_chunk(METADATA_CHUNK_PATH, None)?;
if chunk.layer_index != NO_LAYER_INDEX || chunk.wad_index != NO_WAD_INDEX {
return Err(ModpkgError::InvalidMetaChunk);
}
ModpkgMetadata::read(&mut Cursor::new(self.load_chunk_decompressed(&chunk)?))
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(test, derive(proptest_derive::Arbitrary))]
pub struct DistributorInfo {
pub site_id: String,
pub site_name: String,
pub site_url: String,
pub mod_id: String,
}
impl DistributorInfo {
pub fn new(site_id: String, site_name: String, site_url: String, mod_id: String) -> Self {
Self {
site_id,
site_name,
site_url,
mod_id,
}
}
pub fn site_id(&self) -> &str {
&self.site_id
}
pub fn site_name(&self) -> &str {
&self.site_name
}
pub fn site_url(&self) -> &str {
&self.site_url
}
pub fn mod_id(&self) -> &str {
&self.mod_id
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(test, derive(proptest_derive::Arbitrary))]
pub struct ModpkgLayerMetadata {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
pub priority: i32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
#[cfg_attr(
test,
proptest(strategy = "proptest::collection::hash_map(\
\"[a-z]{2}_[a-z]{2}\", \
proptest::collection::hash_map(\"[a-z_]{1,30}\", \"[a-zA-Z0-9 ]{0,50}\", 0..3), \
0..2\
)")
)]
pub string_overrides: HashMap<String, HashMap<String, String>>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(test, derive(proptest_derive::Arbitrary))]
pub struct ModpkgMetadata {
#[serde(default = "default_schema_version")]
pub schema_version: u32,
pub name: String,
pub display_name: String,
pub description: Option<String>,
#[cfg_attr(test, proptest(value = "Version::new(0, 1, 0)"))]
pub version: Version,
pub distributor: Option<DistributorInfo>,
#[cfg_attr(
test,
proptest(
strategy = "proptest::collection::vec(proptest::prelude::any::<ModpkgAuthor>(), 0..3)"
)
)]
pub authors: Vec<ModpkgAuthor>,
pub license: ModpkgLicense,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[cfg_attr(
test,
proptest(strategy = "proptest::collection::vec(\"[a-z][a-z-]{0,20}\", 0..3)")
)]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[cfg_attr(
test,
proptest(strategy = "proptest::collection::vec(\"[A-Z][a-z]{2,10}\", 0..3)")
)]
pub champions: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[cfg_attr(
test,
proptest(strategy = "proptest::collection::vec(\"[A-Z][a-z]{2,10}\", 0..3)")
)]
pub maps: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[cfg_attr(
test,
proptest(
strategy = "proptest::collection::vec(proptest::prelude::any::<ModpkgLayerMetadata>(), 0..3)"
)
)]
pub layers: Vec<ModpkgLayerMetadata>,
}
impl Default for ModpkgMetadata {
fn default() -> Self {
Self {
schema_version: default_schema_version(),
name: String::new(),
display_name: String::new(),
description: None,
version: Version::new(0, 0, 0),
distributor: None,
authors: Vec::new(),
license: ModpkgLicense::None,
tags: Vec::new(),
champions: Vec::new(),
maps: Vec::new(),
layers: Vec::new(),
}
}
}
pub const CURRENT_SCHEMA_VERSION: u32 = 2;
fn default_schema_version() -> u32 {
2
}
impl ModpkgMetadata {
pub fn path(&self) -> &str {
METADATA_CHUNK_PATH
}
}
impl ModpkgMetadata {
pub fn read<R: Read>(reader: &mut R) -> Result<Self, crate::error::ModpkgError> {
rmp_serde::from_read(reader).map_err(crate::error::ModpkgError::from)
}
pub fn write<W: Write>(&self, writer: &mut W) -> Result<(), crate::error::ModpkgError> {
let encoded = rmp_serde::to_vec_named(self).map_err(crate::error::ModpkgError::from)?;
writer
.write_all(&encoded)
.map_err(crate::error::ModpkgError::from)?;
Ok(())
}
pub fn size(&self) -> usize {
rmp_serde::to_vec_named(self).map(|v| v.len()).unwrap_or(0)
}
}
impl ModpkgMetadata {
pub fn name(&self) -> &str {
&self.name
}
pub fn display_name(&self) -> &str {
&self.display_name
}
pub fn description(&self) -> Option<&str> {
self.description.as_deref()
}
pub fn version(&self) -> &Version {
&self.version
}
pub fn distributor(&self) -> Option<&DistributorInfo> {
self.distributor.as_ref()
}
pub fn authors(&self) -> &[ModpkgAuthor] {
&self.authors
}
pub fn license(&self) -> &ModpkgLicense {
&self.license
}
pub fn tags(&self) -> &[String] {
&self.tags
}
pub fn champions(&self) -> &[String] {
&self.champions
}
pub fn maps(&self) -> &[String] {
&self.maps
}
pub fn layers(&self) -> &[ModpkgLayerMetadata] {
&self.layers
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(test, derive(proptest_derive::Arbitrary))]
pub struct ModpkgAuthor {
pub name: String,
pub role: Option<String>,
}
impl ModpkgAuthor {
pub fn new(name: String, role: Option<String>) -> Self {
Self { name, role }
}
pub fn name(&self) -> &str {
&self.name
}
pub fn role(&self) -> Option<&str> {
self.role.as_deref()
}
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
use std::io::Cursor;
proptest! {
#![proptest_config(ProptestConfig::with_cases(8))]
#[test]
fn test_metadata_roundtrip(metadata: ModpkgMetadata) {
let mut cursor = Cursor::new(Vec::new());
metadata.write(&mut cursor).unwrap();
cursor.set_position(0);
let read_metadata = ModpkgMetadata::read(&mut cursor).unwrap();
prop_assert_eq!(metadata, read_metadata);
}
#[test]
fn test_author_roundtrip(author: ModpkgAuthor) {
let encoded = rmp_serde::to_vec_named(&author).unwrap();
let decoded: ModpkgAuthor = rmp_serde::from_slice(&encoded).unwrap();
prop_assert_eq!(author, decoded);
}
}
#[test]
fn test_modpkg_metadata_read() {
let metadata = ModpkgMetadata {
schema_version: 1,
name: "test".to_string(),
display_name: "test".to_string(),
description: Some("test".to_string()),
version: Version::parse("1.0.0").unwrap(),
distributor: Some(DistributorInfo {
site_id: "test_site".to_string(),
site_name: "Test Site".to_string(),
site_url: "https://test-site.com".to_string(),
mod_id: "12345".to_string(),
}),
authors: vec![ModpkgAuthor {
name: "test".to_string(),
role: Some("test".to_string()),
}],
license: ModpkgLicense::Spdx {
spdx_id: "MIT".to_string(),
},
tags: vec![],
champions: vec![],
maps: vec![],
layers: vec![],
};
let mut cursor = Cursor::new(Vec::new());
metadata.write(&mut cursor).unwrap();
cursor.set_position(0);
let read_metadata = ModpkgMetadata::read(&mut cursor).unwrap();
assert_eq!(metadata, read_metadata);
}
#[test]
fn test_msgpack_format_visualization() {
let metadata = ModpkgMetadata {
schema_version: 1,
name: "TestMod".to_string(),
display_name: "Test Mod".to_string(),
description: Some("A test mod".to_string()),
version: Version::parse("1.0.0").unwrap(),
distributor: Some(DistributorInfo {
site_id: "nexus".to_string(),
site_name: "Nexus Mods".to_string(),
site_url: "https://www.nexusmods.com".to_string(),
mod_id: "12345".to_string(),
}),
authors: vec![ModpkgAuthor {
name: "Author1".to_string(),
role: Some("Developer".to_string()),
}],
license: ModpkgLicense::Spdx {
spdx_id: "MIT".to_string(),
},
tags: vec![],
champions: vec![],
maps: vec![],
layers: vec![],
};
let encoded = rmp_serde::to_vec_named(&metadata).unwrap();
println!("\nMsgpack encoded bytes (hex): {:02x?}", encoded);
println!("Size: {} bytes", encoded.len());
let license_none = ModpkgLicense::None;
let license_spdx = ModpkgLicense::Spdx {
spdx_id: "MIT".to_string(),
};
let license_custom = ModpkgLicense::Custom {
name: "MyLicense".to_string(),
url: "https://example.com".to_string(),
};
println!(
"\nLicense::None: {:02x?}",
rmp_serde::to_vec_named(&license_none).unwrap()
);
println!(
"License::Spdx: {:02x?}",
rmp_serde::to_vec_named(&license_spdx).unwrap()
);
println!(
"License::Custom: {:02x?}",
rmp_serde::to_vec_named(&license_custom).unwrap()
);
}
#[test]
fn test_layer_string_overrides_roundtrip() {
let layer = ModpkgLayerMetadata {
name: "base".to_string(),
display_name: None,
priority: 0,
description: Some("Base layer".to_string()),
string_overrides: HashMap::from([(
"en_us".to_string(),
HashMap::from([
("field_a".to_string(), "New Value A".to_string()),
("field_b".to_string(), "New Value B".to_string()),
]),
)]),
};
let encoded = rmp_serde::to_vec_named(&layer).unwrap();
let decoded: ModpkgLayerMetadata = rmp_serde::from_slice(&encoded).unwrap();
assert_eq!(layer, decoded);
}
#[test]
fn test_layer_empty_overrides_skipped_in_serialization() {
let layer = ModpkgLayerMetadata {
name: "base".to_string(),
display_name: None,
priority: 0,
description: None,
string_overrides: HashMap::new(),
};
let encoded = rmp_serde::to_vec_named(&layer).unwrap();
let as_str = String::from_utf8_lossy(&encoded);
assert!(!as_str.contains("string_overrides"));
let decoded: ModpkgLayerMetadata = rmp_serde::from_slice(&encoded).unwrap();
assert_eq!(layer, decoded);
}
#[test]
fn test_v1_metadata_backward_compat() {
let v1_metadata = ModpkgMetadata {
schema_version: 1,
name: "old-mod".to_string(),
display_name: "Old Mod".to_string(),
description: None,
version: Version::parse("1.0.0").unwrap(),
distributor: None,
authors: vec![],
license: ModpkgLicense::None,
tags: vec![],
champions: vec![],
maps: vec![],
layers: vec![ModpkgLayerMetadata {
name: "base".to_string(),
display_name: None,
priority: 0,
description: None,
string_overrides: HashMap::new(),
}],
};
let mut cursor = Cursor::new(Vec::new());
v1_metadata.write(&mut cursor).unwrap();
cursor.set_position(0);
let read = ModpkgMetadata::read(&mut cursor).unwrap();
assert_eq!(v1_metadata, read);
assert!(read.layers[0].string_overrides.is_empty());
}
#[test]
fn test_v2_metadata_with_string_overrides() {
let metadata = ModpkgMetadata {
schema_version: CURRENT_SCHEMA_VERSION,
name: "test-mod".to_string(),
display_name: "Test Mod".to_string(),
description: Some("A mod with string overrides".to_string()),
version: Version::parse("2.0.0").unwrap(),
distributor: None,
authors: vec![ModpkgAuthor {
name: "Author".to_string(),
role: None,
}],
license: ModpkgLicense::None,
tags: vec![],
champions: vec![],
maps: vec![],
layers: vec![
ModpkgLayerMetadata {
name: "base".to_string(),
display_name: None,
priority: 0,
description: None,
string_overrides: HashMap::from([(
"en_us".to_string(),
HashMap::from([("game_stat_name".to_string(), "Custom Stat".to_string())]),
)]),
},
ModpkgLayerMetadata {
name: "chroma1".to_string(),
display_name: Some("Pink chroma".to_string()),
priority: 10,
description: Some("Pink chroma".to_string()),
string_overrides: HashMap::from([(
"en_us".to_string(),
HashMap::from([
("champion_name".to_string(), "Custom Name".to_string()),
("ability_desc".to_string(), "Custom Description".to_string()),
]),
)]),
},
],
};
let mut cursor = Cursor::new(Vec::new());
metadata.write(&mut cursor).unwrap();
cursor.set_position(0);
let read = ModpkgMetadata::read(&mut cursor).unwrap();
assert_eq!(metadata, read);
assert_eq!(read.schema_version, CURRENT_SCHEMA_VERSION);
assert_eq!(read.layers[0].string_overrides.len(), 1); assert_eq!(read.layers[1].string_overrides.len(), 1); assert_eq!(
read.layers[0]
.string_overrides
.get("en_us")
.and_then(|m| m.get("game_stat_name")),
Some(&"Custom Stat".to_string())
);
}
}