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 as _;
7use rootcause::prelude::ResultExt as _;
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 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        // First remove
165        assert2::assert!(let Ok(()) = rm_f(&path));
166        // Second remove, no error
167        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()); // Should not be removed
181    }
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()); // Should be removed
194    }
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(&regular_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()); // Not removed
214        assert!(!ds_store_in_regular.exists()); // Removed
215    }
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        // Check that error has None path for metadata failure
243        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}