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