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}