#![deny(missing_docs)]
use crate::error::{Error, Result};
use crate::retry::{RetryPolicy, retry_http};
use crate::util::SizeCap;
use serde::{Deserialize, Deserializer, Serialize};
use std::collections::HashMap;
const BUNDLED_ECOSYSTEM: &[u8] = include_bytes!("../assets/ecosystem-schema.json.zst");
pub const DEFAULT_ECOSYSTEM_URL: &str =
"https://thunderstore.io/api/experimental/schema/dev/latest/";
const MAX_ECOSYSTEM_BYTES: u64 = 64 * 1024 * 1024;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Ecosystem {
#[serde(
rename = "schemaVersion",
default,
deserialize_with = "null_as_default"
)]
pub schema_version: String,
#[serde(default, deserialize_with = "null_as_default")]
pub games: HashMap<String, GameDef>,
#[serde(
rename = "modloaderPackages",
default,
deserialize_with = "null_as_default"
)]
pub modloader_packages: Vec<ModloaderPackage>,
}
impl Ecosystem {
pub fn bundled() -> Self {
Self::try_bundled()
.expect("bundled ecosystem snapshot must be valid; this is a build-time invariant")
}
pub fn try_bundled() -> Result<Self> {
let json = zstd::decode_all(BUNDLED_ECOSYSTEM)
.map_err(|e| Error::Ecosystem(format!("failed to decompress bundled schema: {}", e)))?;
Self::from_json_slice(&json)
}
pub fn from_json(json: &str) -> Result<Self> {
Self::from_json_slice(json.as_bytes())
}
fn from_json_slice(bytes: &[u8]) -> Result<Self> {
serde_json::from_slice(bytes).map_err(|e| Error::Ecosystem(format!("failed to parse: {}", e)))
}
pub async fn fetch_latest(client: &reqwest::Client, url: &str) -> Result<Self> {
Self::fetch_latest_bounded(client, url, MAX_ECOSYSTEM_BYTES, &RetryPolicy::default()).await
}
async fn fetch_latest_bounded(
client: &reqwest::Client,
url: &str,
max_bytes: u64,
retry: &RetryPolicy,
) -> Result<Self> {
use futures::StreamExt;
let body = retry_http(retry, async || {
let response = client.get(url).send().await?.error_for_status()?;
let mut cap = SizeCap::new("ecosystem schema", max_bytes);
cap.check_declared(response.content_length())?;
let mut stream = response.bytes_stream();
let mut body: Vec<u8> = Vec::new();
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
cap.add(chunk.len() as u64)?;
body.extend_from_slice(&chunk);
}
Ok(body)
})
.await?;
Self::from_json_slice(&body)
}
pub fn game(&self, slug: &str) -> Option<&GameDef> {
self.games.get(slug)
}
pub fn games(&self) -> impl Iterator<Item = &GameDef> {
self.games.values()
}
pub fn modloader_package(&self, package_full_name: &str) -> Option<&ModloaderPackage> {
self
.modloader_packages
.iter()
.find(|m| m.package_id == package_full_name)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct GameDef {
#[serde(rename = "label", default, deserialize_with = "null_as_default")]
pub slug: String,
#[serde(default, deserialize_with = "null_as_default")]
pub uuid: String,
#[serde(default)]
pub meta: Meta,
#[serde(default, deserialize_with = "null_as_default")]
pub distributions: Vec<Distribution>,
#[serde(rename = "r2modman", default, deserialize_with = "null_as_default")]
pub profiles: Vec<GameProfile>,
}
impl GameDef {
pub fn display_name(&self) -> &str {
&self.meta.display_name
}
pub fn profile(&self) -> Option<&GameProfile> {
self
.profiles
.iter()
.find(|p| p.instance_type == InstanceType::Game)
.or_else(|| self.profiles.first())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct GameProfile {
#[serde(default)]
pub meta: Meta,
#[serde(
rename = "packageLoader",
default,
deserialize_with = "null_as_default"
)]
pub package_loader: LoaderKind,
#[serde(
rename = "gameInstanceType",
default,
deserialize_with = "null_as_default"
)]
pub instance_type: InstanceType,
#[serde(rename = "exeNames", default, deserialize_with = "null_as_default")]
pub exe_names: Vec<String>,
#[serde(default, deserialize_with = "null_as_default")]
pub distributions: Vec<Distribution>,
#[serde(
rename = "relativeFileExclusions",
default,
deserialize_with = "null_as_default"
)]
pub relative_file_exclusions: Vec<String>,
#[serde(rename = "installRules", default, deserialize_with = "null_as_default")]
pub install_rules: Vec<InstallRule>,
#[serde(rename = "dataFolderName", default)]
pub data_folder_name: Option<String>,
#[serde(rename = "settingsIdentifier", default)]
pub settings_identifier: Option<String>,
#[serde(rename = "packageIndex", default)]
pub package_index: Option<String>,
}
impl GameProfile {
pub fn steam_app_id(&self) -> Option<u32> {
self
.distributions
.iter()
.find(|d| d.platform == "steam")
.and_then(|d| d.identifier.parse().ok())
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Meta {
#[serde(rename = "displayName", default, deserialize_with = "null_as_default")]
pub display_name: String,
#[serde(rename = "iconUrl", default, deserialize_with = "null_as_default")]
pub icon_url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Distribution {
#[serde(default, deserialize_with = "null_as_default")]
pub platform: String,
#[serde(default, deserialize_with = "null_as_default")]
pub identifier: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ModloaderPackage {
#[serde(rename = "packageId", default, deserialize_with = "null_as_default")]
pub package_id: String,
#[serde(rename = "rootFolder", default, deserialize_with = "null_as_default")]
pub root_folder: String,
#[serde(default, deserialize_with = "null_as_default")]
pub loader: LoaderKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum TrackingMethod {
#[default]
Subdir,
None,
State,
SubdirNoFlatten,
PackageZip,
#[serde(other)]
Other,
}
impl TrackingMethod {
pub fn is_namespaced(self) -> bool {
matches!(
self,
TrackingMethod::Subdir | TrackingMethod::SubdirNoFlatten
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct InstallRule {
#[serde(default, deserialize_with = "null_as_default")]
pub route: String,
#[serde(
rename = "defaultFileExtensions",
default,
deserialize_with = "null_as_default"
)]
pub default_file_extensions: Vec<String>,
#[serde(
rename = "trackingMethod",
default,
deserialize_with = "null_as_default"
)]
pub tracking_method: TrackingMethod,
#[serde(rename = "subRoutes", default, deserialize_with = "null_as_default")]
pub sub_routes: Vec<InstallRule>,
#[serde(
rename = "isDefaultLocation",
default,
deserialize_with = "null_as_default"
)]
pub is_default_location: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum InstanceType {
#[default]
Game,
Server,
#[serde(other)]
Other,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub enum LoaderKind {
#[serde(rename = "bepinex")]
BepInEx,
#[serde(rename = "bepisloader")]
BepisLoader,
#[serde(rename = "gdweave")]
GdWeave,
#[serde(rename = "godotml")]
GodotMl,
#[serde(rename = "lovely")]
Lovely,
#[serde(rename = "melonloader")]
MelonLoader,
#[serde(rename = "recursive-melonloader")]
RecursiveMelonLoader,
#[serde(rename = "northstar")]
Northstar,
#[serde(rename = "return-of-modding")]
ReturnOfModding,
#[serde(rename = "rivet")]
Rivet,
#[serde(rename = "shimloader")]
Shimloader,
#[serde(rename = "umm")]
Umm,
#[serde(rename = "none")]
None,
#[default]
#[serde(other)]
Other,
}
fn null_as_default<'de, D, T>(deserializer: D) -> std::result::Result<T, D::Error>
where
D: Deserializer<'de>,
T: Deserialize<'de> + Default,
{
let opt = Option::<T>::deserialize(deserializer)?;
Ok(opt.unwrap_or_default())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bundled_parses() {
let eco = Ecosystem::bundled();
assert!(!eco.games.is_empty());
assert!(!eco.modloader_packages.is_empty());
assert_eq!(eco.schema_version, "0.3.0");
}
#[test]
fn test_valheim_bepinex() {
let eco = Ecosystem::bundled();
let valheim = eco.game("valheim").expect("valheim present");
assert_eq!(valheim.slug, "valheim");
assert_eq!(valheim.display_name(), "Valheim");
let profile = valheim.profile().expect("a profile");
assert_eq!(profile.package_loader, LoaderKind::BepInEx);
assert_eq!(profile.instance_type, InstanceType::Game);
assert_eq!(profile.steam_app_id(), Some(892970));
assert!(!profile.install_rules.is_empty());
let plugins = profile
.install_rules
.iter()
.find(|r| r.route == "BepInEx/plugins")
.expect("plugins rule");
assert_eq!(plugins.tracking_method, TrackingMethod::Subdir);
assert!(plugins.tracking_method.is_namespaced());
let monomod = profile
.install_rules
.iter()
.find(|r| r.route == "BepInEx/monomod")
.expect("monomod rule");
assert!(
monomod
.default_file_extensions
.iter()
.any(|e| e == ".mm.dll")
);
let config = profile
.install_rules
.iter()
.find(|r| r.route == "BepInEx/config")
.expect("config rule");
assert!(!config.tracking_method.is_namespaced());
}
#[test]
fn test_modloader_registry_lookup() {
let eco = Ecosystem::bundled();
let pack = eco
.modloader_package("denikson-BepInExPack_Valheim")
.expect("valheim loader pack present");
assert_eq!(pack.root_folder, "BepInExPack_Valheim");
assert_eq!(pack.loader, LoaderKind::BepInEx);
assert!(eco.modloader_package("Author-SomeMod").is_none());
}
#[test]
fn test_melonloader_game_present() {
let eco = Ecosystem::bundled();
let game = eco
.games()
.find(|g| {
g.profile()
.map(|p| p.package_loader == LoaderKind::MelonLoader)
.unwrap_or(false)
})
.expect("at least one MelonLoader game");
assert!(game.profile().is_some());
}
#[test]
fn test_unknown_loader_falls_back_to_other() {
let json = r#"{
"schemaVersion": "9.9.9",
"games": {
"x": { "label": "x", "meta": {"displayName": "X"},
"r2modman": [ { "packageLoader": "someNewLoader", "installRules": [] } ] }
},
"modloaderPackages": []
}"#;
let eco = Ecosystem::from_json(json).unwrap();
let profile = eco.game("x").unwrap().profile().unwrap();
assert_eq!(profile.package_loader, LoaderKind::Other);
}
#[test]
fn test_null_relative_file_exclusions_normalizes_to_empty() {
let json = r#"{
"games": {
"x": { "label": "x",
"r2modman": [ { "packageLoader": "bepinex", "relativeFileExclusions": null, "installRules": [] } ] }
}
}"#;
let eco = Ecosystem::from_json(json).unwrap();
let profile = eco.game("x").unwrap().profile().unwrap();
assert!(profile.relative_file_exclusions.is_empty());
}
#[test]
fn test_null_enum_and_bool_fields_normalize_to_defaults() {
let json = r#"{
"games": {
"x": { "label": "x",
"r2modman": [ { "packageLoader": null, "gameInstanceType": null,
"installRules": [ { "route": "Mods", "trackingMethod": null, "isDefaultLocation": null } ] } ] }
}
}"#;
let eco = Ecosystem::from_json(json).unwrap();
let profile = eco.game("x").unwrap().profile().unwrap();
assert_eq!(profile.package_loader, LoaderKind::Other);
assert_eq!(profile.instance_type, InstanceType::Game);
let rule = &profile.install_rules[0];
assert_eq!(rule.tracking_method, TrackingMethod::Subdir);
assert!(!rule.is_default_location);
}
#[test]
fn test_unknown_tracking_method_falls_back_to_other() {
let json = r#"{
"games": {
"x": { "label": "x",
"r2modman": [ { "packageLoader": "bepinex",
"installRules": [ { "route": "Mods", "trackingMethod": "brand-new-method" } ] } ] }
}
}"#;
let eco = Ecosystem::from_json(json).unwrap();
let rule = &eco.game("x").unwrap().profile().unwrap().install_rules[0];
assert_eq!(rule.tracking_method, TrackingMethod::Other);
assert!(!rule.tracking_method.is_namespaced());
}
#[test]
fn test_fetch_latest_rejects_declared_oversize_body() {
let mut server = mockito::Server::new();
let body = "x".repeat(1000);
let mock = server
.mock("GET", "/schema")
.with_status(200)
.with_body(&body)
.create();
let url = format!("{}/schema", server.url());
let rt = tokio::runtime::Runtime::new().unwrap();
let client = reqwest::Client::new();
let result = rt.block_on(Ecosystem::fetch_latest_bounded(
&client,
&url,
16,
&RetryPolicy::none(),
));
assert!(matches!(result, Err(Error::SizeLimitExceeded { .. })));
mock.assert();
}
#[test]
fn test_fetch_latest_rejects_streamed_oversize_body() {
let mut server = mockito::Server::new();
let mock = server
.mock("GET", "/schema")
.with_status(200)
.with_chunked_body(|w| w.write_all(&vec![b'x'; 1000]))
.create();
let url = format!("{}/schema", server.url());
let rt = tokio::runtime::Runtime::new().unwrap();
let client = reqwest::Client::new();
let result = rt.block_on(Ecosystem::fetch_latest_bounded(
&client,
&url,
16,
&RetryPolicy::none(),
));
assert!(matches!(result, Err(Error::SizeLimitExceeded { .. })));
mock.assert();
}
#[test]
fn test_fetch_latest_parses_network_schema() {
let mut server = mockito::Server::new();
let body = r#"{
"schemaVersion": "0.3.0",
"games": {
"valheim": { "label": "valheim", "meta": {"displayName": "Valheim"},
"r2modman": [ { "packageLoader": "bepinex",
"installRules": [ { "route": "BepInEx/plugins", "trackingMethod": "subdir",
"isDefaultLocation": true, "defaultFileExtensions": [".dll"] } ] } ] }
},
"modloaderPackages": [
{ "packageId": "denikson-BepInExPack_Valheim", "rootFolder": "BepInExPack_Valheim", "loader": "bepinex" }
]
}"#;
let mock = server
.mock("GET", "/schema")
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(body)
.create();
let url = format!("{}/schema", server.url());
let rt = tokio::runtime::Runtime::new().unwrap();
let client = reqwest::Client::new();
let eco = rt.block_on(Ecosystem::fetch_latest(&client, &url)).unwrap();
assert_eq!(eco.schema_version, "0.3.0");
let profile = eco.game("valheim").unwrap().profile().unwrap();
assert_eq!(profile.package_loader, LoaderKind::BepInEx);
assert!(
eco
.modloader_package("denikson-BepInExPack_Valheim")
.is_some()
);
mock.assert();
}
#[test]
fn steam_app_id_none_when_missing_or_unparseable() {
let no_steam: GameProfile = serde_json::from_str(
r#"{"distributions":[{"platform":"epic-games-store","identifier":"abc"}]}"#,
)
.unwrap();
assert_eq!(no_steam.steam_app_id(), None);
let bad_steam: GameProfile =
serde_json::from_str(r#"{"distributions":[{"platform":"steam","identifier":"nope"}]}"#)
.unwrap();
assert_eq!(bad_steam.steam_app_id(), None);
let steam: GameProfile =
serde_json::from_str(r#"{"distributions":[{"platform":"steam","identifier":"892970"}]}"#)
.unwrap();
assert_eq!(steam.steam_app_id(), Some(892970));
}
#[test]
fn game_profile_prefers_game_instance_then_falls_back() {
let server_only: GameDef =
serde_json::from_str(r#"{"r2modman":[{"gameInstanceType":"server"}]}"#).unwrap();
assert_eq!(
server_only.profile().unwrap().instance_type,
InstanceType::Server
);
let mixed: GameDef = serde_json::from_str(
r#"{"r2modman":[{"gameInstanceType":"server"},{"gameInstanceType":"game"}]}"#,
)
.unwrap();
assert_eq!(mixed.profile().unwrap().instance_type, InstanceType::Game);
let empty: GameDef = serde_json::from_str(r#"{"r2modman":[]}"#).unwrap();
assert!(empty.profile().is_none());
}
}