Skip to main content

thunderstore_engine/profile/
modlist.rs

1//! The `mods.yml` model and its read/write.
2//!
3//! `mods.yml` is a YAML array of [`ProfileMod`] and is the authoritative record
4//! of what is installed in a profile. The shape mirrors r2modman's `ManifestV2`
5//! so r2modman can read a profile this engine produced: keys are camelCase, the
6//! `icon` field is never written, and `versionNumber` is a nested object.
7
8#![deny(missing_docs)]
9
10use crate::error::{Error, Result};
11use serde::{Deserialize, Serialize};
12use std::fs;
13use std::path::Path;
14
15/// The `major.minor.patch` version object, serialized as a nested mapping to
16/// match r2modman's `VersionNumber`.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18pub struct SemVer {
19  /// The major component, or `0` if the source string had none.
20  pub major: u32,
21  /// The minor component, or `0` if the source string had none.
22  pub minor: u32,
23  /// The patch component, or `0` if the source string had none. A prerelease
24  /// suffix is discarded, so `1.2.3-beta` parses to a patch of `3`.
25  pub patch: u32,
26}
27
28impl SemVer {
29  /// Parses the first three dot-separated integer components of `s`; a missing or
30  /// non-numeric component becomes `0` (so `"1.2"` -> `1.2.0`, `"1.2.3-beta"` ->
31  /// `1.2.3`).
32  ///
33  /// Never fails and never panics: an unparseable or overflowing component
34  /// degrades to `0` rather than rejecting a `mods.yml` r2modman itself accepts.
35  ///
36  /// # Examples
37  ///
38  /// ```
39  /// use thunderstore_engine::profile::modlist::SemVer;
40  ///
41  /// assert_eq!(SemVer::parse("1.2.3").to_string(), "1.2.3");
42  /// assert_eq!(SemVer::parse("1.2").to_string(), "1.2.0");
43  /// assert_eq!(SemVer::parse("1.2.3-beta").to_string(), "1.2.3");
44  /// assert_eq!(SemVer::parse("not-a-version").to_string(), "0.0.0");
45  /// ```
46  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
66/// Parses the leading run of ASCII digits of an optional component into a `u32`,
67/// yielding `0` for `None`, an empty run, or overflow. This tolerates a
68/// prerelease suffix (`"3-beta"` -> `3`) the way r2modman's parser does.
69fn 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/// One installed mod, mirroring r2modman's `ManifestV2`. Field order matches
77/// r2modman's declaration order; `icon` is never serialized.
78///
79/// `#[non_exhaustive]`: fields may be added as the schema grows.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81#[serde(rename_all = "camelCase")]
82#[non_exhaustive]
83pub struct ProfileMod {
84  /// The `manifest.json` schema version the package declared.
85  pub manifest_version: u32,
86  /// The package name, without its owner prefix.
87  pub name: String,
88  /// The package owner, as Thunderstore spells it.
89  pub author_name: String,
90  /// The package's homepage, or empty when it declared none.
91  pub website_url: String,
92  /// The name to show a user, which may differ from [`Self::name`].
93  pub display_name: String,
94  /// The package's own description, shown verbatim. Not validated or truncated,
95  /// so a UI must treat it as untrusted text from an arbitrary uploader.
96  pub description: String,
97  /// The game version the package declared, carried through verbatim.
98  pub game_version: String,
99  /// r2modman's network-mode marker, carried through verbatim.
100  pub network_mode: String,
101  /// r2modman's package-type marker, carried through verbatim.
102  pub package_type: String,
103  /// r2modman's install-mode marker, carried through verbatim.
104  pub install_mode: String,
105  /// When the mod was installed, as a Unix timestamp in milliseconds.
106  pub installed_at_time: u64,
107  /// Mod loaders the package declared support for.
108  pub loaders: Vec<String>,
109  /// Full `Owner-Name-Version` dependency strings.
110  pub dependencies: Vec<String>,
111  /// Packages this one declares it cannot coexist with.
112  pub incompatibilities: Vec<String>,
113  /// Dependencies the package treats as optional.
114  pub optional_dependencies: Vec<String>,
115  /// The installed version.
116  pub version_number: SemVer,
117  /// Whether the mod is currently enabled in the profile.
118  pub enabled: bool,
119  /// Never written to `mods.yml` (r2modman strips it); ignored on read.
120  #[serde(skip)]
121  pub icon: String,
122  /// Whether the mod came from Thunderstore rather than a local import.
123  pub online_source: bool,
124  /// Absent from the `mods.yml` that installed r2modmanPlus versions actually
125  /// write, so it must not be required: demanding it made importing a real
126  /// profile fail with `missing field `trustedPackage`` *after* every file had
127  /// already been copied into place.
128  ///
129  /// Defaulting to `false` cannot change behavior, because nothing in the engine
130  /// branches on this flag; it is carried purely so a round-trip through
131  /// [`read()`]/[`write()`] does not drop a key a newer r2modman does write. `false`
132  /// is also the conservative reading of "is this package trusted".
133  #[serde(default)]
134  pub trusted_package: bool,
135}
136
137/// The `mods.yml` filename within a profile directory.
138const MODS_YML: &str = "mods.yml";
139
140/// Reads `<profile_dir>/mods.yml`. A missing file is an empty list (a fresh
141/// profile), matching r2modman treating an absent list as `[]`.
142///
143/// A read or parse failure names the file in its message, so a caller does not
144/// need to prepend the path itself.
145pub 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
159/// Writes `mods` to `<profile_dir>/mods.yml`, creating the directory if needed.
160pub 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
172/// Finds an installed mod by its `Owner-Name` identifier.
173pub fn find<'a>(mods: &'a [ProfileMod], full_name: &str) -> Option<&'a ProfileMod> {
174  mods.iter().find(|m| m.name == full_name)
175}
176
177/// Inserts `entry`, replacing any existing entry with the same `name`.
178pub 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
188/// The current time in the epoch-millisecond encoding [`ProfileMod::installed_at_time`]
189/// records, matching r2modman's `installedAtTime`.
190///
191/// A clock reading before the Unix epoch (a misconfigured system clock) falls
192/// back to `0` rather than failing an install over it: nothing in `mods.yml`
193/// or the install pipeline treats this timestamp as anything but display
194/// metadata, so there is no correctness reason to refuse the install.
195pub 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    // camelCase keys, no `icon`, nested versionNumber.
277    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    // A real-shaped entry, including an `icon` key r2modman would have stripped
311    // but a hand-edited file might carry: it must be ignored, not rejected.
312    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  /// The shape r2modman actually writes on disk, verbatim from an installed
346  /// r2modmanPlus profile: no `trustedPackage` key at all. Requiring it made
347  /// importing a real profile fail with `missing field `trustedPackage``, after
348  /// the import had already copied every file into place.
349  #[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    // Absent means false: the conservative reading of "is this package trusted",
381    // and nothing in the engine branches on it, so a default cannot change behavior.
382    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}