use anyhow::{Context, Result};
use rayon::prelude::*;
use serde::{Deserialize, Serialize};
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::io::Read;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use tracing::{debug, error, info};
use walkdir::WalkDir;
use crate::project::RustProject;
use crate::{CleanFailure, CleanResult};
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
pub enum CleanStrategy {
#[default]
CargoClean,
DirectDelete,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum DirectDeleteBackend {
#[default]
Native,
CmdRmdir,
}
#[derive(Debug, Clone)]
pub struct CleanProgress {
pub project_name: String,
pub current_file: Option<String>,
pub files_processed: usize,
pub total_files: Option<usize>,
pub phase: CleanPhase,
}
#[derive(Debug, Clone, PartialEq)]
pub enum CleanPhase {
Starting,
Analyzing,
Cleaning,
Finalizing,
Complete,
}
#[derive(Debug, thiserror::Error)]
#[error("clean cancelled")]
pub struct CleanCancelled;
#[derive(Debug, thiserror::Error)]
#[error("clean timed out after {timeout:?}")]
pub struct CleanTimedOut {
pub timeout: Duration,
}
#[derive(Debug, thiserror::Error)]
#[error("refusing to delete unsafe target directory: {path:?} ({reason})")]
pub struct UnsafeTargetDirectory {
pub path: PathBuf,
pub reason: String,
}
#[derive(Debug, Clone)]
pub struct CleanConfig {
pub strategy: CleanStrategy,
pub dry_run: bool,
pub parallel: bool,
pub timeout_seconds: u64,
pub direct_delete_backend: DirectDeleteBackend,
pub keep_executable: bool,
pub executable_backup_dir: Option<PathBuf>,
}
impl Default for CleanConfig {
fn default() -> Self {
Self {
strategy: CleanStrategy::CargoClean,
dry_run: false,
parallel: true,
timeout_seconds: 0,
direct_delete_backend: DirectDeleteBackend::Native,
keep_executable: false,
executable_backup_dir: None,
}
}
}
pub struct ProjectCleaner {
config: CleanConfig,
}
impl ProjectCleaner {
pub fn new(config: CleanConfig) -> Self {
Self { config }
}
pub fn clean_project(&self, project: &RustProject) -> Result<u64> {
self.clean_project_with_progress(project, |_| {})
}
pub fn clean_project_with_progress<F>(
&self,
project: &RustProject,
progress_callback: F,
) -> Result<u64>
where
F: Fn(CleanProgress),
{
self.clean_project_with_progress_and_cancel(project, None, progress_callback)
}
pub fn clean_project_with_progress_and_cancel<F>(
&self,
project: &RustProject,
cancel_flag: Option<&AtomicBool>,
progress_callback: F,
) -> Result<u64>
where
F: Fn(CleanProgress),
{
match self.clean_project_with_progress_impl(project, cancel_flag, &progress_callback) {
Ok(bytes) => Ok(bytes),
Err(err) => {
if !err.is::<CleanCancelled>() {
error!("清理项目失败 {}: {}", project.name, err);
}
Err(err)
}
}
}
fn clean_project_with_progress_impl<F>(
&self,
project: &RustProject,
cancel_flag: Option<&AtomicBool>,
progress_callback: &F,
) -> Result<u64>
where
F: Fn(CleanProgress),
{
self.check_cancel(cancel_flag)?;
if self.config.dry_run {
let size = if project.has_target {
project.get_target_size()
} else {
0
};
info!(
"DRY RUN: 将清理项目 {} ({})",
project.name,
crate::format_bytes(size)
);
return Ok(size);
}
if !project.has_target && self.config.strategy == CleanStrategy::DirectDelete {
debug!("项目 {} 没有target目录,跳过", project.name);
return Ok(0);
}
info!(
"开始清理项目: {} ({})",
project.name,
project.formatted_size()
);
progress_callback(CleanProgress {
project_name: project.name.clone(),
current_file: None,
files_processed: 0,
total_files: None,
phase: CleanPhase::Starting,
});
let bytes_freed = match self.config.strategy {
CleanStrategy::CargoClean => {
self.clean_with_cargo_progress(project, cancel_flag, progress_callback)?
}
CleanStrategy::DirectDelete => {
self.clean_with_delete_progress(project, cancel_flag, progress_callback)?
}
};
progress_callback(CleanProgress {
project_name: project.name.clone(),
current_file: None,
files_processed: 0,
total_files: None,
phase: CleanPhase::Complete,
});
info!("成功清理项目: {}", project.name);
Ok(bytes_freed)
}
pub fn clean_projects(&self, projects: &[RustProject]) -> CleanResult {
let start_time = Instant::now();
let mut result = CleanResult::new();
info!("开始清理 {} 个项目", projects.len());
if self.config.parallel {
self.clean_projects_parallel(projects, &mut result);
} else {
self.clean_projects_sequential(projects, &mut result);
}
result.duration_ms = start_time.elapsed().as_millis() as u64;
info!(
"清理完成: 成功 {} 个,失败 {} 个,释放空间 {},耗时 {}ms",
result.cleaned_projects,
result.failed_projects.len(),
result.format_size(),
result.duration_ms
);
result
}
fn clean_projects_sequential(&self, projects: &[RustProject], result: &mut CleanResult) {
for project in projects {
match self.clean_project(project) {
Ok(size_freed) => result.add_success(size_freed),
Err(err) => result.add_failure_detail(CleanFailure {
project_name: project.name.clone(),
project_path: project.path.clone(),
error: err.to_string(),
}),
}
}
}
fn clean_projects_parallel(&self, projects: &[RustProject], result: &mut CleanResult) {
let (successes, total_freed, failures): (usize, u64, Vec<CleanFailure>) = projects
.par_iter()
.map(|project| match self.clean_project(project) {
Ok(size_freed) => Ok(size_freed),
Err(err) => Err(CleanFailure {
project_name: project.name.clone(),
project_path: project.path.clone(),
error: err.to_string(),
}),
})
.fold(
|| (0usize, 0u64, Vec::new()),
|mut acc, item| {
match item {
Ok(size_freed) => {
acc.0 += 1;
acc.1 += size_freed;
}
Err(failure) => {
acc.2.push(failure);
}
}
acc
},
)
.reduce(
|| (0usize, 0u64, Vec::new()),
|mut a, b| {
a.0 += b.0;
a.1 += b.1;
a.2.extend(b.2);
a
},
);
result.cleaned_projects += successes;
result.total_size_freed += total_freed;
for failure in failures {
result.add_failure_detail(failure);
}
}
#[allow(dead_code)]
fn clean_with_cargo(&self, project: &RustProject) -> Result<u64> {
self.clean_with_cargo_progress(project, None, &|_| {})
}
fn clean_with_cargo_progress<F>(
&self,
project: &RustProject,
cancel_flag: Option<&AtomicBool>,
progress_callback: &F,
) -> Result<u64>
where
F: Fn(CleanProgress),
{
debug!("使用cargo clean清理项目: {}", project.name);
self.check_cancel(cancel_flag)?;
progress_callback(CleanProgress {
project_name: project.name.clone(),
current_file: Some("cargo clean".to_string()),
files_processed: 0,
total_files: None,
phase: CleanPhase::Analyzing,
});
let target_path = project.target_path();
let size_before = if target_path.exists() {
project.get_target_size()
} else {
0
};
progress_callback(CleanProgress {
project_name: project.name.clone(),
current_file: Some("cargo clean".to_string()),
files_processed: 0,
total_files: None,
phase: CleanPhase::Cleaning,
});
let mut cmd = Command::new("cargo");
cmd.arg("clean")
.current_dir(&project.path)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let output = self.run_command_with_timeout_and_cancel(
cmd,
self.timeout(),
cancel_flag,
|elapsed| {
let ticks = (elapsed.as_millis() / 250) as usize;
progress_callback(CleanProgress {
project_name: project.name.clone(),
current_file: Some(format!("cargo clean ({:?})", elapsed)),
files_processed: ticks,
total_files: None,
phase: CleanPhase::Cleaning,
});
},
)?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("cargo clean失败: {}", stderr.trim());
}
progress_callback(CleanProgress {
project_name: project.name.clone(),
current_file: None,
files_processed: 0,
total_files: None,
phase: CleanPhase::Finalizing,
});
let size_after = if target_path.exists() {
project.get_target_size()
} else {
0
};
Ok(size_before.saturating_sub(size_after))
}
#[allow(dead_code)]
fn clean_with_delete(&self, project: &RustProject) -> Result<u64> {
self.clean_with_delete_progress(project, None, &|_| {})
}
fn clean_with_delete_progress<F>(
&self,
project: &RustProject,
cancel_flag: Option<&AtomicBool>,
progress_callback: &F,
) -> Result<u64>
where
F: Fn(CleanProgress),
{
debug!("直接删除target目录: {}", project.name);
let target_path = project.target_path();
if !target_path.exists() {
return Ok(0);
}
self.check_cancel(cancel_flag)?;
self.validate_safe_target_directory(project, &target_path)?;
progress_callback(CleanProgress {
project_name: project.name.clone(),
current_file: None,
files_processed: 0,
total_files: None,
phase: CleanPhase::Analyzing,
});
if self.config.keep_executable {
self.backup_executables(project, cancel_flag, progress_callback)?;
}
progress_callback(CleanProgress {
project_name: project.name.clone(),
current_file: Some("target".to_string()),
files_processed: 0,
total_files: None,
phase: CleanPhase::Cleaning,
});
let timeout = self.timeout();
let bytes_freed = match self.config.direct_delete_backend {
DirectDeleteBackend::Native => {
if cancel_flag.is_some() || self.config.keep_executable {
self.delete_directory_tree_with_progress(
project,
&target_path,
cancel_flag,
timeout,
progress_callback,
)?
} else {
let size_before = project.get_target_size();
std::fs::remove_dir_all(&target_path).context("删除target目录失败")?;
size_before
}
}
DirectDeleteBackend::CmdRmdir => self.clean_with_windows_rmdir(
project,
&target_path,
cancel_flag,
timeout,
progress_callback,
)?,
};
progress_callback(CleanProgress {
project_name: project.name.clone(),
current_file: None,
files_processed: 0,
total_files: None,
phase: CleanPhase::Finalizing,
});
Ok(bytes_freed)
}
fn clean_with_windows_rmdir<F>(
&self,
project: &RustProject,
target_path: &std::path::Path,
cancel_flag: Option<&AtomicBool>,
timeout: Option<Duration>,
progress_callback: &F,
) -> Result<u64>
where
F: Fn(CleanProgress),
{
#[cfg(windows)]
{
self.check_cancel(cancel_flag)?;
self.validate_safe_target_directory(project, target_path)?;
let size_before = project.get_target_size();
let target_str = target_path.display().to_string();
if target_str.contains('"') {
return self.delete_directory_tree_with_progress(
project,
target_path,
cancel_flag,
timeout,
progress_callback,
);
}
let mut cmd = Command::new("cmd");
cmd.args(["/C", &format!("rmdir /S /Q \"{target_str}\"")]);
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
let output =
self.run_command_with_timeout_and_cancel(cmd, timeout, cancel_flag, |elapsed| {
progress_callback(CleanProgress {
project_name: project.name.clone(),
current_file: Some(format!("target ({:.1}s)", elapsed.as_secs_f32())),
files_processed: 0,
total_files: None,
phase: CleanPhase::Cleaning,
});
})?;
if output.status.success() {
return Ok(size_before);
}
if cancel_flag.is_some_and(|flag| flag.load(Ordering::Relaxed)) {
anyhow::bail!(CleanCancelled);
}
self.delete_directory_tree_with_progress(
project,
target_path,
cancel_flag,
timeout,
progress_callback,
)
}
#[cfg(not(windows))]
{
tracing::warn!(
"CmdRmdir backend requested on non-Windows, falling back to Native for project: {}",
project.name
);
if cancel_flag.is_some() || self.config.keep_executable {
self.delete_directory_tree_with_progress(
project,
target_path,
cancel_flag,
timeout,
progress_callback,
)
} else {
let size_before = project.get_target_size();
std::fs::remove_dir_all(target_path).context("删除target目录失败")?;
Ok(size_before)
}
}
}
fn backup_executables<F>(
&self,
project: &RustProject,
cancel_flag: Option<&AtomicBool>,
progress_callback: &F,
) -> Result<()>
where
F: Fn(CleanProgress),
{
let target_path = project.target_path();
let executables = self.find_executables(&target_path)?;
if executables.is_empty() {
debug!("项目 {} 没有找到可执行文件", project.name);
return Ok(());
}
info!(
"项目 {} 找到 {} 个可执行文件,开始备份",
project.name,
executables.len()
);
let backup_dir = self.get_backup_directory(project)?;
std::fs::create_dir_all(&backup_dir).context("创建备份目录失败")?;
for (i, exe_path) in executables.iter().enumerate() {
self.check_cancel(cancel_flag)?;
let file_name = exe_path
.file_name()
.ok_or_else(|| anyhow::anyhow!("无效的可执行文件路径"))?;
let backup_path = backup_dir.join(file_name);
progress_callback(CleanProgress {
project_name: project.name.clone(),
current_file: Some(format!("备份 {}", file_name.to_string_lossy())),
files_processed: i,
total_files: Some(executables.len()),
phase: CleanPhase::Cleaning,
});
std::fs::copy(exe_path, &backup_path)
.with_context(|| format!("备份可执行文件失败: {exe_path:?} -> {backup_path:?}"))?;
debug!("备份可执行文件: {:?} -> {:?}", exe_path, backup_path);
}
info!(
"成功备份 {} 个可执行文件到 {:?}",
executables.len(),
backup_dir
);
Ok(())
}
fn timeout(&self) -> Option<Duration> {
if self.config.timeout_seconds == 0 {
return None;
}
Some(Duration::from_secs(self.config.timeout_seconds))
}
fn check_cancel(&self, cancel_flag: Option<&AtomicBool>) -> Result<()> {
if cancel_flag.is_some_and(|flag| flag.load(Ordering::Relaxed)) {
anyhow::bail!(CleanCancelled);
}
Ok(())
}
fn validate_safe_target_directory(
&self,
project: &RustProject,
target_path: &std::path::Path,
) -> Result<()> {
let metadata = std::fs::symlink_metadata(target_path).context("读取 target 元数据失败")?;
if metadata.file_type().is_symlink() {
anyhow::bail!(UnsafeTargetDirectory {
path: target_path.to_path_buf(),
reason: "target is a symlink/reparse point".to_string(),
});
}
let canonical_target = target_path.canonicalize().ok();
let canonical_project = project.path.canonicalize().ok();
if let (Some(target), Some(root)) = (canonical_target, canonical_project) {
if !target.starts_with(&root) {
anyhow::bail!(UnsafeTargetDirectory {
path: target_path.to_path_buf(),
reason: format!("target escapes project root: {target:?}"),
});
}
} else if !target_path.starts_with(&project.path) {
anyhow::bail!(UnsafeTargetDirectory {
path: target_path.to_path_buf(),
reason: "target is not under project path".to_string(),
});
}
Ok(())
}
fn delete_directory_tree_with_progress<F>(
&self,
project: &RustProject,
target_path: &std::path::Path,
cancel_flag: Option<&AtomicBool>,
timeout: Option<Duration>,
progress_callback: &F,
) -> Result<u64>
where
F: Fn(CleanProgress),
{
let start = Instant::now();
let mut last_report = Instant::now();
let mut processed = 0usize;
let mut directories: Vec<PathBuf> = Vec::new();
let mut bytes_freed = if project.target_size > 0 {
project.target_size
} else {
0
};
let track_bytes = project.target_size == 0;
if self.config.parallel && cancel_flag.is_some() {
let mut files: Vec<PathBuf> = Vec::new();
for entry in WalkDir::new(target_path).follow_links(false).min_depth(1) {
self.check_cancel(cancel_flag)?;
if let Some(timeout) = timeout {
if start.elapsed() > timeout {
anyhow::bail!(CleanTimedOut { timeout });
}
}
let entry = entry.context("遍历 target 目录失败")?;
let path = entry.path().to_path_buf();
if entry.file_type().is_dir() {
directories.push(path);
} else {
files.push(path);
}
}
let total_files = Some(files.len());
let chunk_size = 1024usize;
for chunk in files.chunks(chunk_size) {
self.check_cancel(cancel_flag)?;
if let Some(timeout) = timeout {
if start.elapsed() > timeout {
anyhow::bail!(CleanTimedOut { timeout });
}
}
let bytes_in_chunk: u64 = chunk
.par_iter()
.map(|path| -> Result<u64> {
if cancel_flag.is_some_and(|flag| flag.load(Ordering::Relaxed)) {
anyhow::bail!(CleanCancelled);
}
if let Some(timeout) = timeout {
if start.elapsed() > timeout {
anyhow::bail!(CleanTimedOut { timeout });
}
}
let mut bytes = 0u64;
if track_bytes {
if let Ok(metadata) = std::fs::symlink_metadata(path) {
bytes = metadata.len();
}
}
Self::remove_path_best_effort(path)
.with_context(|| format!("删除失败: {path:?}"))?;
Ok(bytes)
})
.try_reduce(|| 0u64, |a, b| Ok(a.saturating_add(b)))?;
if track_bytes {
bytes_freed = bytes_freed.saturating_add(bytes_in_chunk);
}
processed = processed.saturating_add(chunk.len());
if last_report.elapsed() >= Duration::from_millis(120) {
last_report = Instant::now();
let current_file = chunk
.last()
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().to_string());
progress_callback(CleanProgress {
project_name: project.name.clone(),
current_file,
files_processed: processed,
total_files,
phase: CleanPhase::Cleaning,
});
}
}
} else {
for entry in WalkDir::new(target_path).follow_links(false).min_depth(1) {
self.check_cancel(cancel_flag)?;
if let Some(timeout) = timeout {
if start.elapsed() > timeout {
anyhow::bail!(CleanTimedOut { timeout });
}
}
let entry = entry.context("遍历 target 目录失败")?;
let path = entry.path().to_path_buf();
if entry.file_type().is_dir() {
directories.push(path);
continue;
}
if track_bytes {
if let Ok(metadata) = std::fs::symlink_metadata(&path) {
bytes_freed = bytes_freed.saturating_add(metadata.len());
}
}
Self::remove_path_best_effort(&path)
.with_context(|| format!("删除失败: {path:?}"))?;
processed = processed.saturating_add(1);
if last_report.elapsed() >= Duration::from_millis(120) {
last_report = Instant::now();
progress_callback(CleanProgress {
project_name: project.name.clone(),
current_file: path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.or_else(|| Some(path.display().to_string())),
files_processed: processed,
total_files: None,
phase: CleanPhase::Cleaning,
});
}
}
}
directories.sort_by_key(|p| std::cmp::Reverse(p.components().count()));
for dir in directories {
self.check_cancel(cancel_flag)?;
if let Some(timeout) = timeout {
if start.elapsed() > timeout {
anyhow::bail!(CleanTimedOut { timeout });
}
}
let _ = Self::remove_dir_best_effort(&dir);
}
self.check_cancel(cancel_flag)?;
if let Some(timeout) = timeout {
if start.elapsed() > timeout {
anyhow::bail!(CleanTimedOut { timeout });
}
}
Self::remove_dir_best_effort(target_path)
.with_context(|| format!("删除 target 根目录失败: {target_path:?}"))?;
Ok(bytes_freed)
}
fn run_command_with_timeout_and_cancel<T>(
&self,
mut cmd: Command,
timeout: Option<Duration>,
cancel_flag: Option<&AtomicBool>,
on_tick: T,
) -> Result<std::process::Output>
where
T: Fn(Duration),
{
let mut child = cmd.spawn().context("启动命令失败")?;
let start = Instant::now();
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let mut stdout_handle = Some(std::thread::spawn(move || -> Vec<u8> {
let mut buf = Vec::new();
if let Some(mut out) = stdout {
let _ = out.read_to_end(&mut buf);
}
buf
}));
let mut stderr_handle = Some(std::thread::spawn(move || -> Vec<u8> {
let mut buf = Vec::new();
if let Some(mut err) = stderr {
let _ = err.read_to_end(&mut buf);
}
buf
}));
let status = loop {
if cancel_flag.is_some_and(|flag| flag.load(Ordering::Relaxed)) {
let _ = child.kill();
let _ = child.wait();
let _ = stdout_handle.take().and_then(|h| h.join().ok());
let _ = stderr_handle.take().and_then(|h| h.join().ok());
anyhow::bail!(CleanCancelled);
}
if let Some(timeout) = timeout {
if start.elapsed() > timeout {
let _ = child.kill();
let _ = child.wait();
let _ = stdout_handle.take().and_then(|h| h.join().ok());
let _ = stderr_handle.take().and_then(|h| h.join().ok());
anyhow::bail!(CleanTimedOut { timeout });
}
}
if let Some(status) = child.try_wait().context("等待子进程失败")? {
break status;
}
on_tick(start.elapsed());
std::thread::sleep(Duration::from_millis(80));
};
let stdout = stdout_handle
.take()
.and_then(|h| h.join().ok())
.unwrap_or_default();
let stderr = stderr_handle
.take()
.and_then(|h| h.join().ok())
.unwrap_or_default();
Ok(std::process::Output {
status,
stdout,
stderr,
})
}
fn remove_path_best_effort(path: &std::path::Path) -> std::io::Result<()> {
if std::fs::remove_file(path).is_ok() {
return Ok(());
}
if std::fs::remove_dir(path).is_ok() {
return Ok(());
}
if let Ok(metadata) = std::fs::symlink_metadata(path) {
let mut perms = metadata.permissions();
perms.set_readonly(false);
let _ = std::fs::set_permissions(path, perms);
}
std::fs::remove_file(path).or_else(|_| std::fs::remove_dir(path))
}
fn remove_dir_best_effort(path: &std::path::Path) -> std::io::Result<()> {
if std::fs::remove_dir(path).is_ok() {
return Ok(());
}
if let Ok(metadata) = std::fs::symlink_metadata(path) {
let mut perms = metadata.permissions();
perms.set_readonly(false);
let _ = std::fs::set_permissions(path, perms);
}
std::fs::remove_dir(path).or_else(|_| std::fs::remove_dir_all(path))
}
fn find_executables(&self, target_path: &std::path::Path) -> Result<Vec<PathBuf>> {
let mut executables = Vec::new();
let exe_dirs = [target_path.join("debug"), target_path.join("release")];
for exe_dir in &exe_dirs {
if exe_dir.exists() {
self.scan_directory_for_executables(exe_dir, &mut executables)?;
}
}
if let Ok(entries) = std::fs::read_dir(target_path) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir()
&& !path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.starts_with('.')
{
if let Ok(sub_entries) = std::fs::read_dir(&path) {
for sub_entry in sub_entries.flatten() {
let sub_path = sub_entry.path();
if sub_path.is_dir()
&& (sub_path.file_name().unwrap_or_default() == "debug"
|| sub_path.file_name().unwrap_or_default() == "release")
{
self.scan_directory_for_executables(&sub_path, &mut executables)?;
}
}
}
}
}
}
Ok(executables)
}
fn scan_directory_for_executables(
&self,
dir: &std::path::Path,
executables: &mut Vec<PathBuf>,
) -> Result<()> {
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() && self.is_executable(&path) {
executables.push(path);
}
}
}
Ok(())
}
fn is_executable(&self, path: &std::path::Path) -> bool {
#[cfg(target_os = "windows")]
{
path.extension().is_some_and(|ext| ext == "exe")
}
#[cfg(not(target_os = "windows"))]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(metadata) = std::fs::metadata(path) {
let permissions = metadata.permissions();
permissions.mode() & 0o111 != 0
} else {
false
}
}
}
fn get_backup_directory(&self, project: &RustProject) -> Result<PathBuf> {
let base_dir = if let Some(ref backup_dir) = self.config.executable_backup_dir {
backup_dir.clone()
} else {
project.path.join("executables")
};
let mut hasher = DefaultHasher::new();
project.path.to_string_lossy().hash(&mut hasher);
let id = hasher.finish();
Ok(base_dir.join(format!("{}-{:016x}", project.name, id)))
}
pub fn preview_clean(&self, projects: &[RustProject]) -> CleanResult {
let mut config = self.config.clone();
config.dry_run = true;
let cleaner = ProjectCleaner::new(config);
cleaner.clean_projects(projects)
}
pub fn check_cargo_available() -> bool {
Command::new("cargo")
.arg("--version")
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
}
impl Default for ProjectCleaner {
fn default() -> Self {
Self::new(CleanConfig::default())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::Path;
use tempfile::TempDir;
fn create_test_project_with_target(dir: &Path, name: &str) -> Result<RustProject> {
let project_dir = dir.join(name);
fs::create_dir_all(&project_dir)?;
let cargo_toml = format!(
r#"
[package]
name = "{name}"
version = "0.1.0"
edition = "2021"
"#
);
fs::write(project_dir.join("Cargo.toml"), cargo_toml)?;
let target_dir = project_dir.join("target");
fs::create_dir_all(&target_dir)?;
fs::write(
target_dir.join("test.txt"),
"test content for size calculation",
)?;
RustProject::from_path(&project_dir)
}
#[test]
fn test_cleaner_dry_run() -> Result<()> {
let temp_dir = TempDir::new()?;
let project = create_test_project_with_target(temp_dir.path(), "test_project")?;
let config = CleanConfig {
dry_run: true,
..Default::default()
};
let cleaner = ProjectCleaner::new(config);
let size_freed = cleaner.clean_project(&project)?;
assert_eq!(size_freed, project.target_size);
assert!(project.target_path().exists());
Ok(())
}
#[test]
fn test_cleaner_direct_delete() -> Result<()> {
let temp_dir = TempDir::new()?;
let project = create_test_project_with_target(temp_dir.path(), "test_project")?;
let config = CleanConfig {
strategy: CleanStrategy::DirectDelete,
..Default::default()
};
let cleaner = ProjectCleaner::new(config);
let size_freed = cleaner.clean_project(&project)?;
assert!(size_freed > 0);
assert!(!project.target_path().exists());
Ok(())
}
#[test]
fn test_check_cargo_available() {
let available = ProjectCleaner::check_cargo_available();
println!("Cargo available: {available}");
}
#[test]
fn test_clean_projects_batch() -> Result<()> {
let temp_dir = TempDir::new()?;
let projects = vec![
create_test_project_with_target(temp_dir.path(), "project1")?,
create_test_project_with_target(temp_dir.path(), "project2")?,
];
let config = CleanConfig {
strategy: CleanStrategy::DirectDelete,
dry_run: false,
..Default::default()
};
let cleaner = ProjectCleaner::new(config);
let result = cleaner.clean_projects(&projects);
assert_eq!(result.cleaned_projects, 2);
assert!(result.total_size_freed > 0);
assert!(result.failed_projects.is_empty());
Ok(())
}
#[test]
fn test_clean_config_default() {
let config = CleanConfig::default();
assert_eq!(config.strategy, CleanStrategy::CargoClean);
assert!(!config.dry_run);
assert!(config.parallel);
assert_eq!(config.timeout_seconds, 0);
assert!(!config.keep_executable);
assert!(config.executable_backup_dir.is_none());
}
#[test]
fn test_clean_progress_phases() {
let progress = CleanProgress {
project_name: "test".to_string(),
current_file: Some("test.txt".to_string()),
files_processed: 5,
total_files: Some(10),
phase: CleanPhase::Cleaning,
};
assert_eq!(progress.project_name, "test");
assert_eq!(progress.current_file, Some("test.txt".to_string()));
assert_eq!(progress.files_processed, 5);
assert_eq!(progress.total_files, Some(10));
assert_eq!(progress.phase, CleanPhase::Cleaning);
}
#[test]
fn test_clean_strategy_default() {
let strategy = CleanStrategy::default();
assert_eq!(strategy, CleanStrategy::CargoClean);
}
#[test]
fn test_clean_with_progress_callback() -> Result<()> {
let temp_dir = TempDir::new()?;
let project = create_test_project_with_target(temp_dir.path(), "test_project")?;
let config = CleanConfig {
strategy: CleanStrategy::DirectDelete,
dry_run: true, ..Default::default()
};
let cleaner = ProjectCleaner::new(config);
let size_freed = cleaner.clean_project_with_progress(&project, |_progress| {
})?;
assert!(size_freed > 0);
Ok(())
}
#[test]
fn test_clean_result_operations() {
let mut result = CleanResult::new();
assert_eq!(result.cleaned_projects, 0);
assert_eq!(result.total_size_freed, 0);
assert!(result.failed_projects.is_empty());
result.add_success(1024);
assert_eq!(result.cleaned_projects, 1);
assert_eq!(result.total_size_freed, 1024);
result.add_failure("failed_project".to_string());
assert_eq!(result.failed_projects.len(), 1);
assert_eq!(result.failed_projects[0], "failed_project");
let formatted = result.format_size();
assert_eq!(formatted, "1.00 KB");
}
#[test]
fn test_clean_nonexistent_project() -> Result<()> {
let temp_dir = TempDir::new()?;
let fake_project = RustProject {
path: temp_dir.path().join("nonexistent"),
name: "nonexistent".to_string(),
target_size: 1000,
last_modified: std::time::SystemTime::now(),
is_workspace: false,
has_target: true,
};
let cleaner = ProjectCleaner::default();
let result = cleaner.clean_project(&fake_project);
if let Ok(size) = result {
assert_eq!(size, 0);
}
Ok(())
}
#[test]
fn test_clean_readonly_target() -> Result<()> {
let temp_dir = TempDir::new()?;
let project = create_test_project_with_target(temp_dir.path(), "readonly_project")?;
let target_path = project.path.join("target");
if target_path.exists() {
#[cfg(windows)]
{
let mut perms = std::fs::metadata(&target_path)?.permissions();
perms.set_readonly(true);
let _ = std::fs::set_permissions(&target_path, perms);
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ =
std::fs::set_permissions(&target_path, std::fs::Permissions::from_mode(0o444));
}
}
let cleaner = ProjectCleaner::default();
let result = cleaner.clean_project(&project);
let _ = result;
Ok(())
}
#[test]
fn test_clean_with_timeout() -> Result<()> {
let temp_dir = TempDir::new()?;
let project = create_test_project_with_target(temp_dir.path(), "timeout_project")?;
let config = CleanConfig {
strategy: CleanStrategy::CargoClean,
timeout_seconds: 1, ..Default::default()
};
let cleaner = ProjectCleaner::new(config);
let result = cleaner.clean_project(&project);
let _ = result;
Ok(())
}
#[test]
fn test_clean_projects_with_mixed_results() -> Result<()> {
let temp_dir = TempDir::new()?;
let good_project = create_test_project_with_target(temp_dir.path(), "good_project")?;
let bad_project_path = temp_dir.path().join("bad_project");
std::fs::create_dir_all(&bad_project_path)?;
std::fs::write(
bad_project_path.join("Cargo.toml"),
r#"
[package]
name = "bad_project"
version = "0.1.0"
edition = "2021"
"#,
)?;
let bad_project = RustProject {
path: bad_project_path,
name: "bad_project".to_string(),
target_size: 0, last_modified: std::time::SystemTime::now(),
is_workspace: false,
has_target: false, };
let projects = vec![good_project, bad_project];
let cleaner = ProjectCleaner::default();
let result = cleaner.clean_projects(&projects);
assert!(result.cleaned_projects + result.failed_projects.len() == 2);
println!(
"清理结果: 成功 {}, 失败 {}",
result.cleaned_projects,
result.failed_projects.len()
);
Ok(())
}
}