rapid_delete/
lib.rs

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