Skip to main content

loadsmith_install/rule/
glob.rs

1use std::borrow::Cow;
2
3use camino::{Utf8Path, Utf8PathBuf};
4use globset::{Glob, GlobBuilder};
5use loadsmith_core::PackageRef;
6use serde::{Deserialize, Serialize};
7
8/// A glob-based install rule that maps matching files to a target directory.
9///
10/// A `GlobRule` matches files by a glob pattern and maps them into a target
11/// directory, with optional path stripping and subdirectory creation.
12///
13/// # Examples
14///
15/// ```rust
16/// use std::borrow::Cow;
17/// use camino::{Utf8Path, Utf8PathBuf};
18/// use globset::Glob;
19/// use loadsmith_core::{PackageRef, PackageId, Version};
20/// use loadsmith_install::GlobRule;
21///
22/// let rule = GlobRule::try_from_pattern("*.dll", Utf8Path::new("BepInEx/plugins")).unwrap();
23/// let pkg = PackageRef::new(PackageId::new("x753-More_Suits"), Version::new(1, 0, 3));
24///
25/// assert!(rule.matches("MyPlugin.dll"));
26/// assert!(!rule.matches("config.txt"));
27/// assert_eq!(
28///     rule.map_file("MyPlugin.dll", &pkg),
29///     Some(Utf8PathBuf::from("BepInEx/plugins/MyPlugin.dll"))
30/// );
31/// ```
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct GlobRule {
34    pattern: Glob,
35    pub(crate) target: Cow<'static, Utf8Path>,
36    strip_levels: usize,
37    subdir: bool,
38    pub(super) use_links: bool,
39}
40
41impl GlobRule {
42    /// Creates a new `GlobRule` from a compiled [`Glob`] pattern and a target path.
43    pub fn new(pattern: Glob, target: impl Into<Cow<'static, Utf8Path>>) -> Self {
44        Self {
45            pattern,
46            target: target.into(),
47            strip_levels: 0,
48            subdir: false,
49            use_links: false,
50        }
51    }
52
53    /// Creates a new `GlobRule` by compiling a glob pattern string.
54    ///
55    /// Returns a [`globset::Error`] if the pattern is invalid.
56    pub fn try_from_pattern(
57        pattern: impl AsRef<str>,
58        target: impl Into<Cow<'static, Utf8Path>>,
59    ) -> Result<Self, globset::Error> {
60        let pattern = GlobBuilder::new(pattern.as_ref()).build()?;
61        Ok(Self::new(pattern, target))
62    }
63
64    /// Strips the top-level directory component from paths before mapping.
65    pub fn strip_top_level(mut self, strip_top_level: bool) -> Self {
66        self.strip_levels = if strip_top_level { 1 } else { 0 };
67        self
68    }
69
70    /// Strips the given number of leading path components before mapping.
71    pub fn strip_levels(mut self, strip_levels: usize) -> Self {
72        self.strip_levels = strip_levels;
73        self
74    }
75
76    /// Sets whether to use hard links instead of copying files.
77    pub fn use_links(mut self, use_links: bool) -> Self {
78        self.use_links = use_links;
79        self
80    }
81
82    /// Sets whether to create a subdirectory named after the package ID inside
83    /// the target directory.
84    pub fn with_subdir(mut self, subdir: bool) -> Self {
85        self.subdir = subdir;
86        self
87    }
88
89    /// Returns `true` if the path matches this rule's glob pattern.
90    pub fn matches(&self, path: impl AsRef<Utf8Path>) -> bool {
91        self.pattern.compile_matcher().is_match(path.as_ref())
92    }
93
94    /// Maps a file path to its install destination under the rule's target directory.
95    ///
96    /// Returns `None` if stripping removes all path components. When `subdir` is
97    /// enabled the package ID is inserted as an intermediate directory.
98    pub fn map_file(
99        &self,
100        path: impl AsRef<Utf8Path>,
101        package: &PackageRef,
102    ) -> Option<Utf8PathBuf> {
103        let path = path.as_ref();
104
105        let suffix = {
106            let mut components = path.components();
107            for _ in 0..self.strip_levels {
108                components.next();
109            }
110
111            if components.clone().next().is_none() {
112                return None;
113            } else {
114                components.as_path()
115            }
116        };
117
118        let mut target = self.target.to_path_buf();
119        if self.subdir {
120            target.push(package.id().as_str());
121        }
122        target.push(suffix);
123
124        Some(target)
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use loadsmith_core::{PackageId, Version};
131
132    use super::*;
133
134    #[test]
135    fn matches() {
136        let rule = GlobRule::new(
137            Glob::new("*.rs").unwrap(),
138            Cow::Borrowed(Utf8Path::new("target")),
139        );
140
141        assert!(rule.matches("main.rs"));
142        assert!(rule.matches("src/main.rs"));
143        assert!(!rule.matches("main.txt"));
144        assert!(!rule.matches("src/main.txt"));
145    }
146
147    #[test]
148    fn map_file() {
149        let rule = GlobRule::try_from_pattern("*.rs", Utf8Path::new("target")).unwrap();
150
151        let package = PackageRef::new(PackageId::new("Author-Name"), Version::new(1, 0, 0));
152
153        assert_eq!(
154            rule.map_file("main.rs", &package),
155            Some(Utf8PathBuf::from("target/main.rs"))
156        );
157
158        assert_eq!(
159            rule.map_file("src/main.rs", &package),
160            Some(Utf8PathBuf::from("target/src/main.rs"))
161        );
162
163        assert_eq!(
164            rule.map_file("main.txt", &package),
165            Some(Utf8PathBuf::from("target/main.txt"))
166        );
167    }
168
169    #[test]
170    fn map_file_strip_top() {
171        let rule = GlobRule::try_from_pattern("in/**", Utf8Path::new("out"))
172            .unwrap()
173            .strip_top_level(true);
174        let package = PackageRef::new(PackageId::new("Author-Name"), Version::new(1, 0, 0));
175
176        assert_eq!(
177            rule.map_file("in/file.txt", &package),
178            Some(Utf8PathBuf::from("out/file.txt"))
179        );
180
181        assert_eq!(
182            rule.map_file("in/nested/file.txt", &package),
183            Some(Utf8PathBuf::from("out/nested/file.txt"))
184        );
185
186        assert_eq!(
187            rule.map_file("in/nested/deep/file.txt", &package),
188            Some(Utf8PathBuf::from("out/nested/deep/file.txt"))
189        );
190
191        assert_eq!(rule.map_file("other.txt", &package), None);
192    }
193
194    #[test]
195    fn map_file_strip_multiple() {
196        let rule = GlobRule::try_from_pattern("UE4SS/Mods/*", Utf8Path::new("shimloader/mod"))
197            .unwrap()
198            .strip_levels(2);
199        let package = PackageRef::new(PackageId::new("Author-Name"), Version::new(1, 0, 0));
200
201        assert_eq!(rule.map_file("file.dll", &package), None);
202
203        assert_eq!(rule.map_file("UE4SS/file.dll", &package), None);
204
205        assert_eq!(
206            rule.map_file("UE4SS/Mods/file.dll", &package),
207            Some(Utf8PathBuf::from("shimloader/mod/file.dll"))
208        );
209
210        assert_eq!(
211            rule.map_file("UE4SS/Mods/nested/file.dll", &package),
212            Some(Utf8PathBuf::from("shimloader/mod/nested/file.dll"))
213        );
214
215        assert_eq!(
216            rule.map_file("UE4SS/Mods/nested/deep/file.dll", &package),
217            Some(Utf8PathBuf::from("shimloader/mod/nested/deep/file.dll"))
218        );
219    }
220}