1use std::collections::HashSet;
8/// Return the names of files/folders inside a directory.
10/// Names are lowercased for easy comparisons.
12fn get_names_in_directory(dir: &Path) -> io::Result<HashSet<OsString>> {
13 let mut names = Vec::new();
15 for entry in fs::read_dir(dir)? {
17 names.push(entry.file_name().to_ascii_lowercase());
20 Ok(HashSet::from_iter(names))
23/// Returns True if this path any ancestor is a `.git` folder,
25fn is_in_git_repository(path: &Path) -> bool {
27 .any(|ancestor| ancestor.file_name().map_or(false, |name| name == ".git"))
30/// DeleteDecision describes whether a directory can be deleted.
32pub enum DeleteDecision {
37// Reason explains why a directory cannot be deleted.
40 NotEmpty(Vec<OsString>),
42 CannotListContents(io::Error),
45impl fmt::Display for Reason {
46 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48 Reason::NotEmpty(entries) => {
51 "directory is not empty; contains {} entr{}:",
53 if entries.len() == 1 { "y" } else { "ies" }
56 // Sort the entries for consistent output.
57 let mut sorted: Vec<_> = entries.iter().collect();
58 sorted.sort_by_key(|s| s.to_string_lossy());
61 write!(f, "\n - {}", entry.to_string_lossy())?;
67 Reason::CannotListContents(err) => {
68 write!(f, "unable to list directory contents: {}", err)
71 Reason::InGitRepository => {
72 write!(f, "directory is inside a .git repository")
78/// can_be_deleted checks whether a directory can be deleted.
79pub fn can_be_deleted(dir_path: &Path) -> DeleteDecision {
80 // Don't delete subfolders of a `.git` directory.
82 // For example, if you delete `.git/refs`, then Git can't detect
83 // the Git directory any more. Observe:
86 // Initialized empty Git repository in tmp.bTrs8ZaWjc/.git/
91 // fatal: not a git repository (or any of the parent directories): .git
93 // Skipping these folders is fine.
94 if is_in_git_repository(dir_path) {
95 return DeleteDecision::CannotDelete(Reason::InGitRepository);
98 // This is the list of entries which I consider safe to delete.
100 // * .DS_Store stores some folder attributes used for showing the folder
101 // in the Finder, which I don't need to keep
102 // * `.ipynb_checkpoints` is a folder used by Jupyter Notebooks, but not
103 // important if I've deleted the notebooks
104 // * `.jekyll-cache` is a cache directory used by Jekyll sites, but
105 // can be easily regenerated and will be rebuilt regularly as part
106 // of the Jekyll build process
107 // * `.venv` is the name I use for virtual environments, which I can
108 // easily regenerate if necessary
109 // * `__pycache__` is the bytecode cache in Python projects, which is
110 // pointless if the original Python files have been removed
111 // * `Thumbs.db` is a file that contains thumbnails on Windows systems
113 // A directory is safe to delete if the ONLY things it contains are these entries;
114 // any other entry should block the directory from being deleted.
116 let deletable_names = HashSet::from([
117 OsString::from(".ds_store"),
118 OsString::from(".ipynb_checkpoints"),
119 OsString::from(".jekyll-cache"),
120 OsString::from(".venv"),
121 OsString::from("__pycache__"),
122 OsString::from("desktop.ini"),
123 OsString::from("thumbs.db"),
126 match get_names_in_directory(dir_path) {
127 Ok(names) if names.is_subset(&deletable_names) => DeleteDecision::CanDelete,
129 let remaining_entries: Vec<OsString> =
130 names.difference(&deletable_names).cloned().collect();
131 DeleteDecision::CannotDelete(Reason::NotEmpty(remaining_entries))
133 Err(e) => DeleteDecision::CannotDelete(Reason::CannotListContents(e)),
138mod test_can_be_deleted {
140 use std::path::{Path, PathBuf};
144 fn test_dir() -> PathBuf {
145 let tmp_dir = tempfile::tempdir().unwrap();
146 let dir_path = tmp_dir.path();
150 fn create_dir(path: &PathBuf) {
151 fs::create_dir_all(path).unwrap();
154 fn create_file(path: PathBuf) {
155 fs::write(&path, "this file is for testing").unwrap();
159 fn a_dir_cant_be_deleted_if_we_cant_read_the_contents() {
160 let dir_path = Path::new("/does/not/exist");
162 can_be_deleted(&dir_path),
163 DeleteDecision::CannotDelete(Reason::CannotListContents(_))
168 fn an_empty_dir_can_be_deleted() {
169 let dir_path = test_dir();
171 // Create the directory, but don't put anything in it
172 create_dir(&dir_path);
175 can_be_deleted(&dir_path),
176 DeleteDecision::CanDelete
181 fn a_directory_with_extra_entries_cannot_be_deleted() {
182 let dir_path = test_dir();
184 // Create the directory, then add a text file
185 create_dir(&dir_path);
187 create_file(dir_path.join("greeting.txt"));
189 match can_be_deleted(&dir_path) {
190 DeleteDecision::CannotDelete(Reason::NotEmpty(entries)) => {
191 assert_eq!(entries, vec![OsString::from("greeting.txt")]);
193 other => panic!("unexpected decision: {other:?}"),
198 fn a_directory_with_only_safe_to_delete_entries_can_be_deleted() {
199 let dir_path = test_dir();
201 // Create the directory, then add subdirectories
202 create_dir(&dir_path);
204 create_dir(&dir_path.join(".venv"));
205 create_dir(&dir_path.join("__pycache__"));
206 create_file(dir_path.join(".DS_Store"));
209 can_be_deleted(&dir_path),
210 DeleteDecision::CanDelete
215 fn a_directory_with_mix_of_safe_and_unsafe_entries_cannot_be_deleted() {
216 let dir_path = test_dir();
218 create_dir(&dir_path);
220 create_file(dir_path.join(".DS_Store"));
221 create_file(dir_path.join("greeting.txt"));
223 match can_be_deleted(&dir_path) {
224 DeleteDecision::CannotDelete(Reason::NotEmpty(entries)) => {
225 // `.DS_Store` is allowed, `greeting.txt` is not
226 assert_eq!(entries, vec![OsString::from("greeting.txt")]);
228 other => panic!("unexpected decision: {other:?}"),
233 fn safe_to_delete_entries_are_case_insensitive() {
234 let dir_path = test_dir();
236 create_dir(&dir_path);
238 create_file(dir_path.join(".ds_store"));
241 can_be_deleted(&dir_path),
242 DeleteDecision::CanDelete
247 fn the_dot_git_folder_cannot_be_deleted() {
248 let dir_path = test_dir();
249 let git_dir = dir_path.join(".git");
251 create_dir(&git_dir);
254 can_be_deleted(&git_dir),
255 DeleteDecision::CannotDelete(Reason::InGitRepository)
260 fn any_subdir_of_the_dot_git_folder_cannot_be_deleted() {
261 let dir_path = test_dir();
262 let refs_dir = dir_path.join(".git/refs");
264 create_dir(&refs_dir);
267 can_be_deleted(&refs_dir),
268 DeleteDecision::CannotDelete(Reason::InGitRepository)