Skip to main content

purger_core/
cleaner.rs

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/// 清理策略
18#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
19pub enum CleanStrategy {
20    /// 使用cargo clean命令
21    #[default]
22    CargoClean,
23    /// 直接删除target目录
24    DirectDelete,
25}
26
27/// Backend for `CleanStrategy::DirectDelete`.
28#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
29#[serde(rename_all = "kebab-case")]
30pub enum DirectDeleteBackend {
31    /// Use Rust filesystem deletion (cross-platform, detailed progress).
32    #[default]
33    Native,
34    /// Use `cmd.exe /C rmdir /S /Q` on Windows (usually faster, coarse progress).
35    CmdRmdir,
36}
37
38/// 清理进度信息
39#[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/// 清理阶段
49#[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/// 清理器配置
76#[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    // 可执行文件保留选项
85    /// 是否保留可执行文件
86    pub keep_executable: bool,
87    /// 可执行文件备份目录(如果为None,则在项目目录下创建executables文件夹)
88    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            // 可执行文件保留选项默认值
101            keep_executable: false,
102            executable_backup_dir: None,
103        }
104    }
105}
106
107/// 项目清理器
108pub struct ProjectCleaner {
109    config: CleanConfig,
110}
111
112impl ProjectCleaner {
113    /// 创建新的清理器
114    pub fn new(config: CleanConfig) -> Self {
115        Self { config }
116    }
117
118    /// 清理单个项目
119    pub fn clean_project(&self, project: &RustProject) -> Result<u64> {
120        self.clean_project_with_progress(project, |_| {})
121    }
122
123    /// 清理单个项目(带进度回调)
124    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    /// 批量清理项目
221    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    /// 串行清理项目
247    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    /// 并行清理项目(注意:这里简化实现,实际可能需要更复杂的并行控制)
261    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    /// 使用cargo clean清理
305    #[allow(dead_code)]
306    fn clean_with_cargo(&self, project: &RustProject) -> Result<u64> {
307        self.clean_with_cargo_progress(project, None, &|_| {})
308    }
309
310    /// 使用cargo clean清理(带进度回调)
311    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        // 最终进度更新
376        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    /// 直接删除target目录
394    #[allow(dead_code)]
395    fn clean_with_delete(&self, project: &RustProject) -> Result<u64> {
396        self.clean_with_delete_progress(project, None, &|_| {})
397    }
398
399    /// 直接删除target目录(带进度回调)
400    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        // 如果需要保留可执行文件,先备份
428        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            // Fallback to native deletion for better diagnostics and permission handling.
529            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    /// 备份可执行文件
561    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        // 确定备份目录
585        let backup_dir = self.get_backup_directory(project)?;
586        std::fs::create_dir_all(&backup_dir).context("创建备份目录失败")?;
587
588        // 备份每个可执行文件
589        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    /// 查找target目录中的可执行文件
938    fn find_executables(&self, target_path: &std::path::Path) -> Result<Vec<PathBuf>> {
939        let mut executables = Vec::new();
940
941        // 检查常见的可执行文件目录
942        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        // 也检查其他可能的目录(如交叉编译目标)
951        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                    // 检查是否是目标架构目录
962                    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    /// 扫描目录查找可执行文件
981    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    /// 判断文件是否为可执行文件
998    fn is_executable(&self, path: &std::path::Path) -> bool {
999        // 在Windows上检查.exe扩展名
1000        #[cfg(target_os = "windows")]
1001        {
1002            path.extension().is_some_and(|ext| ext == "exe")
1003        }
1004
1005        // 在Unix系统上检查可执行权限
1006        #[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    /// 获取备份目录
1019    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    /// 预览清理操作(dry run)
1034    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    /// 检查cargo命令是否可用
1043    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        // 在dry run模式下,应该返回原始大小
1104        assert_eq!(size_freed, project.target_size);
1105
1106        // target目录应该仍然存在
1107        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        // 应该释放了一些空间
1126        assert!(size_freed > 0);
1127
1128        // target目录应该被删除
1129        assert!(!project.target_path().exists());
1130
1131        Ok(())
1132    }
1133
1134    #[test]
1135    fn test_check_cargo_available() {
1136        // 这个测试可能在某些环境中失败,如果cargo不可用
1137        // 在实际项目中,可能需要mock这个功能
1138        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, // 使用dry run避免实际删除
1208            ..Default::default()
1209        };
1210
1211        let cleaner = ProjectCleaner::new(config);
1212
1213        // 简单测试进度回调不会导致panic
1214        let size_freed = cleaner.clean_project_with_progress(&project, |_progress| {
1215            // 进度回调被调用,但我们不在这里做任何可变操作
1216        })?;
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        // 测试初始状态
1228        assert_eq!(result.cleaned_projects, 0);
1229        assert_eq!(result.total_size_freed, 0);
1230        assert!(result.failed_projects.is_empty());
1231
1232        // 测试添加成功
1233        result.add_success(1024);
1234        assert_eq!(result.cleaned_projects, 1);
1235        assert_eq!(result.total_size_freed, 1024);
1236
1237        // 测试添加失败
1238        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        // 测试格式化大小
1243        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        // 清理不存在的项目应该返回0或错误
1263        if let Ok(size) = result {
1264            assert_eq!(size, 0);
1265        }
1266        // 错误也是可接受的
1267
1268        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        // 尝试将target目录设为只读(在某些系统上可能不起作用)
1277        let target_path = project.path.join("target");
1278        if target_path.exists() {
1279            // 在Windows上设置只读属性
1280            #[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            // 在Unix系统上设置只读权限
1288            #[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        // 清理只读目录可能失败,这是预期的
1300        // 如果成功了也没关系,失败是预期的
1301        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        // 使用非常短的超时时间
1312        let config = CleanConfig {
1313            strategy: CleanStrategy::CargoClean,
1314            timeout_seconds: 1, // 1秒超时
1315            ..Default::default()
1316        };
1317
1318        let cleaner = ProjectCleaner::new(config);
1319        let result = cleaner.clean_project(&project);
1320
1321        // 可能会因为超时而失败,也可能成功(如果操作很快)
1322        // 成功也是可能的,超时失败是预期的
1323        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        // 创建一个正常项目
1333        let good_project = create_test_project_with_target(temp_dir.path(), "good_project")?;
1334
1335        // 创建一个不存在target目录的项目
1336        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, // 没有target目录
1352            last_modified: std::time::SystemTime::now(),
1353            is_workspace: false,
1354            has_target: false, // 关键:没有target目录
1355        };
1356
1357        let projects = vec![good_project, bad_project];
1358        let cleaner = ProjectCleaner::default();
1359        let result = cleaner.clean_projects(&projects);
1360
1361        // 验证清理结果
1362        // 可能有一些项目清理成功,一些失败,这是正常的混合结果
1363        assert!(result.cleaned_projects + result.failed_projects.len() == 2);
1364
1365        // 验证至少处理了所有项目
1366        println!(
1367            "清理结果: 成功 {}, 失败 {}",
1368            result.cleaned_projects,
1369            result.failed_projects.len()
1370        );
1371
1372        Ok(())
1373    }
1374}