Skip to main content

loadsmith_core/
lib.rs

1//! Core types, traits, and errors for the loadsmith mod-manager library.
2//!
3//! This is an internal crate of the [`loadsmith`] workspace. Most consumers
4//! should depend on the `loadsmith` facade crate instead of using this
5//! crate directly.
6//!
7//! # Examples
8//!
9//! ```rust
10//! # use loadsmith_core::*;
11//! let pkg = PackageRef::new("denikson-BepInExPack_Valheim", Version::new(5, 4, 2202));
12//! assert_eq!(pkg.to_string(), "denikson-BepInExPack_Valheim@5.4.2202");
13//!
14//! let parsed: PackageRef = "x753-More_Suits@1.4.0".parse()?;
15//! assert_eq!(parsed.id().as_str(), "x753-More_Suits");
16//! assert_eq!(parsed.version().to_string(), "1.4.0");
17//! # Ok::<_, loadsmith_core::Error>(())
18//! ```
19
20use std::{fmt::Display, str::FromStr};
21
22use camino::Utf8PathBuf;
23use chrono::{DateTime, Utc};
24use serde::{Deserialize, Serialize};
25
26mod checksum;
27mod error;
28mod url;
29
30pub use checksum::{Checksum, ChecksumAlgorithm};
31pub use error::{Error, Result};
32/// Re-export of [`semver::Version`] for convenience.
33///
34/// Consumers can use this directly instead of adding `semver` to their own
35/// `Cargo.toml`.
36pub use semver::{Version, VersionReq};
37pub use url::FileUrl;
38
39/// A unique identifier for a package (e.g. `"denikson-BepInExPack_Valheim"`).
40///
41/// This is a plain string wrapper that does not include a version.
42/// Pair it with [`PackageRef`] when you need both.
43#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
44pub struct PackageId(String);
45
46impl PackageId {
47    /// Create a new `PackageId` from anything that can become a `String`.
48    pub fn new(id: impl Into<String>) -> Self {
49        Self(id.into())
50    }
51
52    /// Borrow the inner identifier as a `&str`.
53    pub fn as_str(&self) -> &str {
54        &self.0
55    }
56
57    /// Consume the `PackageId` and return the inner `String`.
58    pub fn into_string(self) -> String {
59        self.0
60    }
61}
62
63impl AsRef<str> for PackageId {
64    fn as_ref(&self) -> &str {
65        self.as_str()
66    }
67}
68
69impl From<String> for PackageId {
70    fn from(id: String) -> Self {
71        Self(id)
72    }
73}
74
75impl From<&str> for PackageId {
76    fn from(id: &str) -> Self {
77        id.to_string().into()
78    }
79}
80
81impl Display for PackageId {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        write!(f, "{}", self.0)
84    }
85}
86
87/// A reference to a specific version of a package, formatted as `"<id>@<version>"`.
88///
89/// ```rust
90/// # use loadsmith_core::{PackageRef, Version};
91/// let pkg = PackageRef::new("denikson-BepInExPack_Valheim", Version::new(5, 4, 2202));
92/// assert_eq!(pkg.to_string(), "denikson-BepInExPack_Valheim@5.4.2202");
93/// assert_eq!(pkg.id().as_str(), "denikson-BepInExPack_Valheim");
94///
95/// assert_eq!(pkg.version().major, 5);
96/// assert_eq!(pkg.version().minor, 4);
97/// assert_eq!(pkg.version().patch, 2202);
98///
99/// let (id, ver) = pkg.into_split();
100/// assert_eq!(id.as_str(), "denikson-BepInExPack_Valheim");
101///
102/// let parsed: PackageRef = "Team17-Valheim@0.220.3".parse().unwrap();
103/// assert_eq!(parsed.id().as_str(), "Team17-Valheim");
104/// assert_eq!(parsed.version().to_string(), "0.220.3");
105/// ```
106#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
107#[serde(into = "String", try_from = "&str")]
108pub struct PackageRef {
109    id: PackageId,
110    version: Version,
111}
112
113impl PackageRef {
114    /// Create a new reference from a package identifier and version.
115    pub fn new(id: impl Into<PackageId>, version: impl Into<Version>) -> Self {
116        Self {
117            id: id.into(),
118            version: version.into(),
119        }
120    }
121
122    /// Borrow the package identifier.
123    pub fn id(&self) -> &PackageId {
124        &self.id
125    }
126
127    /// Borrow the package version.
128    pub fn version(&self) -> &Version {
129        &self.version
130    }
131
132    /// Consume the reference and return the identifier.
133    pub fn into_id(self) -> PackageId {
134        self.id
135    }
136
137    /// Consume the reference and return the version.
138    pub fn into_version(self) -> Version {
139        self.version
140    }
141
142    /// Consume the reference and return both parts as a tuple.
143    pub fn into_split(self) -> (PackageId, Version) {
144        (self.id, self.version)
145    }
146}
147
148impl Display for PackageRef {
149    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150        write!(f, "{}@{}", self.id, self.version)
151    }
152}
153
154impl FromStr for PackageRef {
155    type Err = Error;
156
157    fn from_str(s: &str) -> Result<Self> {
158        let mut split = s.split('@');
159
160        let id = split.next().ok_or(Error::InvalidPackageRefFormat)?;
161        let version = split.next().ok_or(Error::InvalidPackageRefFormat)?;
162
163        if split.next().is_some() {
164            return Err(Error::InvalidPackageRefFormat);
165        }
166
167        let id = PackageId::new(id);
168        let version = Version::from_str(version)?;
169
170        Ok(Self::new(id, version))
171    }
172}
173
174impl From<PackageRef> for String {
175    fn from(package_ref: PackageRef) -> Self {
176        package_ref.to_string()
177    }
178}
179
180impl TryFrom<&str> for PackageRef {
181    type Error = Error;
182
183    fn try_from(s: &str) -> Result<Self> {
184        s.parse()
185    }
186}
187
188/// A record of a package installation at a specific point in time.
189///
190/// Tracks the package reference, install date, file inventory, and an
191/// optional checksum for integrity verification.
192///
193/// ```rust
194/// # use loadsmith_core::*;
195/// let pkg = PackageRef::new("denikson-BepInExPack_Valheim", Version::new(5, 4, 2202));
196/// let file = InstalledFile::new("BepInEx/plugins/MyMod.dll", true);
197///
198/// let record = InstalledPackage::now(pkg, vec![file], None);
199/// assert_eq!(record.ref_().id().as_str(), "denikson-BepInExPack_Valheim");
200/// assert_eq!(record.files().len(), 1);
201/// assert!(record.checksum().is_none());
202/// ```
203#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
204pub struct InstalledPackage {
205    #[serde(rename = "package")]
206    ref_: PackageRef,
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    checksum: Option<Checksum>,
209    date: DateTime<Utc>,
210    files: Vec<InstalledFile>,
211}
212
213/// A single file belonging to an installed package.
214#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
215pub struct InstalledFile {
216    relative_path: Utf8PathBuf,
217    linked: bool,
218}
219
220impl InstalledPackage {
221    /// Create a new install record with the current timestamp.
222    ///
223    /// ```rust
224    /// # use loadsmith_core::*;
225    /// let record = InstalledPackage::now(
226    ///     PackageRef::new("x753-More_Suits", Version::new(1, 4, 0)),
227    ///     vec![InstalledFile::new("BepInEx/plugins/More_Suits.dll", false)],
228    ///     None,
229    /// );
230    /// assert_eq!(record.ref_().id().as_str(), "x753-More_Suits");
231    /// assert!(record.date() <= &chrono::Utc::now());
232    /// ```
233    pub fn now(ref_: PackageRef, files: Vec<InstalledFile>, checksum: Option<Checksum>) -> Self {
234        Self {
235            ref_,
236            files,
237            date: Utc::now(),
238            checksum,
239        }
240    }
241
242    /// Borrow the package reference.
243    pub fn ref_(&self) -> &PackageRef {
244        &self.ref_
245    }
246
247    /// Borrow the list of installed files.
248    pub fn files(&self) -> &[InstalledFile] {
249        &self.files
250    }
251
252    /// Mutate the list of installed files.
253    pub fn files_mut(&mut self) -> &mut Vec<InstalledFile> {
254        &mut self.files
255    }
256
257    /// Borrow the installation timestamp.
258    pub fn date(&self) -> &DateTime<Utc> {
259        &self.date
260    }
261
262    /// Borrow the optional checksum.
263    pub fn checksum(&self) -> Option<&Checksum> {
264        self.checksum.as_ref()
265    }
266}
267
268impl InstalledFile {
269    /// Create a new installed file entry.
270    ///
271    /// `relative_path` is expected to be relative to the package install root.
272    pub fn new(relative_path: impl Into<Utf8PathBuf>, linked: bool) -> Self {
273        Self {
274            relative_path: relative_path.into(),
275            linked,
276        }
277    }
278
279    /// Borrow the relative path of the file.
280    pub fn relative_path(&self) -> &Utf8PathBuf {
281        &self.relative_path
282    }
283
284    /// Whether this file is a symlink rather than a copy.
285    pub fn linked(&self) -> bool {
286        self.linked
287    }
288}
289
290/// A declared dependency on another package.
291///
292/// ```rust
293/// # use loadsmith_core::{Dependency, VersionReq};
294/// let dep = Dependency::new(
295///     "x753-More_Suits",
296///     VersionReq::parse(">=1.0").unwrap(),
297///     "thunderstore",
298/// );
299/// assert_eq!(dep.id.as_str(), "x753-More_Suits");
300///
301/// let dep = dep.with_registry_metadata(
302///     serde_json::json!({"website_url": "https://thunderstore.io/c/valheim/"}),
303/// );
304/// assert!(dep.registry_metadata.is_some());
305/// ```
306#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
307pub struct Dependency {
308    pub id: PackageId,
309    pub version_req: VersionReq,
310    pub source: String,
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub registry_metadata: Option<serde_json::Value>,
313}
314
315impl Dependency {
316    /// Create a new dependency with the given identifier, version
317    /// requirement, and source string.
318    pub fn new(
319        id: impl Into<PackageId>,
320        version_req: impl Into<VersionReq>,
321        source: impl Into<String>,
322    ) -> Self {
323        Self {
324            id: id.into(),
325            version_req: version_req.into(),
326            source: source.into(),
327            registry_metadata: None,
328        }
329    }
330
331    /// Attach registry-specific metadata to this dependency.
332    ///
333    /// ```rust
334    /// # use loadsmith_core::{Dependency, VersionReq};
335    /// let dep = Dependency::new("denikson-BepInExPack_Valheim", VersionReq::STAR, "thunderstore")
336    ///     .with_registry_metadata(serde_json::json!({"key": "val"}));
337    /// assert_eq!(dep.registry_metadata.unwrap()["key"], "val");
338    /// ```
339    pub fn with_registry_metadata(mut self, metadata: impl Into<serde_json::Value>) -> Self {
340        self.registry_metadata = Some(metadata.into());
341        self
342    }
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    use std::assert_matches;
350
351    #[test]
352    fn package_ref_from_str() {
353        let package_ref: PackageRef = "author-name@1.2.3"
354            .parse()
355            .expect("failed to parse package ref");
356        assert_eq!(package_ref.id.as_str(), "author-name");
357        assert_eq!(package_ref.version, Version::new(1, 2, 3));
358    }
359
360    #[test]
361    fn package_ref_from_str_two_ats() {
362        let res: Result<PackageRef> = "author-name@1.2.3@4.5.6".parse();
363        assert_matches!(res, Err(Error::InvalidPackageRefFormat));
364    }
365
366    #[test]
367    fn package_ref_from_str_invalid() {
368        let res: Result<PackageRef> = "author-name-1.2.3".parse();
369        assert_matches!(res, Err(Error::InvalidPackageRefFormat));
370    }
371
372    #[test]
373    fn package_id_from_str() {
374        let package_id: PackageId = PackageId::new("author-name");
375        assert_eq!(package_id.as_str(), "author-name");
376    }
377}