Skip to main content

Merge pull request #72 from alexwlchan/explain-why-not-deleting

ID
7317c84
date
2025-12-16 07:15:14+00:00
author
Alex Chan <alex@alexwlchan.net>
parents
549b6e3, 9f4c718
message
Merge pull request #72 from alexwlchan/explain-why-not-deleting

Explain why a directory can't be deleted
changed files
6 files, 158 additions, 59 deletions

Changed files

CHANGELOG.md (1318) → CHANGELOG.md (1793)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6bfaff7..a34c182 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,23 @@
 # Changelog
 
+## v1.3.0 - 2025-12-16
+
+If `emptydir` looks at a directory but there's a reason the directory can't be deleted, it now prints the reason.
+
+Example:
+
+```console
+$ emptydir ~/Desktop
+directory is not empty; contains 3 entries:
+  - makeup-tips.html
+  - paste_images.py
+  - Screenshot 2024-12-31 at 10.46.41.png
+```
+
+Previously, this would simply report "no empty directories found".
+
+This reason is only printed for the initial target of `emptydir`, if nothing can be deleted.
+
 ## v1.2.2 - 2025-08-16
 
 If `emptydir` tries to delete a directory but gets an error, it now prints that error to stderr. Previously the error would be silently ignored.

Cargo.lock (11081) → Cargo.lock (11081)

