1#![deny(missing_docs)]
22
23use crate::error::{Error, Result};
24use crate::retry::{RetryPolicy, retry_http};
25use crate::util::SizeCap;
26use serde::{Deserialize, Deserializer, Serialize};
27use std::collections::HashMap;
28
29const BUNDLED_ECOSYSTEM: &[u8] = include_bytes!("../assets/ecosystem-schema.json.zst");
32
33pub const DEFAULT_ECOSYSTEM_URL: &str =
48 "https://thunderstore.io/api/experimental/schema/dev/latest/";
49
50const MAX_ECOSYSTEM_BYTES: u64 = 64 * 1024 * 1024;
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
57#[non_exhaustive]
58pub struct Ecosystem {
59 #[serde(
61 rename = "schemaVersion",
62 default,
63 deserialize_with = "null_as_default"
64 )]
65 pub schema_version: String,
66 #[serde(default, deserialize_with = "null_as_default")]
68 pub games: HashMap<String, GameDef>,
69 #[serde(
71 rename = "modloaderPackages",
72 default,
73 deserialize_with = "null_as_default"
74 )]
75 pub modloader_packages: Vec<ModloaderPackage>,
76}
77
78impl Ecosystem {
79 pub fn bundled() -> Self {
100 Self::try_bundled()
101 .expect("bundled ecosystem snapshot must be valid; this is a build-time invariant")
102 }
103
104 pub fn try_bundled() -> Result<Self> {
107 let json = zstd::decode_all(BUNDLED_ECOSYSTEM)
108 .map_err(|e| Error::Ecosystem(format!("failed to decompress bundled schema: {}", e)))?;
109
110 Self::from_json_slice(&json)
111 }
112
113 pub fn from_json(json: &str) -> Result<Self> {
115 Self::from_json_slice(json.as_bytes())
116 }
117
118 fn from_json_slice(bytes: &[u8]) -> Result<Self> {
120 serde_json::from_slice(bytes).map_err(|e| Error::Ecosystem(format!("failed to parse: {}", e)))
121 }
122
123 pub async fn fetch_latest(client: &reqwest::Client, url: &str) -> Result<Self> {
131 Self::fetch_latest_bounded(client, url, MAX_ECOSYSTEM_BYTES, &RetryPolicy::default()).await
132 }
133
134 async fn fetch_latest_bounded(
138 client: &reqwest::Client,
139 url: &str,
140 max_bytes: u64,
141 retry: &RetryPolicy,
142 ) -> Result<Self> {
143 use futures::StreamExt;
144
145 let body = retry_http(retry, async || {
146 let response = client.get(url).send().await?.error_for_status()?;
147
148 let mut cap = SizeCap::new("ecosystem schema", max_bytes);
149
150 cap.check_declared(response.content_length())?;
153
154 let mut stream = response.bytes_stream();
155 let mut body: Vec<u8> = Vec::new();
156
157 while let Some(chunk) = stream.next().await {
158 let chunk = chunk?;
159
160 cap.add(chunk.len() as u64)?;
161
162 body.extend_from_slice(&chunk);
163 }
164
165 Ok(body)
166 })
167 .await?;
168
169 Self::from_json_slice(&body)
170 }
171
172 pub fn game(&self, slug: &str) -> Option<&GameDef> {
174 self.games.get(slug)
175 }
176
177 pub fn games(&self) -> impl Iterator<Item = &GameDef> {
179 self.games.values()
180 }
181
182 pub fn modloader_package(&self, package_full_name: &str) -> Option<&ModloaderPackage> {
190 self
191 .modloader_packages
192 .iter()
193 .find(|m| m.package_id == package_full_name)
194 }
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize)]
201#[non_exhaustive]
202pub struct GameDef {
203 #[serde(rename = "label", default, deserialize_with = "null_as_default")]
205 pub slug: String,
206 #[serde(default, deserialize_with = "null_as_default")]
208 pub uuid: String,
209 #[serde(default)]
211 pub meta: Meta,
212 #[serde(default, deserialize_with = "null_as_default")]
214 pub distributions: Vec<Distribution>,
215 #[serde(rename = "r2modman", default, deserialize_with = "null_as_default")]
218 pub profiles: Vec<GameProfile>,
219}
220
221impl GameDef {
222 pub fn display_name(&self) -> &str {
224 &self.meta.display_name
225 }
226
227 pub fn profile(&self) -> Option<&GameProfile> {
230 self
231 .profiles
232 .iter()
233 .find(|p| p.instance_type == InstanceType::Game)
234 .or_else(|| self.profiles.first())
235 }
236}
237
238#[derive(Debug, Clone, Serialize, Deserialize)]
242#[non_exhaustive]
243pub struct GameProfile {
244 #[serde(default)]
246 pub meta: Meta,
247 #[serde(
249 rename = "packageLoader",
250 default,
251 deserialize_with = "null_as_default"
252 )]
253 pub package_loader: LoaderKind,
254 #[serde(
256 rename = "gameInstanceType",
257 default,
258 deserialize_with = "null_as_default"
259 )]
260 pub instance_type: InstanceType,
261 #[serde(rename = "exeNames", default, deserialize_with = "null_as_default")]
263 pub exe_names: Vec<String>,
264 #[serde(default, deserialize_with = "null_as_default")]
266 pub distributions: Vec<Distribution>,
267 #[serde(
270 rename = "relativeFileExclusions",
271 default,
272 deserialize_with = "null_as_default"
273 )]
274 pub relative_file_exclusions: Vec<String>,
275 #[serde(rename = "installRules", default, deserialize_with = "null_as_default")]
277 pub install_rules: Vec<InstallRule>,
278 #[serde(rename = "dataFolderName", default)]
280 pub data_folder_name: Option<String>,
281 #[serde(rename = "settingsIdentifier", default)]
283 pub settings_identifier: Option<String>,
284 #[serde(rename = "packageIndex", default)]
286 pub package_index: Option<String>,
287}
288
289impl GameProfile {
290 pub fn steam_app_id(&self) -> Option<u32> {
292 self
293 .distributions
294 .iter()
295 .find(|d| d.platform == "steam")
296 .and_then(|d| d.identifier.parse().ok())
297 }
298}
299
300#[derive(Debug, Clone, Default, Serialize, Deserialize)]
302#[non_exhaustive]
303pub struct Meta {
304 #[serde(rename = "displayName", default, deserialize_with = "null_as_default")]
306 pub display_name: String,
307 #[serde(rename = "iconUrl", default, deserialize_with = "null_as_default")]
309 pub icon_url: String,
310}
311
312#[derive(Debug, Clone, Serialize, Deserialize)]
314#[non_exhaustive]
315pub struct Distribution {
316 #[serde(default, deserialize_with = "null_as_default")]
318 pub platform: String,
319 #[serde(default, deserialize_with = "null_as_default")]
322 pub identifier: String,
323}
324
325#[derive(Debug, Clone, Serialize, Deserialize)]
332#[non_exhaustive]
333pub struct ModloaderPackage {
334 #[serde(rename = "packageId", default, deserialize_with = "null_as_default")]
336 pub package_id: String,
337 #[serde(rename = "rootFolder", default, deserialize_with = "null_as_default")]
340 pub root_folder: String,
341 #[serde(default, deserialize_with = "null_as_default")]
343 pub loader: LoaderKind,
344}
345
346#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
351#[serde(rename_all = "kebab-case")]
352#[non_exhaustive]
353pub enum TrackingMethod {
354 #[default]
356 Subdir,
357 None,
359 State,
362 SubdirNoFlatten,
364 PackageZip,
366 #[serde(other)]
368 Other,
369}
370
371impl TrackingMethod {
372 pub fn is_namespaced(self) -> bool {
375 matches!(
376 self,
377 TrackingMethod::Subdir | TrackingMethod::SubdirNoFlatten
378 )
379 }
380}
381
382#[derive(Debug, Clone, Serialize, Deserialize)]
386#[non_exhaustive]
387pub struct InstallRule {
388 #[serde(default, deserialize_with = "null_as_default")]
392 pub route: String,
393 #[serde(
396 rename = "defaultFileExtensions",
397 default,
398 deserialize_with = "null_as_default"
399 )]
400 pub default_file_extensions: Vec<String>,
401 #[serde(
403 rename = "trackingMethod",
404 default,
405 deserialize_with = "null_as_default"
406 )]
407 pub tracking_method: TrackingMethod,
408 #[serde(rename = "subRoutes", default, deserialize_with = "null_as_default")]
410 pub sub_routes: Vec<InstallRule>,
411 #[serde(
414 rename = "isDefaultLocation",
415 default,
416 deserialize_with = "null_as_default"
417 )]
418 pub is_default_location: bool,
419}
420
421#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
423#[serde(rename_all = "lowercase")]
424#[non_exhaustive]
425pub enum InstanceType {
426 #[default]
428 Game,
429 Server,
431 #[serde(other)]
433 Other,
434}
435
436#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
441#[non_exhaustive]
442pub enum LoaderKind {
443 #[serde(rename = "bepinex")]
445 BepInEx,
446 #[serde(rename = "bepisloader")]
449 BepisLoader,
450 #[serde(rename = "gdweave")]
453 GdWeave,
454 #[serde(rename = "godotml")]
457 GodotMl,
458 #[serde(rename = "lovely")]
460 Lovely,
461 #[serde(rename = "melonloader")]
463 MelonLoader,
464 #[serde(rename = "recursive-melonloader")]
468 RecursiveMelonLoader,
469 #[serde(rename = "northstar")]
472 Northstar,
473 #[serde(rename = "return-of-modding")]
476 ReturnOfModding,
477 #[serde(rename = "rivet")]
480 Rivet,
481 #[serde(rename = "shimloader")]
484 Shimloader,
485 #[serde(rename = "umm")]
488 Umm,
489 #[serde(rename = "none")]
491 None,
492 #[default]
494 #[serde(other)]
495 Other,
496}
497
498fn null_as_default<'de, D, T>(deserializer: D) -> std::result::Result<T, D::Error>
501where
502 D: Deserializer<'de>,
503 T: Deserialize<'de> + Default,
504{
505 let opt = Option::<T>::deserialize(deserializer)?;
506
507 Ok(opt.unwrap_or_default())
508}
509
510#[cfg(test)]
511mod tests {
512 use super::*;
513
514 #[test]
515 fn test_bundled_parses() {
516 let eco = Ecosystem::bundled();
517
518 assert!(!eco.games.is_empty());
519 assert!(!eco.modloader_packages.is_empty());
520 assert_eq!(eco.schema_version, "0.3.0");
521 }
522
523 #[test]
524 fn test_valheim_bepinex() {
525 let eco = Ecosystem::bundled();
526 let valheim = eco.game("valheim").expect("valheim present");
527
528 assert_eq!(valheim.slug, "valheim");
529 assert_eq!(valheim.display_name(), "Valheim");
530
531 let profile = valheim.profile().expect("a profile");
532
533 assert_eq!(profile.package_loader, LoaderKind::BepInEx);
534 assert_eq!(profile.instance_type, InstanceType::Game);
535 assert_eq!(profile.steam_app_id(), Some(892970));
536 assert!(!profile.install_rules.is_empty());
537
538 let plugins = profile
539 .install_rules
540 .iter()
541 .find(|r| r.route == "BepInEx/plugins")
542 .expect("plugins rule");
543
544 assert_eq!(plugins.tracking_method, TrackingMethod::Subdir);
545 assert!(plugins.tracking_method.is_namespaced());
546
547 let monomod = profile
548 .install_rules
549 .iter()
550 .find(|r| r.route == "BepInEx/monomod")
551 .expect("monomod rule");
552
553 assert!(
554 monomod
555 .default_file_extensions
556 .iter()
557 .any(|e| e == ".mm.dll")
558 );
559
560 let config = profile
561 .install_rules
562 .iter()
563 .find(|r| r.route == "BepInEx/config")
564 .expect("config rule");
565
566 assert!(!config.tracking_method.is_namespaced());
567 }
568
569 #[test]
570 fn test_modloader_registry_lookup() {
571 let eco = Ecosystem::bundled();
572
573 let pack = eco
574 .modloader_package("denikson-BepInExPack_Valheim")
575 .expect("valheim loader pack present");
576
577 assert_eq!(pack.root_folder, "BepInExPack_Valheim");
578 assert_eq!(pack.loader, LoaderKind::BepInEx);
579
580 assert!(eco.modloader_package("Author-SomeMod").is_none());
581 }
582
583 #[test]
584 fn test_melonloader_game_present() {
585 let eco = Ecosystem::bundled();
586
587 let game = eco
588 .games()
589 .find(|g| {
590 g.profile()
591 .map(|p| p.package_loader == LoaderKind::MelonLoader)
592 .unwrap_or(false)
593 })
594 .expect("at least one MelonLoader game");
595
596 assert!(game.profile().is_some());
597 }
598
599 #[test]
600 fn test_unknown_loader_falls_back_to_other() {
601 let json = r#"{
602 "schemaVersion": "9.9.9",
603 "games": {
604 "x": { "label": "x", "meta": {"displayName": "X"},
605 "r2modman": [ { "packageLoader": "someNewLoader", "installRules": [] } ] }
606 },
607 "modloaderPackages": []
608 }"#;
609
610 let eco = Ecosystem::from_json(json).unwrap();
611 let profile = eco.game("x").unwrap().profile().unwrap();
612
613 assert_eq!(profile.package_loader, LoaderKind::Other);
614 }
615
616 #[test]
617 fn test_null_relative_file_exclusions_normalizes_to_empty() {
618 let json = r#"{
619 "games": {
620 "x": { "label": "x",
621 "r2modman": [ { "packageLoader": "bepinex", "relativeFileExclusions": null, "installRules": [] } ] }
622 }
623 }"#;
624
625 let eco = Ecosystem::from_json(json).unwrap();
626 let profile = eco.game("x").unwrap().profile().unwrap();
627
628 assert!(profile.relative_file_exclusions.is_empty());
629 }
630
631 #[test]
632 fn test_null_enum_and_bool_fields_normalize_to_defaults() {
633 let json = r#"{
634 "games": {
635 "x": { "label": "x",
636 "r2modman": [ { "packageLoader": null, "gameInstanceType": null,
637 "installRules": [ { "route": "Mods", "trackingMethod": null, "isDefaultLocation": null } ] } ] }
638 }
639 }"#;
640
641 let eco = Ecosystem::from_json(json).unwrap();
642 let profile = eco.game("x").unwrap().profile().unwrap();
643
644 assert_eq!(profile.package_loader, LoaderKind::Other);
645 assert_eq!(profile.instance_type, InstanceType::Game);
646
647 let rule = &profile.install_rules[0];
648
649 assert_eq!(rule.tracking_method, TrackingMethod::Subdir);
650 assert!(!rule.is_default_location);
651 }
652
653 #[test]
654 fn test_unknown_tracking_method_falls_back_to_other() {
655 let json = r#"{
656 "games": {
657 "x": { "label": "x",
658 "r2modman": [ { "packageLoader": "bepinex",
659 "installRules": [ { "route": "Mods", "trackingMethod": "brand-new-method" } ] } ] }
660 }
661 }"#;
662
663 let eco = Ecosystem::from_json(json).unwrap();
664 let rule = &eco.game("x").unwrap().profile().unwrap().install_rules[0];
665
666 assert_eq!(rule.tracking_method, TrackingMethod::Other);
667 assert!(!rule.tracking_method.is_namespaced());
668 }
669
670 #[test]
671 fn test_fetch_latest_rejects_declared_oversize_body() {
672 let mut server = mockito::Server::new();
673 let body = "x".repeat(1000);
674
675 let mock = server
676 .mock("GET", "/schema")
677 .with_status(200)
678 .with_body(&body)
679 .create();
680
681 let url = format!("{}/schema", server.url());
682 let rt = tokio::runtime::Runtime::new().unwrap();
683 let client = reqwest::Client::new();
684
685 let result = rt.block_on(Ecosystem::fetch_latest_bounded(
686 &client,
687 &url,
688 16,
689 &RetryPolicy::none(),
690 ));
691
692 assert!(matches!(result, Err(Error::SizeLimitExceeded { .. })));
693
694 mock.assert();
695 }
696
697 #[test]
698 fn test_fetch_latest_rejects_streamed_oversize_body() {
699 let mut server = mockito::Server::new();
700
701 let mock = server
704 .mock("GET", "/schema")
705 .with_status(200)
706 .with_chunked_body(|w| w.write_all(&vec![b'x'; 1000]))
707 .create();
708
709 let url = format!("{}/schema", server.url());
710 let rt = tokio::runtime::Runtime::new().unwrap();
711 let client = reqwest::Client::new();
712
713 let result = rt.block_on(Ecosystem::fetch_latest_bounded(
714 &client,
715 &url,
716 16,
717 &RetryPolicy::none(),
718 ));
719
720 assert!(matches!(result, Err(Error::SizeLimitExceeded { .. })));
721
722 mock.assert();
723 }
724
725 #[test]
726 fn test_fetch_latest_parses_network_schema() {
727 let mut server = mockito::Server::new();
728
729 let body = r#"{
730 "schemaVersion": "0.3.0",
731 "games": {
732 "valheim": { "label": "valheim", "meta": {"displayName": "Valheim"},
733 "r2modman": [ { "packageLoader": "bepinex",
734 "installRules": [ { "route": "BepInEx/plugins", "trackingMethod": "subdir",
735 "isDefaultLocation": true, "defaultFileExtensions": [".dll"] } ] } ] }
736 },
737 "modloaderPackages": [
738 { "packageId": "denikson-BepInExPack_Valheim", "rootFolder": "BepInExPack_Valheim", "loader": "bepinex" }
739 ]
740 }"#;
741
742 let mock = server
743 .mock("GET", "/schema")
744 .with_status(200)
745 .with_header("Content-Type", "application/json")
746 .with_body(body)
747 .create();
748
749 let url = format!("{}/schema", server.url());
750 let rt = tokio::runtime::Runtime::new().unwrap();
751 let client = reqwest::Client::new();
752 let eco = rt.block_on(Ecosystem::fetch_latest(&client, &url)).unwrap();
753
754 assert_eq!(eco.schema_version, "0.3.0");
755
756 let profile = eco.game("valheim").unwrap().profile().unwrap();
757
758 assert_eq!(profile.package_loader, LoaderKind::BepInEx);
759 assert!(
760 eco
761 .modloader_package("denikson-BepInExPack_Valheim")
762 .is_some()
763 );
764
765 mock.assert();
766 }
767
768 #[test]
769 fn steam_app_id_none_when_missing_or_unparseable() {
770 let no_steam: GameProfile = serde_json::from_str(
772 r#"{"distributions":[{"platform":"epic-games-store","identifier":"abc"}]}"#,
773 )
774 .unwrap();
775
776 assert_eq!(no_steam.steam_app_id(), None);
777
778 let bad_steam: GameProfile =
780 serde_json::from_str(r#"{"distributions":[{"platform":"steam","identifier":"nope"}]}"#)
781 .unwrap();
782
783 assert_eq!(bad_steam.steam_app_id(), None);
784
785 let steam: GameProfile =
787 serde_json::from_str(r#"{"distributions":[{"platform":"steam","identifier":"892970"}]}"#)
788 .unwrap();
789
790 assert_eq!(steam.steam_app_id(), Some(892970));
791 }
792
793 #[test]
794 fn game_profile_prefers_game_instance_then_falls_back() {
795 let server_only: GameDef =
797 serde_json::from_str(r#"{"r2modman":[{"gameInstanceType":"server"}]}"#).unwrap();
798
799 assert_eq!(
800 server_only.profile().unwrap().instance_type,
801 InstanceType::Server
802 );
803
804 let mixed: GameDef = serde_json::from_str(
806 r#"{"r2modman":[{"gameInstanceType":"server"},{"gameInstanceType":"game"}]}"#,
807 )
808 .unwrap();
809
810 assert_eq!(mixed.profile().unwrap().instance_type, InstanceType::Game);
811
812 let empty: GameDef = serde_json::from_str(r#"{"r2modman":[]}"#).unwrap();
814
815 assert!(empty.profile().is_none());
816 }
817}