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]
358 Subdir,
359 None,
361 State,
364 SubdirNoFlatten,
366 PackageZip,
368 #[serde(other)]
370 Other,
371}
372
373impl TrackingMethod {
374 pub fn is_namespaced(self) -> bool {
377 matches!(
378 self,
379 TrackingMethod::Subdir | TrackingMethod::SubdirNoFlatten
380 )
381 }
382}
383
384#[derive(Debug, Clone, Serialize, Deserialize)]
388#[non_exhaustive]
389pub struct InstallRule {
390 #[serde(default, deserialize_with = "null_as_default")]
394 pub route: String,
395 #[serde(
398 rename = "defaultFileExtensions",
399 default,
400 deserialize_with = "null_as_default"
401 )]
402 pub default_file_extensions: Vec<String>,
403 #[serde(
405 rename = "trackingMethod",
406 default,
407 deserialize_with = "null_as_default"
408 )]
409 pub tracking_method: TrackingMethod,
410 #[serde(rename = "subRoutes", default, deserialize_with = "null_as_default")]
412 pub sub_routes: Vec<InstallRule>,
413 #[serde(
416 rename = "isDefaultLocation",
417 default,
418 deserialize_with = "null_as_default"
419 )]
420 pub is_default_location: bool,
421}
422
423#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
425#[serde(rename_all = "lowercase")]
426#[non_exhaustive]
427pub enum InstanceType {
428 #[default]
430 Game,
431 Server,
433 #[serde(other)]
435 Other,
436}
437
438#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
443#[non_exhaustive]
444pub enum LoaderKind {
445 #[serde(rename = "bepinex")]
447 BepInEx,
448 #[serde(rename = "bepisloader")]
451 BepisLoader,
452 #[serde(rename = "gdweave")]
455 GdWeave,
456 #[serde(rename = "godotml")]
459 GodotMl,
460 #[serde(rename = "lovely")]
462 Lovely,
463 #[serde(rename = "melonloader")]
465 MelonLoader,
466 #[serde(rename = "recursive-melonloader")]
470 RecursiveMelonLoader,
471 #[serde(rename = "northstar")]
474 Northstar,
475 #[serde(rename = "return-of-modding")]
478 ReturnOfModding,
479 #[serde(rename = "rivet")]
482 Rivet,
483 #[serde(rename = "shimloader")]
486 Shimloader,
487 #[serde(rename = "umm")]
490 Umm,
491 #[serde(rename = "none")]
493 None,
494 #[default]
496 #[serde(other)]
497 Other,
498}
499
500fn null_as_default<'de, D, T>(deserializer: D) -> std::result::Result<T, D::Error>
503where
504 D: Deserializer<'de>,
505 T: Deserialize<'de> + Default,
506{
507 let opt = Option::<T>::deserialize(deserializer)?;
508
509 Ok(opt.unwrap_or_default())
510}
511
512#[cfg(test)]
513mod tests {
514 use super::*;
515
516 #[test]
517 fn test_bundled_parses() {
518 let eco = Ecosystem::bundled();
519
520 assert!(!eco.games.is_empty());
521 assert!(!eco.modloader_packages.is_empty());
522 assert_eq!(eco.schema_version, "0.3.0");
523 }
524
525 #[test]
526 fn test_valheim_bepinex() {
527 let eco = Ecosystem::bundled();
528 let valheim = eco.game("valheim").expect("valheim present");
529
530 assert_eq!(valheim.slug, "valheim");
531 assert_eq!(valheim.display_name(), "Valheim");
532
533 let profile = valheim.profile().expect("a profile");
534
535 assert_eq!(profile.package_loader, LoaderKind::BepInEx);
536 assert_eq!(profile.instance_type, InstanceType::Game);
537 assert_eq!(profile.steam_app_id(), Some(892970));
538 assert!(!profile.install_rules.is_empty());
539
540 let plugins = profile
541 .install_rules
542 .iter()
543 .find(|r| r.route == "BepInEx/plugins")
544 .expect("plugins rule");
545
546 assert_eq!(plugins.tracking_method, TrackingMethod::Subdir);
547 assert!(plugins.tracking_method.is_namespaced());
548
549 let monomod = profile
550 .install_rules
551 .iter()
552 .find(|r| r.route == "BepInEx/monomod")
553 .expect("monomod rule");
554
555 assert!(
556 monomod
557 .default_file_extensions
558 .iter()
559 .any(|e| e == ".mm.dll")
560 );
561
562 let config = profile
563 .install_rules
564 .iter()
565 .find(|r| r.route == "BepInEx/config")
566 .expect("config rule");
567
568 assert!(!config.tracking_method.is_namespaced());
569 }
570
571 #[test]
572 fn test_modloader_registry_lookup() {
573 let eco = Ecosystem::bundled();
574
575 let pack = eco
576 .modloader_package("denikson-BepInExPack_Valheim")
577 .expect("valheim loader pack present");
578
579 assert_eq!(pack.root_folder, "BepInExPack_Valheim");
580 assert_eq!(pack.loader, LoaderKind::BepInEx);
581
582 assert!(eco.modloader_package("Author-SomeMod").is_none());
583 }
584
585 #[test]
586 fn test_melonloader_game_present() {
587 let eco = Ecosystem::bundled();
588
589 let game = eco
590 .games()
591 .find(|g| {
592 g.profile()
593 .map(|p| p.package_loader == LoaderKind::MelonLoader)
594 .unwrap_or(false)
595 })
596 .expect("at least one MelonLoader game");
597
598 assert!(game.profile().is_some());
599 }
600
601 #[test]
602 fn test_unknown_loader_falls_back_to_other() {
603 let json = r#"{
604 "schemaVersion": "9.9.9",
605 "games": {
606 "x": { "label": "x", "meta": {"displayName": "X"},
607 "r2modman": [ { "packageLoader": "someNewLoader", "installRules": [] } ] }
608 },
609 "modloaderPackages": []
610 }"#;
611
612 let eco = Ecosystem::from_json(json).unwrap();
613 let profile = eco.game("x").unwrap().profile().unwrap();
614
615 assert_eq!(profile.package_loader, LoaderKind::Other);
616 }
617
618 #[test]
619 fn test_null_relative_file_exclusions_normalizes_to_empty() {
620 let json = r#"{
621 "games": {
622 "x": { "label": "x",
623 "r2modman": [ { "packageLoader": "bepinex", "relativeFileExclusions": null, "installRules": [] } ] }
624 }
625 }"#;
626
627 let eco = Ecosystem::from_json(json).unwrap();
628 let profile = eco.game("x").unwrap().profile().unwrap();
629
630 assert!(profile.relative_file_exclusions.is_empty());
631 }
632
633 #[test]
634 fn test_null_enum_and_bool_fields_normalize_to_defaults() {
635 let json = r#"{
636 "games": {
637 "x": { "label": "x",
638 "r2modman": [ { "packageLoader": null, "gameInstanceType": null,
639 "installRules": [ { "route": "Mods", "trackingMethod": null, "isDefaultLocation": null } ] } ] }
640 }
641 }"#;
642
643 let eco = Ecosystem::from_json(json).unwrap();
644 let profile = eco.game("x").unwrap().profile().unwrap();
645
646 assert_eq!(profile.package_loader, LoaderKind::Other);
647 assert_eq!(profile.instance_type, InstanceType::Game);
648
649 let rule = &profile.install_rules[0];
650
651 assert_eq!(rule.tracking_method, TrackingMethod::Subdir);
652 assert!(!rule.is_default_location);
653 }
654
655 #[test]
656 fn test_unknown_tracking_method_falls_back_to_other() {
657 let json = r#"{
658 "games": {
659 "x": { "label": "x",
660 "r2modman": [ { "packageLoader": "bepinex",
661 "installRules": [ { "route": "Mods", "trackingMethod": "brand-new-method" } ] } ] }
662 }
663 }"#;
664
665 let eco = Ecosystem::from_json(json).unwrap();
666 let rule = &eco.game("x").unwrap().profile().unwrap().install_rules[0];
667
668 assert_eq!(rule.tracking_method, TrackingMethod::Other);
669 assert!(!rule.tracking_method.is_namespaced());
670 }
671
672 #[test]
673 fn test_fetch_latest_rejects_declared_oversize_body() {
674 let mut server = mockito::Server::new();
675 let body = "x".repeat(1000);
676
677 let mock = server
678 .mock("GET", "/schema")
679 .with_status(200)
680 .with_body(&body)
681 .create();
682
683 let url = format!("{}/schema", server.url());
684 let rt = tokio::runtime::Runtime::new().unwrap();
685 let client = reqwest::Client::new();
686
687 let result = rt.block_on(Ecosystem::fetch_latest_bounded(
688 &client,
689 &url,
690 16,
691 &RetryPolicy::none(),
692 ));
693
694 assert!(matches!(result, Err(Error::SizeLimitExceeded { .. })));
695
696 mock.assert();
697 }
698
699 #[test]
700 fn test_fetch_latest_rejects_streamed_oversize_body() {
701 let mut server = mockito::Server::new();
702
703 let mock = server
706 .mock("GET", "/schema")
707 .with_status(200)
708 .with_chunked_body(|w| w.write_all(&vec![b'x'; 1000]))
709 .create();
710
711 let url = format!("{}/schema", server.url());
712 let rt = tokio::runtime::Runtime::new().unwrap();
713 let client = reqwest::Client::new();
714
715 let result = rt.block_on(Ecosystem::fetch_latest_bounded(
716 &client,
717 &url,
718 16,
719 &RetryPolicy::none(),
720 ));
721
722 assert!(matches!(result, Err(Error::SizeLimitExceeded { .. })));
723
724 mock.assert();
725 }
726
727 #[test]
728 fn test_fetch_latest_parses_network_schema() {
729 let mut server = mockito::Server::new();
730
731 let body = r#"{
732 "schemaVersion": "0.3.0",
733 "games": {
734 "valheim": { "label": "valheim", "meta": {"displayName": "Valheim"},
735 "r2modman": [ { "packageLoader": "bepinex",
736 "installRules": [ { "route": "BepInEx/plugins", "trackingMethod": "subdir",
737 "isDefaultLocation": true, "defaultFileExtensions": [".dll"] } ] } ] }
738 },
739 "modloaderPackages": [
740 { "packageId": "denikson-BepInExPack_Valheim", "rootFolder": "BepInExPack_Valheim", "loader": "bepinex" }
741 ]
742 }"#;
743
744 let mock = server
745 .mock("GET", "/schema")
746 .with_status(200)
747 .with_header("Content-Type", "application/json")
748 .with_body(body)
749 .create();
750
751 let url = format!("{}/schema", server.url());
752 let rt = tokio::runtime::Runtime::new().unwrap();
753 let client = reqwest::Client::new();
754 let eco = rt.block_on(Ecosystem::fetch_latest(&client, &url)).unwrap();
755
756 assert_eq!(eco.schema_version, "0.3.0");
757
758 let profile = eco.game("valheim").unwrap().profile().unwrap();
759
760 assert_eq!(profile.package_loader, LoaderKind::BepInEx);
761 assert!(
762 eco
763 .modloader_package("denikson-BepInExPack_Valheim")
764 .is_some()
765 );
766
767 mock.assert();
768 }
769
770 #[test]
771 fn steam_app_id_none_when_missing_or_unparseable() {
772 let no_steam: GameProfile = serde_json::from_str(
774 r#"{"distributions":[{"platform":"epic-games-store","identifier":"abc"}]}"#,
775 )
776 .unwrap();
777
778 assert_eq!(no_steam.steam_app_id(), None);
779
780 let bad_steam: GameProfile =
782 serde_json::from_str(r#"{"distributions":[{"platform":"steam","identifier":"nope"}]}"#)
783 .unwrap();
784
785 assert_eq!(bad_steam.steam_app_id(), None);
786
787 let steam: GameProfile =
789 serde_json::from_str(r#"{"distributions":[{"platform":"steam","identifier":"892970"}]}"#)
790 .unwrap();
791
792 assert_eq!(steam.steam_app_id(), Some(892970));
793 }
794
795 #[test]
796 fn game_profile_prefers_game_instance_then_falls_back() {
797 let server_only: GameDef =
799 serde_json::from_str(r#"{"r2modman":[{"gameInstanceType":"server"}]}"#).unwrap();
800
801 assert_eq!(
802 server_only.profile().unwrap().instance_type,
803 InstanceType::Server
804 );
805
806 let mixed: GameDef = serde_json::from_str(
808 r#"{"r2modman":[{"gameInstanceType":"server"},{"gameInstanceType":"game"}]}"#,
809 )
810 .unwrap();
811
812 assert_eq!(mixed.profile().unwrap().instance_type, InstanceType::Game);
813
814 let empty: GameDef = serde_json::from_str(r#"{"r2modman":[]}"#).unwrap();
816
817 assert!(empty.profile().is_none());
818 }
819}