1use std::collections::VecDeque;
2use std::ffi::OsStr;
3use std::path::Path;
4use std::path::PathBuf;
5
6use owo_colors::OwoColorize;
7use rootcause::prelude::ResultExt;
8
9pub struct RmFilesOutcome {
11 pub removed: Vec<PathBuf>,
13 pub errors: Vec<(Option<PathBuf>, std::io::Error)>,
15}
16
17pub fn rm_dead_symlinks(dir: &str) -> rootcause::Result<()> {
24 for entry_res in std::fs::read_dir(dir)
25 .context("error reading directory")
26 .attach_with(|| format!("path={dir:?}"))?
27 {
28 let entry = entry_res.context("error getting entry")?;
29 let path = entry.path();
30
31 let metadata = std::fs::symlink_metadata(&path)
32 .context("error reading symlink metadata")
33 .attach_with(|| format!("path={}", path.display()))?;
34 if metadata.file_type().is_symlink() && std::fs::metadata(&path).is_err() {
35 std::fs::remove_file(&path)
36 .context("error removing dead symlink")
37 .attach_with(|| format!("path={}", path.display()))?;
38 println!("{} {}", "Deleted dead symlink".cyan().bold(), path.display());
39 }
40 }
41 Ok(())
42}
43
44pub fn rm_f<P: AsRef<Path>>(path: P) -> std::io::Result<()> {
50 std::fs::remove_file(path).or_else(|err| {
51 if std::io::ErrorKind::NotFound == err.kind() {
52 return Ok(());
53 }
54 Err(err)
55 })
56}
57
58pub fn rm_matching_files<P: AsRef<Path>>(
61 root_path: P,
62 file_name: &str,
63 excluded_dirs: &[&str],
64 dry_run: bool,
65) -> RmFilesOutcome {
66 fn rm_file(
67 path: PathBuf,
68 dry_run: bool,
69 removed: &mut Vec<PathBuf>,
70 errors: &mut Vec<(Option<PathBuf>, std::io::Error)>,
71 ) {
72 if dry_run {
73 removed.push(path);
74 return;
75 }
76 if let Err(err) = std::fs::remove_file(&path) {
77 errors.push((Some(path), err));
78 return;
79 }
80 removed.push(path);
81 }
82
83 fn handle_symlink(
84 path: PathBuf,
85 dry_run: bool,
86 removed: &mut Vec<PathBuf>,
87 errors: &mut Vec<(Option<PathBuf>, std::io::Error)>,
88 ) {
89 match std::fs::read_link(&path) {
90 Ok(target) => rm_file(target, dry_run, removed, errors),
91 Err(err) => errors.push((Some(path.clone()), err)),
92 }
93 rm_file(path, dry_run, removed, errors);
94 }
95
96 fn handle_dir(
97 path: PathBuf,
98 stack: &mut VecDeque<PathBuf>,
99 excluded_dirs: &[&str],
100 errors: &mut Vec<(Option<PathBuf>, std::io::Error)>,
101 ) {
102 if let Some(dir_name) = path.file_name().and_then(|n| n.to_str())
103 && excluded_dirs.contains(&dir_name)
104 {
105 return;
106 }
107 match std::fs::read_dir(&path) {
108 Ok(entries) => {
109 for entry in entries {
110 match entry {
111 Ok(entry) => stack.push_back(entry.path()),
112 Err(err) => errors.push((Some(path.clone()), err)),
113 }
114 }
115 }
116 Err(err) => errors.push((Some(path), err)),
117 }
118 }
119
120 let mut stack = VecDeque::new();
121 stack.push_back(root_path.as_ref().to_path_buf());
122
123 let file_name_os = OsStr::new(file_name);
124 let mut removed = vec![];
125 let mut errors = vec![];
126
127 while let Some(current_path) = stack.pop_back() {
128 if removed.contains(¤t_path) {
131 continue;
132 }
133 match std::fs::symlink_metadata(¤t_path) {
134 Ok(metadata) => {
135 let file_type = metadata.file_type();
136 if file_type.is_dir() {
137 handle_dir(current_path, &mut stack, excluded_dirs, &mut errors);
138 continue;
139 }
140 if current_path.file_name() == Some(file_name_os) {
141 if file_type.is_file() {
142 rm_file(current_path.clone(), dry_run, &mut removed, &mut errors);
143 } else if file_type.is_symlink() {
144 handle_symlink(current_path.clone(), dry_run, &mut removed, &mut errors);
145 }
146 }
147 }
148 Err(err) => errors.push((None, err)),
149 }
150 }
151
152 RmFilesOutcome { removed, errors }
153}
154
155#[cfg(test)]
156mod tests {
157 use test_that::prelude::*;
158
159 use super::*;
160
161 #[test]
162 fn test_rm_f_is_idempotent_for_missing_path() {
163 let tmp = tempfile::NamedTempFile::new().unwrap();
164 let path = tmp.path().to_path_buf();
165
166 assert_that!(rm_f(&path), ok(eq(())));
168 assert_that!(rm_f(&path), ok(eq(())));
170 }
171
172 #[test]
173 fn test_rm_matching_files_dry_run_collects_paths_without_removing() {
174 let dir = tempfile::tempdir().unwrap();
175 let ds_store = dir.path().join(".DS_Store");
176 std::fs::write(&ds_store, b"dummy").unwrap();
177
178 let RmFilesOutcome { removed, errors } = rm_matching_files(dir.path(), ".DS_Store", &[], true);
179
180 assert_eq!(removed, vec![ds_store.clone()]);
181 assert_that!(errors, empty());
182 assert_that!(ds_store.exists(), eq(true)); }
184
185 #[test]
186 fn test_rm_matching_files_removes_matching_files() {
187 let dir = tempfile::tempdir().unwrap();
188 let ds_store = dir.path().join(".DS_Store");
189 std::fs::write(&ds_store, b"dummy").unwrap();
190
191 let RmFilesOutcome { removed, errors } = rm_matching_files(dir.path(), ".DS_Store", &[], false);
192
193 assert_eq!(removed, vec![ds_store.clone()]);
194 assert_that!(errors, empty());
195 assert_that!(ds_store.exists(), eq(false)); }
197
198 #[test]
199 fn test_rm_matching_files_excludes_specified_dirs() {
200 let dir = tempfile::tempdir().unwrap();
201 let excluded_dir = dir.path().join("node_modules");
202 std::fs::create_dir(&excluded_dir).unwrap();
203 let ds_store_in_excluded = excluded_dir.join(".DS_Store");
204 std::fs::write(&ds_store_in_excluded, b"dummy").unwrap();
205
206 let regular_dir = dir.path().join("src");
207 std::fs::create_dir(®ular_dir).unwrap();
208 let ds_store_in_regular = regular_dir.join(".DS_Store");
209 std::fs::write(&ds_store_in_regular, b"dummy").unwrap();
210
211 let RmFilesOutcome { removed, errors } = rm_matching_files(dir.path(), ".DS_Store", &["node_modules"], false);
212
213 assert_eq!(removed, vec![ds_store_in_regular.clone()]);
214 assert_that!(errors, empty());
215 assert_that!(ds_store_in_excluded.exists(), eq(true)); assert_that!(ds_store_in_regular.exists(), eq(false)); }
218
219 #[test]
220 fn test_rm_matching_files_handles_nested_files() {
221 let dir = tempfile::tempdir().unwrap();
222 let sub_dir = dir.path().join("subdir");
223 std::fs::create_dir(&sub_dir).unwrap();
224 let ds_store = sub_dir.join(".DS_Store");
225 std::fs::write(&ds_store, b"dummy").unwrap();
226
227 let RmFilesOutcome { removed, errors } = rm_matching_files(dir.path(), ".DS_Store", &[], false);
228
229 assert_eq!(removed, vec![ds_store.clone()]);
230 assert_that!(errors, empty());
231 assert_that!(ds_store.exists(), eq(false));
232 }
233
234 #[test]
235 fn test_rm_matching_files_collects_errors_for_unreadable_dirs() {
236 let dir = tempfile::tempdir().unwrap();
237 let unreadable_dir = dir.path().join("unreadable");
238 std::fs::create_dir(&unreadable_dir).unwrap();
239
240 let RmFilesOutcome { removed, errors } = rm_matching_files("/non/existent/path", ".DS_Store", &[], false);
241
242 assert_that!(removed, empty());
243 assert_that!(errors, not(empty()));
244 assert_that!(
245 errors,
246 contains(
247 predicate(|(path, _): &(Option<PathBuf>, std::io::Error)| path.is_none())
248 .with_description("has no path", "has a path"),
249 )
250 );
251 }
252
253 #[test]
254 fn test_rm_matching_files_removes_symlink_and_target() {
255 let dir = tempfile::tempdir().unwrap();
256 let target = dir.path().join("target.txt");
257 std::fs::write(&target, b"content").unwrap();
258 let symlink = dir.path().join(".DS_Store");
259 std::os::unix::fs::symlink(&target, &symlink).unwrap();
260
261 let RmFilesOutcome { removed, errors } = rm_matching_files(dir.path(), ".DS_Store", &[], false);
262
263 assert_eq!(removed.len(), 2);
264 assert_that!(errors, empty());
265 assert_that!(removed, contains(eq(symlink.clone())));
266 assert_that!(removed, contains(eq(target.clone())));
267 assert_that!(symlink.exists(), eq(false));
268 assert_that!(target.exists(), eq(false));
269 }
270}