Skip to main content

loadsmith_thunderstore/r2z/
mod.rs

1use std::{
2    fmt::Display,
3    path::{Component, Path},
4};
5
6use camino::Utf8PathBuf;
7use serde::{Deserialize, Serialize};
8use thunderstore::PackageIdent;
9
10use crate::{Error, Result};
11
12mod export;
13mod import;
14
15pub use export::ExportFile;
16pub use import::ImportFile;
17
18/// A profile manifest compatible with the r2modman export format.
19///
20/// The generic parameter `T` captures any extra fields that are flattened
21/// alongside the standard `profile_name` and `mods` fields.
22///
23/// # Examples
24///
25/// ```
26/// use loadsmith_thunderstore::r2z::{ProfileManifest, Mod, Version};
27/// use thunderstore::PackageIdent;
28///
29/// let manifest = ProfileManifest::new(
30///     "My Profile",
31///     vec![Mod::new(
32///         PackageIdent::new("BepInEx", "BepInExPack"),
33///         Version::new(5, 4, 2100),
34///         true,
35///     )],
36///     (),
37/// );
38/// assert_eq!(manifest.profile_name, "My Profile");
39/// assert_eq!(manifest.mods.len(), 1);
40/// ```
41#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
42#[serde(rename_all = "camelCase")]
43pub struct ProfileManifest<T = ()> {
44    pub profile_name: String,
45    pub mods: Vec<Mod>,
46    #[serde(flatten)]
47    pub extra: T,
48}
49
50/// A single mod entry in an r2z profile manifest.
51///
52/// Contains the thunderstore package identifier, the pinned version, and
53/// whether the mod is enabled.
54///
55/// # Examples
56///
57/// ```
58/// use loadsmith_thunderstore::r2z::{Mod, Version};
59/// use thunderstore::PackageIdent;
60///
61/// let mod_entry = Mod::new(
62///     PackageIdent::new("Author", "Package"),
63///     Version::new(1, 0, 0),
64///     true,
65/// );
66/// assert_eq!(mod_entry.name.to_string(), "Author-Package");
67/// ```
68#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
69#[serde(rename_all = "camelCase")]
70pub struct Mod {
71    pub name: PackageIdent,
72    pub version: Version,
73    pub enabled: bool,
74}
75
76/// A semantic version broken into its major, minor, and patch components.
77///
78/// Used in r2z export/import manifests.
79///
80/// # Examples
81///
82/// ```
83/// use loadsmith_thunderstore::r2z::Version;
84/// use loadsmith_core::Version as CoreVersion;
85///
86/// let v = Version::new(1, 2, 3);
87/// assert_eq!(v.to_string(), "1.2.3");
88///
89/// let core: CoreVersion = v.into();
90/// assert_eq!(core, CoreVersion::new(1, 2, 3));
91/// ```
92#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
93#[serde(rename_all = "camelCase")]
94pub struct Version {
95    pub major: u64,
96    pub minor: u64,
97    pub patch: u64,
98}
99
100impl From<Version> for loadsmith_core::Version {
101    fn from(value: Version) -> Self {
102        loadsmith_core::Version::new(value.major, value.minor, value.patch)
103    }
104}
105
106impl From<loadsmith_core::Version> for Version {
107    fn from(value: loadsmith_core::Version) -> Self {
108        Version::new(value.major, value.minor, value.patch)
109    }
110}
111
112impl<T> ProfileManifest<T> {
113    /// Creates a new profile manifest.
114    ///
115    /// # Examples
116    ///
117    /// ```
118    /// use loadsmith_thunderstore::r2z::ProfileManifest;
119    ///
120    /// let manifest = ProfileManifest::new("Test", vec![], ());
121    /// assert_eq!(manifest.profile_name, "Test");
122    /// ```
123    pub fn new(name: impl Into<String>, mods: Vec<Mod>, extra: T) -> Self {
124        Self {
125            profile_name: name.into(),
126            mods,
127            extra,
128        }
129    }
130}
131
132impl Mod {
133    /// Creates a new mod entry.
134    ///
135    /// # Examples
136    ///
137    /// ```
138    /// use loadsmith_thunderstore::r2z::{Mod, Version};
139    /// use thunderstore::PackageIdent;
140    ///
141    /// let m = Mod::new(PackageIdent::new("A", "B"), Version::new(1, 0, 0), true);
142    /// assert!(m.enabled);
143    /// ```
144    pub fn new(name: impl Into<PackageIdent>, version: impl Into<Version>, enabled: bool) -> Self {
145        Self {
146            name: name.into(),
147            version: version.into(),
148            enabled,
149        }
150    }
151}
152
153impl Version {
154    /// Creates a new version from major, minor, and patch components.
155    ///
156    /// # Examples
157    ///
158    /// ```
159    /// use loadsmith_thunderstore::r2z::Version;
160    ///
161    /// let v = Version::new(1, 2, 3);
162    /// assert_eq!(v.to_string(), "1.2.3");
163    /// ```
164    pub fn new(major: u64, minor: u64, patch: u64) -> Self {
165        Self {
166            major,
167            minor,
168            patch,
169        }
170    }
171}
172
173impl Display for Version {
174    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
176    }
177}
178
179/// Normalizes a path so it can be compared against a globset in a predictable way.
180/// Removes `.` and `..` components and converts all separators to `/`.
181fn normalize_zip_file_path(path: impl AsRef<Path>) -> Result<Utf8PathBuf> {
182    let path = path.as_ref();
183    return inner(path).ok_or_else(|| Error::InvalidZipFilePath(path.to_path_buf()));
184
185    fn inner(path: &Path) -> Option<Utf8PathBuf> {
186        let mut normalized = Utf8PathBuf::new();
187
188        for component in path.components() {
189            match component {
190                Component::Normal(os_str) => {
191                    normalized.push(os_str.to_str()?);
192                }
193                Component::CurDir => {}
194                Component::ParentDir => {
195                    if !normalized.pop() {
196                        return None;
197                    }
198                }
199                _ => return None,
200            }
201        }
202
203        Some(normalized)
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use std::{assert_matches, io::Cursor};
210
211    use super::*;
212
213    #[test]
214    fn normalize_path_normalizes() {
215        assert_eq!(normalize_zip_file_path("test.txt").unwrap(), "test.txt");
216        assert_eq!(normalize_zip_file_path("./test.txt").unwrap(), "test.txt");
217        assert_eq!(normalize_zip_file_path("././test.txt").unwrap(), "test.txt");
218        assert_eq!(
219            normalize_zip_file_path("./test.txt/./.").unwrap(),
220            "test.txt"
221        );
222        assert_eq!(
223            normalize_zip_file_path("nested/test.txt").unwrap(),
224            "nested/test.txt"
225        );
226        assert_eq!(
227            normalize_zip_file_path("nested/../test.txt").unwrap(),
228            "test.txt"
229        );
230    }
231
232    #[test]
233    fn normalize_path_rejects_invalid() {
234        assert_matches!(
235            normalize_zip_file_path("../text.txt"),
236            Err(Error::InvalidZipFilePath(_))
237        );
238        assert_matches!(
239            normalize_zip_file_path("nested/../../test.txt"),
240            Err(Error::InvalidZipFilePath(_))
241        );
242        #[cfg(unix)]
243        assert_matches!(
244            normalize_zip_file_path("/absolute/path/test.txt"),
245            Err(Error::InvalidZipFilePath(_))
246        );
247        #[cfg(target_os = "windows")]
248        assert_matches!(
249            normalize_zip_file_path("C:\\absolute\\path\\test.txt"),
250            Err(Error::InvalidZipFilePath(_))
251        );
252    }
253
254    #[test]
255    fn import_export_roundtrip() {
256        let manifest = ProfileManifest::new(
257            "My Profile",
258            vec![Mod::new(
259                PackageIdent::new("BepInEx", "BepInExPack"),
260                Version::new(5, 4, 2100),
261                true,
262            )],
263            (),
264        );
265        let mut export = ExportFile::create(Cursor::new(Vec::new()), &manifest).unwrap();
266        export
267            .write_file("config/BepInEx.cfg", &b"Some bytes"[..])
268            .unwrap();
269        let data = export.finish().unwrap();
270
271        let mut import = ImportFile::open(data).unwrap();
272        let read_manifest = import.read_manifest().unwrap();
273        assert_eq!(read_manifest, manifest);
274
275        import
276            .read_files(|path, file| {
277                assert_eq!(path, Path::new("config/BepInEx.cfg"));
278
279                let mut str = String::new();
280                file.read_to_string(&mut str)?;
281                assert_eq!(str, "Some bytes");
282
283                Ok(())
284            })
285            .unwrap();
286    }
287}