1use std::borrow::Cow;
2
3use camino::{Utf8Path, Utf8PathBuf};
4use loadsmith_core::PackageRef;
5use serde::{Deserialize, Serialize};
6
7use crate::ConflictStrategy;
8
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct RouteRule {
35 name: Cow<'static, str>,
36 pub(super) target: Cow<'static, Utf8Path>,
37 file_extensions: Vec<Cow<'static, str>>,
38 subdir: bool,
39 flatten: bool,
40 mutable: bool,
41}
42
43impl RouteRule {
44 pub fn new_static(path: &'static str) -> Self {
47 Self::new(Cow::Borrowed(Utf8Path::new(path)))
48 }
49
50 pub fn new(path: impl Into<Cow<'static, Utf8Path>>) -> Self {
53 let path = path.into();
54
55 let name = match path {
56 Cow::Borrowed(borrowed) => borrowed.file_name().map(Cow::Borrowed),
57 Cow::Owned(_) => path.file_name().map(|s| Cow::Owned(s.to_string())),
58 }
59 .unwrap_or_default();
60
61 Self::new_with_target(name, path)
62 }
63
64 pub fn new_with_target(
69 name: impl Into<Cow<'static, str>>,
70 target: impl Into<Cow<'static, Utf8Path>>,
71 ) -> Self {
72 Self {
73 name: name.into(),
74 target: target.into(),
75 file_extensions: Vec::new(),
76 flatten: true,
77 subdir: true,
78 mutable: false,
79 }
80 }
81
82 pub fn with_file_extension(mut self, extension: impl Into<Cow<'static, str>>) -> Self {
84 self.file_extensions.push(extension.into());
85 self
86 }
87
88 pub fn with_file_extensions(mut self, extensions: Vec<Cow<'static, str>>) -> Self {
90 self.file_extensions = extensions;
91 self
92 }
93
94 pub fn with_flatten(mut self, flatten: bool) -> Self {
96 self.flatten = flatten;
97 self
98 }
99
100 pub fn with_subdir(mut self, subdir: bool) -> Self {
103 self.subdir = subdir;
104 self
105 }
106
107 pub fn with_mutable(mut self, mutable: bool) -> Self {
109 self.mutable = mutable;
110 self
111 }
112
113 pub fn matches(&self, path: impl AsRef<Utf8Path>) -> bool {
115 let path = path.as_ref();
116 self.matches_extension(path) || self.matches_path(path)
117 }
118
119 pub fn matches_extension(&self, path: impl AsRef<Utf8Path>) -> bool {
121 path.as_ref()
123 .file_name()
124 .and_then(|name| name.split_once('.'))
125 .map(|(_, ext)| self.file_extensions.iter().any(|e| e == ext))
126 .unwrap_or(false)
127 }
128
129 pub fn matches_path(&self, path: impl AsRef<Utf8Path>) -> bool {
131 self.split_path(path.as_ref()).is_some()
132 }
133
134 fn split_path(&self, path: &Utf8Path) -> Option<(Utf8PathBuf, Utf8PathBuf)> {
135 let Some(route_name_index) = path
137 .components()
138 .position(|comp| comp.as_str().eq_ignore_ascii_case(self.name.as_ref()))
139 else {
140 return None;
142 };
143
144 let prefix = path.components().take(route_name_index).collect();
145 let suffix = path.components().skip(route_name_index + 1).collect();
146
147 Some((prefix, suffix))
148 }
149
150 pub fn map_file(
157 &self,
158 path: impl AsRef<Utf8Path>,
159 package: &PackageRef,
160 ) -> Option<Utf8PathBuf> {
161 let path = path.as_ref();
162 let (prefix, suffix) = self.split_path(path).unwrap_or_else(|| {
163 let mut components = path.components();
164 let file_name = components
165 .next_back()
166 .map(|file_name| file_name.as_str())
167 .unwrap_or_default();
168 let prefix = components.collect();
169
170 (prefix, Utf8PathBuf::from(file_name))
171 });
172
173 let mut target_path = Utf8PathBuf::from(self.target.as_ref());
174
175 if self.subdir {
176 target_path.push(package.id().as_str());
177 }
178
179 if !self.flatten {
180 target_path.push(prefix);
181 }
182
183 target_path.push(suffix);
184
185 Some(target_path)
186 }
187
188 pub fn use_links(&self) -> bool {
192 !self.mutable
193 }
194
195 pub fn conflict_strategy(&self) -> ConflictStrategy {
199 ConflictStrategy::Skip
200 }
201}
202
203#[cfg(test)]
204mod tests {
205 use loadsmith_core::{PackageId, Version};
206
207 use super::*;
208
209 #[test]
210 fn new() {
211 let rule = RouteRule::new(Utf8Path::new("BepInEx/plugins"));
212
213 assert_eq!(rule.name, "plugins");
214 assert_eq!(rule.target, Utf8Path::new("BepInEx/plugins"));
215
216 let rule = RouteRule::new(Utf8Path::new("MelonLoader"));
217
218 assert_eq!(rule.name, "MelonLoader");
219 assert_eq!(rule.target, Utf8Path::new("MelonLoader"));
220
221 let rule = RouteRule::new(Utf8Path::new(""));
222
223 assert_eq!(rule.name, "");
224 assert_eq!(rule.target, Utf8Path::new(""));
225 }
226
227 #[test]
228 fn defaults() {
229 let rule = RouteRule::new_static("BepInEx/plugins");
230
231 assert!(rule.flatten);
232 assert!(rule.subdir);
233 assert!(!rule.mutable);
234 }
235
236 #[test]
237 fn matches_path() {
238 let rule = RouteRule::new_static("BepInEx/plugins");
239
240 assert!(rule.matches("BepInEx/plugins/myplugin.dll"));
241 assert!(rule.matches("plugins/myplugin.dll"));
242 assert!(rule.matches("Plugins/myplugin.dll"));
243 assert!(rule.matches("Nested/BepInEx/plugins/myplugin.dll"));
244 assert!(!rule.matches("BepInEx/core/myplugin.dll"));
245 assert!(!rule.matches("myplugin.dll"));
246 }
247
248 #[test]
249 fn matches_extension() {
250 let rule = RouteRule::new_static("BepInEx/monomod").with_file_extension("mm.dll");
251
252 assert!(rule.matches("BepInEx/monomod/myplugin.mm.dll"));
253 assert!(rule.matches("BepInEx/monomod/myplugin.dll"));
254 assert!(rule.matches("myplugin.mm.dll"));
255 assert!(!rule.matches("myplugin.dll"));
256 }
257
258 #[test]
259 fn match_extension_with_dot() {
260 let rule1 = RouteRule::new_static("plugins").with_file_extension("dll");
261
262 assert!(rule1.matches("myplugin.dll"));
263 assert!(!rule1.matches("myplugin.mm.dll"));
264 assert!(!rule1.matches("myplugin.dll.mm"));
265 assert!(!rule1.matches("myplugin.dll.dll"));
266
267 let rule2 = RouteRule::new_static("monomod").with_file_extension("mm.dll");
268
269 assert!(!rule2.matches("myplugin.dll"));
270 assert!(rule2.matches("myplugin.mm.dll"));
271 assert!(!rule2.matches("myplugin.mm"));
272 assert!(!rule2.matches("myplugin.mm.mm.dll"));
273 }
274
275 macro_rules! assert_map {
276 ($rule:expr, $file:expr, $package:expr => None) => {
277 assert_eq!($rule.map_file($file, $package), None);
278 };
279 ($rule:expr, $file:expr, $package:expr => $expected:expr) => {
280 assert_eq!(
281 $rule.map_file($file, $package),
282 Some(Utf8PathBuf::from($expected))
283 );
284 };
285 }
286
287 #[test]
288 fn map_file() {
289 let rule = RouteRule::new_static("BepInEx/plugins")
290 .with_file_extension("plugin")
291 .with_subdir(true)
292 .with_flatten(true);
293
294 let package = PackageRef::new(PackageId::new("Author-Name"), Version::new(1, 0, 0));
295
296 assert_map!(rule, "MyPlugin.dll", &package => "BepInEx/plugins/Author-Name/MyPlugin.dll");
298
299 assert_map!(rule, "plugins/MyPlugin.dll", &package => "BepInEx/plugins/Author-Name/MyPlugin.dll");
301
302 assert_map!(rule, "BepInEx/plugins/MyPlugin.dll", &package => "BepInEx/plugins/Author-Name/MyPlugin.dll");
304
305 assert_map!(rule, "BepInEx/Plugins/MyPlugin.dll", &package => "BepInEx/plugins/Author-Name/MyPlugin.dll");
307
308 assert_map!(rule, "Nested/plugins/MyPlugin.dll", &package => "BepInEx/plugins/Author-Name/MyPlugin.dll");
310
311 assert_map!(rule, "Nested/MyPlugin.dll", &package => "BepInEx/plugins/Author-Name/MyPlugin.dll");
313
314 assert_map!(rule, "Before/plugins/After/MyPlugin.plugin", &package => "BepInEx/plugins/Author-Name/After/MyPlugin.plugin");
316 }
317
318 #[test]
319 fn map_file_flatten() {
320 let rule = RouteRule::new_static("BepInEx/plugins")
321 .with_flatten(true)
322 .with_subdir(true)
323 .with_file_extension("plugin");
324
325 let package = PackageRef::new(PackageId::new("Author-Name"), Version::new(1, 0, 0));
326
327 assert_map!(rule, "MyPlugin.dll", &package => "BepInEx/plugins/Author-Name/MyPlugin.dll");
329
330 assert_map!(rule, "plugins/MyPlugin.dll", &package => "BepInEx/plugins/Author-Name/MyPlugin.dll");
332
333 assert_map!(rule, "BepInEx/plugins/MyPlugin.dll", &package => "BepInEx/plugins/Author-Name/MyPlugin.dll");
335
336 assert_map!(rule, "plugins/Nested/MyPlugin.dll", &package => "BepInEx/plugins/Author-Name/Nested/MyPlugin.dll");
338
339 assert_map!(rule, "plugins/Nested/MyPlugin.plugin", &package => "BepInEx/plugins/Author-Name/Nested/MyPlugin.plugin");
341
342 assert_map!(rule, "Nested/MyPlugin.plugin", &package => "BepInEx/plugins/Author-Name/MyPlugin.plugin");
344 }
345
346 #[test]
347 fn map_file_no_subdir_flatten() {
348 let rule = RouteRule::new_static("BepInEx/plugins")
349 .with_subdir(false)
350 .with_flatten(true)
351 .with_file_extension("plugin");
352
353 let package = PackageRef::new(PackageId::new("Author-Name"), Version::new(1, 0, 0));
354
355 assert_map!(rule, "MyPlugin.dll", &package => "BepInEx/plugins/MyPlugin.dll");
357
358 assert_map!(rule, "plugins/MyPlugin.dll", &package => "BepInEx/plugins/MyPlugin.dll");
360
361 assert_map!(rule, "BepInEx/plugins/MyPlugin.dll", &package => "BepInEx/plugins/MyPlugin.dll");
363
364 assert_map!(rule, "plugins/Nested/MyPlugin.plugin", &package => "BepInEx/plugins/Nested/MyPlugin.plugin");
366
367 assert_map!(rule, "BepInEx/plugins/Nested/MyPlugin.plugin", &package => "BepInEx/plugins/Nested/MyPlugin.plugin");
369 }
370
371 #[test]
372 fn map_file_no_subdir_no_flatten() {
373 let rule = RouteRule::new_static("BepInEx/plugins")
374 .with_subdir(false)
375 .with_flatten(false)
376 .with_file_extension("plugin");
377
378 let package = PackageRef::new(PackageId::new("Author-Name"), Version::new(1, 0, 0));
379
380 assert_map!(rule, "MyPlugin.dll", &package => "BepInEx/plugins/MyPlugin.dll");
382
383 assert_map!(rule, "other/MyPlugin.dll", &package => "BepInEx/plugins/other/MyPlugin.dll");
385
386 assert_map!(rule, "plugins/MyPlugin.dll", &package => "BepInEx/plugins/MyPlugin.dll");
388
389 assert_map!(rule, "BepInEx/plugins/MyPlugin.dll", &package => "BepInEx/plugins/BepInEx/MyPlugin.dll");
391
392 assert_map!(rule, "plugins/Nested/MyPlugin.plugin", &package => "BepInEx/plugins/Nested/MyPlugin.plugin");
394 }
395
396 #[test]
397 fn map_file_empty() {
398 let rule = RouteRule::new_static("BepInEx/plugins").with_flatten(false);
399
400 assert_map!(
402 rule,
403 "",
404 &PackageRef::new(PackageId::new("Author-Name"), Version::new(1, 0, 0)) => "BepInEx/plugins/Author-Name"
405 );
406 }
407}