Skip to main content

loadsmith_thunderstore/
schema.rs

1use std::borrow::Cow;
2
3use camino::Utf8Path;
4use globset::{Glob, GlobSet};
5use loadsmith_install::{OwnedInstallRuleset, RouteRule};
6use loadsmith_loader::{
7    BepInEx, GDWeave, Lovely, MelonLoader, Northstar, ReturnOfModding, Rivet, Shimloader,
8};
9use loadsmith_platform::Platform as LoadsmithPlatform;
10use thunderstore::models::schema::{self, Distribution};
11use tracing::warn;
12
13use crate::{Error, Result};
14
15/// Converts an r2modman loader configuration into a [`Loader`](loadsmith_loader::Loader) implementation.
16///
17/// Maps the thunderstore loader enum to the corresponding loadsmith loader
18/// type (BepInEx, MelonLoader, GDWeave, etc.) and applies any install rules
19/// specified in the config.
20///
21/// # Examples
22///
23/// ```
24/// use loadsmith_thunderstore::r2_config_to_loader;
25/// use thunderstore::models::schema::R2ModmanConfig;
26///
27/// let json = r#"{
28///     "meta": {"displayName": "Test", "iconUrl": null},
29///     "internalFolderName": "test",
30///     "dataFolderName": "test",
31///     "distributions": [],
32///     "settingsIdentifier": "test",
33///     "packageIndex": "",
34///     "steamFolderName": "",
35///     "exeNames": [],
36///     "gameInstanceType": "",
37///     "gameSelectionDisplayMode": "",
38///     "additionalSearchStrings": [],
39///     "packageLoader": "bepinex",
40///     "installRules": []
41/// }"#;
42/// let config: R2ModmanConfig = serde_json::from_str(json).unwrap();
43/// let loader = r2_config_to_loader(&config).unwrap();
44/// ```
45pub fn r2_config_to_loader(
46    config: &schema::R2ModmanConfig,
47) -> Result<Box<dyn loadsmith_loader::Loader>> {
48    match config.package_loader {
49        schema::Loader::BepInEx => {
50            let loader = BepInEx::with_rules(convert_ruleset(config)?);
51            Ok(Box::new(loader))
52        }
53        schema::Loader::MelonLoader => {
54            let loader = MelonLoader::with_rules(convert_ruleset(config)?);
55            Ok(Box::new(loader))
56        }
57        schema::Loader::RecursiveMelonLoader => {
58            let mut loader = MelonLoader::with_default_recursive_rules();
59
60            let (rules, default, exclude) = convert_ruleset(config)?.into_parts();
61
62            if let Some(exclude) = exclude {
63                loader.set_exclude(exclude);
64            }
65            if let Some(default) = default {
66                warn!(
67                    "loader config specifies a default rule index, but the recursive melon loader does not support default rules; ignoring default rule index {default}"
68                );
69            }
70            for rule in rules {
71                loader.add_install_rule(rule);
72            }
73
74            Ok(Box::new(loader))
75        }
76        schema::Loader::Shimloader => {
77            let loader = Shimloader::with_rules(convert_ruleset(config)?);
78            Ok(Box::new(loader))
79        }
80        schema::Loader::ReturnOfModding => {
81            let loader = ReturnOfModding::with_rules(convert_ruleset(config)?);
82            Ok(Box::new(loader))
83        }
84        schema::Loader::BepisLoader => {
85            let loader = BepInEx::with_rules(convert_ruleset(config)?);
86            Ok(Box::new(loader))
87        }
88        schema::Loader::GDWeave => {
89            warn_install_rules_not_supported("GDWeave", &config.install_rules);
90
91            Ok(Box::new(GDWeave::new()))
92        }
93        schema::Loader::Lovely => {
94            warn_install_rules_not_supported("Lovely", &config.install_rules);
95
96            Ok(Box::new(Lovely::new()))
97        }
98        schema::Loader::Rivet => {
99            warn_install_rules_not_supported("Rivet", &config.install_rules);
100
101            Ok(Box::new(Rivet::new()))
102        }
103        schema::Loader::Northstar => {
104            let loader = Northstar::with_rules(convert_ruleset(config)?);
105
106            Ok(Box::new(loader))
107        }
108        loader => Err(Error::UnsupportedLoader(loader)),
109    }
110}
111
112fn warn_install_rules_not_supported(loader_name: &str, install_rules: &[schema::InstallRule]) {
113    if !install_rules.is_empty() {
114        warn!(
115            "loader config specifies install rules, but {loader_name} does not support custom install rules; ignoring rules"
116        );
117    }
118}
119
120fn convert_ruleset(config: &schema::R2ModmanConfig) -> Result<OwnedInstallRuleset> {
121    let default_rule_index = config
122        .install_rules
123        .iter()
124        .position(|rule| rule.is_default_location);
125
126    let converted_rules = config
127        .install_rules
128        .iter()
129        .map(|rule| rule_to_loadsmith(rule, Utf8Path::new("")))
130        .collect::<Result<Vec<_>>>()?
131        .into_iter()
132        .flatten()
133        .collect();
134
135    let exclude_glob_set = match &config.relative_file_exclusions {
136        Some(exclusions) => {
137            let globs = exclusions
138                .iter()
139                .map(|glob| Glob::new(glob).map_err(Error::Glob))
140                .collect::<Result<Vec<_>>>()?;
141
142            Some(GlobSet::new(globs)?)
143        }
144        None => None,
145    };
146
147    let mut ruleset = OwnedInstallRuleset::with_rules(converted_rules, default_rule_index)
148        .expect("default index should be in range");
149
150    if let Some(exclude) = exclude_glob_set {
151        ruleset.set_exclude(exclude);
152    }
153
154    Ok(ruleset)
155}
156
157fn rule_to_loadsmith(
158    rule: &schema::InstallRule,
159    prefix: &Utf8Path,
160) -> Result<Vec<loadsmith_install::InstallRule>> {
161    use schema::TrackingMethod;
162
163    if rule.tracking_method == TrackingMethod::PackageZip {
164        return Err(Error::UnsupportedTrackingMethod(rule.tracking_method));
165    }
166
167    let name = rule
168        .route
169        .rsplit_once('/')
170        .map(|(_, last)| last.to_string())
171        .unwrap_or(rule.route.clone());
172
173    let extensions = rule
174        .default_file_extensions
175        .iter()
176        .map(|ext| {
177            if let Some(stripped) = ext.strip_prefix('.') {
178                stripped.to_string()
179            } else {
180                ext.clone()
181            }
182        })
183        .map(Cow::Owned)
184        .collect();
185
186    let subdir = matches!(
187        rule.tracking_method,
188        TrackingMethod::SubdirNoFlatten | TrackingMethod::Subdir
189    );
190
191    let flatten = matches!(
192        rule.tracking_method,
193        TrackingMethod::Subdir | TrackingMethod::None | TrackingMethod::PackageZip
194    );
195
196    let mutable = matches!(
197        rule.tracking_method,
198        TrackingMethod::State | TrackingMethod::None
199    );
200
201    let target = prefix.join(&rule.route);
202    let new_prefix = prefix.join(&name);
203
204    let root_rule = loadsmith_install::InstallRule::Route(
205        RouteRule::new_with_target(name, target)
206            .with_file_extensions(extensions)
207            .with_subdir(subdir)
208            .with_flatten(flatten)
209            .with_mutable(mutable),
210    );
211
212    let sub_rules = rule
213        .sub_routes
214        .iter()
215        .map(|sub_route| rule_to_loadsmith(sub_route, &new_prefix))
216        .collect::<Result<Vec<Vec<_>>>>()?;
217
218    let rules = std::iter::once(root_rule)
219        .chain(sub_rules.into_iter().flatten())
220        .collect();
221
222    Ok(rules)
223}
224
225/// Converts a thunderstore [`Distribution`] into a [`loadsmith_platform::Platform`].
226///
227/// Handles Steam (with numeric ID), Epic Games, Xbox Game Pass, Oculus,
228/// Origin, and generic platforms.
229///
230/// # Examples
231///
232/// ```
233/// use loadsmith_thunderstore::distribution_into_platform;
234/// use thunderstore::models::schema::Distribution;
235///
236/// let json = r#"{"platform": "steam", "identifier": "12345"}"#;
237/// let dist: Distribution = serde_json::from_str(json).unwrap();
238/// let platform = distribution_into_platform(dist).unwrap();
239/// assert!(matches!(platform, loadsmith_platform::Platform::Steam { id: 12345 }));
240/// ```
241pub fn distribution_into_platform(distribution: Distribution) -> Result<LoadsmithPlatform> {
242    let identifier = distribution
243        .identifier
244        .ok_or_else(|| Error::DistributionIsMissingIdentifier);
245
246    let platform = match distribution.platform {
247        schema::Platform::Steam => {
248            let id = identifier?.parse::<u32>().map_err(Error::InvalidSteamId)?;
249
250            Ok(LoadsmithPlatform::Steam { id })
251        }
252        schema::Platform::EpicGamesStore => {
253            identifier.map(|identifier| LoadsmithPlatform::EpicGames { identifier })
254        }
255        schema::Platform::XboxGamePass => {
256            identifier.map(|identifier| LoadsmithPlatform::XboxStore { identifier })
257        }
258        schema::Platform::Other => Ok(LoadsmithPlatform::Other),
259        schema::Platform::OculusStore => Ok(LoadsmithPlatform::Oculus),
260        schema::Platform::Origin => Ok(LoadsmithPlatform::Origin),
261        platform => return Err(Error::UnsupportedPlatform(platform)),
262    }?;
263
264    Ok(platform)
265}
266
267#[cfg(test)]
268mod test {
269    use super::*;
270
271    use thunderstore::models::schema;
272
273    #[test]
274    fn parse_ecosystem() {
275        let json = include_str!("../fixtures/ecosystem.json");
276        let _ecosystem: schema::Schema =
277            serde_json::from_str(json).expect("failed to parse ecosystem");
278    }
279
280    #[test]
281    #[ignore]
282    fn fetch_and_parse_ecosystem_live() {
283        const URL: &str = "https://thunderstore.io/api/experimental/schema/dev/latest/";
284        let response = reqwest::blocking::get(URL)
285            .expect("failed to fetch ecosystem")
286            .error_for_status()
287            .expect("ecosystem fetch returned error status")
288            .json::<schema::Schema>()
289            .expect("failed to parse ecosystem");
290
291        println!("{response:#?}");
292    }
293
294    fn assert_loader_snapshot(name: &'static str, json: &str) {
295        let r2config: schema::R2ModmanConfig =
296            serde_json::from_str(json).expect("failed to parse loader config");
297
298        let loader = r2_config_to_loader(&r2config).expect("failed to make loader");
299
300        insta::assert_debug_snapshot!(name, loader);
301    }
302
303    #[test]
304    fn make_loader_lethal_company() {
305        let json = include_str!("../fixtures/r2modman-lethal-company.json");
306        assert_loader_snapshot("lethal-company", json);
307    }
308
309    #[test]
310    fn make_loader_boneworks() {
311        let json = include_str!("../fixtures/r2modman-boneworks.json");
312        assert_loader_snapshot("boneworks", json);
313    }
314
315    #[test]
316    fn make_loader_schedule_i() {
317        let json = include_str!("../fixtures/r2modman-schedule-i.json");
318        assert_loader_snapshot("schedule-i", json);
319    }
320
321    #[test]
322    fn make_loader_voices_of_the_void() {
323        let json = include_str!("../fixtures/r2modman-voices-of-the-void.json");
324        assert_loader_snapshot("voices-of-the-void", json);
325    }
326}