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)]
10pub struct RouteRule {
11 name: Cow<'static, str>,
12 pub(super) target: Cow<'static, Utf8Path>,
13 file_extensions: Vec<Cow<'static, str>>,
14 subdir: bool,
15 flatten: bool,
16 mutable: bool,
17}
18
19impl RouteRule {
20 pub fn new_static(path: &'static str) -> Self {
21 Self::new(Cow::Borrowed(Utf8Path::new(path)))
22 }
23
24 pub fn new(path: impl Into<Cow<'static, Utf8Path>>) -> Self {
25 let path = path.into();
26
27 let name = match path {
28 Cow::Borrowed(borrowed) => borrowed.file_name().map(Cow::Borrowed),
29 Cow::Owned(_) => path.file_name().map(|s| Cow::Owned(s.to_string())),
30 }
31 .unwrap_or_default();
32
33 Self::new_with_target(name, path)
34 }
35
36 pub fn new_with_target(
37 name: impl Into<Cow<'static, str>>,
38 target: impl Into<Cow<'static, Utf8Path>>,
39 ) -> Self {
40 Self {
41 name: name.into(),
42 target: target.into(),
43 file_extensions: Vec::new(),
44 flatten: true,
45 subdir: true,
46 mutable: false,
47 }
48 }
49
50 pub fn with_file_extension(mut self, extension: impl Into<Cow<'static, str>>) -> Self {
51 self.file_extensions.push(extension.into());
52 self
53 }
54
55 pub fn with_file_extensions(mut self, extensions: Vec<Cow<'static, str>>) -> Self {
56 self.file_extensions = extensions;
57 self
58 }
59
60 pub fn with_flatten(mut self, flatten: bool) -> Self {
61 self.flatten = flatten;
62 self
63 }
64
65 pub fn with_subdir(mut self, subdir: bool) -> Self {
66 self.subdir = subdir;
67 self
68 }
69
70 pub fn with_mutable(mut self, mutable: bool) -> Self {
71 self.mutable = mutable;
72 self
73 }
74
75 pub fn matches(&self, path: impl AsRef<Utf8Path>) -> bool {
76 let path = path.as_ref();
77 self.matches_extension(path) || self.matches_path(path)
78 }
79
80 pub fn matches_extension(&self, path: impl AsRef<Utf8Path>) -> bool {
81 path.as_ref()
83 .file_name()
84 .and_then(|name| name.split_once('.'))
85 .map(|(_, ext)| self.file_extensions.iter().any(|e| e == ext))
86 .unwrap_or(false)
87 }
88
89 pub fn matches_path(&self, path: impl AsRef<Utf8Path>) -> bool {
90 self.split_path(path.as_ref()).is_some()
91 }
92
93 fn split_path(&self, path: &Utf8Path) -> Option<(Utf8PathBuf, Utf8PathBuf)> {
94 let Some(route_name_index) = path
96 .components()
97 .position(|comp| comp.as_str().eq_ignore_ascii_case(self.name.as_ref()))
98 else {
99 return None;
101 };
102
103 let prefix = path.components().take(route_name_index).collect();
104 let suffix = path.components().skip(route_name_index + 1).collect();
105
106 Some((prefix, suffix))
107 }
108
109 pub fn map_file(
110 &self,
111 path: impl AsRef<Utf8Path>,
112 package: &PackageRef,
113 ) -> Option<Utf8PathBuf> {
114 let path = path.as_ref();
115 let (prefix, suffix) = self.split_path(path).unwrap_or_else(|| {
116 let mut components = path.components();
117 let file_name = components
118 .next_back()
119 .map(|file_name| file_name.as_str())
120 .unwrap_or_default();
121 let prefix = components.collect();
122
123 (prefix, Utf8PathBuf::from(file_name))
124 });
125
126 let mut target_path = Utf8PathBuf::from(self.target.as_ref());
127
128 if self.subdir {
129 target_path.push(package.id().as_str());
130 }
131
132 if !self.flatten {
133 target_path.push(prefix);
134 }
135
136 target_path.push(suffix);
137
138 Some(target_path)
139 }
140
141 pub fn use_links(&self) -> bool {
142 !self.mutable
143 }
144
145 pub fn conflict_strategy(&self) -> ConflictStrategy {
146 ConflictStrategy::Skip
147 }
148}
149
150#[cfg(test)]
151mod tests {
152 use loadsmith_core::{PackageId, Version};
153
154 use super::*;
155
156 #[test]
157 fn new() {
158 let rule = RouteRule::new(Utf8Path::new("BepInEx/plugins"));
159
160 assert_eq!(rule.name, "plugins");
161 assert_eq!(rule.target, Utf8Path::new("BepInEx/plugins"));
162
163 let rule = RouteRule::new(Utf8Path::new("MelonLoader"));
164
165 assert_eq!(rule.name, "MelonLoader");
166 assert_eq!(rule.target, Utf8Path::new("MelonLoader"));
167
168 let rule = RouteRule::new(Utf8Path::new(""));
169
170 assert_eq!(rule.name, "");
171 assert_eq!(rule.target, Utf8Path::new(""));
172 }
173
174 #[test]
175 fn defaults() {
176 let rule = RouteRule::new_static("BepInEx/plugins");
177
178 assert!(rule.flatten);
179 assert!(rule.subdir);
180 assert!(!rule.mutable);
181 }
182
183 #[test]
184 fn matches_path() {
185 let rule = RouteRule::new_static("BepInEx/plugins");
186
187 assert!(rule.matches("BepInEx/plugins/myplugin.dll"));
188 assert!(rule.matches("plugins/myplugin.dll"));
189 assert!(rule.matches("Plugins/myplugin.dll"));
190 assert!(rule.matches("Nested/BepInEx/plugins/myplugin.dll"));
191 assert!(!rule.matches("BepInEx/core/myplugin.dll"));
192 assert!(!rule.matches("myplugin.dll"));
193 }
194
195 #[test]
196 fn matches_extension() {
197 let rule = RouteRule::new_static("BepInEx/monomod").with_file_extension("mm.dll");
198
199 assert!(rule.matches("BepInEx/monomod/myplugin.mm.dll"));
200 assert!(rule.matches("BepInEx/monomod/myplugin.dll"));
201 assert!(rule.matches("myplugin.mm.dll"));
202 assert!(!rule.matches("myplugin.dll"));
203 }
204
205 #[test]
206 fn match_extension_with_dot() {
207 let rule1 = RouteRule::new_static("plugins").with_file_extension("dll");
208
209 assert!(rule1.matches("myplugin.dll"));
210 assert!(!rule1.matches("myplugin.mm.dll"));
211 assert!(!rule1.matches("myplugin.dll.mm"));
212 assert!(!rule1.matches("myplugin.dll.dll"));
213
214 let rule2 = RouteRule::new_static("monomod").with_file_extension("mm.dll");
215
216 assert!(!rule2.matches("myplugin.dll"));
217 assert!(rule2.matches("myplugin.mm.dll"));
218 assert!(!rule2.matches("myplugin.mm"));
219 assert!(!rule2.matches("myplugin.mm.mm.dll"));
220 }
221
222 macro_rules! assert_map {
223 ($rule:expr, $file:expr, $package:expr => None) => {
224 assert_eq!($rule.map_file($file, $package), None);
225 };
226 ($rule:expr, $file:expr, $package:expr => $expected:expr) => {
227 assert_eq!(
228 $rule.map_file($file, $package),
229 Some(Utf8PathBuf::from($expected))
230 );
231 };
232 }
233
234 #[test]
235 fn map_file() {
236 let rule = RouteRule::new_static("BepInEx/plugins")
237 .with_file_extension("plugin")
238 .with_subdir(true)
239 .with_flatten(true);
240
241 let package = PackageRef::new(PackageId::new("Author-Name"), Version::new(1, 0, 0));
242
243 assert_map!(rule, "MyPlugin.dll", &package => "BepInEx/plugins/Author-Name/MyPlugin.dll");
245
246 assert_map!(rule, "plugins/MyPlugin.dll", &package => "BepInEx/plugins/Author-Name/MyPlugin.dll");
248
249 assert_map!(rule, "BepInEx/plugins/MyPlugin.dll", &package => "BepInEx/plugins/Author-Name/MyPlugin.dll");
251
252 assert_map!(rule, "BepInEx/Plugins/MyPlugin.dll", &package => "BepInEx/plugins/Author-Name/MyPlugin.dll");
254
255 assert_map!(rule, "Nested/plugins/MyPlugin.dll", &package => "BepInEx/plugins/Author-Name/MyPlugin.dll");
257
258 assert_map!(rule, "Nested/MyPlugin.dll", &package => "BepInEx/plugins/Author-Name/MyPlugin.dll");
260
261 assert_map!(rule, "Before/plugins/After/MyPlugin.plugin", &package => "BepInEx/plugins/Author-Name/After/MyPlugin.plugin");
263 }
264
265 #[test]
266 fn map_file_flatten() {
267 let rule = RouteRule::new_static("BepInEx/plugins")
268 .with_flatten(true)
269 .with_subdir(true)
270 .with_file_extension("plugin");
271
272 let package = PackageRef::new(PackageId::new("Author-Name"), Version::new(1, 0, 0));
273
274 assert_map!(rule, "MyPlugin.dll", &package => "BepInEx/plugins/Author-Name/MyPlugin.dll");
276
277 assert_map!(rule, "plugins/MyPlugin.dll", &package => "BepInEx/plugins/Author-Name/MyPlugin.dll");
279
280 assert_map!(rule, "BepInEx/plugins/MyPlugin.dll", &package => "BepInEx/plugins/Author-Name/MyPlugin.dll");
282
283 assert_map!(rule, "plugins/Nested/MyPlugin.dll", &package => "BepInEx/plugins/Author-Name/Nested/MyPlugin.dll");
285
286 assert_map!(rule, "plugins/Nested/MyPlugin.plugin", &package => "BepInEx/plugins/Author-Name/Nested/MyPlugin.plugin");
288
289 assert_map!(rule, "Nested/MyPlugin.plugin", &package => "BepInEx/plugins/Author-Name/MyPlugin.plugin");
291 }
292
293 #[test]
294 fn map_file_no_subdir_flatten() {
295 let rule = RouteRule::new_static("BepInEx/plugins")
296 .with_subdir(false)
297 .with_flatten(true)
298 .with_file_extension("plugin");
299
300 let package = PackageRef::new(PackageId::new("Author-Name"), Version::new(1, 0, 0));
301
302 assert_map!(rule, "MyPlugin.dll", &package => "BepInEx/plugins/MyPlugin.dll");
304
305 assert_map!(rule, "plugins/MyPlugin.dll", &package => "BepInEx/plugins/MyPlugin.dll");
307
308 assert_map!(rule, "BepInEx/plugins/MyPlugin.dll", &package => "BepInEx/plugins/MyPlugin.dll");
310
311 assert_map!(rule, "plugins/Nested/MyPlugin.plugin", &package => "BepInEx/plugins/Nested/MyPlugin.plugin");
313
314 assert_map!(rule, "BepInEx/plugins/Nested/MyPlugin.plugin", &package => "BepInEx/plugins/Nested/MyPlugin.plugin");
316 }
317
318 #[test]
319 fn map_file_no_subdir_no_flatten() {
320 let rule = RouteRule::new_static("BepInEx/plugins")
321 .with_subdir(false)
322 .with_flatten(false)
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/MyPlugin.dll");
329
330 assert_map!(rule, "other/MyPlugin.dll", &package => "BepInEx/plugins/other/MyPlugin.dll");
332
333 assert_map!(rule, "plugins/MyPlugin.dll", &package => "BepInEx/plugins/MyPlugin.dll");
335
336 assert_map!(rule, "BepInEx/plugins/MyPlugin.dll", &package => "BepInEx/plugins/BepInEx/MyPlugin.dll");
338
339 assert_map!(rule, "plugins/Nested/MyPlugin.plugin", &package => "BepInEx/plugins/Nested/MyPlugin.plugin");
341 }
342
343 #[test]
344 fn map_file_empty() {
345 let rule = RouteRule::new_static("BepInEx/plugins").with_flatten(false);
346
347 assert_map!(
349 rule,
350 "",
351 &PackageRef::new(PackageId::new("Author-Name"), Version::new(1, 0, 0)) => "BepInEx/plugins/Author-Name"
352 );
353 }
354}