1use std::{
2 cell::{Cell, RefCell},
3 collections::HashSet,
4 path::{Path, PathBuf},
5};
6
7use crate::{
8 filesystem::FileSystem,
9 models::RemovalCandidate,
10 models::{FileInfo, SimpleFileKind},
11 rule::{CleanAction, Rule, Target},
12};
13use eyre::Report;
14use eyre::Result;
15
16pub const VCS_DIRS: &[&str] = &[".git", ".svn", ".hg", ".jj", ".bzr"];
21
22#[derive(Debug, Default, Clone)]
23pub struct WalkOptions {
24 pub ignores: HashSet<PathBuf>,
26
27 pub walk_all: bool,
29
30 pub scanned_hidden: HashSet<String>,
35
36 pub max_depth: Option<usize>,
38
39 pub one_file_system: bool,
41}
42
43#[derive(Debug, Default)]
48pub struct PrunedSet {
49 inner: RefCell<HashSet<PathBuf>>,
50}
51
52impl PrunedSet {
53 pub fn new() -> Self {
55 Self {
56 inner: RefCell::new(HashSet::new()),
57 }
58 }
59
60 pub fn contains(&self, path: &Path) -> bool {
62 self.inner.borrow().contains(path)
63 }
64
65 pub fn insert(&self, path: PathBuf) {
67 self.inner.borrow_mut().insert(path);
68 }
69
70 pub fn is_already_claimed(&self, path: &Path) -> bool {
85 let pruned = self.inner.borrow();
86 path.ancestors().any(|ancestor| pruned.contains(ancestor))
87 || pruned.iter().any(|claimed| claimed.starts_with(path))
88 }
89}
90
91pub struct Walker<FS: FileSystem, N: WalkNotifier> {
92 fs: FS,
93 rules: Vec<Rule>,
94 notifier: N,
95 options: WalkOptions,
96 pruned: PrunedSet,
98 root_device: RefCell<Option<u64>>,
99 visited: RefCell<HashSet<PathBuf>>,
102 pending_worktrees: RefCell<Vec<FileInfo>>,
104 directories_scanned: Cell<usize>,
105 candidates_found: Cell<usize>,
106}
107
108pub trait WalkNotifier {
109 fn notify_entered_directory(&self, dir: &FileInfo);
110 fn notify_candidate_for_removal(&self, candidate: RemovalCandidate);
111 fn notify_fail_to_scan(&self, e: &FileInfo, report: Report);
112 fn notify_walk_finish(&self);
113}
114
115enum DirOutcome {
117 Descend(Vec<FileInfo>),
119 Reclaimed,
121}
122
123impl<FS: FileSystem, N: WalkNotifier> Walker<FS, N> {
124 pub fn new(fs: FS, rules: Vec<Rule>, notifier: N, options: WalkOptions) -> Self {
125 Self {
126 fs,
127 rules,
128 notifier,
129 options,
130 pruned: PrunedSet::new(),
131 root_device: RefCell::default(),
132 visited: RefCell::default(),
133 pending_worktrees: RefCell::default(),
134 directories_scanned: Cell::default(),
135 candidates_found: Cell::default(),
136 }
137 }
138
139 pub fn walk_from_path(&self, path: &FileInfo) {
140 if self.options.one_file_system {
141 *self.root_device.borrow_mut() = self.fs.device_id(path);
142 }
143
144 log::info!(
145 "scanning {} with {} rules",
146 path.path.display(),
147 self.rules.len()
148 );
149 self.process_dir(path, 0);
150 self.process_pending_worktrees(&path.path);
151 log::info!(
152 "scanned {} directories, found {} candidates",
153 self.directories_scanned.get(),
154 self.candidates_found.get()
155 );
156
157 self.notifier.notify_walk_finish();
158 }
159
160 fn process_pending_worktrees(&self, root: &Path) {
167 loop {
168 let next = self.pending_worktrees.borrow_mut().pop();
171 let Some(worktree) = next else {
172 break;
173 };
174
175 if worktree.path.starts_with(root) {
176 log::debug!("following linked worktree {}", worktree.path.display());
177 self.process_dir(&worktree, 0);
178 } else {
179 log::debug!(
180 "skipping worktree outside the scan root: {}",
181 worktree.path.display()
182 );
183 }
184 }
185 }
186
187 fn process_dir(&self, file: &FileInfo, depth: usize) {
188 if self.is_ignored(&file.path) || self.pruned.contains(&file.path) {
190 return;
191 }
192 if !self.visited.borrow_mut().insert(file.path.clone()) {
193 return;
194 }
195
196 match self.process_entries(file, depth) {
197 Ok(DirOutcome::Descend(children)) => children
198 .iter()
199 .for_each(|child| self.process_dir(child, depth + 1)),
200 Ok(DirOutcome::Reclaimed) => (),
201 Err(report) => self.notifier.notify_fail_to_scan(file, report),
202 }
203 }
204
205 fn process_entries(&self, dir: &FileInfo, depth: usize) -> Result<DirOutcome> {
206 self.notifier.notify_entered_directory(dir);
207 self.directories_scanned
208 .set(self.directories_scanned.get() + 1);
209
210 let listing = self.fs.list_files(dir)?;
211 listing
212 .errors
213 .into_iter()
214 .for_each(|report| self.notifier.notify_fail_to_scan(dir, report));
215 let mut entries = listing.entries;
216
217 for rule in &self.rules {
218 if !rule.matches(&entries) {
219 continue;
220 }
221
222 match rule.action() {
223 CleanAction::RemoveSelf if depth > 0 => {
226 if self.claim(rule, dir.clone()) {
227 return Ok(DirOutcome::Reclaimed);
228 }
229 }
230 CleanAction::RemoveSelf => (),
231 CleanAction::Remove(targets) => {
232 let claimed = self.claim_targets(rule, &entries, targets);
233 entries.retain(|entry| !claimed.contains(&entry.path));
234 }
235 CleanAction::Run(command) => {
236 self.notifier
237 .notify_candidate_for_removal(RemovalCandidate::new_cmd(
238 rule.name.clone(),
239 dir.clone(),
240 command.clone(),
241 ));
242 }
243 CleanAction::RemoveStaleWorktrees => {
244 self.claim_stale_worktrees(rule, dir);
245 self.queue_linked_worktrees(dir);
246 }
247 }
248 }
249
250 entries.retain(|entry| self.is_walkable(entry, depth));
251 Ok(DirOutcome::Descend(entries))
252 }
253
254 fn claim_targets(
259 &self,
260 rule: &Rule,
261 entries: &[FileInfo],
262 targets: &[Target],
263 ) -> HashSet<PathBuf> {
264 targets
265 .iter()
266 .flat_map(|target| self.resolve_target(entries, target))
267 .filter_map(|found| {
268 let path = found.path.clone();
269 self.claim(rule, found).then_some(path)
270 })
271 .collect()
272 }
273
274 fn resolve_target(&self, entries: &[FileInfo], target: &Target) -> Vec<FileInfo> {
280 let Some((first, rest)) = target.components.split_first() else {
281 return Vec::new();
282 };
283
284 let mut found: Vec<FileInfo> = entries
285 .iter()
286 .filter(|entry| first.matches(&entry.name))
287 .cloned()
288 .collect();
289
290 for component in rest {
291 found = found
292 .iter()
293 .filter(|entry| entry.kind == SimpleFileKind::Directory)
294 .filter_map(|dir| self.fs.list_files(dir).ok())
295 .flat_map(|listing| listing.entries)
296 .filter(|entry| component.matches(&entry.name))
297 .collect();
298 }
299
300 found.retain(|entry| target.kind.is_none_or(|kind| kind == entry.kind));
301 found
302 }
303
304 fn claim_stale_worktrees(&self, rule: &Rule, dir: &FileInfo) {
310 crate::git::stale_worktree_records(&dir.path.join(".git"))
311 .into_iter()
312 .for_each(|record| {
313 let name = record
314 .file_name()
315 .map(|name| name.to_string_lossy().into_owned())
316 .unwrap_or_default();
317 self.claim(rule, FileInfo::new(record, name, SimpleFileKind::Directory));
318 });
319 }
320
321 fn queue_linked_worktrees(&self, dir: &FileInfo) {
326 let found = crate::git::linked_worktree_paths(&dir.path.join(".git"));
327
328 self.pending_worktrees
329 .borrow_mut()
330 .extend(found.into_iter().map(|path| {
331 let name = path
332 .file_name()
333 .map(|name| name.to_string_lossy().into_owned())
334 .unwrap_or_default();
335 FileInfo::new(path, name, SimpleFileKind::Directory)
336 }));
337 }
338
339 fn claim(&self, rule: &Rule, file: FileInfo) -> bool {
345 if self.is_ignored(&file.path) || self.pruned.is_already_claimed(&file.path) {
346 return false;
347 }
348 self.pruned.insert(file.path.clone());
349
350 let size = match self.fs.file_size(&file) {
351 Ok(size) => Some(size),
352 Err(report) => {
353 log::debug!("cannot size {}: {report:#}", file.path.display());
355 None
356 }
357 };
358 log::debug!(
359 "rule `{}` claims {} ({})",
360 rule.name,
361 file.path.display(),
362 size.map_or_else(
363 || "size unknown".to_string(),
364 |size| format!("{size} bytes")
365 )
366 );
367 self.candidates_found.update(|n| n + 1);
368 self.notifier
369 .notify_candidate_for_removal(RemovalCandidate::new(rule.name.clone(), file, size));
370 true
371 }
372
373 fn is_ignored(&self, path: &Path) -> bool {
374 self.options.ignores.contains(path)
375 }
376
377 fn is_walkable(&self, file: &FileInfo, depth: usize) -> bool {
378 file.kind == SimpleFileKind::Directory
379 && self.within_depth(depth)
380 && self.is_scannable_name(&file.name)
381 && self.stays_on_one_filesystem(file)
382 }
383
384 fn within_depth(&self, depth: usize) -> bool {
385 self.options
386 .max_depth
387 .is_none_or(|max_depth| depth < max_depth)
388 }
389
390 fn is_scannable_name(&self, name: &str) -> bool {
391 if VCS_DIRS.contains(&name) {
392 log::trace!("skipping {name}: version control metadata");
393 false
394 } else if name.starts_with('.') {
395 let scannable = self.options.walk_all || self.options.scanned_hidden.contains(name);
396 if !scannable {
397 log::debug!("skipping hidden {name}; use --all to descend into it");
399 }
400 scannable
401 } else {
402 true
403 }
404 }
405
406 fn stays_on_one_filesystem(&self, file: &FileInfo) -> bool {
407 match (self.options.one_file_system, *self.root_device.borrow()) {
408 (true, Some(root)) => self.fs.device_id(file).is_none_or(|device| device == root),
409 _ => true,
410 }
411 }
412}
413
414#[cfg(test)]
415mod tests;