loadsmith_thunderstore/
schema.rs1use 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
15pub 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
225pub 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}