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>/`. The contents of a matched override
355  /// folder keep their structure; a file reached outside one (by extension or
356  /// the default location) is flattened to its base name. The default.
357  #[default]
358  Subdir,
359  /// Written directly under `route/`, shared and untracked (e.g. config).
360  None,
361  /// Written directly under `route/`, shared, but each file recorded for
362  /// exact uninstall.
363  State,
364  /// Namespaced under `route/<Owner-Name>/`, preserving internal structure.
365  SubdirNoFlatten,
366  /// The package archive is copied into `route/` as a zip.
367  PackageZip,
368  /// An unrecognized tracking method from a newer schema.
369  #[serde(other)]
370  Other,
371}
372
373impl TrackingMethod {
374  /// Whether files routed via this method live in a per-mod `<Owner-Name>/`
375  /// folder (and are therefore reconciled by directory in [`crate::install`]).
376  pub fn is_namespaced(self) -> bool {
377    matches!(
378      self,
379      TrackingMethod::Subdir | TrackingMethod::SubdirNoFlatten
380    )
381  }
382}
383
384/// A single, possibly nested, routing rule for a regular mod's files.
385///
386/// `#[non_exhaustive]`: fields may be added as the ecosystem schema grows.
387#[derive(Debug, Clone, Serialize, Deserialize)]
388#[non_exhaustive]
389pub struct InstallRule {
390  /// Destination path relative to the profile root, e.g. `"BepInEx/plugins"`.
391  /// The final path segment is the override-folder name matched at a package's
392  /// root (e.g. `plugins`).
393  #[serde(default, deserialize_with = "null_as_default")]
394  pub route: String,
395  /// Extensions (matched as filename suffixes) that route a loose file here.
396  /// When several rules match, the longest suffix wins.
397  #[serde(
398    rename = "defaultFileExtensions",
399    default,
400    deserialize_with = "null_as_default"
401  )]
402  pub default_file_extensions: Vec<String>,
403  /// How matched files are placed and tracked under [`InstallRule::route`].
404  #[serde(
405    rename = "trackingMethod",
406    default,
407    deserialize_with = "null_as_default"
408  )]
409  pub tracking_method: TrackingMethod,
410  /// Nested rules applied within this rule's route.
411  #[serde(rename = "subRoutes", default, deserialize_with = "null_as_default")]
412  pub sub_routes: Vec<InstallRule>,
413  /// Whether this rule is the catch-all for files matching no other rule.
414  /// At most one rule per profile is the default location; some have none.
415  #[serde(
416    rename = "isDefaultLocation",
417    default,
418    deserialize_with = "null_as_default"
419  )]
420  pub is_default_location: bool,
421}
422
423/// Whether a [`GameProfile`] targets the game itself or a dedicated server.
424#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
425#[serde(rename_all = "lowercase")]
426#[non_exhaustive]
427pub enum InstanceType {
428  /// The playable game (the default).
429  #[default]
430  Game,
431  /// A dedicated server build.
432  Server,
433  /// An unrecognized instance type from a newer schema.
434  #[serde(other)]
435  Other,
436}
437
438/// The mod-loader family. Unknown values deserialize to [`LoaderKind::Other`].
439///
440/// `#[non_exhaustive]`: new loader families may be added without a breaking
441/// change, so downstream `match`es must include a wildcard arm.
442#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
443#[non_exhaustive]
444pub enum LoaderKind {
445  /// BepInEx (`bepinex`), injected through Unity Doorstop.
446  #[serde(rename = "bepinex")]
447  BepInEx,
448  /// BepisLoader (`bepisloader`), the BepInEx successor, which takes its own
449  /// argument set rather than Doorstop's.
450  #[serde(rename = "bepisloader")]
451  BepisLoader,
452  /// GDWeave (`gdweave`), pointed at its mod folder with
453  /// `--gdweave-folder-override`.
454  #[serde(rename = "gdweave")]
455  GdWeave,
456  /// Godot Mod Loader (`godotml`), driven by a `--script` entry point plus
457  /// `--mods-path`.
458  #[serde(rename = "godotml")]
459  GodotMl,
460  /// Lovely (`lovely`), pointed at its mod folder with `--mod-dir`.
461  #[serde(rename = "lovely")]
462  Lovely,
463  /// MelonLoader (`melonloader`).
464  #[serde(rename = "melonloader")]
465  MelonLoader,
466  /// MelonLoader shipped in a nested package layout (`recursive-melonloader`).
467  /// Injects identically to [`LoaderKind::MelonLoader`]; only the package's file
468  /// layout differs.
469  #[serde(rename = "recursive-melonloader")]
470  RecursiveMelonLoader,
471  /// Northstar (`northstar`), selected with `-northstar` and a `-profile=`
472  /// directory.
473  #[serde(rename = "northstar")]
474  Northstar,
475  /// ReturnOfModding (`return-of-modding`), pointed at its root with
476  /// `--rom_modding_root_folder`.
477  #[serde(rename = "return-of-modding")]
478  ReturnOfModding,
479  /// Rivet (`rivet`), enabled with `-rivetEnable` plus explicit loader and mod
480  /// paths.
481  #[serde(rename = "rivet")]
482  Rivet,
483  /// Shimloader (`shimloader`), which takes separate mod, pak, cfg, and overlay
484  /// directories.
485  #[serde(rename = "shimloader")]
486  Shimloader,
487  /// Unity Mod Manager (`umm`), injected through Unity Doorstop like
488  /// [`LoaderKind::BepInEx`].
489  #[serde(rename = "umm")]
490  Umm,
491  /// A community with no mod loader.
492  #[serde(rename = "none")]
493  None,
494  /// An unrecognized loader family from a newer schema, or an absent value.
495  #[default]
496  #[serde(other)]
497  Other,
498}
499
500/// Normalizes a JSON `null` (or absent value) to `T::default()` during
501/// deserialization, so a nullable list field becomes an empty `Vec`.
502fn 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    // A chunked body carries no Content-Length, so the cap must be enforced from
704    // the actual streamed bytes.
705    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    // No steam distribution => None.
773    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    // A steam distribution whose id is not numeric => None.
781    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    // A steam distribution with a numeric id => Some.
788    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    // Only a server-instance profile: `profile()` falls back to the first one.
798    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    // A game-instance profile is preferred over a server one, whatever the order.
807    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    // No profiles at all => None.
815    let empty: GameDef = serde_json::from_str(r#"{"r2modman":[]}"#).unwrap();
816
817    assert!(empty.profile().is_none());
818  }
819}