1use std::{collections::HashSet, path::PathBuf};
2
3use crate::{
4 filesystem::FileSystem,
5 matcher::{CleanStrategy, Matcher},
6 models::RemovalCandidate,
7 models::{FileInfo, SimpleFileKind},
8};
9use eyre::Report;
10use eyre::Result;
11
12pub struct Walker<FS: FileSystem, N: WalkNotifier> {
13 fs: FS,
14 matchers: Vec<Matcher>,
15 notifier: N,
16 ignores: HashSet<PathBuf>,
17 walk_all: bool,
18}
19
20pub trait WalkNotifier {
21 fn notify_entered_directory(&self, dir: &FileInfo);
22 fn notify_candidate_for_removal(&self, candidate: RemovalCandidate);
23 fn notify_fail_to_scan(&self, e: &FileInfo, report: Report);
24 fn notify_walk_finish(&self);
25}
26
27impl<FS: FileSystem, N: WalkNotifier> Walker<FS, N> {
28 pub fn new(
29 fs: FS,
30 matchers: Vec<Matcher>,
31 notifier: N,
32 ignores: HashSet<PathBuf>,
33 walk_all: bool,
34 ) -> Self {
35 Self {
36 fs,
37 matchers,
38 notifier,
39 ignores,
40 walk_all,
41 }
42 }
43
44 pub fn walk_from_path(&self, path: &FileInfo) {
45 self.process_dir(path);
46 self.notifier.notify_walk_finish();
47 }
48
49 fn process_dir(&self, file: &FileInfo) {
50 if self.ignores.contains(&file.path) {
51 return;
52 }
53 match self.process_entries(file) {
54 Ok(children) => {
55 children.iter().for_each(|d| self.process_dir(d));
56 }
57 Err(report) => self.notifier.notify_fail_to_scan(file, report),
58 }
59 }
60
61 fn process_entries(&self, file: &FileInfo) -> Result<Vec<FileInfo>> {
62 self.notifier.notify_entered_directory(file);
63 let mut entries = self.fs.list_files(file)?;
64
65 for matcher in &self.matchers {
66 entries = self.process_matcher(file, matcher, entries);
67 }
68 entries.retain(|f| self.is_walkable(f));
69 Ok(entries)
70 }
71
72 fn process_matcher(
73 &self,
74 work_dir: &FileInfo,
75 matcher: &Matcher,
76 entries: Vec<FileInfo>,
77 ) -> Vec<FileInfo> {
78 if matcher.any_entry_match(&entries) {
79 match &matcher.clean_strategy {
80 CleanStrategy::Remove(pattern) => {
81 let (mut to_remove, remaining) = pattern.find_files_to_remove(entries);
82 to_remove.retain(|p| !self.ignores.contains(&p.path));
83 self.notify_removal_candidates(matcher, to_remove);
84 remaining
85 }
86 CleanStrategy::RunCommand(cmd) => {
87 let candidate = RemovalCandidate::new_cmd(
88 matcher.name.clone(),
89 work_dir.clone(),
90 cmd.clone(),
91 );
92 self.notifier.notify_candidate_for_removal(candidate);
93 entries
94 }
95 }
96 } else {
97 entries
98 }
99 }
100
101 fn notify_removal_candidates(&self, matcher: &Matcher, to_remove: Vec<FileInfo>) {
102 to_remove
103 .into_iter()
104 .map(|f| self.removal_candidate(matcher, f))
105 .for_each(|c| self.notifier.notify_candidate_for_removal(c));
106 }
107
108 fn removal_candidate(&self, matcher: &Matcher, file: FileInfo) -> RemovalCandidate {
109 let size = self.fs.file_size(&file).ok();
110 RemovalCandidate::new(matcher.name.clone(), file, size)
111 }
112
113 fn is_walkable(&self, file: &FileInfo) -> bool {
114 file.kind == SimpleFileKind::Directory && (self.walk_all || !file.name.starts_with('.'))
115 }
116}
117
118#[cfg(test)]
119mod tests {
120 use std::{cell::RefCell, collections::HashSet, path::PathBuf, str::FromStr};
121
122 use glob::Pattern;
123
124 use crate::{
125 filesystem::FileSystem,
126 matcher::Matcher,
127 models::FileInfo,
128 test_utils::{MockFS, MockFSNode},
129 walker::Walker,
130 };
131
132 use super::WalkNotifier;
133 use crate::models::{RemovalAction, RemovalCandidate};
134
135 #[derive(Debug, Default)]
136 struct VecWalkNotifier {
137 pub to_remove: RefCell<Vec<RemovalCandidate>>,
138 }
139
140 impl WalkNotifier for &VecWalkNotifier {
141 fn notify_entered_directory(&self, _dir: &FileInfo) {}
142
143 fn notify_candidate_for_removal(&self, candidate: RemovalCandidate) {
144 self.to_remove.borrow_mut().push(candidate);
145 }
146
147 fn notify_fail_to_scan(&self, _e: &FileInfo, _report: eyre::Error) {}
148
149 fn notify_walk_finish(&self) {}
150 }
151
152 fn setup_mock_fs() -> MockFS {
153 MockFS::new(MockFSNode::dir(
154 "/",
155 vec![MockFSNode::dir(
156 "home",
157 vec![MockFSNode::dir(
158 "user",
159 vec![
160 MockFSNode::dir(
161 "projectA",
162 vec![MockFSNode::file("Cargo.toml"), MockFSNode::file("target")],
163 ),
164 MockFSNode::dir("projectB", vec![MockFSNode::file("target")]),
165 ],
166 )],
167 )],
168 ))
169 }
170
171 #[test]
172 fn test() -> eyre::Result<()> {
173 let fs = setup_mock_fs();
174 let current_dir = setup_mock_fs().current_directory()?;
175 let notifier = VecWalkNotifier::default();
176 let walker = Walker::new(
177 fs,
178 vec![Matcher::with_remove_strategy(
179 "Cargo".into(),
180 Pattern::new("Cargo.toml")?,
181 Pattern::new("target")?,
182 )],
183 ¬ifier,
184 HashSet::new(),
185 false,
186 );
187 walker.walk_from_path(¤t_dir);
188
189 let to_remove = notifier.to_remove.into_inner();
190
191 assert_eq!(1, to_remove.len());
192 let c = to_remove.into_iter().next().unwrap();
193 assert_eq!(c.matcher_name.as_ref(), "Cargo");
194
195 match c.action {
196 RemovalAction::Delete { file_info, .. } => {
197 assert_eq!(
198 file_info.path,
199 PathBuf::from_str("/home/user/projectA/target").unwrap()
200 )
201 }
202 RemovalAction::RunCommand { work_dir, command } => {
203 panic!("should be delete")
204 }
205 }
206
207 Ok(())
208 }
209}