diff --git a/Cargo.lock b/Cargo.lock index 4909db9..151a441 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,7 +1,7 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "empty-dir" -version = "0.1.0" +version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index c064830..2e48c43 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "empty-dir" -version = "0.1.0" +version = "0.2.0" authors = ["licsber "] edition = "2021" diff --git a/README.md b/README.md index 79618a2..bdd39da 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,21 @@ # empty-dir -Remove empty dirs. +一个用于清理空目录和 macOS 元数据文件(`.DS_Store`、`._*`)的极简 CLI。 + +## 使用方式 + +```bash +cargo install empty-dir +# or +cargo run -- <需要清理的目录> +``` + +- 若未传参,则默认清理当前目录。 +- 仅会删除完全为空的子目录。 +- 符合条件的 macOS 元数据文件会被直接删除。 + +## 开发 + +```bash +cargo publish +``` diff --git a/src/cleaner.rs b/src/cleaner.rs new file mode 100644 index 0000000..113c63f --- /dev/null +++ b/src/cleaner.rs @@ -0,0 +1,64 @@ +use std::{fs, io, path::Path}; + +use crate::{logger::log_error, walker::Walker}; + +pub struct Cleaner { + walker: Walker, +} + +impl Default for Cleaner { + fn default() -> Self { + Self { + walker: Walker::new(), + } + } +} + +impl Cleaner { + pub fn clean(&self, path: &Path) -> bool { + match self.run(path) { + Ok(_) => true, + Err(err) => { + log_error(path, &err); + false + } + } + } + + fn run(&self, path: &Path) -> io::Result<()> { + if !ensure_directory(path)? { + return Ok(()); + } + + let became_empty = self.walker.prune(path)?; + if became_empty { + println!("删除空目录 `{}`", path.display()); + if let Err(err) = fs::remove_dir(path) { + if err.kind() != io::ErrorKind::NotFound { + log_error(path, &err); + } + } + } + + Ok(()) + } +} + +fn ensure_directory(path: &Path) -> io::Result { + match fs::metadata(path) { + Ok(metadata) => { + if !metadata.is_dir() { + eprintln!("路径 `{}` 不是文件夹,已跳过。", path.display()); + return Ok(false); + } + Ok(true) + } + Err(err) => { + if err.kind() == io::ErrorKind::PermissionDenied { + eprintln!("无法访问 `{}`:{}", path.display(), err); + return Ok(false); + } + Err(err) + } + } +} diff --git a/src/logger.rs b/src/logger.rs new file mode 100644 index 0000000..3904bfa --- /dev/null +++ b/src/logger.rs @@ -0,0 +1,5 @@ +use std::path::Path; + +pub fn log_error(path: &Path, err: &dyn std::error::Error) { + eprintln!("处理 `{}` 时发生错误:{}", path.display(), err); +} diff --git a/src/mac_meta.rs b/src/mac_meta.rs new file mode 100644 index 0000000..f681372 --- /dev/null +++ b/src/mac_meta.rs @@ -0,0 +1,15 @@ +use std::fs; +use std::path::Path; + +const MAC_META_STR1: &str = "com.apple.quarantine"; +const MAC_META_STR2: &str = "This resource fork intentionally left blank"; + +pub fn is_mac_meta_file(path: &Path) -> bool { + match fs::read(path) { + Ok(buffer) => { + let content = String::from_utf8_lossy(&buffer); + content.contains(MAC_META_STR1) || content.contains(MAC_META_STR2) + } + Err(_) => false, + } +} diff --git a/src/main.rs b/src/main.rs index e7a11a9..05a0d34 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,30 @@ +mod cleaner; +mod logger; +mod mac_meta; +mod walker; + +use std::{ + env, + path::{Path, PathBuf}, + process, +}; + +use cleaner::Cleaner; + fn main() { - println!("Hello, world!"); + let targets: Vec = env::args().skip(1).map(PathBuf::from).collect(); + let cleaner = Cleaner::default(); + + let success = if targets.is_empty() { + cleaner.clean(Path::new(".")) + } else { + targets + .iter() + .map(|path| cleaner.clean(path)) + .all(|result| result) + }; + + if !success { + process::exit(1); + } } diff --git a/src/walker.rs b/src/walker.rs new file mode 100644 index 0000000..3527b5f --- /dev/null +++ b/src/walker.rs @@ -0,0 +1,131 @@ +use std::{ + ffi::OsStr, + fs::{self, DirEntry, ReadDir}, + io, + path::Path, +}; + +use crate::{logger::log_error, mac_meta}; + +pub struct Walker; + +impl Walker { + pub fn new() -> Self { + Self + } + + pub fn prune(&self, path: &Path) -> io::Result { + let entries = match self.read_entries(path)? { + EntryStream::Entries(entries) => entries, + EntryStream::Skip => return Ok(false), + }; + + let mut is_empty = true; + + for entry_result in entries { + if !self.process_entry(path, entry_result) { + is_empty = false; + } + } + + Ok(is_empty) + } + + fn read_entries(&self, path: &Path) -> io::Result { + match fs::read_dir(path) { + Ok(entries) => Ok(EntryStream::Entries(entries)), + Err(err) => { + if err.kind() == io::ErrorKind::PermissionDenied { + eprintln!("无法读取 `{}`:{}", path.display(), err); + Ok(EntryStream::Skip) + } else { + Err(err) + } + } + } + } + + fn process_entry(&self, parent: &Path, entry_result: io::Result) -> bool { + let entry = match entry_result { + Ok(entry) => entry, + Err(err) => { + log_error(parent, &err); + return false; + } + }; + + let child_path = entry.path(); + let file_type = match entry.file_type() { + Ok(file_type) => file_type, + Err(err) => { + log_error(&child_path, &err); + return false; + } + }; + + if file_type.is_dir() { + return self.handle_directory(&child_path); + } + + if file_type.is_file() || file_type.is_symlink() { + let name = entry.file_name(); + return self.handle_metadata_file(&name, &child_path); + } + + false + } + + fn handle_directory(&self, child_path: &Path) -> bool { + match self.prune(child_path) { + Ok(true) => match fs::remove_dir(child_path) { + Ok(_) => { + println!("删除空目录 `{}`", child_path.display()); + true + } + Err(err) => { + log_error(child_path, &err); + false + } + }, + Ok(false) => false, + Err(err) => { + log_error(child_path, &err); + false + } + } + } + + fn handle_metadata_file(&self, name: &OsStr, path: &Path) -> bool { + if !should_remove_file(name, path) { + return false; + } + + println!("删除元数据文件 `{}`", path.display()); + match fs::remove_file(path) { + Ok(_) => true, + Err(err) => { + log_error(path, &err); + false + } + } + } +} + +enum EntryStream { + Entries(ReadDir), + Skip, +} + +fn should_remove_file(name: &OsStr, path: &Path) -> bool { + if name == OsStr::new(".DS_Store") { + return true; + } + + if let Some(name_str) = name.to_str() { + if name_str.starts_with("._") { + return mac_meta::is_mac_meta_file(path); + } + } + + false +}