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    #[cfg(feature = "progressbar")]
195    let pb = ProgressBar::new(entries.len() as u64);
196
197    for entry in entries {
198        tree.entry(entry.depth as u64)
199            .or_insert_with(Vec::new)
200            .push(entry.path());
201    }
202
203    let pool = ThreadPool::default();
204
205    let mut handles = vec![];
206
207    for (_, entries) in tree.into_iter().rev() {
208        #[cfg(feature = "progressbar")]
209        let pb = pb.clone();
210        handles.push(pool.evaluate(move || {
211            entries.par_iter().for_each(|entry| {
212                let _ = std::fs::remove_dir_all(entry);
213                #[cfg(feature = "progressbar")]
214                pb.inc(1);
215            });
216        }));
217    }
218
219    for handle in handles {
220        handle.await_complete();
221    }
222
223    if dpath.exists() {
224        // Try to fix permisssion issues and delete again
225        set_folder_writable(&dpath)?;
226
227        std::fs::remove_dir_all(dpath)
228            .map_err(|err| RdError::RemoveError(dpath.to_path_buf(), err))?;
229    }
230
231    #[cfg(feature = "progressbar")]
232    pb.finish();
233
234    Ok(())
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use std::fs;
241    use tempfile::TempDir;
242
243    /// Helper to create a test directory structure
244    fn create_test_structure(base: &Path) -> std::io::Result<()> {
245        // Create nested directories
246        fs::create_dir_all(base.join("dir1/subdir1"))?;
247        fs::create_dir_all(base.join("dir1/subdir2"))?;
248        fs::create_dir_all(base.join("dir2"))?;
249
250        // Create some files
251        fs::write(base.join("file1.txt"), "content1")?;
252        fs::write(base.join("dir1/file2.txt"), "content2")?;
253        fs::write(base.join("dir1/subdir1/file3.txt"), "content3")?;
254        fs::write(base.join("dir2/file4.txt"), "content4")?;
255
256        Ok(())
257    }
258
259    /// Helper to make files read-only
260    fn make_readonly(path: &Path) -> std::io::Result<()> {
261        let mut perms = fs::metadata(path)?.permissions();
262        perms.set_readonly(true);
263        fs::set_permissions(path, perms)?;
264        Ok(())
265    }
266
267    #[test]
268    fn test_delete_empty_directory() {
269        let temp_dir = TempDir::new().unwrap();
270        let test_path = temp_dir.path().join("empty_dir");
271        fs::create_dir(&test_path).unwrap();
272
273        assert!(test_path.exists());
274        delete_folder(&test_path).unwrap();
275        assert!(!test_path.exists());
276    }
277
278    #[test]
279    fn test_delete_directory_with_files() {
280        let temp_dir = TempDir::new().unwrap();
281        let test_path = temp_dir.path().join("dir_with_files");
282        fs::create_dir(&test_path).unwrap();
283        fs::write(test_path.join("file1.txt"), "content").unwrap();
284        fs::write(test_path.join("file2.txt"), "content").unwrap();
285
286        assert!(test_path.exists());
287        delete_folder(&test_path).unwrap();
288        assert!(!test_path.exists());
289    }
290
291    #[test]
292    fn test_delete_nested_directory_structure() {
293        let temp_dir = TempDir::new().unwrap();
294        let test_path = temp_dir.path().join("nested");
295        create_test_structure(&test_path).unwrap();
296
297        assert!(test_path.exists());
298        assert!(test_path.join("dir1/subdir1/file3.txt").exists());
299
300        delete_folder(&test_path).unwrap();
301
302        assert!(!test_path.exists());
303        assert!(!test_path.join("dir1").exists());
304    }
305
306    #[test]
307    fn test_delete_directory_with_readonly_files() {
308        let temp_dir = TempDir::new().unwrap();
309        let test_path = temp_dir.path().join("readonly_test");
310        create_test_structure(&test_path).unwrap();
311
312        // Make some files read-only
313        make_readonly(&test_path.join("file1.txt")).unwrap();
314        make_readonly(&test_path.join("dir1/file2.txt")).unwrap();
315
316        assert!(test_path.exists());
317        delete_folder(&test_path).unwrap();
318        assert!(!test_path.exists());
319    }
320
321    #[test]
322    fn test_delete_nonexistent_directory() {
323        let temp_dir = TempDir::new().unwrap();
324        let test_path = temp_dir.path().join("does_not_exist");
325
326        // Should handle gracefully - directory doesn't exist
327        let result = delete_folder(&test_path);
328
329        // This might succeed (nothing to delete) or fail with WalkdirError
330        // depending on jwalk's behavior
331        match result {
332            Ok(_) => assert!(!test_path.exists()),
333            Err(RdError::WalkdirError(_, _)) => {}
334            Err(e) => panic!("Unexpected error: {:?}", e),
335        }
336    }
337
338    #[test]
339    fn test_delete_directory_with_symlinks() {
340        let temp_dir = TempDir::new().unwrap();
341
342        // Create a directory outside the target
343        let external_dir = temp_dir.path().join("external");
344        fs::create_dir(&external_dir).unwrap();
345        fs::write(external_dir.join("important.txt"), "don't delete me").unwrap();
346
347        // Create target directory with symlink
348        let test_path = temp_dir.path().join("with_symlink");
349        fs::create_dir(&test_path).unwrap();
350
351        #[cfg(unix)]
352        {
353            use std::os::unix::fs::symlink;
354            symlink(&external_dir, test_path.join("link_to_external")).unwrap();
355        }
356
357        #[cfg(windows)]
358        {
359            use std::os::windows::fs::symlink_dir;
360            symlink_dir(&external_dir, test_path.join("link_to_external")).unwrap();
361        }
362
363        // Delete target directory
364        delete_folder(&test_path).unwrap();
365
366        // Target should be gone
367        assert!(!test_path.exists());
368
369        // External directory should still exist (wasn't followed)
370        assert!(external_dir.exists());
371        assert!(external_dir.join("important.txt").exists());
372    }
373
374    #[test]
375    fn test_set_writable_on_readonly_file() {
376        let temp_dir = TempDir::new().unwrap();
377        let file_path = temp_dir.path().join("readonly_file.txt");
378        fs::write(&file_path, "content").unwrap();
379
380        // Make it read-only
381        make_readonly(&file_path).unwrap();
382        let perms = fs::metadata(&file_path).unwrap().permissions();
383        assert!(perms.readonly());
384
385        // Make it writable using our function
386        set_writable(&file_path).unwrap();
387
388        let perms = fs::metadata(&file_path).unwrap().permissions();
389        assert!(!perms.readonly());
390    }
391
392    #[test]
393    fn test_delete_large_directory_structure() {
394        let temp_dir = TempDir::new().unwrap();
395        let test_path = temp_dir.path().join("large_structure");
396        fs::create_dir(&test_path).unwrap();
397
398        // Create many nested directories and files
399        for i in 0..10 {
400            let dir = test_path.join(format!("dir_{}", i));
401            fs::create_dir(&dir).unwrap();
402
403            for j in 0..5 {
404                fs::write(dir.join(format!("file_{}.txt", j)), "content").unwrap();
405            }
406
407            // Create a subdirectory
408            let subdir = dir.join("subdir");
409            fs::create_dir(&subdir).unwrap();
410            for k in 0..3 {
411                fs::write(subdir.join(format!("subfile_{}.txt", k)), "content").unwrap();
412            }
413        }
414
415        assert!(test_path.exists());
416        delete_folder(&test_path).unwrap();
417        assert!(!test_path.exists());
418    }
419
420    #[test]
421    fn test_error_on_invalid_path() {
422        // Test with a path that can't be walked (e.g., a file instead of directory)
423        let temp_dir = TempDir::new().unwrap();
424        let file_path = temp_dir.path().join("not_a_dir.txt");
425        fs::write(&file_path, "content").unwrap();
426
427        let result = delete_folder(&file_path);
428
429        // Should succeed (remove_dir_all works on files too) or error gracefully
430        // Behavior depends on the implementation
431        match result {
432            Ok(_) => assert!(!file_path.exists()),
433            Err(_) => {} // Acceptable to error on non-directory
434        }
435    }
436}