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/// A route-based install rule that maps files by directory name and file extension.
10///
11/// A `RouteRule` looks for a named directory component in the input path and maps
12/// the remaining suffix into the target directory. File extensions can be used as
13/// an additional matching criterion.
14///
15/// # Examples
16///
17/// ```rust
18/// use camino::Utf8PathBuf;
19/// use loadsmith_core::{PackageRef, PackageId, Version};
20/// use loadsmith_install::RouteRule;
21///
22/// let rule = RouteRule::new_static("BepInEx\\plugins")
23///     .with_file_extension("dll");
24/// let pkg = PackageRef::new(PackageId::new("x753-More_Suits"), Version::new(1, 0, 3));
25///
26/// assert!(rule.matches("MyPlugin.dll"));
27/// assert!(rule.matches_path("BepInEx\\plugins\\MyPlugin.dll"));
28/// assert_eq!(
29///     rule.map_file("plugins\\MyPlugin.dll", &pkg),
30///     Some(Utf8PathBuf::from("BepInEx\\plugins\\x753-More_Suits\\MyPlugin.dll"))
31/// );
32/// ```
33#[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    /// Creates a route rule from a static path string. The rule name is derived
45    /// from the last path component.
46    pub fn new_static(path: &'static str) -> Self {
47        Self::new(Cow::Borrowed(Utf8Path::new(path)))
48    }
49
50    /// Creates a route rule from any path. The rule name is derived from the
51    /// last path component.
52    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    /// Creates a route rule with an explicit name and target path.
65    ///
66    /// The `name` is the directory component that triggers a match; `target` is
67    /// the directory where matched files are installed.
68    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    /// Adds a file extension to the matching set.
83    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    /// Replaces the entire set of matching file extensions.
89    pub fn with_file_extensions(mut self, extensions: Vec<Cow<'static, str>>) -> Self {
90        self.file_extensions = extensions;
91        self
92    }
93
94    /// Sets whether to flatten the mapped path (remove intermediate directories).
95    pub fn with_flatten(mut self, flatten: bool) -> Self {
96        self.flatten = flatten;
97        self
98    }
99
100    /// Sets whether to create a subdirectory named after the package ID inside
101    /// the target directory.
102    pub fn with_subdir(mut self, subdir: bool) -> Self {
103        self.subdir = subdir;
104        self
105    }
106
107    /// Sets whether the rule allows mutable (user-modifiable) files.
108    pub fn with_mutable(mut self, mutable: bool) -> Self {
109        self.mutable = mutable;
110        self
111    }
112
113    /// Returns `true` if the path matches by extension or by route name.
114    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    /// Returns `true` if the file name has one of the rule's registered extensions.
120    pub fn matches_extension(&self, path: impl AsRef<Utf8Path>) -> bool {
121        // check the whole extension, that is everything after the first dot in the file name
122        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    /// Returns `true` if the path contains the route's name as a path component.
130    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        // eat components until we find the route name
136        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            // no match
141            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    /// Maps a file path to its install destination under the rule's target directory.
151    ///
152    /// If the path contains the route name, the part before the route name becomes
153    /// the prefix; otherwise the parent directory becomes the prefix. When `subdir`
154    /// is enabled the package ID is inserted as an intermediate directory. When
155    /// `flatten` is disabled the prefix is preserved in the output.
156    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    /// Returns `true` if the rule prefers hard links over file copies.
189    ///
190    /// Links are used when the rule is not marked as mutable.
191    pub fn use_links(&self) -> bool {
192        !self.mutable
193    }
194
195    /// Returns the conflict strategy for this route rule (always [`Skip`](ConflictStrategy::Skip)).
196    ///
197    /// Route rules never overwrite existing files and never error; they silently skip.
198    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        // defaults correctly when name is not present in the path
297        assert_map!(rule, "MyPlugin.dll", &package => "BepInEx/plugins/Author-Name/MyPlugin.dll");
298
299        // removes the matched "plugins" component
300        assert_map!(rule, "plugins/MyPlugin.dll", &package => "BepInEx/plugins/Author-Name/MyPlugin.dll");
301
302        // removes the matched "pluguins" component and flattens the "BepInEx" component
303        assert_map!(rule, "BepInEx/plugins/MyPlugin.dll", &package => "BepInEx/plugins/Author-Name/MyPlugin.dll");
304
305        // case-insensitive match of "plugins"
306        assert_map!(rule, "BepInEx/Plugins/MyPlugin.dll", &package => "BepInEx/plugins/Author-Name/MyPlugin.dll");
307
308        // flattens the "Nested" component
309        assert_map!(rule, "Nested/plugins/MyPlugin.dll", &package => "BepInEx/plugins/Author-Name/MyPlugin.dll");
310
311        // flattens the "Nested" component
312        assert_map!(rule, "Nested/MyPlugin.dll", &package => "BepInEx/plugins/Author-Name/MyPlugin.dll");
313
314        // retains nesting after the matched "plugins" component but flattens before
315        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        // Flattened routing keeps only the file name and package folder.
328        assert_map!(rule, "MyPlugin.dll", &package => "BepInEx/plugins/Author-Name/MyPlugin.dll");
329
330        // When the route name is already present, it is removed from the output path.
331        assert_map!(rule, "plugins/MyPlugin.dll", &package => "BepInEx/plugins/Author-Name/MyPlugin.dll");
332
333        // A fully qualified route path still collapses down to the target folder.
334        assert_map!(rule, "BepInEx/plugins/MyPlugin.dll", &package => "BepInEx/plugins/Author-Name/MyPlugin.dll");
335
336        // Extra nesting after the route name is preserved even when flattening.
337        assert_map!(rule, "plugins/Nested/MyPlugin.dll", &package => "BepInEx/plugins/Author-Name/Nested/MyPlugin.dll");
338
339        // File extension matching does not change the mapped directory layout.
340        assert_map!(rule, "plugins/Nested/MyPlugin.plugin", &package => "BepInEx/plugins/Author-Name/Nested/MyPlugin.plugin");
341
342        // Paths without the route name still map relative to the file name.
343        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        // Without subdir support, the package id is never inserted.
356        assert_map!(rule, "MyPlugin.dll", &package => "BepInEx/plugins/MyPlugin.dll");
357
358        // The route prefix is still stripped when it appears in the input path.
359        assert_map!(rule, "plugins/MyPlugin.dll", &package => "BepInEx/plugins/MyPlugin.dll");
360
361        // Flattening keeps fully qualified paths at the target root.
362        assert_map!(rule, "BepInEx/plugins/MyPlugin.dll", &package => "BepInEx/plugins/MyPlugin.dll");
363
364        // Nested content stays nested when flattening does not remove it.
365        assert_map!(rule, "plugins/Nested/MyPlugin.plugin", &package => "BepInEx/plugins/Nested/MyPlugin.plugin");
366
367        // Nested content stays nested when flattening does not remove it.
368        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        // No package folder is inserted when subdir is disabled.
381        assert_map!(rule, "MyPlugin.dll", &package => "BepInEx/plugins/MyPlugin.dll");
382
383        // Nested routes are preserved even when the route name doesn't appear.
384        assert_map!(rule, "other/MyPlugin.dll", &package => "BepInEx/plugins/other/MyPlugin.dll");
385
386        // A matching route component is removed before the remainder is appended.
387        assert_map!(rule, "plugins/MyPlugin.dll", &package => "BepInEx/plugins/MyPlugin.dll");
388
389        // Without flattening, the unmatched prefix is preserved in the output path.
390        assert_map!(rule, "BepInEx/plugins/MyPlugin.dll", &package => "BepInEx/plugins/BepInEx/MyPlugin.dll");
391
392        // Nested content remains nested when the route name is stripped.
393        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        // An empty path falls back to the package directory when flattening is off.
401        assert_map!(
402            rule,
403            "",
404            &PackageRef::new(PackageId::new("Author-Name"), Version::new(1, 0, 0)) => "BepInEx/plugins/Author-Name"
405        );
406    }
407}