Skip to main content

ocy_core/
rule.rs

1use crate::models::{FileInfo, SimpleFileKind};
2use glob::{Pattern, PatternError};
3use std::sync::Arc;
4
5/// Why a [`Rule`] could not be built.
6#[derive(Debug, thiserror::Error)]
7pub enum RuleError {
8    #[error("rule `{name}` has no markers, so it would match every directory scanned")]
9    NoMarkers { name: String },
10
11    #[error("rule `{name}` reclaims nothing")]
12    NoTargets { name: String },
13
14    #[error("rule `{name}` has an empty target path")]
15    EmptyTarget { name: String },
16
17    #[error("rule `{name}` has an invalid pattern `{pattern}`")]
18    InvalidPattern {
19        name: String,
20        pattern: String,
21        #[source]
22        source: PatternError,
23    },
24}
25
26/// A path to reclaim, relative to the directory whose markers matched.
27///
28/// Components are matched one directory level at a time, so a target may reach into a
29/// subdirectory -- `.angular/cache` reclaims only the cache, not the whole `.angular`
30/// directory. Every component is a glob, which is what makes `cmake-build-*` expressible.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct Target {
33    pub components: Vec<Pattern>,
34    /// The kind the entry must have, or [`None`] to accept any.
35    pub kind: Option<SimpleFileKind>,
36}
37
38impl Target {
39    /// A target that reclaims a directory at `path`, relative to the project directory.
40    pub fn directory(path: &str) -> Result<Self, PatternError> {
41        Ok(Self {
42            components: Self::parse(path)?,
43            kind: Some(SimpleFileKind::Directory),
44        })
45    }
46
47    /// A target that reclaims a file at `path`, relative to the project directory.
48    pub fn file(path: &str) -> Result<Self, PatternError> {
49        Ok(Self {
50            components: Self::parse(path)?,
51            kind: Some(SimpleFileKind::File),
52        })
53    }
54
55    /// A target that reclaims whatever is at `path`, whether file, directory or symlink.
56    pub fn any(path: &str) -> Result<Self, PatternError> {
57        Ok(Self {
58            components: Self::parse(path)?,
59            kind: None,
60        })
61    }
62
63    fn parse(path: &str) -> Result<Vec<Pattern>, PatternError> {
64        path.split('/')
65            .filter(|component| !component.is_empty())
66            .map(Pattern::new)
67            .collect()
68    }
69}
70
71/// What a rule reclaims once its markers have matched.
72#[derive(Debug, Clone)]
73pub enum CleanAction {
74    /// Reclaim these paths from inside the matched directory.
75    Remove(Vec<Target>),
76
77    /// Reclaim the matched directory itself.
78    ///
79    /// This is what makes self-describing artifacts expressible: a Python virtual
80    /// environment is identified by the `pyvenv.cfg` it *contains*, not by anything
81    /// beside it, so no sibling-only rule can name it.
82    RemoveSelf,
83
84    /// Run the project's own clean command in the matched directory.
85    Run(Arc<str>),
86
87    /// Reclaim the administrative directories of git worktrees that no longer exist.
88    ///
89    /// This is what `git worktree prune` removes. It needs to read each record's `gitdir`
90    /// pointer and check whether the checkout is still there, which no glob can express.
91    RemoveStaleWorktrees,
92}
93
94/// A cleanup rule: what identifies a project, and what may be reclaimed from it.
95#[derive(Debug, Clone)]
96pub struct Rule {
97    pub name: Arc<str>,
98    markers: Vec<Pattern>,
99    action: CleanAction,
100}
101
102impl Rule {
103    /// A rule that reclaims `targets` from any directory containing all of `markers`.
104    pub fn remove(name: &str, markers: &[&str], targets: &[&str]) -> Result<Self, RuleError> {
105        if targets.is_empty() {
106            Err(RuleError::NoTargets {
107                name: name.to_string(),
108            })
109        } else {
110            let targets = targets
111                .iter()
112                .map(|target| {
113                    Target::directory(target).map_err(|source| RuleError::InvalidPattern {
114                        name: name.to_string(),
115                        pattern: (*target).to_string(),
116                        source,
117                    })
118                })
119                .collect::<Result<Vec<_>, _>>()?;
120
121            Self::new(name, markers, CleanAction::Remove(targets))
122        }
123    }
124
125    /// A rule that reclaims `targets`, for targets that are not plain directories.
126    pub fn remove_targets(
127        name: &str,
128        markers: &[&str],
129        targets: Vec<Target>,
130    ) -> Result<Self, RuleError> {
131        if targets.is_empty() {
132            Err(RuleError::NoTargets {
133                name: name.to_string(),
134            })
135        } else {
136            Self::new(name, markers, CleanAction::Remove(targets))
137        }
138    }
139
140    /// A rule that reclaims the matched directory itself.
141    pub fn remove_self(name: &str, markers: &[&str]) -> Result<Self, RuleError> {
142        Self::new(name, markers, CleanAction::RemoveSelf)
143    }
144
145    /// A rule that prunes the records of git worktrees whose checkout is gone.
146    pub fn prune_stale_worktrees(name: &str, markers: &[&str]) -> Result<Self, RuleError> {
147        Self::new(name, markers, CleanAction::RemoveStaleWorktrees)
148    }
149
150    /// A rule that runs `command` in the matched directory.
151    pub fn run(name: &str, markers: &[&str], command: &str) -> Result<Self, RuleError> {
152        Self::new(name, markers, CleanAction::Run(command.into()))
153    }
154
155    fn new(name: &str, markers: &[&str], action: CleanAction) -> Result<Self, RuleError> {
156        // A rule without markers matches every directory. For `RemoveSelf` that would
157        // propose deleting the entire tree, so it is rejected rather than trusted.
158        if markers.is_empty() {
159            Err(RuleError::NoMarkers {
160                name: name.to_string(),
161            })
162        } else {
163            let markers = markers
164                .iter()
165                .map(|marker| {
166                    Pattern::new(marker).map_err(|source| RuleError::InvalidPattern {
167                        name: name.to_string(),
168                        pattern: (*marker).to_string(),
169                        source,
170                    })
171                })
172                .collect::<Result<Vec<_>, _>>()?;
173
174            Ok(Self {
175                name: name.into(),
176                markers,
177                action,
178            })
179        }
180    }
181
182    pub fn action(&self) -> &CleanAction {
183        &self.action
184    }
185
186    /// Whether a directory holding `entries` is a project this rule applies to.
187    ///
188    /// Every marker must be present. Alternatives are expressed as globs
189    /// (`build.gradle*`) or as separate rules, so there is no any-of mode to configure.
190    pub fn matches(&self, entries: &[FileInfo]) -> bool {
191        self.markers
192            .iter()
193            .all(|marker| entries.iter().any(|entry| marker.matches(&entry.name)))
194    }
195}
196
197/// The width of the widest rule name, for column alignment.
198pub fn widest_name(rules: &[Rule]) -> usize {
199    rules
200        .iter()
201        .map(|rule| rule.name.chars().count())
202        .max()
203        .unwrap_or(0)
204}
205
206#[cfg(test)]
207mod tests;