Skip to main content

loadsmith_install/rule/
route.rs

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        // check the whole extension, that is everything after the first dot in the file name
82        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        // eat components until we find the route name
95        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            // no match
100            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        // defaults correctly when name is not present in the path
244        assert_map!(rule, "MyPlugin.dll", &package => "BepInEx/plugins/Author-Name/MyPlugin.dll");
245
246        // removes the matched "plugins" component
247        assert_map!(rule, "plugins/MyPlugin.dll", &package => "BepInEx/plugins/Author-Name/MyPlugin.dll");
248
249        // removes the matched "pluguins" component and flattens the "BepInEx" component
250        assert_map!(rule, "BepInEx/plugins/MyPlugin.dll", &package => "BepInEx/plugins/Author-Name/MyPlugin.dll");
251
252        // case-insensitive match of "plugins"
253        assert_map!(rule, "BepInEx/Plugins/MyPlugin.dll", &package => "BepInEx/plugins/Author-Name/MyPlugin.dll");
254
255        // flattens the "Nested" component
256        assert_map!(rule, "Nested/plugins/MyPlugin.dll", &package => "BepInEx/plugins/Author-Name/MyPlugin.dll");
257
258        // flattens the "Nested" component
259        assert_map!(rule, "Nested/MyPlugin.dll", &package => "BepInEx/plugins/Author-Name/MyPlugin.dll");
260
261        // retains nesting after the matched "plugins" component but flattens before
262        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        // Flattened routing keeps only the file name and package folder.
275        assert_map!(rule, "MyPlugin.dll", &package => "BepInEx/plugins/Author-Name/MyPlugin.dll");
276
277        // When the route name is already present, it is removed from the output path.
278        assert_map!(rule, "plugins/MyPlugin.dll", &package => "BepInEx/plugins/Author-Name/MyPlugin.dll");
279
280        // A fully qualified route path still collapses down to the target folder.
281        assert_map!(rule, "BepInEx/plugins/MyPlugin.dll", &package => "BepInEx/plugins/Author-Name/MyPlugin.dll");
282
283        // Extra nesting after the route name is preserved even when flattening.
284        assert_map!(rule, "plugins/Nested/MyPlugin.dll", &package => "BepInEx/plugins/Author-Name/Nested/MyPlugin.dll");
285
286        // File extension matching does not change the mapped directory layout.
287        assert_map!(rule, "plugins/Nested/MyPlugin.plugin", &package => "BepInEx/plugins/Author-Name/Nested/MyPlugin.plugin");
288
289        // Paths without the route name still map relative to the file name.
290        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        // Without subdir support, the package id is never inserted.
303        assert_map!(rule, "MyPlugin.dll", &package => "BepInEx/plugins/MyPlugin.dll");
304
305        // The route prefix is still stripped when it appears in the input path.
306        assert_map!(rule, "plugins/MyPlugin.dll", &package => "BepInEx/plugins/MyPlugin.dll");
307
308        // Flattening keeps fully qualified paths at the target root.
309        assert_map!(rule, "BepInEx/plugins/MyPlugin.dll", &package => "BepInEx/plugins/MyPlugin.dll");
310
311        // Nested content stays nested when flattening does not remove it.
312        assert_map!(rule, "plugins/Nested/MyPlugin.plugin", &package => "BepInEx/plugins/Nested/MyPlugin.plugin");
313
314        // Nested content stays nested when flattening does not remove it.
315        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        // No package folder is inserted when subdir is disabled.
328        assert_map!(rule, "MyPlugin.dll", &package => "BepInEx/plugins/MyPlugin.dll");
329
330        // Nested routes are preserved even when the route name doesn't appear.
331        assert_map!(rule, "other/MyPlugin.dll", &package => "BepInEx/plugins/other/MyPlugin.dll");
332
333        // A matching route component is removed before the remainder is appended.
334        assert_map!(rule, "plugins/MyPlugin.dll", &package => "BepInEx/plugins/MyPlugin.dll");
335
336        // Without flattening, the unmatched prefix is preserved in the output path.
337        assert_map!(rule, "BepInEx/plugins/MyPlugin.dll", &package => "BepInEx/plugins/BepInEx/MyPlugin.dll");
338
339        // Nested content remains nested when the route name is stripped.
340        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        // An empty path falls back to the package directory when flattening is off.
348        assert_map!(
349            rule,
350            "",
351            &PackageRef::new(PackageId::new("Author-Name"), Version::new(1, 0, 0)) => "BepInEx/plugins/Author-Name"
352        );
353    }
354}