thunderstore_engine/profile/
modlist.rs1#![deny(missing_docs)]
9
10use crate::error::{Error, Result};
11use serde::{Deserialize, Serialize};
12use std::fs;
13use std::path::Path;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18pub struct SemVer {
19 pub major: u32,
21 pub minor: u32,
23 pub patch: u32,
26}
27
28impl SemVer {
29 pub fn parse(s: &str) -> SemVer {
47 let mut parts = s.split('.');
48 let major = parse_leading_u32(parts.next());
49 let minor = parse_leading_u32(parts.next());
50 let patch = parse_leading_u32(parts.next());
51
52 SemVer {
53 major,
54 minor,
55 patch,
56 }
57 }
58}
59
60impl std::fmt::Display for SemVer {
61 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
63 }
64}
65
66fn parse_leading_u32(component: Option<&str>) -> u32 {
70 let text = component.unwrap_or("");
71 let digits: String = text.chars().take_while(|c| c.is_ascii_digit()).collect();
72
73 digits.parse().unwrap_or(0)
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
81#[serde(rename_all = "camelCase")]
82#[non_exhaustive]
83pub struct ProfileMod {
84 pub manifest_version: u32,
86 pub name: String,
88 pub author_name: String,
90 pub website_url: String,
92 pub display_name: String,
94 pub description: String,
97 pub game_version: String,
99 pub network_mode: String,
101 pub package_type: String,
103 pub install_mode: String,
105 pub installed_at_time: u64,
107 pub loaders: Vec<String>,
109 pub dependencies: Vec<String>,
111 pub incompatibilities: Vec<String>,
113 pub optional_dependencies: Vec<String>,
115 pub version_number: SemVer,
117 pub enabled: bool,
119 #[serde(skip)]
121 pub icon: String,
122 pub online_source: bool,
124 #[serde(default)]
134 pub trusted_package: bool,
135}
136
137const MODS_YML: &str = "mods.yml";
139
140pub fn read(profile_dir: &Path) -> Result<Vec<ProfileMod>> {
146 let path = profile_dir.join(MODS_YML);
147
148 if !path.exists() {
149 return Ok(Vec::new());
150 }
151
152 let text = fs::read_to_string(&path)
153 .map_err(|e| Error::Profile(format!("reading {}: {}", path.display(), e)))?;
154
155 serde_yaml_ng::from_str(&text)
156 .map_err(|e| Error::Profile(format!("parsing {}: {}", path.display(), e)))
157}
158
159pub fn write(profile_dir: &Path, mods: &[ProfileMod]) -> Result<()> {
161 fs::create_dir_all(profile_dir)
162 .map_err(|e| Error::Profile(format!("creating {}: {}", profile_dir.display(), e)))?;
163
164 let text = serde_yaml_ng::to_string(mods)
165 .map_err(|e| Error::Profile(format!("serializing mods.yml: {}", e)))?;
166
167 let path = profile_dir.join(MODS_YML);
168
169 fs::write(&path, text).map_err(|e| Error::Profile(format!("writing {}: {}", path.display(), e)))
170}
171
172pub fn find<'a>(mods: &'a [ProfileMod], full_name: &str) -> Option<&'a ProfileMod> {
174 mods.iter().find(|m| m.name == full_name)
175}
176
177pub fn upsert(mods: &mut Vec<ProfileMod>, entry: ProfileMod) {
179 if let Some(existing) = mods.iter_mut().find(|m| m.name == entry.name) {
180 *existing = entry;
181
182 return;
183 }
184
185 mods.push(entry);
186}
187
188pub fn now_millis() -> u64 {
196 std::time::SystemTime::now()
197 .duration_since(std::time::UNIX_EPOCH)
198 .map(|elapsed| elapsed.as_millis() as u64)
199 .unwrap_or(0)
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205 use tempfile::tempdir;
206
207 fn sample() -> ProfileMod {
208 ProfileMod {
209 manifest_version: 1,
210 name: "bbepis-BepInExPack".to_string(),
211 author_name: "bbepis".to_string(),
212 website_url: "https://example.com".to_string(),
213 display_name: "BepInExPack".to_string(),
214 description: "pack".to_string(),
215 game_version: "0".to_string(),
216 network_mode: "both".to_string(),
217 package_type: "other".to_string(),
218 install_mode: "managed".to_string(),
219 installed_at_time: 1_699_999_999_999,
220 loaders: vec![],
221 dependencies: vec![],
222 incompatibilities: vec![],
223 optional_dependencies: vec![],
224 version_number: SemVer {
225 major: 5,
226 minor: 4,
227 patch: 2100,
228 },
229 enabled: true,
230 icon: String::new(),
231 online_source: false,
232 trusted_package: false,
233 }
234 }
235
236 #[test]
237 fn semver_parse_takes_first_three_numeric_components() {
238 assert_eq!(
239 SemVer::parse("5.4.2100"),
240 SemVer {
241 major: 5,
242 minor: 4,
243 patch: 2100
244 }
245 );
246 assert_eq!(
247 SemVer::parse("1.2"),
248 SemVer {
249 major: 1,
250 minor: 2,
251 patch: 0
252 }
253 );
254 assert_eq!(
255 SemVer::parse("1.2.3-beta"),
256 SemVer {
257 major: 1,
258 minor: 2,
259 patch: 3
260 }
261 );
262 assert_eq!(
263 SemVer::parse("garbage"),
264 SemVer {
265 major: 0,
266 minor: 0,
267 patch: 0
268 }
269 );
270 }
271
272 #[test]
273 fn serialized_yaml_is_r2modman_readable() {
274 let yaml = serde_yaml_ng::to_string(&vec![sample()]).unwrap();
275
276 assert!(yaml.contains("manifestVersion:"));
278 assert!(yaml.contains("name: bbepis-BepInExPack"));
279 assert!(yaml.contains("versionNumber:"));
280 assert!(yaml.contains("major: 5"));
281 assert!(yaml.contains("networkMode: both"));
282 assert!(!yaml.contains("icon"));
283 assert!(!yaml.contains("version_number"));
284 }
285
286 #[test]
287 fn read_write_round_trips_and_missing_is_empty() {
288 let dir = tempdir().unwrap();
289
290 assert_eq!(read(dir.path()).unwrap().len(), 0);
291
292 write(dir.path(), &[sample()]).unwrap();
293 let back = read(dir.path()).unwrap();
294
295 assert_eq!(back.len(), 1);
296 assert_eq!(back[0].name, "bbepis-BepInExPack");
297 assert_eq!(
298 back[0].version_number,
299 SemVer {
300 major: 5,
301 minor: 4,
302 patch: 2100
303 }
304 );
305 assert!(back[0].enabled);
306 }
307
308 #[test]
309 fn parses_real_r2modman_sample_ignoring_icon() {
310 let yaml = r#"- manifestVersion: 1
313 name: Author-Mod
314 authorName: Author
315 websiteUrl: ''
316 displayName: Mod
317 description: ''
318 gameVersion: '0'
319 networkMode: both
320 packageType: other
321 installMode: managed
322 installedAtTime: 1699999999999
323 loaders: []
324 dependencies:
325 - Other-Dep-1.2.3
326 incompatibilities: []
327 optionalDependencies: []
328 versionNumber:
329 major: 1
330 minor: 0
331 patch: 0
332 enabled: false
333 icon: /some/path/icon.png
334 onlineSource: true
335 trustedPackage: false
336"#;
337 let mods: Vec<ProfileMod> = serde_yaml_ng::from_str(yaml).unwrap();
338
339 assert_eq!(mods.len(), 1);
340 assert_eq!(mods[0].dependencies, vec!["Other-Dep-1.2.3".to_string()]);
341 assert!(!mods[0].enabled);
342 assert!(mods[0].online_source);
343 }
344
345 #[test]
350 fn parses_an_r2modman_entry_with_no_trusted_package_key() {
351 let yaml = r#"- manifestVersion: 1
352 name: denikson-BepInExPack_Valheim
353 authorName: denikson
354 websiteUrl: https://thunderstore.io/c/valheim/p/denikson/BepInExPack_Valheim/
355 displayName: BepInExPack_Valheim
356 description: BepInEx pack for Valheim.
357 gameVersion: '0'
358 networkMode: both
359 packageType: other
360 installMode: managed
361 installedAtTime: 1771107231154
362 loaders: []
363 dependencies: []
364 incompatibilities: []
365 optionalDependencies: []
366 versionNumber:
367 major: 5
368 minor: 4
369 patch: 2333
370 enabled: true
371 onlineSource: false
372"#;
373
374 let mods: Vec<ProfileMod> = serde_yaml_ng::from_str(yaml).unwrap();
375
376 assert_eq!(mods.len(), 1);
377 assert_eq!(mods[0].name, "denikson-BepInExPack_Valheim");
378 assert!(mods[0].enabled);
379
380 assert!(!mods[0].trusted_package);
383 }
384
385 #[test]
386 fn upsert_replaces_by_name_and_find_locates() {
387 let mut mods = vec![sample()];
388 let mut updated = sample();
389 updated.enabled = false;
390
391 upsert(&mut mods, updated);
392
393 assert_eq!(mods.len(), 1);
394 assert!(!find(&mods, "bbepis-BepInExPack").unwrap().enabled);
395 assert!(find(&mods, "nope").is_none());
396 }
397
398 #[test]
399 fn semver_renders_as_major_minor_patch() {
400 let version = SemVer {
401 major: 5,
402 minor: 4,
403 patch: 2200,
404 };
405
406 assert_eq!(version.to_string(), "5.4.2200");
407 assert_eq!(format!("{version}"), "5.4.2200");
408 }
409
410 #[test]
411 fn an_unreadable_record_names_its_path() {
412 let dir = tempdir().unwrap();
413
414 std::fs::write(dir.path().join("mods.yml"), "not: [valid").unwrap();
415
416 let message = read(dir.path()).unwrap_err().to_string();
417
418 assert!(
419 message.contains(&dir.path().join("mods.yml").display().to_string()),
420 "a read failure must name the file, so a caller need not add it; got: {message}"
421 );
422 }
423}