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