1use anyhow::{Context, Result};
2use rayon::prelude::*;
3use serde::{Deserialize, Serialize};
4use std::collections::hash_map::DefaultHasher;
5use std::hash::{Hash, Hasher};
6use std::io::Read;
7use std::path::PathBuf;
8use std::process::{Command, Stdio};
9use std::sync::atomic::{AtomicBool, Ordering};
10use std::time::{Duration, Instant};
11use tracing::{debug, error, info};
12use walkdir::WalkDir;
13
14use crate::project::RustProject;
15use crate::{CleanFailure, CleanResult};
16
17#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
19pub enum CleanStrategy {
20 #[default]
22 CargoClean,
23 DirectDelete,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
29#[serde(rename_all = "kebab-case")]
30pub enum DirectDeleteBackend {
31 #[default]
33 Native,
34 CmdRmdir,
36}
37
38#[derive(Debug, Clone)]
40pub struct CleanProgress {
41 pub project_name: String,
42 pub current_file: Option<String>,
43 pub files_processed: usize,
44 pub total_files: Option<usize>,
45 pub phase: CleanPhase,
46}
47
48#[derive(Debug, Clone, PartialEq)]
50pub enum CleanPhase {
51 Starting,
52 Analyzing,
53 Cleaning,
54 Finalizing,
55 Complete,
56}
57
58#[derive(Debug, thiserror::Error)]
59#[error("clean cancelled")]
60pub struct CleanCancelled;
61
62#[derive(Debug, thiserror::Error)]
63#[error("clean timed out after {timeout:?}")]
64pub struct CleanTimedOut {
65 pub timeout: Duration,
66}
67
68#[derive(Debug, thiserror::Error)]
69#[error("refusing to delete unsafe target directory: {path:?} ({reason})")]
70pub struct UnsafeTargetDirectory {
71 pub path: PathBuf,
72 pub reason: String,
73}
74
75#[derive(Debug, Clone)]
77pub struct CleanConfig {
78 pub strategy: CleanStrategy,
79 pub dry_run: bool,
80 pub parallel: bool,
81 pub timeout_seconds: u64,
82 pub direct_delete_backend: DirectDeleteBackend,
83
84 pub keep_executable: bool,
87 pub executable_backup_dir: Option<PathBuf>,
89}
90
91impl Default for CleanConfig {
92 fn default() -> Self {
93 Self {
94 strategy: CleanStrategy::CargoClean,
95 dry_run: false,
96 parallel: true,
97 timeout_seconds: 0,
98 direct_delete_backend: DirectDeleteBackend::Native,
99
100 keep_executable: false,
102 executable_backup_dir: None,
103 }
104 }
105}
106
107pub struct ProjectCleaner {
109 config: CleanConfig,
110}
111
112impl ProjectCleaner {
113 pub fn new(config: CleanConfig) -> Self {
115 Self { config }
116 }
117
118 pub fn clean_project(&self, project: &RustProject) -> Result<u64> {
120 self.clean_project_with_progress(project, |_| {})
121 }
122
123 pub fn clean_project_with_progress<F>(
125 &self,
126 project: &RustProject,
127 progress_callback: F,
128 ) -> Result<u64>
129 where
130 F: Fn(CleanProgress),
131 {
132 self.clean_project_with_progress_and_cancel(project, None, progress_callback)
133 }
134
135 pub fn clean_project_with_progress_and_cancel<F>(
136 &self,
137 project: &RustProject,
138 cancel_flag: Option<&AtomicBool>,
139 progress_callback: F,
140 ) -> Result<u64>
141 where
142 F: Fn(CleanProgress),
143 {
144 match self.clean_project_with_progress_impl(project, cancel_flag, &progress_callback) {
145 Ok(bytes) => Ok(bytes),
146 Err(err) => {
147 if !err.is::<CleanCancelled>() {
148 error!("清理项目失败 {}: {}", project.name, err);
149 }
150 Err(err)
151 }
152 }
153 }
154
155 fn clean_project_with_progress_impl<F>(
156 &self,
157 project: &RustProject,
158 cancel_flag: Option<&AtomicBool>,
159 progress_callback: &F,
160 ) -> Result<u64>
161 where
162 F: Fn(CleanProgress),
163 {
164 self.check_cancel(cancel_flag)?;
165
166 if self.config.dry_run {
167 let size = if project.has_target {
168 project.get_target_size()
169 } else {
170 0
171 };
172 info!(
173 "DRY RUN: 将清理项目 {} ({})",
174 project.name,
175 crate::format_bytes(size)
176 );
177 return Ok(size);
178 }
179
180 if !project.has_target && self.config.strategy == CleanStrategy::DirectDelete {
181 debug!("项目 {} 没有target目录,跳过", project.name);
182 return Ok(0);
183 }
184
185 info!(
186 "开始清理项目: {} ({})",
187 project.name,
188 project.formatted_size()
189 );
190
191 progress_callback(CleanProgress {
192 project_name: project.name.clone(),
193 current_file: None,
194 files_processed: 0,
195 total_files: None,
196 phase: CleanPhase::Starting,
197 });
198
199 let bytes_freed = match self.config.strategy {
200 CleanStrategy::CargoClean => {
201 self.clean_with_cargo_progress(project, cancel_flag, progress_callback)?
202 }
203 CleanStrategy::DirectDelete => {
204 self.clean_with_delete_progress(project, cancel_flag, progress_callback)?
205 }
206 };
207
208 progress_callback(CleanProgress {
209 project_name: project.name.clone(),
210 current_file: None,
211 files_processed: 0,
212 total_files: None,
213 phase: CleanPhase::Complete,
214 });
215
216 info!("成功清理项目: {}", project.name);
217 Ok(bytes_freed)
218 }
219
220 pub fn clean_projects(&self, projects: &[RustProject]) -> CleanResult {
222 let start_time = Instant::now();
223 let mut result = CleanResult::new();
224
225 info!("开始清理 {} 个项目", projects.len());
226
227 if self.config.parallel {
228 self.clean_projects_parallel(projects, &mut result);
229 } else {
230 self.clean_projects_sequential(projects, &mut result);
231 }
232
233 result.duration_ms = start_time.elapsed().as_millis() as u64;
234
235 info!(
236 "清理完成: 成功 {} 个,失败 {} 个,释放空间 {},耗时 {}ms",
237 result.cleaned_projects,
238 result.failed_projects.len(),
239 result.format_size(),
240 result.duration_ms
241 );
242
243 result
244 }
245
246 fn clean_projects_sequential(&self, projects: &[RustProject], result: &mut CleanResult) {
248 for project in projects {
249 match self.clean_project(project) {
250 Ok(size_freed) => result.add_success(size_freed),
251 Err(err) => result.add_failure_detail(CleanFailure {
252 project_name: project.name.clone(),
253 project_path: project.path.clone(),
254 error: err.to_string(),
255 }),
256 }
257 }
258 }
259
260 fn clean_projects_parallel(&self, projects: &[RustProject], result: &mut CleanResult) {
262 let (successes, total_freed, failures): (usize, u64, Vec<CleanFailure>) = projects
263 .par_iter()
264 .map(|project| match self.clean_project(project) {
265 Ok(size_freed) => Ok(size_freed),
266 Err(err) => Err(CleanFailure {
267 project_name: project.name.clone(),
268 project_path: project.path.clone(),
269 error: err.to_string(),
270 }),
271 })
272 .fold(
273 || (0usize, 0u64, Vec::new()),
274 |mut acc, item| {
275 match item {
276 Ok(size_freed) => {
277 acc.0 += 1;
278 acc.1 += size_freed;
279 }
280 Err(failure) => {
281 acc.2.push(failure);
282 }
283 }
284 acc
285 },
286 )
287 .reduce(
288 || (0usize, 0u64, Vec::new()),
289 |mut a, b| {
290 a.0 += b.0;
291 a.1 += b.1;
292 a.2.extend(b.2);
293 a
294 },
295 );
296
297 result.cleaned_projects += successes;
298 result.total_size_freed += total_freed;
299 for failure in failures {
300 result.add_failure_detail(failure);
301 }
302 }
303
304 #[allow(dead_code)]
306 fn clean_with_cargo(&self, project: &RustProject) -> Result<u64> {
307 self.clean_with_cargo_progress(project, None, &|_| {})
308 }
309
310 fn clean_with_cargo_progress<F>(
312 &self,
313 project: &RustProject,
314 cancel_flag: Option<&AtomicBool>,
315 progress_callback: &F,
316 ) -> Result<u64>
317 where
318 F: Fn(CleanProgress),
319 {
320 debug!("使用cargo clean清理项目: {}", project.name);
321
322 self.check_cancel(cancel_flag)?;
323
324 progress_callback(CleanProgress {
325 project_name: project.name.clone(),
326 current_file: Some("cargo clean".to_string()),
327 files_processed: 0,
328 total_files: None,
329 phase: CleanPhase::Analyzing,
330 });
331
332 let target_path = project.target_path();
333 let size_before = if target_path.exists() {
334 project.get_target_size()
335 } else {
336 0
337 };
338
339 progress_callback(CleanProgress {
340 project_name: project.name.clone(),
341 current_file: Some("cargo clean".to_string()),
342 files_processed: 0,
343 total_files: None,
344 phase: CleanPhase::Cleaning,
345 });
346
347 let mut cmd = Command::new("cargo");
348 cmd.arg("clean")
349 .current_dir(&project.path)
350 .stdin(Stdio::null())
351 .stdout(Stdio::piped())
352 .stderr(Stdio::piped());
353
354 let output = self.run_command_with_timeout_and_cancel(
355 cmd,
356 self.timeout(),
357 cancel_flag,
358 |elapsed| {
359 let ticks = (elapsed.as_millis() / 250) as usize;
360 progress_callback(CleanProgress {
361 project_name: project.name.clone(),
362 current_file: Some(format!("cargo clean ({:?})", elapsed)),
363 files_processed: ticks,
364 total_files: None,
365 phase: CleanPhase::Cleaning,
366 });
367 },
368 )?;
369
370 if !output.status.success() {
371 let stderr = String::from_utf8_lossy(&output.stderr);
372 anyhow::bail!("cargo clean失败: {}", stderr.trim());
373 }
374
375 progress_callback(CleanProgress {
377 project_name: project.name.clone(),
378 current_file: None,
379 files_processed: 0,
380 total_files: None,
381 phase: CleanPhase::Finalizing,
382 });
383
384 let size_after = if target_path.exists() {
385 project.get_target_size()
386 } else {
387 0
388 };
389
390 Ok(size_before.saturating_sub(size_after))
391 }
392
393 #[allow(dead_code)]
395 fn clean_with_delete(&self, project: &RustProject) -> Result<u64> {
396 self.clean_with_delete_progress(project, None, &|_| {})
397 }
398
399 fn clean_with_delete_progress<F>(
401 &self,
402 project: &RustProject,
403 cancel_flag: Option<&AtomicBool>,
404 progress_callback: &F,
405 ) -> Result<u64>
406 where
407 F: Fn(CleanProgress),
408 {
409 debug!("直接删除target目录: {}", project.name);
410
411 let target_path = project.target_path();
412 if !target_path.exists() {
413 return Ok(0);
414 }
415
416 self.check_cancel(cancel_flag)?;
417 self.validate_safe_target_directory(project, &target_path)?;
418
419 progress_callback(CleanProgress {
420 project_name: project.name.clone(),
421 current_file: None,
422 files_processed: 0,
423 total_files: None,
424 phase: CleanPhase::Analyzing,
425 });
426
427 if self.config.keep_executable {
429 self.backup_executables(project, cancel_flag, progress_callback)?;
430 }
431
432 progress_callback(CleanProgress {
433 project_name: project.name.clone(),
434 current_file: Some("target".to_string()),
435 files_processed: 0,
436 total_files: None,
437 phase: CleanPhase::Cleaning,
438 });
439
440 let timeout = self.timeout();
441 let bytes_freed = match self.config.direct_delete_backend {
442 DirectDeleteBackend::Native => {
443 if cancel_flag.is_some() || self.config.keep_executable {
444 self.delete_directory_tree_with_progress(
445 project,
446 &target_path,
447 cancel_flag,
448 timeout,
449 progress_callback,
450 )?
451 } else {
452 let size_before = project.get_target_size();
453 std::fs::remove_dir_all(&target_path).context("删除target目录失败")?;
454 size_before
455 }
456 }
457 DirectDeleteBackend::CmdRmdir => self.clean_with_windows_rmdir(
458 project,
459 &target_path,
460 cancel_flag,
461 timeout,
462 progress_callback,
463 )?,
464 };
465
466 progress_callback(CleanProgress {
467 project_name: project.name.clone(),
468 current_file: None,
469 files_processed: 0,
470 total_files: None,
471 phase: CleanPhase::Finalizing,
472 });
473
474 Ok(bytes_freed)
475 }
476
477 fn clean_with_windows_rmdir<F>(
478 &self,
479 project: &RustProject,
480 target_path: &std::path::Path,
481 cancel_flag: Option<&AtomicBool>,
482 timeout: Option<Duration>,
483 progress_callback: &F,
484 ) -> Result<u64>
485 where
486 F: Fn(CleanProgress),
487 {
488 #[cfg(windows)]
489 {
490 self.check_cancel(cancel_flag)?;
491 self.validate_safe_target_directory(project, target_path)?;
492
493 let size_before = project.get_target_size();
494 let target_str = target_path.display().to_string();
495 if target_str.contains('"') {
496 return self.delete_directory_tree_with_progress(
497 project,
498 target_path,
499 cancel_flag,
500 timeout,
501 progress_callback,
502 );
503 }
504
505 let mut cmd = Command::new("cmd");
506 cmd.args(["/C", &format!("rmdir /S /Q \"{target_str}\"")]);
507 cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
508
509 let output =
510 self.run_command_with_timeout_and_cancel(cmd, timeout, cancel_flag, |elapsed| {
511 progress_callback(CleanProgress {
512 project_name: project.name.clone(),
513 current_file: Some(format!("target ({:.1}s)", elapsed.as_secs_f32())),
514 files_processed: 0,
515 total_files: None,
516 phase: CleanPhase::Cleaning,
517 });
518 })?;
519
520 if output.status.success() {
521 return Ok(size_before);
522 }
523
524 if cancel_flag.is_some_and(|flag| flag.load(Ordering::Relaxed)) {
525 anyhow::bail!(CleanCancelled);
526 }
527
528 self.delete_directory_tree_with_progress(
530 project,
531 target_path,
532 cancel_flag,
533 timeout,
534 progress_callback,
535 )
536 }
537
538 #[cfg(not(windows))]
539 {
540 tracing::warn!(
541 "CmdRmdir backend requested on non-Windows, falling back to Native for project: {}",
542 project.name
543 );
544 if cancel_flag.is_some() || self.config.keep_executable {
545 self.delete_directory_tree_with_progress(
546 project,
547 target_path,
548 cancel_flag,
549 timeout,
550 progress_callback,
551 )
552 } else {
553 let size_before = project.get_target_size();
554 std::fs::remove_dir_all(target_path).context("删除target目录失败")?;
555 Ok(size_before)
556 }
557 }
558 }
559
560 fn backup_executables<F>(
562 &self,
563 project: &RustProject,
564 cancel_flag: Option<&AtomicBool>,
565 progress_callback: &F,
566 ) -> Result<()>
567 where
568 F: Fn(CleanProgress),
569 {
570 let target_path = project.target_path();
571 let executables = self.find_executables(&target_path)?;
572
573 if executables.is_empty() {
574 debug!("项目 {} 没有找到可执行文件", project.name);
575 return Ok(());
576 }
577
578 info!(
579 "项目 {} 找到 {} 个可执行文件,开始备份",
580 project.name,
581 executables.len()
582 );
583
584 let backup_dir = self.get_backup_directory(project)?;
586 std::fs::create_dir_all(&backup_dir).context("创建备份目录失败")?;
587
588 for (i, exe_path) in executables.iter().enumerate() {
590 self.check_cancel(cancel_flag)?;
591 let file_name = exe_path
592 .file_name()
593 .ok_or_else(|| anyhow::anyhow!("无效的可执行文件路径"))?;
594 let backup_path = backup_dir.join(file_name);
595
596 progress_callback(CleanProgress {
597 project_name: project.name.clone(),
598 current_file: Some(format!("备份 {}", file_name.to_string_lossy())),
599 files_processed: i,
600 total_files: Some(executables.len()),
601 phase: CleanPhase::Cleaning,
602 });
603
604 std::fs::copy(exe_path, &backup_path)
605 .with_context(|| format!("备份可执行文件失败: {exe_path:?} -> {backup_path:?}"))?;
606
607 debug!("备份可执行文件: {:?} -> {:?}", exe_path, backup_path);
608 }
609
610 info!(
611 "成功备份 {} 个可执行文件到 {:?}",
612 executables.len(),
613 backup_dir
614 );
615 Ok(())
616 }
617
618 fn timeout(&self) -> Option<Duration> {
619 if self.config.timeout_seconds == 0 {
620 return None;
621 }
622 Some(Duration::from_secs(self.config.timeout_seconds))
623 }
624
625 fn check_cancel(&self, cancel_flag: Option<&AtomicBool>) -> Result<()> {
626 if cancel_flag.is_some_and(|flag| flag.load(Ordering::Relaxed)) {
627 anyhow::bail!(CleanCancelled);
628 }
629 Ok(())
630 }
631
632 fn validate_safe_target_directory(
633 &self,
634 project: &RustProject,
635 target_path: &std::path::Path,
636 ) -> Result<()> {
637 let metadata = std::fs::symlink_metadata(target_path).context("读取 target 元数据失败")?;
638 if metadata.file_type().is_symlink() {
639 anyhow::bail!(UnsafeTargetDirectory {
640 path: target_path.to_path_buf(),
641 reason: "target is a symlink/reparse point".to_string(),
642 });
643 }
644
645 let canonical_target = target_path.canonicalize().ok();
646 let canonical_project = project.path.canonicalize().ok();
647
648 if let (Some(target), Some(root)) = (canonical_target, canonical_project) {
649 if !target.starts_with(&root) {
650 anyhow::bail!(UnsafeTargetDirectory {
651 path: target_path.to_path_buf(),
652 reason: format!("target escapes project root: {target:?}"),
653 });
654 }
655 } else if !target_path.starts_with(&project.path) {
656 anyhow::bail!(UnsafeTargetDirectory {
657 path: target_path.to_path_buf(),
658 reason: "target is not under project path".to_string(),
659 });
660 }
661
662 Ok(())
663 }
664
665 fn delete_directory_tree_with_progress<F>(
666 &self,
667 project: &RustProject,
668 target_path: &std::path::Path,
669 cancel_flag: Option<&AtomicBool>,
670 timeout: Option<Duration>,
671 progress_callback: &F,
672 ) -> Result<u64>
673 where
674 F: Fn(CleanProgress),
675 {
676 let start = Instant::now();
677 let mut last_report = Instant::now();
678 let mut processed = 0usize;
679 let mut directories: Vec<PathBuf> = Vec::new();
680
681 let mut bytes_freed = if project.target_size > 0 {
682 project.target_size
683 } else {
684 0
685 };
686 let track_bytes = project.target_size == 0;
687
688 if self.config.parallel && cancel_flag.is_some() {
689 let mut files: Vec<PathBuf> = Vec::new();
690
691 for entry in WalkDir::new(target_path).follow_links(false).min_depth(1) {
692 self.check_cancel(cancel_flag)?;
693 if let Some(timeout) = timeout {
694 if start.elapsed() > timeout {
695 anyhow::bail!(CleanTimedOut { timeout });
696 }
697 }
698
699 let entry = entry.context("遍历 target 目录失败")?;
700 let path = entry.path().to_path_buf();
701
702 if entry.file_type().is_dir() {
703 directories.push(path);
704 } else {
705 files.push(path);
706 }
707 }
708
709 let total_files = Some(files.len());
710 let chunk_size = 1024usize;
711 for chunk in files.chunks(chunk_size) {
712 self.check_cancel(cancel_flag)?;
713 if let Some(timeout) = timeout {
714 if start.elapsed() > timeout {
715 anyhow::bail!(CleanTimedOut { timeout });
716 }
717 }
718
719 let bytes_in_chunk: u64 = chunk
720 .par_iter()
721 .map(|path| -> Result<u64> {
722 if cancel_flag.is_some_and(|flag| flag.load(Ordering::Relaxed)) {
723 anyhow::bail!(CleanCancelled);
724 }
725 if let Some(timeout) = timeout {
726 if start.elapsed() > timeout {
727 anyhow::bail!(CleanTimedOut { timeout });
728 }
729 }
730
731 let mut bytes = 0u64;
732 if track_bytes {
733 if let Ok(metadata) = std::fs::symlink_metadata(path) {
734 bytes = metadata.len();
735 }
736 }
737
738 Self::remove_path_best_effort(path)
739 .with_context(|| format!("删除失败: {path:?}"))?;
740 Ok(bytes)
741 })
742 .try_reduce(|| 0u64, |a, b| Ok(a.saturating_add(b)))?;
743
744 if track_bytes {
745 bytes_freed = bytes_freed.saturating_add(bytes_in_chunk);
746 }
747 processed = processed.saturating_add(chunk.len());
748
749 if last_report.elapsed() >= Duration::from_millis(120) {
750 last_report = Instant::now();
751 let current_file = chunk
752 .last()
753 .and_then(|p| p.file_name())
754 .map(|n| n.to_string_lossy().to_string());
755 progress_callback(CleanProgress {
756 project_name: project.name.clone(),
757 current_file,
758 files_processed: processed,
759 total_files,
760 phase: CleanPhase::Cleaning,
761 });
762 }
763 }
764 } else {
765 for entry in WalkDir::new(target_path).follow_links(false).min_depth(1) {
766 self.check_cancel(cancel_flag)?;
767 if let Some(timeout) = timeout {
768 if start.elapsed() > timeout {
769 anyhow::bail!(CleanTimedOut { timeout });
770 }
771 }
772
773 let entry = entry.context("遍历 target 目录失败")?;
774 let path = entry.path().to_path_buf();
775
776 if entry.file_type().is_dir() {
777 directories.push(path);
778 continue;
779 }
780
781 if track_bytes {
782 if let Ok(metadata) = std::fs::symlink_metadata(&path) {
783 bytes_freed = bytes_freed.saturating_add(metadata.len());
784 }
785 }
786
787 Self::remove_path_best_effort(&path)
788 .with_context(|| format!("删除失败: {path:?}"))?;
789 processed = processed.saturating_add(1);
790
791 if last_report.elapsed() >= Duration::from_millis(120) {
792 last_report = Instant::now();
793 progress_callback(CleanProgress {
794 project_name: project.name.clone(),
795 current_file: path
796 .file_name()
797 .map(|n| n.to_string_lossy().to_string())
798 .or_else(|| Some(path.display().to_string())),
799 files_processed: processed,
800 total_files: None,
801 phase: CleanPhase::Cleaning,
802 });
803 }
804 }
805 }
806
807 directories.sort_by_key(|p| std::cmp::Reverse(p.components().count()));
808 for dir in directories {
809 self.check_cancel(cancel_flag)?;
810 if let Some(timeout) = timeout {
811 if start.elapsed() > timeout {
812 anyhow::bail!(CleanTimedOut { timeout });
813 }
814 }
815 let _ = Self::remove_dir_best_effort(&dir);
816 }
817
818 self.check_cancel(cancel_flag)?;
819 if let Some(timeout) = timeout {
820 if start.elapsed() > timeout {
821 anyhow::bail!(CleanTimedOut { timeout });
822 }
823 }
824
825 Self::remove_dir_best_effort(target_path)
826 .with_context(|| format!("删除 target 根目录失败: {target_path:?}"))?;
827
828 Ok(bytes_freed)
829 }
830
831 fn run_command_with_timeout_and_cancel<T>(
832 &self,
833 mut cmd: Command,
834 timeout: Option<Duration>,
835 cancel_flag: Option<&AtomicBool>,
836 on_tick: T,
837 ) -> Result<std::process::Output>
838 where
839 T: Fn(Duration),
840 {
841 let mut child = cmd.spawn().context("启动命令失败")?;
842 let start = Instant::now();
843
844 let stdout = child.stdout.take();
845 let stderr = child.stderr.take();
846
847 let mut stdout_handle = Some(std::thread::spawn(move || -> Vec<u8> {
848 let mut buf = Vec::new();
849 if let Some(mut out) = stdout {
850 let _ = out.read_to_end(&mut buf);
851 }
852 buf
853 }));
854
855 let mut stderr_handle = Some(std::thread::spawn(move || -> Vec<u8> {
856 let mut buf = Vec::new();
857 if let Some(mut err) = stderr {
858 let _ = err.read_to_end(&mut buf);
859 }
860 buf
861 }));
862
863 let status = loop {
864 if cancel_flag.is_some_and(|flag| flag.load(Ordering::Relaxed)) {
865 let _ = child.kill();
866 let _ = child.wait();
867 let _ = stdout_handle.take().and_then(|h| h.join().ok());
868 let _ = stderr_handle.take().and_then(|h| h.join().ok());
869 anyhow::bail!(CleanCancelled);
870 }
871
872 if let Some(timeout) = timeout {
873 if start.elapsed() > timeout {
874 let _ = child.kill();
875 let _ = child.wait();
876 let _ = stdout_handle.take().and_then(|h| h.join().ok());
877 let _ = stderr_handle.take().and_then(|h| h.join().ok());
878 anyhow::bail!(CleanTimedOut { timeout });
879 }
880 }
881
882 if let Some(status) = child.try_wait().context("等待子进程失败")? {
883 break status;
884 }
885
886 on_tick(start.elapsed());
887 std::thread::sleep(Duration::from_millis(80));
888 };
889
890 let stdout = stdout_handle
891 .take()
892 .and_then(|h| h.join().ok())
893 .unwrap_or_default();
894 let stderr = stderr_handle
895 .take()
896 .and_then(|h| h.join().ok())
897 .unwrap_or_default();
898
899 Ok(std::process::Output {
900 status,
901 stdout,
902 stderr,
903 })
904 }
905
906 fn remove_path_best_effort(path: &std::path::Path) -> std::io::Result<()> {
907 if std::fs::remove_file(path).is_ok() {
908 return Ok(());
909 }
910 if std::fs::remove_dir(path).is_ok() {
911 return Ok(());
912 }
913
914 if let Ok(metadata) = std::fs::symlink_metadata(path) {
915 let mut perms = metadata.permissions();
916 perms.set_readonly(false);
917 let _ = std::fs::set_permissions(path, perms);
918 }
919
920 std::fs::remove_file(path).or_else(|_| std::fs::remove_dir(path))
921 }
922
923 fn remove_dir_best_effort(path: &std::path::Path) -> std::io::Result<()> {
924 if std::fs::remove_dir(path).is_ok() {
925 return Ok(());
926 }
927
928 if let Ok(metadata) = std::fs::symlink_metadata(path) {
929 let mut perms = metadata.permissions();
930 perms.set_readonly(false);
931 let _ = std::fs::set_permissions(path, perms);
932 }
933
934 std::fs::remove_dir(path).or_else(|_| std::fs::remove_dir_all(path))
935 }
936
937 fn find_executables(&self, target_path: &std::path::Path) -> Result<Vec<PathBuf>> {
939 let mut executables = Vec::new();
940
941 let exe_dirs = [target_path.join("debug"), target_path.join("release")];
943
944 for exe_dir in &exe_dirs {
945 if exe_dir.exists() {
946 self.scan_directory_for_executables(exe_dir, &mut executables)?;
947 }
948 }
949
950 if let Ok(entries) = std::fs::read_dir(target_path) {
952 for entry in entries.flatten() {
953 let path = entry.path();
954 if path.is_dir()
955 && !path
956 .file_name()
957 .unwrap_or_default()
958 .to_string_lossy()
959 .starts_with('.')
960 {
961 if let Ok(sub_entries) = std::fs::read_dir(&path) {
963 for sub_entry in sub_entries.flatten() {
964 let sub_path = sub_entry.path();
965 if sub_path.is_dir()
966 && (sub_path.file_name().unwrap_or_default() == "debug"
967 || sub_path.file_name().unwrap_or_default() == "release")
968 {
969 self.scan_directory_for_executables(&sub_path, &mut executables)?;
970 }
971 }
972 }
973 }
974 }
975 }
976
977 Ok(executables)
978 }
979
980 fn scan_directory_for_executables(
982 &self,
983 dir: &std::path::Path,
984 executables: &mut Vec<PathBuf>,
985 ) -> Result<()> {
986 if let Ok(entries) = std::fs::read_dir(dir) {
987 for entry in entries.flatten() {
988 let path = entry.path();
989 if path.is_file() && self.is_executable(&path) {
990 executables.push(path);
991 }
992 }
993 }
994 Ok(())
995 }
996
997 fn is_executable(&self, path: &std::path::Path) -> bool {
999 #[cfg(target_os = "windows")]
1001 {
1002 path.extension().is_some_and(|ext| ext == "exe")
1003 }
1004
1005 #[cfg(not(target_os = "windows"))]
1007 {
1008 use std::os::unix::fs::PermissionsExt;
1009 if let Ok(metadata) = std::fs::metadata(path) {
1010 let permissions = metadata.permissions();
1011 permissions.mode() & 0o111 != 0
1012 } else {
1013 false
1014 }
1015 }
1016 }
1017
1018 fn get_backup_directory(&self, project: &RustProject) -> Result<PathBuf> {
1020 let base_dir = if let Some(ref backup_dir) = self.config.executable_backup_dir {
1021 backup_dir.clone()
1022 } else {
1023 project.path.join("executables")
1024 };
1025
1026 let mut hasher = DefaultHasher::new();
1027 project.path.to_string_lossy().hash(&mut hasher);
1028 let id = hasher.finish();
1029
1030 Ok(base_dir.join(format!("{}-{:016x}", project.name, id)))
1031 }
1032
1033 pub fn preview_clean(&self, projects: &[RustProject]) -> CleanResult {
1035 let mut config = self.config.clone();
1036 config.dry_run = true;
1037
1038 let cleaner = ProjectCleaner::new(config);
1039 cleaner.clean_projects(projects)
1040 }
1041
1042 pub fn check_cargo_available() -> bool {
1044 Command::new("cargo")
1045 .arg("--version")
1046 .output()
1047 .map(|output| output.status.success())
1048 .unwrap_or(false)
1049 }
1050}
1051
1052impl Default for ProjectCleaner {
1053 fn default() -> Self {
1054 Self::new(CleanConfig::default())
1055 }
1056}
1057
1058#[cfg(test)]
1059mod tests {
1060 use super::*;
1061 use std::fs;
1062 use std::path::Path;
1063 use tempfile::TempDir;
1064
1065 fn create_test_project_with_target(dir: &Path, name: &str) -> Result<RustProject> {
1066 let project_dir = dir.join(name);
1067 fs::create_dir_all(&project_dir)?;
1068
1069 let cargo_toml = format!(
1070 r#"
1071[package]
1072name = "{name}"
1073version = "0.1.0"
1074edition = "2021"
1075"#
1076 );
1077
1078 fs::write(project_dir.join("Cargo.toml"), cargo_toml)?;
1079
1080 let target_dir = project_dir.join("target");
1081 fs::create_dir_all(&target_dir)?;
1082 fs::write(
1083 target_dir.join("test.txt"),
1084 "test content for size calculation",
1085 )?;
1086
1087 RustProject::from_path(&project_dir)
1088 }
1089
1090 #[test]
1091 fn test_cleaner_dry_run() -> Result<()> {
1092 let temp_dir = TempDir::new()?;
1093 let project = create_test_project_with_target(temp_dir.path(), "test_project")?;
1094
1095 let config = CleanConfig {
1096 dry_run: true,
1097 ..Default::default()
1098 };
1099
1100 let cleaner = ProjectCleaner::new(config);
1101 let size_freed = cleaner.clean_project(&project)?;
1102
1103 assert_eq!(size_freed, project.target_size);
1105
1106 assert!(project.target_path().exists());
1108
1109 Ok(())
1110 }
1111
1112 #[test]
1113 fn test_cleaner_direct_delete() -> Result<()> {
1114 let temp_dir = TempDir::new()?;
1115 let project = create_test_project_with_target(temp_dir.path(), "test_project")?;
1116
1117 let config = CleanConfig {
1118 strategy: CleanStrategy::DirectDelete,
1119 ..Default::default()
1120 };
1121
1122 let cleaner = ProjectCleaner::new(config);
1123 let size_freed = cleaner.clean_project(&project)?;
1124
1125 assert!(size_freed > 0);
1127
1128 assert!(!project.target_path().exists());
1130
1131 Ok(())
1132 }
1133
1134 #[test]
1135 fn test_check_cargo_available() {
1136 let available = ProjectCleaner::check_cargo_available();
1139 println!("Cargo available: {available}");
1140 }
1141
1142 #[test]
1143 fn test_clean_projects_batch() -> Result<()> {
1144 let temp_dir = TempDir::new()?;
1145 let projects = vec![
1146 create_test_project_with_target(temp_dir.path(), "project1")?,
1147 create_test_project_with_target(temp_dir.path(), "project2")?,
1148 ];
1149
1150 let config = CleanConfig {
1151 strategy: CleanStrategy::DirectDelete,
1152 dry_run: false,
1153 ..Default::default()
1154 };
1155
1156 let cleaner = ProjectCleaner::new(config);
1157 let result = cleaner.clean_projects(&projects);
1158
1159 assert_eq!(result.cleaned_projects, 2);
1160 assert!(result.total_size_freed > 0);
1161 assert!(result.failed_projects.is_empty());
1162
1163 Ok(())
1164 }
1165
1166 #[test]
1167 fn test_clean_config_default() {
1168 let config = CleanConfig::default();
1169 assert_eq!(config.strategy, CleanStrategy::CargoClean);
1170 assert!(!config.dry_run);
1171 assert!(config.parallel);
1172 assert_eq!(config.timeout_seconds, 0);
1173 assert!(!config.keep_executable);
1174 assert!(config.executable_backup_dir.is_none());
1175 }
1176
1177 #[test]
1178 fn test_clean_progress_phases() {
1179 let progress = CleanProgress {
1180 project_name: "test".to_string(),
1181 current_file: Some("test.txt".to_string()),
1182 files_processed: 5,
1183 total_files: Some(10),
1184 phase: CleanPhase::Cleaning,
1185 };
1186
1187 assert_eq!(progress.project_name, "test");
1188 assert_eq!(progress.current_file, Some("test.txt".to_string()));
1189 assert_eq!(progress.files_processed, 5);
1190 assert_eq!(progress.total_files, Some(10));
1191 assert_eq!(progress.phase, CleanPhase::Cleaning);
1192 }
1193
1194 #[test]
1195 fn test_clean_strategy_default() {
1196 let strategy = CleanStrategy::default();
1197 assert_eq!(strategy, CleanStrategy::CargoClean);
1198 }
1199
1200 #[test]
1201 fn test_clean_with_progress_callback() -> Result<()> {
1202 let temp_dir = TempDir::new()?;
1203 let project = create_test_project_with_target(temp_dir.path(), "test_project")?;
1204
1205 let config = CleanConfig {
1206 strategy: CleanStrategy::DirectDelete,
1207 dry_run: true, ..Default::default()
1209 };
1210
1211 let cleaner = ProjectCleaner::new(config);
1212
1213 let size_freed = cleaner.clean_project_with_progress(&project, |_progress| {
1215 })?;
1217
1218 assert!(size_freed > 0);
1219
1220 Ok(())
1221 }
1222
1223 #[test]
1224 fn test_clean_result_operations() {
1225 let mut result = CleanResult::new();
1226
1227 assert_eq!(result.cleaned_projects, 0);
1229 assert_eq!(result.total_size_freed, 0);
1230 assert!(result.failed_projects.is_empty());
1231
1232 result.add_success(1024);
1234 assert_eq!(result.cleaned_projects, 1);
1235 assert_eq!(result.total_size_freed, 1024);
1236
1237 result.add_failure("failed_project".to_string());
1239 assert_eq!(result.failed_projects.len(), 1);
1240 assert_eq!(result.failed_projects[0], "failed_project");
1241
1242 let formatted = result.format_size();
1244 assert_eq!(formatted, "1.00 KB");
1245 }
1246
1247 #[test]
1248 fn test_clean_nonexistent_project() -> Result<()> {
1249 let temp_dir = TempDir::new()?;
1250 let fake_project = RustProject {
1251 path: temp_dir.path().join("nonexistent"),
1252 name: "nonexistent".to_string(),
1253 target_size: 1000,
1254 last_modified: std::time::SystemTime::now(),
1255 is_workspace: false,
1256 has_target: true,
1257 };
1258
1259 let cleaner = ProjectCleaner::default();
1260 let result = cleaner.clean_project(&fake_project);
1261
1262 if let Ok(size) = result {
1264 assert_eq!(size, 0);
1265 }
1266 Ok(())
1269 }
1270
1271 #[test]
1272 fn test_clean_readonly_target() -> Result<()> {
1273 let temp_dir = TempDir::new()?;
1274 let project = create_test_project_with_target(temp_dir.path(), "readonly_project")?;
1275
1276 let target_path = project.path.join("target");
1278 if target_path.exists() {
1279 #[cfg(windows)]
1281 {
1282 let mut perms = std::fs::metadata(&target_path)?.permissions();
1283 perms.set_readonly(true);
1284 let _ = std::fs::set_permissions(&target_path, perms);
1285 }
1286
1287 #[cfg(unix)]
1289 {
1290 use std::os::unix::fs::PermissionsExt;
1291 let _ =
1292 std::fs::set_permissions(&target_path, std::fs::Permissions::from_mode(0o444));
1293 }
1294 }
1295
1296 let cleaner = ProjectCleaner::default();
1297 let result = cleaner.clean_project(&project);
1298
1299 let _ = result;
1302
1303 Ok(())
1304 }
1305
1306 #[test]
1307 fn test_clean_with_timeout() -> Result<()> {
1308 let temp_dir = TempDir::new()?;
1309 let project = create_test_project_with_target(temp_dir.path(), "timeout_project")?;
1310
1311 let config = CleanConfig {
1313 strategy: CleanStrategy::CargoClean,
1314 timeout_seconds: 1, ..Default::default()
1316 };
1317
1318 let cleaner = ProjectCleaner::new(config);
1319 let result = cleaner.clean_project(&project);
1320
1321 let _ = result;
1324
1325 Ok(())
1326 }
1327
1328 #[test]
1329 fn test_clean_projects_with_mixed_results() -> Result<()> {
1330 let temp_dir = TempDir::new()?;
1331
1332 let good_project = create_test_project_with_target(temp_dir.path(), "good_project")?;
1334
1335 let bad_project_path = temp_dir.path().join("bad_project");
1337 std::fs::create_dir_all(&bad_project_path)?;
1338 std::fs::write(
1339 bad_project_path.join("Cargo.toml"),
1340 r#"
1341[package]
1342name = "bad_project"
1343version = "0.1.0"
1344edition = "2021"
1345"#,
1346 )?;
1347
1348 let bad_project = RustProject {
1349 path: bad_project_path,
1350 name: "bad_project".to_string(),
1351 target_size: 0, last_modified: std::time::SystemTime::now(),
1353 is_workspace: false,
1354 has_target: false, };
1356
1357 let projects = vec![good_project, bad_project];
1358 let cleaner = ProjectCleaner::default();
1359 let result = cleaner.clean_projects(&projects);
1360
1361 assert!(result.cleaned_projects + result.failed_projects.len() == 2);
1364
1365 println!(
1367 "清理结果: 成功 {}, 失败 {}",
1368 result.cleaned_projects,
1369 result.failed_projects.len()
1370 );
1371
1372 Ok(())
1373 }
1374}