#![deny(missing_docs)]
use crate::error::{Error, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SemVer {
pub major: u32,
pub minor: u32,
pub patch: u32,
}
impl SemVer {
pub fn parse(s: &str) -> SemVer {
let mut parts = s.split('.');
let major = parse_leading_u32(parts.next());
let minor = parse_leading_u32(parts.next());
let patch = parse_leading_u32(parts.next());
SemVer {
major,
minor,
patch,
}
}
}
impl std::fmt::Display for SemVer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
}
}
fn parse_leading_u32(component: Option<&str>) -> u32 {
let text = component.unwrap_or("");
let digits: String = text.chars().take_while(|c| c.is_ascii_digit()).collect();
digits.parse().unwrap_or(0)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct ProfileMod {
pub manifest_version: u32,
pub name: String,
pub author_name: String,
pub website_url: String,
pub display_name: String,
pub description: String,
pub game_version: String,
pub network_mode: String,
pub package_type: String,
pub install_mode: String,
pub installed_at_time: u64,
pub loaders: Vec<String>,
pub dependencies: Vec<String>,
pub incompatibilities: Vec<String>,
pub optional_dependencies: Vec<String>,
pub version_number: SemVer,
pub enabled: bool,
#[serde(skip)]
pub icon: String,
pub online_source: bool,
#[serde(default)]
pub trusted_package: bool,
}
const MODS_YML: &str = "mods.yml";
pub fn read(profile_dir: &Path) -> Result<Vec<ProfileMod>> {
let path = profile_dir.join(MODS_YML);
if !path.exists() {
return Ok(Vec::new());
}
let text = fs::read_to_string(&path)
.map_err(|e| Error::Profile(format!("reading {}: {}", path.display(), e)))?;
serde_yaml_ng::from_str(&text)
.map_err(|e| Error::Profile(format!("parsing {}: {}", path.display(), e)))
}
pub fn write(profile_dir: &Path, mods: &[ProfileMod]) -> Result<()> {
fs::create_dir_all(profile_dir)
.map_err(|e| Error::Profile(format!("creating {}: {}", profile_dir.display(), e)))?;
let text = serde_yaml_ng::to_string(mods)
.map_err(|e| Error::Profile(format!("serializing mods.yml: {}", e)))?;
let path = profile_dir.join(MODS_YML);
fs::write(&path, text).map_err(|e| Error::Profile(format!("writing {}: {}", path.display(), e)))
}
pub fn find<'a>(mods: &'a [ProfileMod], full_name: &str) -> Option<&'a ProfileMod> {
mods.iter().find(|m| m.name == full_name)
}
pub fn upsert(mods: &mut Vec<ProfileMod>, entry: ProfileMod) {
if let Some(existing) = mods.iter_mut().find(|m| m.name == entry.name) {
*existing = entry;
return;
}
mods.push(entry);
}
pub fn now_millis() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|elapsed| elapsed.as_millis() as u64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn sample() -> ProfileMod {
ProfileMod {
manifest_version: 1,
name: "bbepis-BepInExPack".to_string(),
author_name: "bbepis".to_string(),
website_url: "https://example.com".to_string(),
display_name: "BepInExPack".to_string(),
description: "pack".to_string(),
game_version: "0".to_string(),
network_mode: "both".to_string(),
package_type: "other".to_string(),
install_mode: "managed".to_string(),
installed_at_time: 1_699_999_999_999,
loaders: vec![],
dependencies: vec![],
incompatibilities: vec![],
optional_dependencies: vec![],
version_number: SemVer {
major: 5,
minor: 4,
patch: 2100,
},
enabled: true,
icon: String::new(),
online_source: false,
trusted_package: false,
}
}
#[test]
fn semver_parse_takes_first_three_numeric_components() {
assert_eq!(
SemVer::parse("5.4.2100"),
SemVer {
major: 5,
minor: 4,
patch: 2100
}
);
assert_eq!(
SemVer::parse("1.2"),
SemVer {
major: 1,
minor: 2,
patch: 0
}
);
assert_eq!(
SemVer::parse("1.2.3-beta"),
SemVer {
major: 1,
minor: 2,
patch: 3
}
);
assert_eq!(
SemVer::parse("garbage"),
SemVer {
major: 0,
minor: 0,
patch: 0
}
);
}
#[test]
fn serialized_yaml_is_r2modman_readable() {
let yaml = serde_yaml_ng::to_string(&vec![sample()]).unwrap();
assert!(yaml.contains("manifestVersion:"));
assert!(yaml.contains("name: bbepis-BepInExPack"));
assert!(yaml.contains("versionNumber:"));
assert!(yaml.contains("major: 5"));
assert!(yaml.contains("networkMode: both"));
assert!(!yaml.contains("icon"));
assert!(!yaml.contains("version_number"));
}
#[test]
fn read_write_round_trips_and_missing_is_empty() {
let dir = tempdir().unwrap();
assert_eq!(read(dir.path()).unwrap().len(), 0);
write(dir.path(), &[sample()]).unwrap();
let back = read(dir.path()).unwrap();
assert_eq!(back.len(), 1);
assert_eq!(back[0].name, "bbepis-BepInExPack");
assert_eq!(
back[0].version_number,
SemVer {
major: 5,
minor: 4,
patch: 2100
}
);
assert!(back[0].enabled);
}
#[test]
fn parses_real_r2modman_sample_ignoring_icon() {
let yaml = r#"- manifestVersion: 1
name: Author-Mod
authorName: Author
websiteUrl: ''
displayName: Mod
description: ''
gameVersion: '0'
networkMode: both
packageType: other
installMode: managed
installedAtTime: 1699999999999
loaders: []
dependencies:
- Other-Dep-1.2.3
incompatibilities: []
optionalDependencies: []
versionNumber:
major: 1
minor: 0
patch: 0
enabled: false
icon: /some/path/icon.png
onlineSource: true
trustedPackage: false
"#;
let mods: Vec<ProfileMod> = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(mods.len(), 1);
assert_eq!(mods[0].dependencies, vec!["Other-Dep-1.2.3".to_string()]);
assert!(!mods[0].enabled);
assert!(mods[0].online_source);
}
#[test]
fn parses_an_r2modman_entry_with_no_trusted_package_key() {
let yaml = r#"- manifestVersion: 1
name: denikson-BepInExPack_Valheim
authorName: denikson
websiteUrl: https://thunderstore.io/c/valheim/p/denikson/BepInExPack_Valheim/
displayName: BepInExPack_Valheim
description: BepInEx pack for Valheim.
gameVersion: '0'
networkMode: both
packageType: other
installMode: managed
installedAtTime: 1771107231154
loaders: []
dependencies: []
incompatibilities: []
optionalDependencies: []
versionNumber:
major: 5
minor: 4
patch: 2333
enabled: true
onlineSource: false
"#;
let mods: Vec<ProfileMod> = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(mods.len(), 1);
assert_eq!(mods[0].name, "denikson-BepInExPack_Valheim");
assert!(mods[0].enabled);
assert!(!mods[0].trusted_package);
}
#[test]
fn upsert_replaces_by_name_and_find_locates() {
let mut mods = vec![sample()];
let mut updated = sample();
updated.enabled = false;
upsert(&mut mods, updated);
assert_eq!(mods.len(), 1);
assert!(!find(&mods, "bbepis-BepInExPack").unwrap().enabled);
assert!(find(&mods, "nope").is_none());
}
#[test]
fn semver_renders_as_major_minor_patch() {
let version = SemVer {
major: 5,
minor: 4,
patch: 2200,
};
assert_eq!(version.to_string(), "5.4.2200");
assert_eq!(format!("{version}"), "5.4.2200");
}
#[test]
fn an_unreadable_record_names_its_path() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("mods.yml"), "not: [valid").unwrap();
let message = read(dir.path()).unwrap_err().to_string();
assert!(
message.contains(&dir.path().join("mods.yml").display().to_string()),
"a read failure must name the file, so a caller need not add it; got: {message}"
);
}
}