diff --git a/Cargo.lock b/Cargo.lock
index d711031..062bfcf 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -126,7 +126,7 @@ dependencies = [
 
 [[package]]
 name = "emptydir"
-version = "1.2.2"
+version = "1.3.0"
 dependencies = [
  "clap",
  "colored",

Cargo.toml (200) → Cargo.toml (200)

diff --git a/Cargo.toml b/Cargo.toml
index b3cec7f..2f4798e 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "emptydir"
-version = "1.2.2"
+version = "1.3.0"
 edition = "2021"
 
 [dependencies]

src/can_be_deleted.rs (5958) → src/can_be_deleted.rs (8231)

diff --git a/src/can_be_deleted.rs b/src/can_be_deleted.rs
index b0ac862..5d4edcc 100644
--- a/src/can_be_deleted.rs
+++ b/src/can_be_deleted.rs
@@ -1,5 +1,6 @@
 use std::collections::HashSet;
 use std::ffi::OsString;
+use std::fmt;
 use std::fs;
 use std::io;
 use std::path::Path;
@@ -26,17 +27,56 @@ fn is_in_git_repository(path: &Path) -> bool {
         .any(|ancestor| ancestor.file_name().map_or(false, |name| name == ".git"))
 }
 
-pub fn can_be_deleted(path: &Path) -> bool {
-    // This is a folder where I put files that I explicitly don't want
-    // to include in my backups.
-    //
-    // It may sometimes be empty, but I never want to delete it.
-    // See https://overcast.fm/+R7DX9_W-Y/21:22 or my Obsidian note.
-    match path.canonicalize() {
-        Ok(p) if p == Path::new("/Users/alexwlchan/Desktop/do not back up") => return false,
-        _ => (),
-    };
+/// DeleteDecision describes whether a directory can be deleted.
+#[derive(Debug)]
+pub enum DeleteDecision {
+    CanDelete,
+    CannotDelete(Reason),
+}
+
+// Reason explains why a directory cannot be deleted.
+#[derive(Debug)]
+pub enum Reason {
+    NotEmpty(Vec<OsString>),
+    InGitRepository,
+    CannotListContents(io::Error),
+}
+
+impl fmt::Display for Reason {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            Reason::NotEmpty(entries) => {
+                write!(
+                    f,
+                    "directory is not empty; contains {} entr{}:",
+                    entries.len(),
+                    if entries.len() == 1 { "y" } else { "ies" }
+                )?;
+
+                // Sort the entries for consistent output.
+                let mut sorted: Vec<_> = entries.iter().collect();
+                sorted.sort_by_key(|s| s.to_string_lossy());
+
+                for entry in sorted {
+                    write!(f, "\n  - {}", entry.to_string_lossy())?;
+                }
+
+                Ok(())
+            }
+
+            Reason::CannotListContents(err) => {
+                write!(f, "unable to list directory contents: {}", err)
+            }
+
+            Reason::InGitRepository => {
+                write!(f, "directory is inside a .git repository")
+            }
+        }
+    }
+}
 
+/// can_be_deleted checks whether a directory can be deleted.
+pub fn can_be_deleted(dir_path: &Path) -> DeleteDecision {
     // Don't delete subfolders of a `.git` directory.
     //
     // For example, if you delete `.git/refs`, then Git can't detect
@@ -51,8 +91,8 @@ pub fn can_be_deleted(path: &Path) -> bool {
     //     fatal: not a git repository (or any of the parent directories): .git
     //
     // Skipping these folders is fine.
-    if is_in_git_repository(path) {
-        return false;
+    if is_in_git_repository(dir_path) {
+        return DeleteDecision::CannotDelete(Reason::InGitRepository);
     }
 
     // This is the list of entries which I consider safe to delete.
@@ -83,10 +123,14 @@ pub fn can_be_deleted(path: &Path) -> bool {
         OsString::from("thumbs.db"),
     ]);
 
-    match get_names_in_directory(path) {
-        Ok(names) if names.is_empty() => true,
-        Ok(names) => names.is_subset(&deletable_names),
-        Err(_) => false,
+    match get_names_in_directory(dir_path) {
+        Ok(names) if names.is_subset(&deletable_names) => DeleteDecision::CanDelete,
+        Ok(names) => {
+            let remaining_entries: Vec<OsString> =
+                names.difference(&deletable_names).cloned().collect();
+            DeleteDecision::CannotDelete(Reason::NotEmpty(remaining_entries))
+        }
+        Err(e) => DeleteDecision::CannotDelete(Reason::CannotListContents(e)),
     }
 }
 
@@ -99,8 +143,8 @@ mod test_can_be_deleted {
 
     fn test_dir() -> PathBuf {
         let tmp_dir = tempfile::tempdir().unwrap();
-        let path = tmp_dir.path();
-        path.to_owned()
+        let dir_path = tmp_dir.path();
+        dir_path.to_owned()
     }
 
     fn create_dir(path: &PathBuf) {
@@ -112,93 +156,116 @@ mod test_can_be_deleted {
     }
 
     #[test]
-    fn it_doesnt_delete_my_do_not_backup() {
-        let path = Path::new("/Users/alexwlchan/Desktop/do not back up");
-        assert_eq!(can_be_deleted(&path), false);
-    }
-
-    #[test]
     fn a_dir_cant_be_deleted_if_we_cant_read_the_contents() {
-        let path = Path::new("/does/not/exist");
-        assert_eq!(can_be_deleted(&path), false);
+        let dir_path = Path::new("/does/not/exist");
+        assert!(matches!(
+            can_be_deleted(&dir_path),
+            DeleteDecision::CannotDelete(Reason::CannotListContents(_))
+        ));
     }
 
     #[test]
     fn an_empty_dir_can_be_deleted() {
-        let path = test_dir();
+        let dir_path = test_dir();
 
         // Create the directory, but don't put anything in it
-        create_dir(&path);
+        create_dir(&dir_path);
 
-        assert_eq!(can_be_deleted(&path), true);
+        assert!(matches!(
+            can_be_deleted(&dir_path),
+            DeleteDecision::CanDelete
+        ));
     }
 
     #[test]
     fn a_directory_with_extra_entries_cannot_be_deleted() {
-        let path = test_dir();
+        let dir_path = test_dir();
 
         // Create the directory, then add a text file
-        create_dir(&path);
+        create_dir(&dir_path);
 
-        create_file(path.join("greeting.txt"));
+        create_file(dir_path.join("greeting.txt"));
 
-        assert_eq!(can_be_deleted(&path), false);
+        match can_be_deleted(&dir_path) {
+            DeleteDecision::CannotDelete(Reason::NotEmpty(entries)) => {
+                assert_eq!(entries, vec![OsString::from("greeting.txt")]);
+            }
+            other => panic!("unexpected decision: {other:?}"),
+        }
     }
 
     #[test]
     fn a_directory_with_only_safe_to_delete_entries_can_be_deleted() {
-        let path = test_dir();
+        let dir_path = test_dir();
 
         // Create the directory, then add subdirectories
-        create_dir(&path);
+        create_dir(&dir_path);
 
-        create_dir(&path.join(".venv"));
-        create_dir(&path.join("__pycache__"));
-        create_file(path.join(".DS_Store"));
+        create_dir(&dir_path.join(".venv"));
+        create_dir(&dir_path.join("__pycache__"));
+        create_file(dir_path.join(".DS_Store"));
 
-        assert_eq!(can_be_deleted(&path), true);
+        assert!(matches!(
+            can_be_deleted(&dir_path),
+            DeleteDecision::CanDelete
+        ));
     }
 
     #[test]
     fn a_directory_with_mix_of_safe_and_unsafe_entries_cannot_be_deleted() {
-        let path = test_dir();
+        let dir_path = test_dir();
 
-        create_dir(&path);
+        create_dir(&dir_path);
 
-        create_file(path.join(".DS_Store"));
-        create_file(path.join("greeting.txt"));
+        create_file(dir_path.join(".DS_Store"));
+        create_file(dir_path.join("greeting.txt"));
 
-        assert_eq!(can_be_deleted(&path), false);
+        match can_be_deleted(&dir_path) {
+            DeleteDecision::CannotDelete(Reason::NotEmpty(entries)) => {
+                // `.DS_Store` is allowed, `greeting.txt` is not
+                assert_eq!(entries, vec![OsString::from("greeting.txt")]);
+            }
+            other => panic!("unexpected decision: {other:?}"),
+        }
     }
 
     #[test]
     fn safe_to_delete_entries_are_case_insensitive() {
-        let path = test_dir();
+        let dir_path = test_dir();
 
-        create_dir(&path);
+        create_dir(&dir_path);
 
-        create_file(path.join(".ds_store"));
+        create_file(dir_path.join(".ds_store"));
 
-        assert_eq!(can_be_deleted(&path), true);
+        assert!(matches!(
+            can_be_deleted(&dir_path),
+            DeleteDecision::CanDelete
+        ));
     }
 
     #[test]
     fn the_dot_git_folder_cannot_be_deleted() {
-        let path = test_dir();
-        let git_dir = path.join(".git");
+        let dir_path = test_dir();
+        let git_dir = dir_path.join(".git");
 
         create_dir(&git_dir);
 
-        assert_eq!(can_be_deleted(&git_dir), false);
+        assert!(matches!(
+            can_be_deleted(&git_dir),
+            DeleteDecision::CannotDelete(Reason::InGitRepository)
+        ));
     }
 
     #[test]
     fn any_subdir_of_the_dot_git_folder_cannot_be_deleted() {
-        let path = test_dir();
-        let refs_dir = path.join(".git/refs");
+        let dir_path = test_dir();
+        let refs_dir = dir_path.join(".git/refs");
 
         create_dir(&refs_dir);
 
-        assert_eq!(can_be_deleted(&refs_dir), false);
+        assert!(matches!(
+            can_be_deleted(&refs_dir),
+            DeleteDecision::CannotDelete(Reason::InGitRepository)
+        ));
     }
 }

src/emptydir.rs (6817) → src/emptydir.rs (7038)

diff --git a/src/emptydir.rs b/src/emptydir.rs
index 1324574..a8802bd 100644
--- a/src/emptydir.rs
+++ b/src/emptydir.rs
@@ -1,6 +1,7 @@
 use std::fs;
 use std::path::Path;
 
+use crate::can_be_deleted::DeleteDecision;
 use colored::*;
 use walkdir::WalkDir;
 
@@ -20,7 +21,12 @@ pub fn emptydir(root: &Path) -> EmptydirResult {
         .into_iter()
         .filter_map(|e| e.ok())
         .filter(|e| e.file_type().is_dir())
-        .filter(|e| crate::can_be_deleted::can_be_deleted(e.path()));
+        .filter(|e| {
+            matches!(
+                crate::can_be_deleted::can_be_deleted(e.path()),
+                DeleteDecision::CanDelete
+            )
+        });
 
     let mut count_deleted: u32 = 0;
     let mut count_errors: u32 = 0;
@@ -48,7 +54,10 @@ pub fn emptydir(root: &Path) -> EmptydirResult {
     let mut current_parent = root.parent();
 
     while let Some(parent) = current_parent {
-        if !crate::can_be_deleted::can_be_deleted(parent) {
+        if !matches!(
+            crate::can_be_deleted::can_be_deleted(parent),
+            DeleteDecision::CanDelete
+        ) {
             break;
         }
 

src/main.rs (1065) → src/main.rs (1233)

diff --git a/src/main.rs b/src/main.rs
index 65ee544..7c2319a 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -24,7 +24,12 @@ fn main() -> Result<(), std::io::Error> {
     let result = emptydir::emptydir(root);
 
     match (result.count_deleted, result.count_errors) {
-        (0, 0) => println!("{}", "No empty directories found".blue()),
+        (0, 0) => match can_be_deleted::can_be_deleted(&root) {
+            can_be_deleted::DeleteDecision::CannotDelete(reason) => {
+                eprintln!("{}", reason.to_string().red());
+            }
+            _ => (),
+        },
         (0, _) => println!("{}", "Unable to delete empty directories".red()),
         (1, _) => println!("{}", "1 directory deleted".green()),
         _ => {