1use std::collections::VecDeque;
2use std::ffi::OsStr;
3use std::path::Path;
4use std::path::PathBuf;
5
6use owo_colors::OwoColorize as _;
7use rootcause::prelude::ResultExt as _;
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 super::*;
158
159 #[test]
160 fn rm_f_is_idempotent_for_missing_path() {
161 let tmp = tempfile::NamedTempFile::new().unwrap();
162 let path = tmp.path().to_path_buf();
163
164 assert2::assert!(let Ok(()) = rm_f(&path));
166 assert2::assert!(let Ok(()) = rm_f(&path));
168 }
169
170 #[test]
171 fn rm_matching_files_dry_run_collects_paths_without_removing() {
172 let dir = tempfile::tempdir().unwrap();
173 let ds_store = dir.path().join(".DS_Store");
174 std::fs::write(&ds_store, b"dummy").unwrap();
175
176 let RmFilesOutcome { removed, errors } = rm_matching_files(dir.path(), ".DS_Store", &[], true);
177
178 assert_eq!(removed, vec![ds_store.clone()]);
179 assert!(errors.is_empty());
180 assert!(ds_store.exists()); }
182
183 #[test]
184 fn rm_matching_files_removes_matching_files() {
185 let dir = tempfile::tempdir().unwrap();
186 let ds_store = dir.path().join(".DS_Store");
187 std::fs::write(&ds_store, b"dummy").unwrap();
188
189 let RmFilesOutcome { removed, errors } = rm_matching_files(dir.path(), ".DS_Store", &[], false);
190
191 assert_eq!(removed, vec![ds_store.clone()]);
192 assert!(errors.is_empty());
193 assert!(!ds_store.exists()); }
195
196 #[test]
197 fn rm_matching_files_excludes_specified_dirs() {
198 let dir = tempfile::tempdir().unwrap();
199 let excluded_dir = dir.path().join("node_modules");
200 std::fs::create_dir(&excluded_dir).unwrap();
201 let ds_store_in_excluded = excluded_dir.join(".DS_Store");
202 std::fs::write(&ds_store_in_excluded, b"dummy").unwrap();
203
204 let regular_dir = dir.path().join("src");
205 std::fs::create_dir(®ular_dir).unwrap();
206 let ds_store_in_regular = regular_dir.join(".DS_Store");
207 std::fs::write(&ds_store_in_regular, b"dummy").unwrap();
208
209 let RmFilesOutcome { removed, errors } = rm_matching_files(dir.path(), ".DS_Store", &["node_modules"], false);
210
211 assert_eq!(removed, vec![ds_store_in_regular.clone()]);
212 assert!(errors.is_empty());
213 assert!(ds_store_in_excluded.exists()); assert!(!ds_store_in_regular.exists()); }
216
217 #[test]
218 fn rm_matching_files_handles_nested_files() {
219 let dir = tempfile::tempdir().unwrap();
220 let sub_dir = dir.path().join("subdir");
221 std::fs::create_dir(&sub_dir).unwrap();
222 let ds_store = sub_dir.join(".DS_Store");
223 std::fs::write(&ds_store, b"dummy").unwrap();
224
225 let RmFilesOutcome { removed, errors } = rm_matching_files(dir.path(), ".DS_Store", &[], false);
226
227 assert_eq!(removed, vec![ds_store.clone()]);
228 assert!(errors.is_empty());
229 assert!(!ds_store.exists());
230 }
231
232 #[test]
233 fn rm_matching_files_collects_errors_for_unreadable_dirs() {
234 let dir = tempfile::tempdir().unwrap();
235 let unreadable_dir = dir.path().join("unreadable");
236 std::fs::create_dir(&unreadable_dir).unwrap();
237
238 let RmFilesOutcome { removed, errors } = rm_matching_files("/non/existent/path", ".DS_Store", &[], false);
239
240 assert!(removed.is_empty());
241 assert!(!errors.is_empty());
242 assert!(errors.iter().any(|(path, _)| path.is_none()));
244 }
245
246 #[test]
247 fn rm_matching_files_removes_symlink_and_target() {
248 let dir = tempfile::tempdir().unwrap();
249 let target = dir.path().join("target.txt");
250 std::fs::write(&target, b"content").unwrap();
251 let symlink = dir.path().join(".DS_Store");
252 std::os::unix::fs::symlink(&target, &symlink).unwrap();
253
254 let RmFilesOutcome { removed, errors } = rm_matching_files(dir.path(), ".DS_Store", &[], false);
255
256 assert_eq!(removed.len(), 2);
257 assert!(errors.is_empty());
258 assert!(removed.contains(&symlink));
259 assert!(removed.contains(&target));
260 assert!(!symlink.exists());
261 assert!(!target.exists());
262 }
263}