1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4use std::process::Command;
5use std::time::Instant;
6use tracing::{debug, error, info};
7
8use crate::CleanResult;
9use crate::project::RustProject;
10
11#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
13pub enum CleanStrategy {
14 #[default]
16 CargoClean,
17 DirectDelete,
19}
20
21#[derive(Debug, Clone)]
23pub struct CleanProgress {
24 pub project_name: String,
25 pub current_file: Option<String>,
26 pub files_processed: usize,
27 pub total_files: Option<usize>,
28 pub phase: CleanPhase,
29}
30
31#[derive(Debug, Clone, PartialEq)]
33pub enum CleanPhase {
34 Starting,
35 Analyzing,
36 Cleaning,
37 Finalizing,
38 Complete,
39}
40
41#[derive(Debug, Clone)]
43pub struct CleanConfig {
44 pub strategy: CleanStrategy,
45 pub dry_run: bool,
46 pub parallel: bool,
47 pub timeout_seconds: u64,
48
49 pub keep_executable: bool,
52 pub executable_backup_dir: Option<PathBuf>,
54}
55
56impl Default for CleanConfig {
57 fn default() -> Self {
58 Self {
59 strategy: CleanStrategy::CargoClean,
60 dry_run: false,
61 parallel: true,
62 timeout_seconds: 30,
63
64 keep_executable: false,
66 executable_backup_dir: None,
67 }
68 }
69}
70
71pub struct ProjectCleaner {
73 config: CleanConfig,
74}
75
76impl ProjectCleaner {
77 pub fn new(config: CleanConfig) -> Self {
79 Self { config }
80 }
81
82 pub fn clean_project(&self, project: &RustProject) -> Result<u64> {
84 self.clean_project_with_progress(project, |_| {})
85 }
86
87 pub fn clean_project_with_progress<F>(
89 &self,
90 project: &RustProject,
91 progress_callback: F,
92 ) -> Result<u64>
93 where
94 F: Fn(CleanProgress),
95 {
96 if self.config.dry_run {
97 info!(
98 "DRY RUN: 将清理项目 {} ({})",
99 project.name,
100 project.formatted_size()
101 );
102 return Ok(project.target_size);
103 }
104
105 if !project.has_target {
106 debug!("项目 {} 没有target目录,跳过", project.name);
107 return Ok(0);
108 }
109
110 let size_before = project.target_size;
111 info!(
112 "开始清理项目: {} ({})",
113 project.name,
114 project.formatted_size()
115 );
116
117 progress_callback(CleanProgress {
119 project_name: project.name.clone(),
120 current_file: None,
121 files_processed: 0,
122 total_files: None,
123 phase: CleanPhase::Starting,
124 });
125
126 let result = match self.config.strategy {
127 CleanStrategy::CargoClean => {
128 self.clean_with_cargo_progress(project, &progress_callback)
129 }
130 CleanStrategy::DirectDelete => {
131 self.clean_with_delete_progress(project, &progress_callback)
132 }
133 };
134
135 match result {
136 Ok(_) => {
137 progress_callback(CleanProgress {
139 project_name: project.name.clone(),
140 current_file: None,
141 files_processed: 0,
142 total_files: Some(0),
143 phase: CleanPhase::Complete,
144 });
145 info!("成功清理项目: {}", project.name);
146 Ok(size_before)
147 }
148 Err(e) => {
149 error!("清理项目失败 {}: {}", project.name, e);
150 Err(e)
151 }
152 }
153 }
154
155 pub fn clean_projects(&self, projects: &[RustProject]) -> CleanResult {
157 let start_time = Instant::now();
158 let mut result = CleanResult::new();
159
160 info!("开始清理 {} 个项目", projects.len());
161
162 if self.config.parallel {
163 self.clean_projects_parallel(projects, &mut result);
164 } else {
165 self.clean_projects_sequential(projects, &mut result);
166 }
167
168 result.duration_ms = start_time.elapsed().as_millis() as u64;
169
170 info!(
171 "清理完成: 成功 {} 个,失败 {} 个,释放空间 {},耗时 {}ms",
172 result.cleaned_projects,
173 result.failed_projects.len(),
174 result.format_size(),
175 result.duration_ms
176 );
177
178 result
179 }
180
181 fn clean_projects_sequential(&self, projects: &[RustProject], result: &mut CleanResult) {
183 for project in projects {
184 match self.clean_project(project) {
185 Ok(size_freed) => result.add_success(size_freed),
186 Err(_) => result.add_failure(project.path.to_string_lossy().to_string()),
187 }
188 }
189 }
190
191 fn clean_projects_parallel(&self, projects: &[RustProject], result: &mut CleanResult) {
193 self.clean_projects_sequential(projects, result);
196 }
197
198 #[allow(dead_code)]
200 fn clean_with_cargo(&self, project: &RustProject) -> Result<()> {
201 self.clean_with_cargo_progress(project, &|_| {})
202 }
203
204 fn clean_with_cargo_progress<F>(
206 &self,
207 project: &RustProject,
208 progress_callback: &F,
209 ) -> Result<()>
210 where
211 F: Fn(CleanProgress),
212 {
213 debug!("使用cargo clean清理项目: {}", project.name);
214
215 progress_callback(CleanProgress {
217 project_name: project.name.clone(),
218 current_file: None,
219 files_processed: 0,
220 total_files: None,
221 phase: CleanPhase::Analyzing,
222 });
223
224 let file_list = self.get_cargo_clean_file_list(project)?;
225 let total_files = file_list.len();
226
227 progress_callback(CleanProgress {
228 project_name: project.name.clone(),
229 current_file: None,
230 files_processed: 0,
231 total_files: Some(total_files),
232 phase: CleanPhase::Cleaning,
233 });
234
235 let mut cmd = Command::new("cargo");
237 cmd.arg("clean").current_dir(&project.path);
238
239 let handle = std::thread::spawn(move || cmd.output());
241
242 let mut processed = 0;
244 while !handle.is_finished() {
245 if processed < total_files {
246 processed = (processed + total_files / 10).min(total_files);
247 progress_callback(CleanProgress {
248 project_name: project.name.clone(),
249 current_file: file_list.get(processed.saturating_sub(1)).cloned(),
250 files_processed: processed,
251 total_files: Some(total_files),
252 phase: CleanPhase::Cleaning,
253 });
254 }
255 std::thread::sleep(std::time::Duration::from_millis(100));
256 }
257
258 let output = handle
259 .join()
260 .map_err(|_| anyhow::anyhow!("清理线程异常"))?
261 .context("执行cargo clean失败")?;
262
263 if !output.status.success() {
264 let stderr = String::from_utf8_lossy(&output.stderr);
265 anyhow::bail!("cargo clean失败: {}", stderr);
266 }
267
268 progress_callback(CleanProgress {
270 project_name: project.name.clone(),
271 current_file: None,
272 files_processed: total_files,
273 total_files: Some(total_files),
274 phase: CleanPhase::Finalizing,
275 });
276
277 Ok(())
278 }
279
280 fn get_cargo_clean_file_list(&self, project: &RustProject) -> Result<Vec<String>> {
282 let mut cmd = Command::new("cargo");
283 cmd.arg("clean").arg("--dry-run").current_dir(&project.path);
284
285 let output = cmd.output().context("执行cargo clean --dry-run失败")?;
286
287 if !output.status.success() {
288 let stderr = String::from_utf8_lossy(&output.stderr);
289 anyhow::bail!("cargo clean --dry-run失败: {}", stderr);
290 }
291
292 let stdout = String::from_utf8_lossy(&output.stdout);
293 let files: Vec<String> = stdout
294 .lines()
295 .filter(|line| !line.trim().is_empty() && !line.contains("Summary"))
296 .map(|line| {
297 if let Some(file_name) = std::path::Path::new(line.trim()).file_name() {
299 file_name.to_string_lossy().to_string()
300 } else {
301 line.trim().to_string()
302 }
303 })
304 .collect();
305
306 Ok(files)
307 }
308
309 #[allow(dead_code)]
311 fn clean_with_delete(&self, project: &RustProject) -> Result<()> {
312 self.clean_with_delete_progress(project, &|_| {})
313 }
314
315 fn clean_with_delete_progress<F>(
317 &self,
318 project: &RustProject,
319 progress_callback: &F,
320 ) -> Result<()>
321 where
322 F: Fn(CleanProgress),
323 {
324 debug!("直接删除target目录: {}", project.name);
325
326 let target_path = project.target_path();
327 if !target_path.exists() {
328 return Ok(());
329 }
330
331 progress_callback(CleanProgress {
332 project_name: project.name.clone(),
333 current_file: None,
334 files_processed: 0,
335 total_files: None,
336 phase: CleanPhase::Analyzing,
337 });
338
339 if self.config.keep_executable {
341 self.backup_executables(project, progress_callback)?;
342 }
343
344 let file_count = self.count_files_in_dir(&target_path)?;
346
347 progress_callback(CleanProgress {
348 project_name: project.name.clone(),
349 current_file: Some("target".to_string()),
350 files_processed: 0,
351 total_files: Some(file_count),
352 phase: CleanPhase::Cleaning,
353 });
354
355 std::fs::remove_dir_all(&target_path).context("删除target目录失败")?;
357
358 progress_callback(CleanProgress {
359 project_name: project.name.clone(),
360 current_file: None,
361 files_processed: file_count,
362 total_files: Some(file_count),
363 phase: CleanPhase::Finalizing,
364 });
365
366 Ok(())
367 }
368
369 fn backup_executables<F>(&self, project: &RustProject, progress_callback: &F) -> Result<()>
371 where
372 F: Fn(CleanProgress),
373 {
374 let target_path = project.target_path();
375 let executables = self.find_executables(&target_path)?;
376
377 if executables.is_empty() {
378 debug!("项目 {} 没有找到可执行文件", project.name);
379 return Ok(());
380 }
381
382 info!(
383 "项目 {} 找到 {} 个可执行文件,开始备份",
384 project.name,
385 executables.len()
386 );
387
388 let backup_dir = self.get_backup_directory(project)?;
390 std::fs::create_dir_all(&backup_dir).context("创建备份目录失败")?;
391
392 for (i, exe_path) in executables.iter().enumerate() {
394 let file_name = exe_path
395 .file_name()
396 .ok_or_else(|| anyhow::anyhow!("无效的可执行文件路径"))?;
397 let backup_path = backup_dir.join(file_name);
398
399 progress_callback(CleanProgress {
400 project_name: project.name.clone(),
401 current_file: Some(format!("备份 {}", file_name.to_string_lossy())),
402 files_processed: i,
403 total_files: Some(executables.len()),
404 phase: CleanPhase::Cleaning,
405 });
406
407 std::fs::copy(exe_path, &backup_path)
408 .with_context(|| format!("备份可执行文件失败: {exe_path:?} -> {backup_path:?}"))?;
409
410 debug!("备份可执行文件: {:?} -> {:?}", exe_path, backup_path);
411 }
412
413 info!(
414 "成功备份 {} 个可执行文件到 {:?}",
415 executables.len(),
416 backup_dir
417 );
418 Ok(())
419 }
420
421 fn find_executables(&self, target_path: &std::path::Path) -> Result<Vec<PathBuf>> {
423 let mut executables = Vec::new();
424
425 let exe_dirs = [target_path.join("debug"), target_path.join("release")];
427
428 for exe_dir in &exe_dirs {
429 if exe_dir.exists() {
430 self.scan_directory_for_executables(exe_dir, &mut executables)?;
431 }
432 }
433
434 if let Ok(entries) = std::fs::read_dir(target_path) {
436 for entry in entries.flatten() {
437 let path = entry.path();
438 if path.is_dir()
439 && !path
440 .file_name()
441 .unwrap_or_default()
442 .to_string_lossy()
443 .starts_with('.')
444 {
445 if let Ok(sub_entries) = std::fs::read_dir(&path) {
447 for sub_entry in sub_entries.flatten() {
448 let sub_path = sub_entry.path();
449 if sub_path.is_dir()
450 && (sub_path.file_name().unwrap_or_default() == "debug"
451 || sub_path.file_name().unwrap_or_default() == "release")
452 {
453 self.scan_directory_for_executables(&sub_path, &mut executables)?;
454 }
455 }
456 }
457 }
458 }
459 }
460
461 Ok(executables)
462 }
463
464 fn scan_directory_for_executables(
466 &self,
467 dir: &std::path::Path,
468 executables: &mut Vec<PathBuf>,
469 ) -> Result<()> {
470 if let Ok(entries) = std::fs::read_dir(dir) {
471 for entry in entries.flatten() {
472 let path = entry.path();
473 if path.is_file() && self.is_executable(&path) {
474 executables.push(path);
475 }
476 }
477 }
478 Ok(())
479 }
480
481 fn is_executable(&self, path: &std::path::Path) -> bool {
483 #[cfg(target_os = "windows")]
485 {
486 path.extension().is_some_and(|ext| ext == "exe")
487 }
488
489 #[cfg(not(target_os = "windows"))]
491 {
492 use std::os::unix::fs::PermissionsExt;
493 if let Ok(metadata) = std::fs::metadata(path) {
494 let permissions = metadata.permissions();
495 permissions.mode() & 0o111 != 0
496 } else {
497 false
498 }
499 }
500 }
501
502 fn get_backup_directory(&self, project: &RustProject) -> Result<PathBuf> {
504 if let Some(ref backup_dir) = self.config.executable_backup_dir {
505 Ok(backup_dir.join(&project.name))
507 } else {
508 Ok(project.path.join("executables"))
510 }
511 }
512
513 fn count_files_in_dir(&self, dir: &std::path::Path) -> Result<usize> {
515 use walkdir::WalkDir;
516
517 let count = WalkDir::new(dir)
518 .into_iter()
519 .filter_map(|entry| entry.ok())
520 .filter(|entry| entry.file_type().is_file())
521 .count();
522
523 Ok(count)
524 }
525
526 pub fn preview_clean(&self, projects: &[RustProject]) -> CleanResult {
528 let mut config = self.config.clone();
529 config.dry_run = true;
530
531 let cleaner = ProjectCleaner::new(config);
532 cleaner.clean_projects(projects)
533 }
534
535 pub fn check_cargo_available() -> bool {
537 Command::new("cargo")
538 .arg("--version")
539 .output()
540 .map(|output| output.status.success())
541 .unwrap_or(false)
542 }
543}
544
545impl Default for ProjectCleaner {
546 fn default() -> Self {
547 Self::new(CleanConfig::default())
548 }
549}
550
551#[cfg(test)]
552mod tests {
553 use super::*;
554 use std::fs;
555 use std::path::Path;
556 use tempfile::TempDir;
557
558 fn create_test_project_with_target(dir: &Path, name: &str) -> Result<RustProject> {
559 let project_dir = dir.join(name);
560 fs::create_dir_all(&project_dir)?;
561
562 let cargo_toml = format!(
563 r#"
564[package]
565name = "{name}"
566version = "0.1.0"
567edition = "2021"
568"#
569 );
570
571 fs::write(project_dir.join("Cargo.toml"), cargo_toml)?;
572
573 let target_dir = project_dir.join("target");
574 fs::create_dir_all(&target_dir)?;
575 fs::write(
576 target_dir.join("test.txt"),
577 "test content for size calculation",
578 )?;
579
580 RustProject::from_path(&project_dir)
581 }
582
583 #[test]
584 fn test_cleaner_dry_run() -> Result<()> {
585 let temp_dir = TempDir::new()?;
586 let project = create_test_project_with_target(temp_dir.path(), "test_project")?;
587
588 let config = CleanConfig {
589 dry_run: true,
590 ..Default::default()
591 };
592
593 let cleaner = ProjectCleaner::new(config);
594 let size_freed = cleaner.clean_project(&project)?;
595
596 assert_eq!(size_freed, project.target_size);
598
599 assert!(project.target_path().exists());
601
602 Ok(())
603 }
604
605 #[test]
606 fn test_cleaner_direct_delete() -> Result<()> {
607 let temp_dir = TempDir::new()?;
608 let project = create_test_project_with_target(temp_dir.path(), "test_project")?;
609
610 let config = CleanConfig {
611 strategy: CleanStrategy::DirectDelete,
612 ..Default::default()
613 };
614
615 let cleaner = ProjectCleaner::new(config);
616 let size_freed = cleaner.clean_project(&project)?;
617
618 assert!(size_freed > 0);
620
621 assert!(!project.target_path().exists());
623
624 Ok(())
625 }
626
627 #[test]
628 fn test_check_cargo_available() {
629 let available = ProjectCleaner::check_cargo_available();
632 println!("Cargo available: {available}");
633 }
634
635 #[test]
636 fn test_clean_projects_batch() -> Result<()> {
637 let temp_dir = TempDir::new()?;
638 let projects = vec![
639 create_test_project_with_target(temp_dir.path(), "project1")?,
640 create_test_project_with_target(temp_dir.path(), "project2")?,
641 ];
642
643 let config = CleanConfig {
644 strategy: CleanStrategy::DirectDelete,
645 dry_run: false,
646 ..Default::default()
647 };
648
649 let cleaner = ProjectCleaner::new(config);
650 let result = cleaner.clean_projects(&projects);
651
652 assert_eq!(result.cleaned_projects, 2);
653 assert!(result.total_size_freed > 0);
654 assert!(result.failed_projects.is_empty());
655
656 Ok(())
657 }
658
659 #[test]
660 fn test_clean_config_default() {
661 let config = CleanConfig::default();
662 assert_eq!(config.strategy, CleanStrategy::CargoClean);
663 assert!(!config.dry_run);
664 assert!(config.parallel);
665 assert_eq!(config.timeout_seconds, 30);
666 assert!(!config.keep_executable);
667 assert!(config.executable_backup_dir.is_none());
668 }
669
670 #[test]
671 fn test_clean_progress_phases() {
672 let progress = CleanProgress {
673 project_name: "test".to_string(),
674 current_file: Some("test.txt".to_string()),
675 files_processed: 5,
676 total_files: Some(10),
677 phase: CleanPhase::Cleaning,
678 };
679
680 assert_eq!(progress.project_name, "test");
681 assert_eq!(progress.current_file, Some("test.txt".to_string()));
682 assert_eq!(progress.files_processed, 5);
683 assert_eq!(progress.total_files, Some(10));
684 assert_eq!(progress.phase, CleanPhase::Cleaning);
685 }
686
687 #[test]
688 fn test_clean_strategy_default() {
689 let strategy = CleanStrategy::default();
690 assert_eq!(strategy, CleanStrategy::CargoClean);
691 }
692
693 #[test]
694 fn test_clean_with_progress_callback() -> Result<()> {
695 let temp_dir = TempDir::new()?;
696 let project = create_test_project_with_target(temp_dir.path(), "test_project")?;
697
698 let config = CleanConfig {
699 strategy: CleanStrategy::DirectDelete,
700 dry_run: true, ..Default::default()
702 };
703
704 let cleaner = ProjectCleaner::new(config);
705
706 let size_freed = cleaner.clean_project_with_progress(&project, |_progress| {
708 })?;
710
711 assert!(size_freed > 0);
712
713 Ok(())
714 }
715
716 #[test]
717 fn test_clean_result_operations() {
718 let mut result = CleanResult::new();
719
720 assert_eq!(result.cleaned_projects, 0);
722 assert_eq!(result.total_size_freed, 0);
723 assert!(result.failed_projects.is_empty());
724
725 result.add_success(1024);
727 assert_eq!(result.cleaned_projects, 1);
728 assert_eq!(result.total_size_freed, 1024);
729
730 result.add_failure("failed_project".to_string());
732 assert_eq!(result.failed_projects.len(), 1);
733 assert_eq!(result.failed_projects[0], "failed_project");
734
735 let formatted = result.format_size();
737 assert_eq!(formatted, "1.00 KB");
738 }
739
740 #[test]
741 fn test_clean_nonexistent_project() -> Result<()> {
742 let temp_dir = TempDir::new()?;
743 let fake_project = RustProject {
744 path: temp_dir.path().join("nonexistent"),
745 name: "nonexistent".to_string(),
746 target_size: 1000,
747 last_modified: std::time::SystemTime::now(),
748 is_workspace: false,
749 has_target: true,
750 };
751
752 let cleaner = ProjectCleaner::default();
753 let result = cleaner.clean_project(&fake_project);
754
755 if let Ok(size) = result {
757 assert_eq!(size, 0);
758 }
759 Ok(())
762 }
763
764 #[test]
765 fn test_clean_readonly_target() -> Result<()> {
766 let temp_dir = TempDir::new()?;
767 let project = create_test_project_with_target(temp_dir.path(), "readonly_project")?;
768
769 let target_path = project.path.join("target");
771 if target_path.exists() {
772 #[cfg(windows)]
774 {
775 let mut perms = std::fs::metadata(&target_path)?.permissions();
776 perms.set_readonly(true);
777 let _ = std::fs::set_permissions(&target_path, perms);
778 }
779
780 #[cfg(unix)]
782 {
783 use std::os::unix::fs::PermissionsExt;
784 let _ =
785 std::fs::set_permissions(&target_path, std::fs::Permissions::from_mode(0o444));
786 }
787 }
788
789 let cleaner = ProjectCleaner::default();
790 let result = cleaner.clean_project(&project);
791
792 let _ = result;
795
796 Ok(())
797 }
798
799 #[test]
800 fn test_clean_with_timeout() -> Result<()> {
801 let temp_dir = TempDir::new()?;
802 let project = create_test_project_with_target(temp_dir.path(), "timeout_project")?;
803
804 let config = CleanConfig {
806 strategy: CleanStrategy::CargoClean,
807 timeout_seconds: 1, ..Default::default()
809 };
810
811 let cleaner = ProjectCleaner::new(config);
812 let result = cleaner.clean_project(&project);
813
814 let _ = result;
817
818 Ok(())
819 }
820
821 #[test]
822 fn test_clean_projects_with_mixed_results() -> Result<()> {
823 let temp_dir = TempDir::new()?;
824
825 let good_project = create_test_project_with_target(temp_dir.path(), "good_project")?;
827
828 let bad_project_path = temp_dir.path().join("bad_project");
830 std::fs::create_dir_all(&bad_project_path)?;
831 std::fs::write(
832 bad_project_path.join("Cargo.toml"),
833 r#"
834[package]
835name = "bad_project"
836version = "0.1.0"
837edition = "2021"
838"#,
839 )?;
840
841 let bad_project = RustProject {
842 path: bad_project_path,
843 name: "bad_project".to_string(),
844 target_size: 0, last_modified: std::time::SystemTime::now(),
846 is_workspace: false,
847 has_target: false, };
849
850 let projects = vec![good_project, bad_project];
851 let cleaner = ProjectCleaner::default();
852 let result = cleaner.clean_projects(&projects);
853
854 assert!(result.cleaned_projects + result.failed_projects.len() == 2);
857
858 println!(
860 "清理结果: 成功 {}, 失败 {}",
861 result.cleaned_projects,
862 result.failed_projects.len()
863 );
864
865 Ok(())
866 }
867}