ytil_sys/
rm.rs

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
10/// Outcome of file removal operations.
11pub struct RmFilesOutcome {
12    /// Paths successfully removed or collected in dry run.
13    pub removed: Vec<PathBuf>,
14    /// Errors encountered, paired with optional affected paths.
15    pub errors: Vec<(Option<PathBuf>, std::io::Error)>,
16}
17
18/// Removes dead symbolic links from the specified directory.
19///
20/// # Errors
21/// - A filesystem operation (open/read/write/remove) fails.
22/// - Directory traversal fails.
23/// - Removing a dead symlink fails.
24pub 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
40/// Removes the file at the specified path, ignoring if the file does not exist.
41///
42/// # Errors
43/// - A filesystem operation (open/read/write/remove) fails.
44/// - An unexpected I/O failure (other than [`std::io::ErrorKind::NotFound`]) occurs.
45pub 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
54/// Iteratively removes all files with the specified name starting from the given root path, with optional directory
55/// exclusions.
56///
57/// This function uses a depth-first iterative traversal with a stack to avoid recursion limits.
58/// It checks each file/symlink and removes those matching the name, skipping any directories listed in `excluded_dirs`.
59/// Metadata is read once per path to determine file type, reducing syscalls.
60///
61/// # Performance
62/// - Reads metadata once per path using `symlink_metadata`, avoiding redundant syscalls.
63/// - Iterative approach prevents stack overflow in deep trees.
64/// - Exclusions reduce unnecessary traversal, improving performance for large trees with skipped dirs.
65/// - IO-bound; memory usage scales with stack depth and error count.
66pub 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(&current_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        // First remove
166        assert2::let_assert!(Ok(()) = rm_f(&path));
167        // Second remove, no error
168        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()); // Should not be removed
182    }
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()); // Should be removed
195    }
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(&regular_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()); // Not removed
215        assert!(!ds_store_in_regular.exists()); // Removed
216    }
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        // Check that error has None path for metadata failure
244        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}