Skip to main content

thunderstore_engine/
ecosystem.rs

1//! The Thunderstore ecosystem schema: games, mod loaders, and install rules.
2//!
3//! This mirrors Thunderstore's upstream ecosystem schema — the same data that
4//! drives r2modman — rather than a bespoke normalized copy. Each game carries
5//! one or more profiles (`r2modman[]`) with a [`LoaderKind`](crate::ecosystem::LoaderKind),
6//! recursive [`InstallRule`](crate::ecosystem::InstallRule)s that route a package's
7//! files, and metadata (executables, store distributions). A top-level
8//! [`ModloaderPackage`](crate::ecosystem::ModloaderPackage) registry names every
9//! package that *is* a loader pack (and the subfolder of it that unpacks to the
10//! root), so the engine never hard-codes per-game loader facts.
11//!
12//! The canonical source is the Thunderstore ecosystem schema, published as JSON
13//! at [`DEFAULT_ECOSYSTEM_URL`](crate::ecosystem::DEFAULT_ECOSYSTEM_URL). A
14//! compressed snapshot is embedded at compile time as a fallback
15//! ([`Ecosystem::bundled`](crate::ecosystem::Ecosystem::bundled));
16//! [`Ecosystem::fetch_latest`](crate::ecosystem::Ecosystem::fetch_latest) refreshes
17//! it from the network. Parsing is intentionally defensive (`#[serde(default)]`,
18//! unknown loaders/tracking methods fall back to `Other`) because this is an
19//! external, evolving contract.
20
21#![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
29/// Compressed (`zstd`) snapshot of the ecosystem schema embedded at compile time
30/// as an offline fallback. Regenerate with the command documented in `README.md`.
31const BUNDLED_ECOSYSTEM: &[u8] = include_bytes!("../assets/ecosystem-schema.json.zst");
32
33/// The default URL used by [`Ecosystem::fetch_latest`] to refresh the snapshot.
34///
35/// # Trust
36///
37/// This is Thunderstore's official (experimental) schema endpoint — the same
38/// one r2modman consumes. Because the ecosystem drives install *routing* (where
39/// a package's files land on disk), whatever this endpoint returns is fully
40/// trusted; only refresh from a source you trust. Being an *experimental*
41/// endpoint, its shape may change over time, so parsing is defensive.
42///
43/// [`Ecosystem::fetch_latest`] returns an `Err` on any refresh failure and does
44/// *not* fall back on its own; a caller that wants stale-over-broken behavior
45/// should use [`Ecosystem::bundled`] (or a previously fetched value) when the
46/// refresh fails.
47pub const DEFAULT_ECOSYSTEM_URL: &str =
48  "https://thunderstore.io/api/experimental/schema/dev/latest/";
49
50/// Maximum size of an ecosystem schema fetched over the network. The real schema
51/// is small relative to this generous cap, which bounds the memory a misbehaving or
52/// compromised endpoint can force [`Ecosystem::fetch_latest`] to allocate.
53const MAX_ECOSYSTEM_BYTES: u64 = 64 * 1024 * 1024;
54
55/// A parsed ecosystem schema: the known games plus the mod-loader package registry.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57#[non_exhaustive]
58pub struct Ecosystem {
59  /// The schema version string reported by the source (e.g. `"0.3.0"`).
60  #[serde(
61    rename = "schemaVersion",
62    default,
63    deserialize_with = "null_as_default"
64  )]
65  pub schema_version: String,
66  /// All known games, keyed by their community slug.
67  #[serde(default, deserialize_with = "null_as_default")]
68  pub games: HashMap<String, GameDef>,
69  /// Every package that is itself a mod-loader pack, with its unpack root.
70  #[serde(
71    rename = "modloaderPackages",
72    default,
73    deserialize_with = "null_as_default"
74  )]
75  pub modloader_packages: Vec<ModloaderPackage>,
76}
77
78impl Ecosystem {
79  /// Loads the bundled ecosystem snapshot embedded in the binary.
80  ///
81  /// # Panics
82  ///
83  /// Panics if the bundled snapshot fails to decompress or parse, which is a
84  /// build-time invariant. Use [`Ecosystem::try_bundled`] to handle the error.
85  ///
86  /// # Examples
87  ///
88  /// Routing works offline with no network call, which is the point of the
89  /// bundled snapshot:
90  ///
91  /// ```
92  /// use thunderstore_engine::ecosystem::Ecosystem;
93  ///
94  /// let eco = Ecosystem::bundled();
95  ///
96  /// assert!(eco.games().count() > 0);
97  /// assert!(eco.game("valheim").is_some());
98  /// ```
99  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  /// Loads the bundled ecosystem snapshot, returning an error instead of
105  /// panicking if it cannot be decompressed or parsed.
106  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  /// Parses an ecosystem snapshot from a JSON string.
114  pub fn from_json(json: &str) -> Result<Self> {
115    Self::from_json_slice(json.as_bytes())
116  }
117
118  /// Parses an ecosystem snapshot from JSON bytes.
119  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  /// Fetches the latest ecosystem snapshot from `url` and parses it.
124  ///
125  /// Use [`DEFAULT_ECOSYSTEM_URL`] for the default location. The response is
126  /// fully trusted to define install routing, so only fetch from a source you
127  /// trust — see the [`DEFAULT_ECOSYSTEM_URL`] trust note. The response body is
128  /// size-bounded (see `MAX_ECOSYSTEM_BYTES`) so a misbehaving endpoint cannot
129  /// force unbounded allocation.
130  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  /// [`fetch_latest`](Self::fetch_latest) with an explicit body-size cap and
135  /// retry policy. Transient network failures are retried; a size-cap breach or
136  /// a parse failure is terminal.
137  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      // Reject a declared-oversize body up front, before streaming a single byte,
151      // then enforce the cap against the actual streamed bytes.
152      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  /// Returns the game definition for a community slug, if known.
173  pub fn game(&self, slug: &str) -> Option<&GameDef> {
174    self.games.get(slug)
175  }
176
177  /// Iterates over all known games.
178  pub fn games(&self) -> impl Iterator<Item = &GameDef> {
179    self.games.values()
180  }
181
182  /// Looks up a package in the mod-loader registry by its `Owner-Name`
183  /// identifier, returning its entry if the package is a loader pack.
184  ///
185  /// This is the reverse lookup that replaces per-game loader configuration: a
186  /// package that appears here is unpacked to the profile root (see
187  /// [`ModloaderPackage::root_folder`]); anything else is routed as a regular
188  /// mod via the game's [`InstallRule`]s.
189  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/// A single game's definition.
198///
199/// `#[non_exhaustive]`: fields may be added as the ecosystem schema grows.
200#[derive(Debug, Clone, Serialize, Deserialize)]
201#[non_exhaustive]
202pub struct GameDef {
203  /// The Thunderstore community slug.
204  #[serde(rename = "label", default, deserialize_with = "null_as_default")]
205  pub slug: String,
206  /// The schema's stable identifier for this game.
207  #[serde(default, deserialize_with = "null_as_default")]
208  pub uuid: String,
209  /// Display metadata (name, icon).
210  #[serde(default)]
211  pub meta: Meta,
212  /// Store distributions (Steam/Epic/etc.) at the game level.
213  #[serde(default, deserialize_with = "null_as_default")]
214  pub distributions: Vec<Distribution>,
215  /// Per-manager profiles. Most games have one; a few add a dedicated-server
216  /// profile whose install rules match the primary profile's.
217  #[serde(rename = "r2modman", default, deserialize_with = "null_as_default")]
218  pub profiles: Vec<GameProfile>,
219}
220
221impl GameDef {
222  /// The human-readable display name of the game.
223  pub fn display_name(&self) -> &str {
224    &self.meta.display_name
225  }
226
227  /// Returns the primary profile to route against: the first `game`-instance
228  /// profile, or the first profile of any kind.
229  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/// A per-manager profile for a game: its loader, install rules, and metadata.
239///
240/// `#[non_exhaustive]`: fields may be added as the ecosystem schema grows.
241#[derive(Debug, Clone, Serialize, Deserialize)]
242#[non_exhaustive]
243pub struct GameProfile {
244  /// Display metadata for this profile.
245  #[serde(default)]
246  pub meta: Meta,
247  /// The mod-loader family this profile uses.
248  #[serde(
249    rename = "packageLoader",
250    default,
251    deserialize_with = "null_as_default"
252  )]
253  pub package_loader: LoaderKind,
254  /// Whether this profile targets the game or a dedicated server.
255  #[serde(
256    rename = "gameInstanceType",
257    default,
258    deserialize_with = "null_as_default"
259  )]
260  pub instance_type: InstanceType,
261  /// Candidate executable names for game-directory validation.
262  #[serde(rename = "exeNames", default, deserialize_with = "null_as_default")]
263  pub exe_names: Vec<String>,
264  /// Store distributions for this profile.
265  #[serde(default, deserialize_with = "null_as_default")]
266  pub distributions: Vec<Distribution>,
267  /// Filenames to skip when copying a package's contents (e.g. `manifest.json`).
268  /// A JSON `null` is normalized to an empty list.
269  #[serde(
270    rename = "relativeFileExclusions",
271    default,
272    deserialize_with = "null_as_default"
273  )]
274  pub relative_file_exclusions: Vec<String>,
275  /// The routing rules for a regular mod's files (a recursive forest).
276  #[serde(rename = "installRules", default, deserialize_with = "null_as_default")]
277  pub install_rules: Vec<InstallRule>,
278  /// The loader-specific data subdirectory, when the schema records one.
279  #[serde(rename = "dataFolderName", default)]
280  pub data_folder_name: Option<String>,
281  /// The manager's settings identifier for this profile.
282  #[serde(rename = "settingsIdentifier", default)]
283  pub settings_identifier: Option<String>,
284  /// The package-index URL for this community.
285  #[serde(rename = "packageIndex", default)]
286  pub package_index: Option<String>,
287}
288
289impl GameProfile {
290  /// The Steam application id, parsed from the profile's distributions, if any.
291  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/// Display metadata (name and icon) attached to a game or profile.
301#[derive(Debug, Clone, Default, Serialize, Deserialize)]
302#[non_exhaustive]
303pub struct Meta {
304  /// Human-readable display name.
305  #[serde(rename = "displayName", default, deserialize_with = "null_as_default")]
306  pub display_name: String,
307  /// Relative URL to the icon asset.
308  #[serde(rename = "iconUrl", default, deserialize_with = "null_as_default")]
309  pub icon_url: String,
310}
311
312/// A store distribution: which platform lists the game and under what id.
313#[derive(Debug, Clone, Serialize, Deserialize)]
314#[non_exhaustive]
315pub struct Distribution {
316  /// The platform, e.g. `"steam"`, `"epic-games-store"`, `"xbox-game-pass"`.
317  #[serde(default, deserialize_with = "null_as_default")]
318  pub platform: String,
319  /// The platform-specific identifier (e.g. a Steam app id as a string). May be
320  /// `null` upstream (normalized to an empty string).
321  #[serde(default, deserialize_with = "null_as_default")]
322  pub identifier: String,
323}
324
325/// A package that is itself a mod-loader pack.
326///
327/// Membership in the registry is how the engine decides a package unpacks to the
328/// profile root rather than being routed as a regular mod.
329///
330/// `#[non_exhaustive]`: fields may be added as the ecosystem schema grows.
331#[derive(Debug, Clone, Serialize, Deserialize)]
332#[non_exhaustive]
333pub struct ModloaderPackage {
334  /// The package's `Owner-Name` identifier.
335  #[serde(rename = "packageId", default, deserialize_with = "null_as_default")]
336  pub package_id: String,
337  /// The subfolder within the pack whose *contents* unpack to the root, or an
338  /// empty string when the pack's own root should be copied directly.
339  #[serde(rename = "rootFolder", default, deserialize_with = "null_as_default")]
340  pub root_folder: String,
341  /// The loader family this pack provides.
342  #[serde(default, deserialize_with = "null_as_default")]
343  pub loader: LoaderKind,
344}
345
346/// How files matched by an [`InstallRule`] are placed and tracked under its route.
347///
348/// `#[non_exhaustive]`: new tracking methods may be added without a breaking
349/// change, so downstream `match`es must include a wildcard arm.
350#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
351#[serde(rename_all = "kebab-case")]
352#[non_exhaustive]
353pub enum TrackingMethod {
354  /// Namespaced under `route/<Owner-Name>/`, flattened. The default.
355  #[default]
356  Subdir,
357  /// Written directly under `route/`, shared and untracked (e.g. config).
358  None,
359  /// Written directly under `route/`, shared, but each file recorded for
360  /// exact uninstall.
361  State,
362  /// Namespaced under `route/<Owner-Name>/`, preserving internal structure.
363  SubdirNoFlatten,
364  /// The package archive is copied into `route/` as a zip.
365  PackageZip,
366  /// An unrecognized tracking method from a newer schema.
367  #[serde(other)]
368  Other,
369}
370
371impl TrackingMethod {
372  /// Whether files routed via this method live in a per-mod `<Owner-Name>/`
373  /// folder (and are therefore reconciled by directory in [`crate::install`]).
374  pub fn is_namespaced(self) -> bool {
375    matches!(
376      self,
377      TrackingMethod::Subdir | TrackingMethod::SubdirNoFlatten
378    )
379  }
380}
381
382/// A single, possibly nested, routing rule for a regular mod's files.
383///
384/// `#[non_exhaustive]`: fields may be added as the ecosystem schema grows.
385#[derive(Debug, Clone, Serialize, Deserialize)]
386#[non_exhaustive]
387pub struct InstallRule {
388  /// Destination path relative to the profile root, e.g. `"BepInEx/plugins"`.
389  /// The final path segment is the override-folder name matched at a package's
390  /// root (e.g. `plugins`).
391  #[serde(default, deserialize_with = "null_as_default")]
392  pub route: String,
393  /// Extensions (matched as filename suffixes) that route a loose file here.
394  /// When several rules match, the longest suffix wins.
395  #[serde(
396    rename = "defaultFileExtensions",
397    default,
398    deserialize_with = "null_as_default"
399  )]
400  pub default_file_extensions: Vec<String>,
401  /// How matched files are placed and tracked under [`InstallRule::route`].
402  #[serde(
403    rename = "trackingMethod",
404    default,
405    deserialize_with = "null_as_default"
406  )]
407  pub tracking_method: TrackingMethod,
408  /// Nested rules applied within this rule's route.
409  #[serde(rename = "subRoutes", default, deserialize_with = "null_as_default")]
410  pub sub_routes: Vec<InstallRule>,
411  /// Whether this rule is the catch-all for files matching no other rule.
412  /// At most one rule per profile is the default location; some have none.
413  #[serde(
414    rename = "isDefaultLocation",
415    default,
416    deserialize_with = "null_as_default"
417  )]
418  pub is_default_location: bool,
419}
420
421/// Whether a [`GameProfile`] targets the game itself or a dedicated server.
422#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
423#[serde(rename_all = "lowercase")]
424#[non_exhaustive]
425pub enum InstanceType {
426  /// The playable game (the default).
427  #[default]
428  Game,
429  /// A dedicated server build.
430  Server,
431  /// An unrecognized instance type from a newer schema.
432  #[serde(other)]
433  Other,
434}
435
436/// The mod-loader family. Unknown values deserialize to [`LoaderKind::Other`].
437///
438/// `#[non_exhaustive]`: new loader families may be added without a breaking
439/// change, so downstream `match`es must include a wildcard arm.
440#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
441#[non_exhaustive]
442pub enum LoaderKind {
443  /// BepInEx (`bepinex`), injected through Unity Doorstop.
444  #[serde(rename = "bepinex")]
445  BepInEx,
446  /// BepisLoader (`bepisloader`), the BepInEx successor, which takes its own
447  /// argument set rather than Doorstop's.
448  #[serde(rename = "bepisloader")]
449  BepisLoader,
450  /// GDWeave (`gdweave`), pointed at its mod folder with
451  /// `--gdweave-folder-override`.
452  #[serde(rename = "gdweave")]
453  GdWeave,
454  /// Godot Mod Loader (`godotml`), driven by a `--script` entry point plus
455  /// `--mods-path`.
456  #[serde(rename = "godotml")]
457  GodotMl,
458  /// Lovely (`lovely`), pointed at its mod folder with `--mod-dir`.
459  #[serde(rename = "lovely")]
460  Lovely,
461  /// MelonLoader (`melonloader`).
462  #[serde(rename = "melonloader")]
463  MelonLoader,
464  /// MelonLoader shipped in a nested package layout (`recursive-melonloader`).
465  /// Injects identically to [`LoaderKind::MelonLoader`]; only the package's file
466  /// layout differs.
467  #[serde(rename = "recursive-melonloader")]
468  RecursiveMelonLoader,
469  /// Northstar (`northstar`), selected with `-northstar` and a `-profile=`
470  /// directory.
471  #[serde(rename = "northstar")]
472  Northstar,
473  /// ReturnOfModding (`return-of-modding`), pointed at its root with
474  /// `--rom_modding_root_folder`.
475  #[serde(rename = "return-of-modding")]
476  ReturnOfModding,
477  /// Rivet (`rivet`), enabled with `-rivetEnable` plus explicit loader and mod
478  /// paths.
479  #[serde(rename = "rivet")]
480  Rivet,
481  /// Shimloader (`shimloader`), which takes separate mod, pak, cfg, and overlay
482  /// directories.
483  #[serde(rename = "shimloader")]
484  Shimloader,
485  /// Unity Mod Manager (`umm`), injected through Unity Doorstop like
486  /// [`LoaderKind::BepInEx`].
487  #[serde(rename = "umm")]
488  Umm,
489  /// A community with no mod loader.
490  #[serde(rename = "none")]
491  None,
492  /// An unrecognized loader family from a newer schema, or an absent value.
493  #[default]
494  #[serde(other)]
495  Other,
496}
497
498/// Normalizes a JSON `null` (or absent value) to `T::default()` during
499/// deserialization, so a nullable list field becomes an empty `Vec`.
500fn 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    // A chunked body carries no Content-Length, so the cap must be enforced from
702    // the actual streamed bytes.
703    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    // No steam distribution => None.
771    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    // A steam distribution whose id is not numeric => None.
779    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    // A steam distribution with a numeric id => Some.
786    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    // Only a server-instance profile: `profile()` falls back to the first one.
796    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    // A game-instance profile is preferred over a server one, whatever the order.
805    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    // No profiles at all => None.
813    let empty: GameDef = serde_json::from_str(r#"{"r2modman":[]}"#).unwrap();
814
815    assert!(empty.profile().is_none());
816  }
817}