Skip to main content

loadsmith_install/rule/
mod.rs

1use std::path::Path;
2
3use camino::{Utf8Path, Utf8PathBuf};
4use globset::GlobSet;
5use loadsmith_core::PackageRef;
6use serde::{Deserialize, Serialize};
7
8use crate::ConflictStrategy;
9
10pub use glob::GlobRule;
11pub use route::RouteRule;
12
13mod glob;
14mod route;
15
16/// A file-mapping rule that can be either a [`GlobRule`] or a [`RouteRule`].
17///
18/// # Examples
19///
20/// ```rust
21/// use camino::Utf8Path;
22/// use loadsmith_install::{InstallRule, GlobRule, RouteRule};
23///
24/// let glob: InstallRule = GlobRule::try_from_pattern("*.dll", Utf8Path::new("BepInEx/plugins")).unwrap().into();
25/// let route: InstallRule = RouteRule::new_static("plugins").into();
26/// ```
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub enum InstallRule {
29    /// Maps files using a glob pattern.
30    Glob(GlobRule),
31    /// Maps files by matching a route (directory name) and file extension.
32    Route(RouteRule),
33}
34
35/// A borrowed set of install rules used to map and filter package files.
36///
37/// `InstallRuleset` holds references to an existing rules slice, an optional
38/// exclude glob set, and an optional default rule. Use [`OwnedInstallRuleset`]
39/// when you need owned storage.
40///
41/// # Examples
42///
43/// ```rust
44/// use camino::{Utf8Path, Utf8PathBuf};
45/// use loadsmith_core::{PackageRef, PackageId, Version};
46/// use loadsmith_install::{InstallRuleset, InstallRule, GlobRule};
47///
48/// let rules = [
49///     InstallRule::Glob(GlobRule::try_from_pattern("*.dll", Utf8Path::new("BepInEx/plugins")).unwrap()),
50/// ];
51/// let ruleset = InstallRuleset::new(&rules);
52/// let pkg = PackageRef::new(PackageId::new("x753-More_Suits"), Version::new(1, 0, 3));
53///
54/// assert_eq!(
55///     ruleset.map_file("MyPlugin.dll", &pkg),
56///     Some(Utf8PathBuf::from("BepInEx/plugins/MyPlugin.dll"))
57/// );
58/// ```
59#[derive(Debug, Clone, Copy)]
60pub struct InstallRuleset<'a> {
61    exclude: Option<&'a GlobSet>,
62    rules: &'a [InstallRule],
63    default_rule: Option<&'a InstallRule>,
64}
65
66/// An owned set of install rules with optional exclude glob and default rule.
67///
68/// Unlike [`InstallRuleset`], this struct owns all its data. Use
69/// [`as_ref()`](OwnedInstallRuleset::as_ref) to borrow it as an `InstallRuleset`.
70///
71/// # Examples
72///
73/// ```rust
74/// use camino::Utf8Path;
75/// use loadsmith_install::{OwnedInstallRuleset, InstallRule, GlobRule};
76///
77/// let rules: Vec<InstallRule> = vec![
78///     GlobRule::try_from_pattern("*.dll", Utf8Path::new("BepInEx/plugins")).unwrap().into(),
79/// ];
80/// let owned = OwnedInstallRuleset::with_rules(rules, None).unwrap();
81/// let borrowed = owned.as_ref();
82/// assert_eq!(borrowed.rules().len(), 1);
83/// ```
84#[derive(Debug, Clone, Default)]
85pub struct OwnedInstallRuleset {
86    exclude: Option<GlobSet>,
87    rules: Vec<InstallRule>,
88    default_rule: Option<usize>,
89}
90
91impl InstallRule {
92    /// Returns `true` if the path matches this rule by path content or file extension.
93    pub fn matches(&self, path: impl AsRef<Utf8Path>) -> bool {
94        let path = path.as_ref();
95        self.matches_path(path) || self.matches_extension(path)
96    }
97
98    /// Returns `true` if the path matches this rule by path content.
99    ///
100    /// For [`Glob`](InstallRule::Glob) rules this checks the glob pattern. For
101    /// [`Route`](InstallRule::Route) rules this looks for the route name in the path.
102    pub fn matches_path(&self, path: impl AsRef<Utf8Path>) -> bool {
103        let path = path.as_ref();
104        match self {
105            InstallRule::Glob(glob) => glob.matches(path),
106            InstallRule::Route(route) => route.matches_path(path),
107        }
108    }
109
110    /// Returns `true` if the file extension matches this rule.
111    ///
112    /// For [`Glob`](InstallRule::Glob) rules this is identical to `matches_path`.
113    /// For [`Route`](InstallRule::Route) rules this checks the registered extensions.
114    pub fn matches_extension(&self, path: impl AsRef<Utf8Path>) -> bool {
115        let path = path.as_ref();
116        match self {
117            InstallRule::Glob(glob) => glob.matches(path),
118            InstallRule::Route(route) => route.matches_extension(path),
119        }
120    }
121
122    /// Maps a file path to its install destination.
123    pub fn map_file(
124        &self,
125        path: impl AsRef<Utf8Path>,
126        package: &PackageRef,
127    ) -> Option<Utf8PathBuf> {
128        match self {
129            InstallRule::Glob(glob) => glob.map_file(path, package),
130            InstallRule::Route(route) => route.map_file(path, package),
131        }
132    }
133
134    /// Returns `true` if the mapped path starts with this rule's target directory.
135    pub fn matches_mapped(&self, path: impl AsRef<Utf8Path>) -> bool {
136        let path = path.as_ref();
137        match self {
138            InstallRule::Glob(glob) => path.starts_with(&*glob.target),
139            InstallRule::Route(route) => path.starts_with(&*route.target),
140        }
141    }
142
143    /// Returns `true` if the rule prefers hard links over file copies.
144    pub fn use_links(&self) -> bool {
145        match self {
146            InstallRule::Glob(glob) => glob.use_links,
147            InstallRule::Route(route) => route.use_links(),
148        }
149    }
150
151    /// Returns the conflict strategy for this rule.
152    ///
153    /// [`Glob`](InstallRule::Glob) rules always use [`Overwrite`](ConflictStrategy::Overwrite).
154    /// [`Route`](InstallRule::Route) rules always use [`Skip`](ConflictStrategy::Skip).
155    pub fn conflict_strategy(&self) -> ConflictStrategy {
156        match self {
157            InstallRule::Glob(_) => ConflictStrategy::Overwrite,
158            InstallRule::Route(route) => route.conflict_strategy(),
159        }
160    }
161}
162
163impl From<GlobRule> for InstallRule {
164    fn from(glob: GlobRule) -> Self {
165        InstallRule::Glob(glob)
166    }
167}
168
169impl From<RouteRule> for InstallRule {
170    fn from(route: RouteRule) -> Self {
171        InstallRule::Route(route)
172    }
173}
174
175impl<'a> InstallRuleset<'a> {
176    /// Creates a new `InstallRuleset` from a slice of rules.
177    pub const fn new(rules: &'a [InstallRule]) -> Self {
178        Self {
179            rules,
180            default_rule: None,
181            exclude: None,
182        }
183    }
184
185    /// Sets the default rule used when no other rule matches a file.
186    pub fn with_default_rule(mut self, default_rule: &'a InstallRule) -> Self {
187        self.default_rule = Some(default_rule);
188        self
189    }
190
191    /// Sets a glob set for excluding files from installation.
192    pub fn with_exclude(mut self, exclude: &'a GlobSet) -> Self {
193        self.exclude = Some(exclude);
194        self
195    }
196
197    /// Returns the list of install rules.
198    pub fn rules(&self) -> &[InstallRule] {
199        self.rules
200    }
201
202    /// Returns the default rule, if any.
203    pub fn default_rule(&self) -> Option<&InstallRule> {
204        self.default_rule
205    }
206
207    /// Returns the exclude glob set, if any.
208    pub fn exclude(&self) -> Option<&GlobSet> {
209        self.exclude
210    }
211
212    /// Finds the first rule that matches the given path, falling back to the
213    /// default rule if no path or extension match is found.
214    ///
215    /// Path-based matches are checked before extension-based matches.
216    pub fn find_rule_for_path(&self, path: impl AsRef<Utf8Path>) -> Option<&'a InstallRule> {
217        let path = path.as_ref();
218        self.rules
219            .iter()
220            .find(|rule| rule.matches_path(path))
221            .or_else(|| self.rules.iter().find(|rule| rule.matches_extension(path)))
222            .or(self.default_rule)
223    }
224
225    /// Finds the first rule whose mapped output path starts with the given path.
226    pub fn find_rule_for_mapped_path(&self, path: impl AsRef<Utf8Path>) -> Option<&'a InstallRule> {
227        let path = path.as_ref();
228        self.rules.iter().find(|rule| rule.matches_mapped(path))
229    }
230
231    /// Maps a file path through the ruleset, returning the install destination.
232    ///
233    /// Returns `None` if the file is excluded or no rule matches.
234    pub fn map_file(
235        &self,
236        path: impl AsRef<Utf8Path>,
237        package: &PackageRef,
238    ) -> Option<Utf8PathBuf> {
239        self.map_file_and_return_rule(path, package)
240            .map(|(mapped, _rule)| mapped)
241    }
242
243    /// Maps a file path and returns the install destination together with the
244    /// matching rule.
245    ///
246    /// Returns `None` if the file is excluded or no rule matches.
247    pub fn map_file_and_return_rule(
248        &self,
249        path: impl AsRef<Utf8Path>,
250        package: &PackageRef,
251    ) -> Option<(Utf8PathBuf, &InstallRule)> {
252        let path = path.as_ref();
253        if self.is_excluded(path) {
254            return None;
255        }
256
257        self.find_rule_for_path(path)
258            .and_then(|rule| rule.map_file(path, package).map(|path| (path, rule)))
259    }
260
261    /// Returns `true` if the path matches the exclude glob set.
262    pub fn is_excluded(&self, path: impl AsRef<Path>) -> bool {
263        self.exclude
264            .as_ref()
265            .map_or(false, |exclude| exclude.is_match(path))
266    }
267}
268
269impl OwnedInstallRuleset {
270    /// Creates an `OwnedInstallRuleset` from an iterator of items that convert
271    /// into [`InstallRule`].
272    ///
273    /// Returns `None` if `default_rule_index` is out of bounds.
274    pub fn from_rule_iter<I, R>(rules: I, default_rule_index: Option<usize>) -> Option<Self>
275    where
276        I: IntoIterator<Item = R>,
277        R: Into<InstallRule>,
278    {
279        let rules = rules.into_iter().map(Into::into).collect();
280        Self::with_rules(rules, default_rule_index)
281    }
282
283    /// Creates an `OwnedInstallRuleset` from a vector of rules.
284    ///
285    /// Returns `None` if `default_rule_index` is out of bounds.
286    pub fn with_rules(rules: Vec<InstallRule>, default_rule_index: Option<usize>) -> Option<Self> {
287        if default_rule_index.is_some_and(|index| !(0..rules.len()).contains(&index)) {
288            return None;
289        }
290
291        Some(Self {
292            rules,
293            default_rule: default_rule_index,
294            exclude: None,
295        })
296    }
297
298    /// Sets an exclude glob set using builder pattern.
299    pub fn with_exclude(mut self, exclude: GlobSet) -> Self {
300        self.exclude = Some(exclude);
301        self
302    }
303
304    /// Appends a rule to the end of the rule list.
305    pub fn add_rule(&mut self, rule: InstallRule) {
306        self.rules.push(rule);
307    }
308
309    /// Inserts a rule at the given index, adjusting the default rule index if needed.
310    pub fn insert_rule(&mut self, index: usize, rule: InstallRule) {
311        if let Some(default_index) = self.default_rule {
312            if index <= default_index {
313                self.default_rule = Some(default_index + 1);
314            }
315        }
316
317        self.rules.insert(index, rule);
318    }
319
320    /// Replaces or sets the exclude glob set.
321    pub fn set_exclude(&mut self, exclude: GlobSet) {
322        self.exclude = Some(exclude);
323    }
324
325    /// Returns a reference to the list of rules.
326    pub fn rules(&self) -> &[InstallRule] {
327        &self.rules
328    }
329
330    /// Returns the default rule, if any.
331    pub fn default_rule(&self) -> Option<&InstallRule> {
332        self.default_rule.map(|index| &self.rules[index])
333    }
334
335    /// Returns the exclude glob set, if any.
336    pub fn exclude(&self) -> Option<&GlobSet> {
337        self.exclude.as_ref()
338    }
339
340    /// Consumes the ruleset and returns the inner parts:
341    /// `(rules, default_rule_index, exclude)`.
342    pub fn into_parts(self) -> (Vec<InstallRule>, Option<usize>, Option<GlobSet>) {
343        (self.rules, self.default_rule, self.exclude)
344    }
345
346    /// Borrows this owned ruleset as an [`InstallRuleset`].
347    pub fn as_ref(&self) -> InstallRuleset<'_> {
348        InstallRuleset {
349            rules: &self.rules,
350            default_rule: self.default_rule.map(|index| &self.rules[index]),
351            exclude: self.exclude.as_ref(),
352        }
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use loadsmith_core::Version;
359
360    use super::*;
361
362    #[test]
363    fn ruleset_matches_path_over_extension() {
364        let rules = vec![
365            InstallRule::Route(RouteRule::new_static("Mods").with_file_extension("dll")),
366            InstallRule::Route(RouteRule::new_static("Plugins")),
367        ];
368
369        let ruleset = InstallRuleset::new(&rules);
370
371        let pkg = PackageRef::new("test".to_string(), Version::new(1, 0, 0));
372
373        // The path takes precedence over file extension, so the "Plugins" rule
374        // should match even though the file has a .dll extension.
375        assert_eq!(
376            ruleset.map_file("Plugins/Plugin.dll", &pkg),
377            Some(Utf8PathBuf::from("Plugins/test/Plugin.dll"))
378        );
379    }
380}