use crate::ui::Theme;
use anyhow::{Context, Result, anyhow};
use std::fs;
use std::path::{Path, PathBuf};
#[cfg(target_os = "windows")]
const RETRY_MAX_ATTEMPTS: u32 = 5;
#[cfg(target_os = "windows")]
const RETRY_INITIAL_WAIT_MS: u64 = 100;
#[cfg(target_os = "windows")]
const RETRY_MAX_WAIT_MS: u64 = 1000;
#[derive(Debug, Clone)]
pub struct FileInfo {
pub path: PathBuf,
pub is_dir: bool,
pub size: u64,
pub is_symlink: bool,
}
pub fn validate_paths(paths: &[PathBuf]) -> Result<()> {
for path in paths {
if !path.exists() {
return Err(anyhow!("Path does not exist: {}", path.display()));
}
}
Ok(())
}
pub fn collect_files_to_remove(paths: &[PathBuf], recursive: bool) -> Result<Vec<FileInfo>> {
let mut files = Vec::new();
for path in paths {
let metadata = path
.symlink_metadata()
.with_context(|| format!("Failed to get file metadata: {}", path.display()))?;
let is_symlink = metadata.file_type().is_symlink();
let is_dir = metadata.is_dir() && !is_symlink;
if is_dir {
if recursive {
collect_dir_files(path, &mut files)?;
files.push(FileInfo {
path: path.clone(),
is_dir: true,
size: 0,
is_symlink: false,
});
} else {
if path.read_dir()?.next().is_some() {
return Err(anyhow!(
"Directory requires -r/--recursive flag: {}",
path.display()
));
}
files.push(FileInfo {
path: path.clone(),
is_dir: true,
size: 0,
is_symlink: false,
});
}
} else {
files.push(FileInfo {
path: path.clone(),
is_dir: false,
size: metadata.len(),
is_symlink,
});
}
}
Ok(files)
}
fn collect_dir_files(dir: &Path, files: &mut Vec<FileInfo>) -> Result<()> {
for entry in
fs::read_dir(dir).with_context(|| format!("Failed to read directory: {}", dir.display()))?
{
let entry =
entry.with_context(|| format!("Failed to read directory entry: {}", dir.display()))?;
let path = entry.path();
let metadata = path
.symlink_metadata()
.with_context(|| format!("Failed to get file metadata: {}", path.display()))?;
let is_symlink = metadata.file_type().is_symlink();
let is_dir = metadata.is_dir() && !is_symlink;
if is_dir {
collect_dir_files(&path, files)?;
files.push(FileInfo {
path,
is_dir: true,
size: 0,
is_symlink: false,
});
} else {
files.push(FileInfo {
path,
is_dir: false,
size: metadata.len(),
is_symlink,
});
}
}
Ok(())
}
pub fn remove_files(
files: &[FileInfo],
dry_run: bool,
verbose: bool,
anyway: bool,
) -> Vec<(PathBuf, Result<()>)> {
let theme = Theme::new();
#[cfg(target_os = "windows")]
if let Some(results) = try_windows_bulk_remove(files, dry_run, verbose, anyway, &theme) {
return results;
}
remove_files_individually(files, dry_run, verbose, anyway, &theme)
}
#[cfg(target_os = "windows")]
fn try_windows_bulk_remove(
files: &[FileInfo],
dry_run: bool,
verbose: bool,
anyway: bool,
theme: &Theme,
) -> Option<Vec<(PathBuf, Result<()>)>> {
let root_dir = files.iter().find(|f| {
f.is_dir
&& !files
.iter()
.any(|other| other.path != f.path && f.path.starts_with(&other.path))
})?;
if dry_run {
return Some(vec![(root_dir.path.clone(), Ok(()))]);
}
use crate::core::process::{find_processes_by_file, kill_process_force};
let mut wait_ms = RETRY_INITIAL_WAIT_MS;
let mut last_err = None;
let mut success = false;
for attempt in 0..=RETRY_MAX_ATTEMPTS {
match remove_dir_all_with_symlinks(&root_dir.path) {
Ok(_) => {
success = true;
break;
}
Err(e) => {
last_err = Some(e);
let io_err = last_err
.as_ref()
.and_then(|e| e.downcast_ref::<std::io::Error>());
let should_retry = io_err.is_some_and(is_retryable_error);
if !should_retry || attempt == RETRY_MAX_ATTEMPTS {
break;
}
if anyway {
if let Ok(pids) = find_processes_by_file(&root_dir.path) {
for pid in pids {
let _ = kill_process_force(pid);
}
}
}
if verbose {
println!(
"{} {}",
theme.icon_warning(),
theme.muted(format!(
"Retrying ({}/{})...",
attempt + 1,
RETRY_MAX_ATTEMPTS
))
);
}
std::thread::sleep(std::time::Duration::from_millis(wait_ms));
wait_ms = (wait_ms * 2).min(RETRY_MAX_WAIT_MS);
}
}
}
if success {
if verbose {
println!(
"{} {}",
theme.icon_success(),
theme.muted(format!("Removed {}", root_dir.path.display()))
);
}
Some(vec![(root_dir.path.clone(), Ok(()))])
} else {
if verbose {
println!(
"{} {}",
theme.icon_warning(),
theme.warning(format!(
"Bulk delete failed, trying individual deletion: {}",
last_err.unwrap_or_else(|| anyhow::anyhow!("Unknown error"))
))
);
}
None
}
}
fn remove_files_individually(
files: &[FileInfo],
dry_run: bool,
verbose: bool,
anyway: bool,
theme: &Theme,
) -> Vec<(PathBuf, Result<()>)> {
let mut results = Vec::new();
let mut sorted = files.to_vec();
sorted.sort_by(|a, b| {
if a.is_dir && !b.is_dir {
std::cmp::Ordering::Greater
} else if !a.is_dir && b.is_dir {
std::cmp::Ordering::Less
} else {
let depth_a = a.path.components().count();
let depth_b = b.path.components().count();
depth_b.cmp(&depth_a)
}
});
for file in sorted {
let result = if dry_run {
Ok(())
} else {
remove_with_retry(&file, anyway)
};
if verbose {
match &result {
Ok(_) => println!(
"{} {}",
theme.icon_success(),
theme.muted(format!("Removed {}", file.path.display()))
),
Err(e) => println!(
"{} {}",
theme.icon_error(),
theme.error(format!("Failed to delete {} - {}", file.path.display(), e))
),
}
}
results.push((file.path, result));
}
results
}
#[cfg(target_os = "windows")]
fn remove_dir_all_with_symlinks(path: &Path) -> Result<()> {
use std::os::windows::ffi::OsStrExt;
let long_path = to_long_path(path)?;
unsafe {
use windows_sys::Win32::Foundation::GetLastError;
use windows_sys::Win32::Storage::FileSystem::{
DeleteFileW, FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_READONLY,
FILE_ATTRIBUTE_REPARSE_POINT, GetFileAttributesW, INVALID_FILE_ATTRIBUTES,
RemoveDirectoryW, SetFileAttributesW,
};
let path_wide: Vec<u16> = long_path
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
let attrs = GetFileAttributesW(path_wide.as_ptr());
if attrs == INVALID_FILE_ATTRIBUTES {
return Err(anyhow!("Failed to get file attributes: {}", path.display()));
}
let is_reparse_point = (attrs & FILE_ATTRIBUTE_REPARSE_POINT) != 0;
if is_reparse_point {
if DeleteFileW(path_wide.as_ptr()) == 0 {
let err = GetLastError();
return Err(std::io::Error::from_raw_os_error(err as i32))
.with_context(|| format!("Failed to delete symlink: {}", path.display()));
}
} else if (attrs & FILE_ATTRIBUTE_DIRECTORY) != 0 {
remove_directory_recursive(&long_path).with_context(|| {
format!(
"Failed to recursively delete directory contents: {}",
path.display()
)
})?;
SetFileAttributesW(path_wide.as_ptr(), attrs & !FILE_ATTRIBUTE_READONLY);
if RemoveDirectoryW(path_wide.as_ptr()) == 0 {
let err = GetLastError();
return Err(std::io::Error::from_raw_os_error(err as i32))
.with_context(|| format!("Failed to delete directory: {}", path.display()));
}
} else {
SetFileAttributesW(path_wide.as_ptr(), attrs & !FILE_ATTRIBUTE_READONLY);
if DeleteFileW(path_wide.as_ptr()) == 0 {
let err = GetLastError();
return Err(std::io::Error::from_raw_os_error(err as i32))
.with_context(|| format!("Failed to delete file: {}", path.display()));
}
}
}
Ok(())
}
#[cfg(target_os = "windows")]
unsafe fn remove_directory_recursive(path: &Path) -> Result<()> {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Storage::FileSystem::{
DeleteFileW, FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_READONLY,
FILE_ATTRIBUTE_REPARSE_POINT, FindClose, FindFirstFileW, FindNextFileW, RemoveDirectoryW,
SetFileAttributesW, WIN32_FIND_DATAW,
};
let mut search_pattern = path.to_path_buf();
search_pattern.push("*");
let search_wide: Vec<u16> = search_pattern
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
let mut find_data: WIN32_FIND_DATAW = unsafe { std::mem::zeroed() };
let find_handle = unsafe { FindFirstFileW(search_wide.as_ptr(), &mut find_data) };
if find_handle == windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE {
return Ok(());
}
loop {
let name = find_data.cFileName[..]
.iter()
.take_while(|&&c| c != 0)
.copied()
.collect::<Vec<_>>();
let name_str = String::from_utf16_lossy(&name);
if name_str != "." && name_str != ".." {
let mut item_path = path.to_path_buf();
item_path.push(&name_str);
let item_wide: Vec<u16> = item_path
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
let is_dir = (find_data.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0;
let is_reparse_point = (find_data.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0;
if is_dir && !is_reparse_point {
unsafe {
remove_directory_recursive(&item_path)?;
}
unsafe {
SetFileAttributesW(
item_wide.as_ptr(),
find_data.dwFileAttributes & !FILE_ATTRIBUTE_READONLY,
);
}
if unsafe { RemoveDirectoryW(item_wide.as_ptr()) } == 0 {
let err = unsafe { windows_sys::Win32::Foundation::GetLastError() };
unsafe {
FindClose(find_handle);
}
return Err(std::io::Error::from_raw_os_error(err as i32)).with_context(|| {
format!("Failed to delete directory: {}", item_path.display())
});
}
} else {
unsafe {
SetFileAttributesW(
item_wide.as_ptr(),
find_data.dwFileAttributes & !FILE_ATTRIBUTE_READONLY,
);
}
if unsafe { DeleteFileW(item_wide.as_ptr()) } == 0 {
let err = unsafe { windows_sys::Win32::Foundation::GetLastError() };
unsafe {
FindClose(find_handle);
}
return Err(std::io::Error::from_raw_os_error(err as i32)).with_context(|| {
format!("Failed to delete file: {}", item_path.display())
});
}
}
}
if unsafe { FindNextFileW(find_handle, &mut find_data) } == 0 {
break;
}
}
unsafe {
FindClose(find_handle);
}
Ok(())
}
#[cfg(target_os = "windows")]
fn to_long_path(path: &Path) -> Result<PathBuf> {
let absolute = match fs::canonicalize(path) {
Ok(p) => p,
Err(_) => {
if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.ok()
.map(|cwd| cwd.join(path))
.unwrap_or_else(|| path.to_path_buf())
}
}
};
let path_str = absolute.to_string_lossy().to_string();
let has_prefix = path_str.starts_with(r"\\?\") || path_str.starts_with(r"\\?\UNC\");
if has_prefix {
return Ok(absolute);
}
let long_path = if let Some(stripped) = path_str.strip_prefix(r"\\") {
PathBuf::from(format!(r"\\?\UNC\{}", stripped))
} else {
PathBuf::from(format!(r"\\?{}", path_str))
};
Ok(long_path)
}
#[cfg(target_os = "windows")]
fn remove_readonly_recursively(path: &Path) -> Result<()> {
let metadata = path
.symlink_metadata()
.with_context(|| format!("Failed to get path metadata: {}", path.display()))?;
if !metadata.file_type().is_symlink() {
#[allow(clippy::permissions_set_readonly_false)]
{
let mut perms = metadata.permissions();
perms.set_readonly(false);
fs::set_permissions(path, perms)
.with_context(|| format!("Failed to set permissions: {}", path.display()))?;
}
if metadata.is_dir() {
for entry in fs::read_dir(path)
.with_context(|| format!("Failed to read directory: {}", path.display()))?
{
let entry = entry.with_context(|| {
format!("Failed to read directory entry: {}", path.display())
})?;
remove_readonly_recursively(&entry.path())?;
}
}
}
Ok(())
}
#[cfg(target_os = "windows")]
fn is_retryable_error(e: &std::io::Error) -> bool {
match e.kind() {
std::io::ErrorKind::PermissionDenied => true,
_ => {
let os_code = e.raw_os_error();
matches!(os_code, Some(5) | Some(32) | Some(33))
}
}
}
#[cfg(target_os = "windows")]
fn remove_with_retry(file: &FileInfo, anyway: bool) -> Result<()> {
use crate::core::process::{find_processes_by_file, kill_process_force};
use std::thread;
use std::time::Duration;
let mut wait_ms = RETRY_INITIAL_WAIT_MS;
for attempt in 0..=RETRY_MAX_ATTEMPTS {
match remove_entry(file) {
Ok(()) => return Ok(()),
Err(err) => {
let io_err = err.downcast_ref::<std::io::Error>();
let should_retry = io_err.is_some_and(is_retryable_error);
if !should_retry || attempt == RETRY_MAX_ATTEMPTS {
return Err(err.context(format!(
"Deletion failed (after {} retries): {}",
attempt,
file.path.display()
)));
}
eprintln!(
" Retrying ({}/{})... file may be in use: {}",
attempt + 1,
RETRY_MAX_ATTEMPTS,
file.path.display()
);
if anyway {
if let Ok(pids) = find_processes_by_file(&file.path) {
for pid in pids {
let _ = kill_process_force(pid);
}
}
}
thread::sleep(Duration::from_millis(wait_ms));
wait_ms = (wait_ms * 2).min(RETRY_MAX_WAIT_MS);
}
}
}
unreachable!()
}
#[cfg(not(target_os = "windows"))]
fn remove_with_retry(file: &FileInfo, _anyway: bool) -> Result<()> {
remove_entry(file)
}
fn remove_entry(file: &FileInfo) -> Result<()> {
#[cfg(target_os = "windows")]
{
if file.is_symlink {
match fs::remove_file(&file.path) {
Ok(_) => return Ok(()),
Err(e) => {
if e.kind() == std::io::ErrorKind::PermissionDenied {
if let Ok(metadata) = file.path.metadata() {
#[allow(clippy::permissions_set_readonly_false)]
{
let mut attrs = metadata.permissions();
attrs.set_readonly(false);
if let Err(_) = fs::set_permissions(&file.path, attrs) {
}
}
}
return fs::remove_file(&file.path).with_context(|| {
format!("Failed to delete symlink: {}", file.path.display())
});
}
return Err(e.into());
}
}
}
}
let result = if file.is_symlink {
fs::remove_file(&file.path)
} else if file.is_dir {
match fs::remove_dir(&file.path) {
Ok(_) => Ok(()),
Err(e) => {
if e.kind() == std::io::ErrorKind::PermissionDenied {
#[cfg(target_os = "windows")]
if let Err(_) = remove_readonly_recursively(&file.path) {
}
fs::remove_dir_all(&file.path)
} else {
Err(e)
}
}
}
} else {
fs::remove_file(&file.path)
};
result.with_context(|| format!("Deletion failed: {}", file.path.display()))
}
pub fn format_size(size: u64) -> String {
const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
let mut size = size as f64;
let mut unit_index = 0;
while size >= 1024.0 && unit_index < UNITS.len() - 1 {
size /= 1024.0;
unit_index += 1;
}
if unit_index == 0 {
format!("{} {}", size as u64, UNITS[unit_index])
} else {
format!("{:.1} {}", size, UNITS[unit_index])
}
}