Skip to main content

loadsmith_thunderstore/r2z/
export.rs

1use std::{
2    fs::File,
3    io::{self, Read, Seek, Write},
4    sync::LazyLock,
5};
6
7use camino::Utf8Path;
8use globset::{Glob, GlobBuilder, GlobSet, GlobSetBuilder};
9use serde::Serialize;
10use walkdir::WalkDir;
11use zip::{ZipWriter, write::SimpleFileOptions};
12
13use crate::{Error, Result, r2z::ProfileManifest};
14
15/// Writes an r2z (r2modman zip) profile export.
16///
17/// Produces a zip archive containing a YAML manifest (`export.r2x`) and
18/// optional config files.
19///
20/// # Examples
21///
22/// ```
23/// use std::io::Cursor;
24/// use loadsmith_thunderstore::r2z::{ExportFile, ProfileManifest, Mod, Version};
25/// use thunderstore::PackageIdent;
26///
27/// let manifest = ProfileManifest::new(
28///     "Test Profile",
29///     vec![Mod::new(PackageIdent::new("A", "B"), Version::new(1, 0, 0), true)],
30///     (),
31/// );
32///
33/// let mut export = ExportFile::create(Cursor::new(Vec::new()), &manifest).unwrap();
34/// export.write_file("config/settings.cfg", &b"enabled=true"[..]).unwrap();
35/// let data: Cursor<Vec<u8>> = export.finish().unwrap();
36/// assert!(!data.get_ref().is_empty());
37/// ```
38pub struct ExportFile<W: Write + Seek> {
39    zip: ZipWriter<W>,
40}
41
42impl<W: Write + Seek> ExportFile<W> {
43    /// Creates a new export archive and writes the manifest as `export.r2x`.
44    ///
45    /// # Examples
46    ///
47    /// ```
48    /// use std::io::Cursor;
49    /// use loadsmith_thunderstore::r2z::{ExportFile, ProfileManifest};
50    ///
51    /// let manifest = ProfileManifest::new("Test", vec![], ());
52    /// let export = ExportFile::create(Cursor::new(Vec::new()), &manifest).unwrap();
53    /// ```
54    pub fn create<T: Serialize>(writer: W, manifest: &ProfileManifest<T>) -> Result<Self> {
55        let mut zip = ZipWriter::new(writer);
56
57        zip.start_file("export.r2x", SimpleFileOptions::default())?;
58        serde_yaml_ng::to_writer(&mut zip, manifest)?;
59
60        Ok(Self { zip })
61    }
62
63    /// Finalizes the zip archive and returns the inner writer.
64    ///
65    /// # Examples
66    ///
67    /// ```
68    /// use std::io::Cursor;
69    /// use loadsmith_thunderstore::r2z::{ExportFile, ProfileManifest};
70    ///
71    /// let manifest = ProfileManifest::new("Test", vec![], ());
72    /// let export = ExportFile::create(Cursor::new(Vec::new()), &manifest).unwrap();
73    /// let data = export.finish().unwrap();
74    /// ```
75    pub fn finish(self) -> Result<W> {
76        self.zip.finish().map_err(Error::Zip)
77    }
78
79    /// Writes a file into the zip archive at the given virtual path.
80    ///
81    /// The path is normalized before being stored in the archive.
82    ///
83    /// # Examples
84    ///
85    /// ```
86    /// use std::io::Cursor;
87    /// use loadsmith_thunderstore::r2z::{ExportFile, ProfileManifest};
88    ///
89    /// let manifest = ProfileManifest::new("Test", vec![], ());
90    /// let mut export = ExportFile::create(Cursor::new(Vec::new()), &manifest).unwrap();
91    /// export.write_file("BepInEx/config/plugin.cfg", &b"key=value"[..]).unwrap();
92    /// let _data = export.finish().unwrap();
93    /// ```
94    pub fn write_file(
95        &mut self,
96        zip_path: impl AsRef<Utf8Path>,
97        mut reader: impl Read,
98    ) -> Result<()> {
99        let path = super::normalize_zip_file_path(zip_path.as_ref())?;
100        let path = path.into_string().replace("\\", "/");
101        self.zip.start_file(path, SimpleFileOptions::default())?;
102
103        io::copy(&mut reader, &mut self.zip)?;
104
105        Ok(())
106    }
107
108    /// Walks a directory and writes its contents into the archive.
109    ///
110    /// When `filter` is `true`, only files matching common config extensions
111    /// (`.cfg`, `.txt`, `.json`, `.yml`, `.yaml`, `.ini`) or paths under
112    /// `BepInEx/config/` are included.
113    ///
114    /// # Examples
115    ///
116    /// ```no_run
117    /// use std::io::Cursor;
118    /// use loadsmith_thunderstore::r2z::{ExportFile, ProfileManifest};
119    /// use camino::Utf8Path;
120    ///
121    /// let manifest = ProfileManifest::new("Test", vec![], ());
122    /// let mut export = ExportFile::create(Cursor::new(Vec::new()), &manifest).unwrap();
123    /// export.write_config_from_dir(Utf8Path::new("./config"), true).unwrap();
124    /// let _data = export.finish().unwrap();
125    /// ```
126    pub fn write_config_from_dir(
127        &mut self,
128        directory: impl AsRef<Utf8Path>,
129        filter: bool,
130    ) -> Result<()> {
131        let directory = directory.as_ref();
132        WalkDir::new(directory)
133            .follow_links(false)
134            .into_iter()
135            .filter_map(|entry| entry.ok())
136            .filter(|entry| entry.file_type().is_file())
137            .try_for_each(|entry| {
138                let source_path = Utf8Path::from_path(entry.path())
139                    .ok_or_else(|| Error::NonUtf8Path(entry.path().to_path_buf()))?;
140
141                let relative_path = source_path.strip_prefix(directory).unwrap_or(source_path);
142                let normalized_path = super::normalize_zip_file_path(relative_path)?;
143
144                if filter && !is_included(&normalized_path) {
145                    return Ok(());
146                }
147
148                let mut file = File::open(source_path)?;
149                self.write_file(relative_path, &mut file)
150            })
151    }
152}
153
154fn is_included(relative_path: impl AsRef<Utf8Path>) -> bool {
155    static INCLUDE_SET: LazyLock<GlobSet> = LazyLock::new(|| {
156        GlobSetBuilder::new()
157            .add(Glob::new("BepInEx/config/*").unwrap())
158            .add(Glob::new("*.{cfg,txt,json,yml,yaml,ini}").unwrap())
159            .build()
160            .unwrap()
161    });
162
163    static EXCLUDE_SET: LazyLock<GlobSet> = LazyLock::new(|| {
164        GlobSetBuilder::new()
165            .add(Glob::new("{dotnet,_state,MelonLoader}/*").unwrap())
166            .add(Glob::new("dotnet/*").unwrap())
167            .add(Glob::new("GDWeave/{GDWeave.log,core/*,mods/*}").unwrap())
168            .add(Glob::new("mods.yml").unwrap())
169            .add(
170                GlobBuilder::new("BepInEx/plugins/*/manifest.json")
171                    .literal_separator(true)
172                    .build()
173                    .unwrap(),
174            )
175            .build()
176            .unwrap()
177    });
178
179    let path = relative_path.as_ref();
180    INCLUDE_SET.is_match(path) && !EXCLUDE_SET.is_match(path)
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn test_is_included() {
189        assert!(is_included("config.ini"));
190        assert!(is_included("other/test.json"));
191        assert!(is_included("BepInEx/config/test.png"));
192        assert!(is_included("BepInEx/config/test.cfg"));
193
194        assert!(!is_included("test.dll"));
195        assert!(!is_included("nested/latest.log"));
196        assert!(!is_included("mods.yml"));
197        assert!(!is_included("random/test.exe"));
198        assert!(!is_included("dotnet/test.cfg"));
199
200        assert!(is_included("GDWeave/test.cfg"));
201        assert!(!is_included("GDWeave/GDWeave.log"));
202        assert!(!is_included("GDWeave/core/test.cfg"));
203        assert!(!is_included("GDWeave/mods/test.cfg"));
204
205        assert!(is_included("BepInEx/plugins/test.txt"));
206        assert!(!is_included("BepInEx/plugins/Author-Name/manifest.json"));
207        assert!(is_included(
208            "BepInEx/plugins/Author-Name/nested/manifest.json"
209        ));
210    }
211}