rapid_delete/
lib.rs

1/*
2  Copyright 2025 Adam Sweeney
3
4  Licensed under the Apache License, Version 2.0 (the "License");
5  you may not use this file except in compliance with the License.
6  You may obtain a copy of the License at
7
8      http://www.apache.org/licenses/LICENSE-2.0
9
10  Unless required by applicable law or agreed to in writing, software
11  distributed under the License is distributed on an "AS IS" BASIS,
12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  See the License for the specific language governing permissions and
14  limitations under the License.
15*/
16
17// NOTICE
18// Modified from turbo-delete (https://github.com/suptejas/turbo-delete)
19// Licensed under Apache-2.0
20// Changes: refactored for library use
21
22/*
23  Copyright 2022 Tejas Ravishankar
24
25  Licensed under the Apache License, Version 2.0 (the "License");
26  you may not use this file except in compliance with the License.
27  You may obtain a copy of the License at
28
29      http://www.apache.org/licenses/LICENSE-2.0
30
31  Unless required by applicable law or agreed to in writing, software
32  distributed under the License is distributed on an "AS IS" BASIS,
33  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
34  See the License for the specific language governing permissions and
35  limitations under the License.
36*/
37
38//! A library for fast, parallel directory deletion with permission handling.
39//!
40//! This library provides functionality to recursively delete directories,
41//! automatically handling read-only files and permission issues that might
42//! prevent deletion.
43
44#[cfg(feature = "progressbar")]
45use indicatif::ProgressBar;
46use jwalk::DirEntry;
47use rayon::iter::{IntoParallelRefIterator, ParallelBridge, ParallelIterator};
48use rusty_pool::ThreadPool;
49use std::{
50    collections::BTreeMap,
51    path::{Path, PathBuf},
52};
53use thiserror::Error;
54
55#[derive(Error, Debug)]
56pub enum RdError {
57    #[error("Failed to read metadata for {0}: {1}")]
58    MetadataError(PathBuf, std::io::Error),
59
60    #[error("Failed to set permissions for: {0}: {1}")]
61    PermissionError(PathBuf, std::io::Error),
62
63    #[error("Failed to remove item {0}: {1}")]
64    RemoveError(PathBuf, std::io::Error),
65
66    #[error("Failed to walk directory: {0}: {1}")]
67    WalkdirError(PathBuf, String),
68
69    #[error("IO error: {0}")]
70    Io(#[from] std::io::Error),
71}
72
73impl From<RdError> for std::io::Error {
74    fn from(err: RdError) -> Self {
75        match err {
76            RdError::Io(e) => e,
77            RdError::MetadataError(_, e) => e,
78            RdError::PermissionError(_, e) => e,
79            RdError::RemoveError(_, e) => e,
80            RdError::WalkdirError(path, msg) => std::io::Error::new(
81                std::io::ErrorKind::Other,
82                format!("Failed to walk directory {}: {}", path.display(), msg),
83            ),
84        }
85    }
86}
87
88/// Makes a file or directory writable by removing the read-only flag.
89///
90/// # Arguments
91///
92/// * `path` - The path to the file or directory to make writable
93///
94/// # Errors
95///
96/// Returns an error if:
97/// * The metadata cannot be read
98/// * The permissions cannot be set
99fn set_writable(path: &Path) -> Result<(), RdError> {
100    let mut perms = std::fs::metadata(path)
101        .map_err(|err| RdError::MetadataError(path.to_path_buf(), err))?
102        .permissions();
103
104    perms.set_readonly(false);
105    std::fs::set_permissions(path, perms)
106        .map_err(|err| RdError::PermissionError(path.to_path_buf(), err))?;
107
108    Ok(())
109}
110
111/// Recursively makes all files in a directory writable.
112///
113/// This function walks through all files in the given directory (following symlinks)
114/// and removes the read-only flag from each file in parallel.
115///
116/// # Arguments
117///
118/// * `path` - The root directory path to process
119///
120/// # Errors
121///
122/// Returns an error if:
123/// * The directory walk fails
124/// * Any file's permissions cannot be modified
125fn set_folder_writable(path: &Path) -> Result<(), RdError> {
126    let entries: Vec<DirEntry<((), ())>> = jwalk::WalkDir::new(&path)
127        .skip_hidden(false)
128        .into_iter()
129        .filter_map(|i| match i {
130            Ok(entry) if entry.file_type().is_file() => Some(Ok(entry)),
131            Ok(_) => None,
132            Err(e) => Some(Err(e)),
133        })
134        .collect::<Result<Vec<_>, _>>()
135        .map_err(|err| RdError::WalkdirError(path.to_path_buf(), err.to_string()))?;
136
137    let errors: Vec<_> = entries
138        .par_iter()
139        .filter_map(|entry| set_writable(&entry.path()).err())
140        .collect();
141
142    if let Some(err) = errors.into_iter().next() {
143        return Err(err);
144    }
145
146    Ok(())
147}
148
149/// Deletes a directory and all its contents in parallel.
150///
151/// This function performs a fast, parallel deletion of a directory by:
152/// 1. Walking the directory tree to catalog all subdirectories by depth
153/// 2. Deleting directories in parallel, starting from the deepest level
154/// 3. If deletion fails, attempting to fix permission issues and retrying
155///
156/// The parallel approach significantly speeds up deletion of large directory trees.
157///
158/// # Arguments
159///
160/// * `dpath` - The path to the directory to delete
161///
162/// # Errors
163///
164/// Returns an error if:
165/// * The directory walk fails
166/// * Permissions cannot be fixed
167/// * The final deletion attempt fails
168///
169/// # Examples
170///
171/// ```no_run
172/// use std::path::PathBuf;
173/// # use rapid_delete::delete_folder;
174///
175/// let path = PathBuf::from("/tmp/test_dir");
176/// delete_folder(&path)?;
177/// # Ok::<(), Box<dyn std::error::Error>>(())
178/// ```
179pub fn delete_folder(dpath: &Path) -> Result<(), RdError> {
180    let mut tree: BTreeMap<u64, Vec<PathBuf>> = BTreeMap::new();
181
182    let entries: Vec<DirEntry<((), ())>> = jwalk::WalkDir::new(&dpath)
183        .skip_hidden(false)
184        .into_iter()
185        .par_bridge()
186        .filter_map(|i| match i {
187            Ok(entry) if entry.path().is_dir() => Some(Ok(entry)),
188            Ok(_) => None,
189            Err(e) => Some(Err(e)),
190        })
191        .collect::<Result<Vec<_>, _>>()
192        .map_err(|err| RdError::WalkdirError(dpath.to_path_buf(), err.to_string()))?;
193
194    for entry in entries {
195        tree.entry(entry.depth as u64)
196            .or_insert_with(Vec::new)
197            .push(entry.path());
198    }
199
200    let pool = ThreadPool::default();
201
202    let mut handles = vec![];
203
204    #[cfg(feature = "progressbar")]
205    let pb = ProgressBar::new(entries.len() as u64);
206
207    for (_, entries) in tree.into_iter().rev() {
208        handles.push(pool.evaluate(move || {
209            entries.par_iter().for_each(|entry| {
210                let _ = std::fs::remove_dir_all(entry);
211                #[cfg(feature = "progressbar")]
212                pb.inc(1);
213            });
214        }));
215    }
216
217    for handle in handles {
218        handle.await_complete();
219    }
220
221    if dpath.exists() {
222        // Try to fix permisssion issues and delete again
223        set_folder_writable(&dpath)?;
224
225        std::fs::remove_dir_all(dpath)
226            .map_err(|err| RdError::RemoveError(dpath.to_path_buf(), err))?;
227    }
228
229    #[cfg(feature = "progressbar")]
230    pb.finish();
231
232    Ok(())
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use std::fs;
239    use tempfile::TempDir;
240
241    /// Helper to create a test directory structure
242    fn create_test_structure(base: &Path) -> std::io::Result<()> {
243        // Create nested directories
244        fs::create_dir_all(base.join("dir1/subdir1"))?;
245        fs::create_dir_all(base.join("dir1/subdir2"))?;
246        fs::create_dir_all(base.join("dir2"))?;
247
248        // Create some files
249        fs::write(base.join("file1.txt"), "content1")?;
250        fs::write(base.join("dir1/file2.txt"), "content2")?;
251        fs::write(base.join("dir1/subdir1/file3.txt"), "content3")?;
252        fs::write(base.join("dir2/file4.txt"), "content4")?;
253
254        Ok(())
255    }
256
257    /// Helper to make files read-only
258    fn make_readonly(path: &Path) -> std::io::Result<()> {
259        let mut perms = fs::metadata(path)?.permissions();
260        perms.set_readonly(true);
261        fs::set_permissions(path, perms)?;
262        Ok(())
263    }
264
265    #[test]
266    fn test_delete_empty_directory() {
267        let temp_dir = TempDir::new().unwrap();
268        let test_path = temp_dir.path().join("empty_dir");
269        fs::create_dir(&test_path).unwrap();
270
271        assert!(test_path.exists());
272        delete_folder(&test_path).unwrap();
273        assert!(!test_path.exists());
274    }
275
276    #[test]
277    fn test_delete_directory_with_files() {
278        let temp_dir = TempDir::new().unwrap();
279        let test_path = temp_dir.path().join("dir_with_files");
280        fs::create_dir(&test_path).unwrap();
281        fs::write(test_path.join("file1.txt"), "content").unwrap();
282        fs::write(test_path.join("file2.txt"), "content").unwrap();
283
284        assert!(test_path.exists());
285        delete_folder(&test_path).unwrap();
286        assert!(!test_path.exists());
287    }
288
289    #[test]
290    fn test_delete_nested_directory_structure() {
291        let temp_dir = TempDir::new().unwrap();
292        let test_path = temp_dir.path().join("nested");
293        create_test_structure(&test_path).unwrap();
294
295        assert!(test_path.exists());
296        assert!(test_path.join("dir1/subdir1/file3.txt").exists());
297
298        delete_folder(&test_path).unwrap();
299
300        assert!(!test_path.exists());
301        assert!(!test_path.join("dir1").exists());
302    }
303
304    #[test]
305    fn test_delete_directory_with_readonly_files() {
306        let temp_dir = TempDir::new().unwrap();
307        let test_path = temp_dir.path().join("readonly_test");
308        create_test_structure(&test_path).unwrap();
309
310        // Make some files read-only
311        make_readonly(&test_path.join("file1.txt")).unwrap();
312        make_readonly(&test_path.join("dir1/file2.txt")).unwrap();
313
314        assert!(test_path.exists());
315        delete_folder(&test_path).unwrap();
316        assert!(!test_path.exists());
317    }
318
319    #[test]
320    fn test_delete_nonexistent_directory() {
321        let temp_dir = TempDir::new().unwrap();
322        let test_path = temp_dir.path().join("does_not_exist");
323
324        // Should handle gracefully - directory doesn't exist
325        let result = delete_folder(&test_path);
326
327        // This might succeed (nothing to delete) or fail with WalkdirError
328        // depending on jwalk's behavior
329        match result {
330            Ok(_) => assert!(!test_path.exists()),
331            Err(RdError::WalkdirError(_, _)) => {}
332            Err(e) => panic!("Unexpected error: {:?}", e),
333        }
334    }
335
336    #[test]
337    fn test_delete_directory_with_symlinks() {
338        let temp_dir = TempDir::new().unwrap();
339
340        // Create a directory outside the target
341        let external_dir = temp_dir.path().join("external");
342        fs::create_dir(&external_dir).unwrap();
343        fs::write(external_dir.join("important.txt"), "don't delete me").unwrap();
344
345        // Create target directory with symlink
346        let test_path = temp_dir.path().join("with_symlink");
347        fs::create_dir(&test_path).unwrap();
348
349        #[cfg(unix)]
350        {
351            use std::os::unix::fs::symlink;
352            symlink(&external_dir, test_path.join("link_to_external")).unwrap();
353        }
354
355        #[cfg(windows)]
356        {
357            use std::os::windows::fs::symlink_dir;
358            symlink_dir(&external_dir, test_path.join("link_to_external")).unwrap();
359        }
360
361        // Delete target directory
362        delete_folder(&test_path).unwrap();
363
364        // Target should be gone
365        assert!(!test_path.exists());
366
367        // External directory should still exist (wasn't followed)
368        assert!(external_dir.exists());
369        assert!(external_dir.join("important.txt").exists());
370    }
371
372    #[test]
373    fn test_set_writable_on_readonly_file() {
374        let temp_dir = TempDir::new().unwrap();
375        let file_path = temp_dir.path().join("readonly_file.txt");
376        fs::write(&file_path, "content").unwrap();
377
378        // Make it read-only
379        make_readonly(&file_path).unwrap();
380        let perms = fs::metadata(&file_path).unwrap().permissions();
381        assert!(perms.readonly());
382
383        // Make it writable using our function
384        set_writable(&file_path).unwrap();
385
386        let perms = fs::metadata(&file_path).unwrap().permissions();
387        assert!(!perms.readonly());
388    }
389
390    #[test]
391    fn test_delete_large_directory_structure() {
392        let temp_dir = TempDir::new().unwrap();
393        let test_path = temp_dir.path().join("large_structure");
394        fs::create_dir(&test_path).unwrap();
395
396        // Create many nested directories and files
397        for i in 0..10 {
398            let dir = test_path.join(format!("dir_{}", i));
399            fs::create_dir(&dir).unwrap();
400
401            for j in 0..5 {
402                fs::write(dir.join(format!("file_{}.txt", j)), "content").unwrap();
403            }
404
405            // Create a subdirectory
406            let subdir = dir.join("subdir");
407            fs::create_dir(&subdir).unwrap();
408            for k in 0..3 {
409                fs::write(subdir.join(format!("subfile_{}.txt", k)), "content").unwrap();
410            }
411        }
412
413        assert!(test_path.exists());
414        delete_folder(&test_path).unwrap();
415        assert!(!test_path.exists());
416    }
417
418    #[test]
419    fn test_error_on_invalid_path() {
420        // Test with a path that can't be walked (e.g., a file instead of directory)
421        let temp_dir = TempDir::new().unwrap();
422        let file_path = temp_dir.path().join("not_a_dir.txt");
423        fs::write(&file_path, "content").unwrap();
424
425        let result = delete_folder(&file_path);
426
427        // Should succeed (remove_dir_all works on files too) or error gracefully
428        // Behavior depends on the implementation
429        match result {
430            Ok(_) => assert!(!file_path.exists()),
431            Err(_) => {} // Acceptable to error on non-directory
432        }
433    }
434}