Skip to main content

ytil_sys/
rm.rs

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
9/// Outcome of file removal operations.
10pub struct RmFilesOutcome {
11    /// Paths successfully removed or collected in dry run.
12    pub removed: Vec<PathBuf>,
13    /// Errors encountered, paired with optional affected paths.
14    pub errors: Vec<(Option<PathBuf>, std::io::Error)>,
15}
16
17/// Removes dead symbolic links from the specified directory.
18///
19/// # Errors
20/// - A filesystem operation (open/read/write/remove) fails.
21/// - Directory traversal fails.
22/// - Removing a dead symlink fails.
23pub 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
44/// Removes the file at the specified path, ignoring if the file does not exist.
45///
46/// # Errors
47/// - A filesystem operation (open/read/write/remove) fails.
48/// - An unexpected I/O failure (other than [`std::io::ErrorKind::NotFound`]) occurs.
49pub 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
58/// Iteratively removes all files with the specified name starting from the given root path, with optional directory
59/// exclusions.
60pub 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        // Skip paths removed during this traversal (e.g. a symlink target deleted by
129        // `handle_symlink` that later appears as its own directory entry).
130        if removed.contains(&current_path) {
131            continue;
132        }
133        match std::fs::symlink_metadata(&current_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        // First remove
167        assert_that!(rm_f(&path), ok(eq(())));
168        // Second remove, no error
169        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)); // Should not be removed
183    }
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)); // Should be removed
196    }
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(&regular_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)); // Not removed
216        assert_that!(ds_store_in_regular.exists(), eq(false)); // Removed
217    }
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}