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::ProgressIterator;
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
73/// Makes a file or directory writable by removing the read-only flag.
74///
75/// # Arguments
76///
77/// * `path` - The path to the file or directory to make writable
78///
79/// # Errors
80///
81/// Returns an error if:
82/// * The metadata cannot be read
83/// * The permissions cannot be set
84fn set_writable(path: &Path) -> Result<(), RdError> {
85    let mut perms = std::fs::metadata(path)
86        .map_err(|err| RdError::MetadataError(path.to_path_buf(), err))?
87        .permissions();
88
89    perms.set_readonly(false);
90    std::fs::set_permissions(path, perms)
91        .map_err(|err| RdError::PermissionError(path.to_path_buf(), err))?;
92
93    Ok(())
94}
95
96/// Recursively makes all files in a directory writable.
97///
98/// This function walks through all files in the given directory (following symlinks)
99/// and removes the read-only flag from each file in parallel.
100///
101/// # Arguments
102///
103/// * `path` - The root directory path to process
104///
105/// # Errors
106///
107/// Returns an error if:
108/// * The directory walk fails
109/// * Any file's permissions cannot be modified
110fn set_folder_writable(path: &Path) -> Result<(), RdError> {
111    let entries: Vec<DirEntry<((), ())>> = jwalk::WalkDir::new(&path)
112        .skip_hidden(false)
113        .into_iter()
114        .filter_map(|i| match i {
115            Ok(entry) if entry.file_type().is_file() => Some(Ok(entry)),
116            Ok(_) => None,
117            Err(e) => Some(Err(e)),
118        })
119        .collect::<Result<Vec<_>, _>>()
120        .map_err(|err| RdError::WalkdirError(path.to_path_buf(), err.to_string()))?;
121
122    let errors: Vec<_> = entries
123        .par_iter()
124        .filter_map(|entry| set_writable(&entry.path()).err())
125        .collect();
126
127    if let Some(err) = errors.into_iter().next() {
128        return Err(err);
129    }
130
131    Ok(())
132}
133
134/// Deletes a directory and all its contents in parallel.
135///
136/// This function performs a fast, parallel deletion of a directory by:
137/// 1. Walking the directory tree to catalog all subdirectories by depth
138/// 2. Deleting directories in parallel, starting from the deepest level
139/// 3. If deletion fails, attempting to fix permission issues and retrying
140///
141/// The parallel approach significantly speeds up deletion of large directory trees.
142///
143/// # Arguments
144///
145/// * `dpath` - The path to the directory to delete
146///
147/// # Errors
148///
149/// Returns an error if:
150/// * The directory walk fails
151/// * Permissions cannot be fixed
152/// * The final deletion attempt fails
153///
154/// # Examples
155///
156/// ```no_run
157/// use std::path::PathBuf;
158/// # use rapid_delete::delete_folder;
159///
160/// let path = PathBuf::from("/tmp/test_dir");
161/// delete_folder(&path)?;
162/// # Ok::<(), Box<dyn std::error::Error>>(())
163/// ```
164pub fn delete_folder(dpath: &Path) -> Result<(), RdError> {
165    let mut tree: BTreeMap<u64, Vec<PathBuf>> = BTreeMap::new();
166
167    let entries: Vec<DirEntry<((), ())>> = jwalk::WalkDir::new(&dpath)
168        .skip_hidden(false)
169        .into_iter()
170        .par_bridge()
171        .filter_map(|i| match i {
172            Ok(entry) if entry.path().is_dir() => Some(Ok(entry)),
173            Ok(_) => None,
174            Err(e) => Some(Err(e)),
175        })
176        .collect::<Result<Vec<_>, _>>()
177        .map_err(|err| RdError::WalkdirError(dpath.to_path_buf(), err.to_string()))?;
178
179    for entry in entries {
180        tree.entry(entry.depth as u64)
181            .or_insert_with(Vec::new)
182            .push(entry.path());
183    }
184
185    let pool = ThreadPool::default();
186
187    let mut handles = vec![];
188
189    let itr = tree.into_iter().rev();
190
191    #[cfg(feature = "progressbar")]
192    let itr = itr.progress();
193
194    for (_, entries) in itr {
195        handles.push(pool.evaluate(move || {
196            entries.par_iter().for_each(|entry| {
197                let _ = std::fs::remove_dir_all(entry);
198            });
199        }));
200    }
201
202    for handle in handles {
203        handle.await_complete();
204    }
205
206    if dpath.exists() {
207        // Try to fix permisssion issues and delete again
208        set_folder_writable(&dpath)?;
209
210        std::fs::remove_dir_all(dpath)
211            .map_err(|err| RdError::RemoveError(dpath.to_path_buf(), err))?;
212    }
213
214    Ok(())
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use std::fs;
221    use tempfile::TempDir;
222
223    /// Helper to create a test directory structure
224    fn create_test_structure(base: &Path) -> std::io::Result<()> {
225        // Create nested directories
226        fs::create_dir_all(base.join("dir1/subdir1"))?;
227        fs::create_dir_all(base.join("dir1/subdir2"))?;
228        fs::create_dir_all(base.join("dir2"))?;
229
230        // Create some files
231        fs::write(base.join("file1.txt"), "content1")?;
232        fs::write(base.join("dir1/file2.txt"), "content2")?;
233        fs::write(base.join("dir1/subdir1/file3.txt"), "content3")?;
234        fs::write(base.join("dir2/file4.txt"), "content4")?;
235
236        Ok(())
237    }
238
239    /// Helper to make files read-only
240    fn make_readonly(path: &Path) -> std::io::Result<()> {
241        let mut perms = fs::metadata(path)?.permissions();
242        perms.set_readonly(true);
243        fs::set_permissions(path, perms)?;
244        Ok(())
245    }
246
247    #[test]
248    fn test_delete_empty_directory() {
249        let temp_dir = TempDir::new().unwrap();
250        let test_path = temp_dir.path().join("empty_dir");
251        fs::create_dir(&test_path).unwrap();
252
253        assert!(test_path.exists());
254        delete_folder(&test_path).unwrap();
255        assert!(!test_path.exists());
256    }
257
258    #[test]
259    fn test_delete_directory_with_files() {
260        let temp_dir = TempDir::new().unwrap();
261        let test_path = temp_dir.path().join("dir_with_files");
262        fs::create_dir(&test_path).unwrap();
263        fs::write(test_path.join("file1.txt"), "content").unwrap();
264        fs::write(test_path.join("file2.txt"), "content").unwrap();
265
266        assert!(test_path.exists());
267        delete_folder(&test_path).unwrap();
268        assert!(!test_path.exists());
269    }
270
271    #[test]
272    fn test_delete_nested_directory_structure() {
273        let temp_dir = TempDir::new().unwrap();
274        let test_path = temp_dir.path().join("nested");
275        create_test_structure(&test_path).unwrap();
276
277        assert!(test_path.exists());
278        assert!(test_path.join("dir1/subdir1/file3.txt").exists());
279
280        delete_folder(&test_path).unwrap();
281
282        assert!(!test_path.exists());
283        assert!(!test_path.join("dir1").exists());
284    }
285
286    #[test]
287    fn test_delete_directory_with_readonly_files() {
288        let temp_dir = TempDir::new().unwrap();
289        let test_path = temp_dir.path().join("readonly_test");
290        create_test_structure(&test_path).unwrap();
291
292        // Make some files read-only
293        make_readonly(&test_path.join("file1.txt")).unwrap();
294        make_readonly(&test_path.join("dir1/file2.txt")).unwrap();
295
296        assert!(test_path.exists());
297        delete_folder(&test_path).unwrap();
298        assert!(!test_path.exists());
299    }
300
301    #[test]
302    fn test_delete_nonexistent_directory() {
303        let temp_dir = TempDir::new().unwrap();
304        let test_path = temp_dir.path().join("does_not_exist");
305
306        // Should handle gracefully - directory doesn't exist
307        let result = delete_folder(&test_path);
308
309        // This might succeed (nothing to delete) or fail with WalkdirError
310        // depending on jwalk's behavior
311        match result {
312            Ok(_) => assert!(!test_path.exists()),
313            Err(RdError::WalkdirError(_, _)) => {}
314            Err(e) => panic!("Unexpected error: {:?}", e),
315        }
316    }
317
318    #[test]
319    fn test_delete_directory_with_symlinks() {
320        let temp_dir = TempDir::new().unwrap();
321
322        // Create a directory outside the target
323        let external_dir = temp_dir.path().join("external");
324        fs::create_dir(&external_dir).unwrap();
325        fs::write(external_dir.join("important.txt"), "don't delete me").unwrap();
326
327        // Create target directory with symlink
328        let test_path = temp_dir.path().join("with_symlink");
329        fs::create_dir(&test_path).unwrap();
330
331        #[cfg(unix)]
332        {
333            use std::os::unix::fs::symlink;
334            symlink(&external_dir, test_path.join("link_to_external")).unwrap();
335        }
336
337        #[cfg(windows)]
338        {
339            use std::os::windows::fs::symlink_dir;
340            symlink_dir(&external_dir, test_path.join("link_to_external")).unwrap();
341        }
342
343        // Delete target directory
344        delete_folder(&test_path).unwrap();
345
346        // Target should be gone
347        assert!(!test_path.exists());
348
349        // External directory should still exist (wasn't followed)
350        assert!(external_dir.exists());
351        assert!(external_dir.join("important.txt").exists());
352    }
353
354    #[test]
355    fn test_set_writable_on_readonly_file() {
356        let temp_dir = TempDir::new().unwrap();
357        let file_path = temp_dir.path().join("readonly_file.txt");
358        fs::write(&file_path, "content").unwrap();
359
360        // Make it read-only
361        make_readonly(&file_path).unwrap();
362        let perms = fs::metadata(&file_path).unwrap().permissions();
363        assert!(perms.readonly());
364
365        // Make it writable using our function
366        set_writable(&file_path).unwrap();
367
368        let perms = fs::metadata(&file_path).unwrap().permissions();
369        assert!(!perms.readonly());
370    }
371
372    #[test]
373    fn test_delete_large_directory_structure() {
374        let temp_dir = TempDir::new().unwrap();
375        let test_path = temp_dir.path().join("large_structure");
376        fs::create_dir(&test_path).unwrap();
377
378        // Create many nested directories and files
379        for i in 0..10 {
380            let dir = test_path.join(format!("dir_{}", i));
381            fs::create_dir(&dir).unwrap();
382
383            for j in 0..5 {
384                fs::write(dir.join(format!("file_{}.txt", j)), "content").unwrap();
385            }
386
387            // Create a subdirectory
388            let subdir = dir.join("subdir");
389            fs::create_dir(&subdir).unwrap();
390            for k in 0..3 {
391                fs::write(subdir.join(format!("subfile_{}.txt", k)), "content").unwrap();
392            }
393        }
394
395        assert!(test_path.exists());
396        delete_folder(&test_path).unwrap();
397        assert!(!test_path.exists());
398    }
399
400    #[test]
401    fn test_error_on_invalid_path() {
402        // Test with a path that can't be walked (e.g., a file instead of directory)
403        let temp_dir = TempDir::new().unwrap();
404        let file_path = temp_dir.path().join("not_a_dir.txt");
405        fs::write(&file_path, "content").unwrap();
406
407        let result = delete_folder(&file_path);
408
409        // Should succeed (remove_dir_all works on files too) or error gracefully
410        // Behavior depends on the implementation
411        match result {
412            Ok(_) => assert!(!file_path.exists()),
413            Err(_) => {} // Acceptable to error on non-directory
414        }
415    }
416}