sasurahime 0.1.21

macOS developer cache cleaner — scan and wipe stale caches from 40+ tools
use crate::cleaner::{CleanResult, Cleaner, ScanResult, ScanStatus};
use crate::command::CommandRunner;
use crate::format::dir_size;
use crate::progress::ProgressReporter;
use anyhow::Result;
use std::path::{Path, PathBuf};

pub struct CargoCleaner {
    home: PathBuf,
    runner: Box<dyn CommandRunner>,
}

impl CargoCleaner {
    pub fn new(home: &Path, runner: Box<dyn CommandRunner>) -> Self {
        Self {
            home: home.to_path_buf(),
            runner,
        }
    }

    fn find_target_dirs(home: &Path) -> Vec<(PathBuf, u64)> {
        let mut targets = vec![];
        for entry in walkdir::WalkDir::new(home)
            .max_depth(5)
            .follow_links(false)
            .into_iter()
            .filter_map(|e| e.ok())
        {
            let fname = entry.file_name().to_string_lossy();
            if fname == "target" && entry.file_type().is_dir() {
                let path = entry.path();
                if path.components().any(|c| c.as_os_str() == ".cargo") {
                    continue;
                }
                let size = dir_size(path);
                targets.push((path.to_path_buf(), size));
            }
        }
        targets
    }
}

impl Cleaner for CargoCleaner {
    fn name(&self) -> &'static str {
        "cargo"
    }

    fn detect(&self) -> ScanResult {
        let reg = self.home.join(".cargo/registry/cache");
        let reg_size = if reg.exists() {
            let s = dir_size(&reg);
            println!("[cargo] registry cache: {}", crate::format::format_bytes(s));
            s
        } else {
            0
        };

        let targets = Self::find_target_dirs(&self.home);
        let target_size: u64 = targets.iter().map(|(_, s)| s).sum();
        if !targets.is_empty() {
            println!("[cargo] found {} target/ directory(ies)", targets.len());
        }

        let total = reg_size + target_size;
        let mut r = ScanResult::new(
            self.name(),
            if total > 0 {
                ScanStatus::Pruneable(total)
            } else {
                ScanStatus::Clean
            },
        );
        if crate::context::is_verbose() {
            // Report the Cargo registry cache as primary target.
            r = r.with_target(reg.to_string_lossy().to_string());
        }
        r
    }

    fn clean(&self, dry_run: bool, reporter: &dyn ProgressReporter) -> Result<CleanResult> {
        let mut freed: u64 = 0;

        let reg = self.home.join(".cargo/registry/cache");
        if reg.exists() {
            let size = dir_size(&reg);
            if dry_run {
                println!(
                    "[dry-run] [cargo] would remove registry cache: {} ({})",
                    reg.display(),
                    crate::format::format_bytes(size)
                );
            } else {
                self.runner
                    .run("chflags", &["-R", "nouchg", &reg.to_string_lossy()])
                    .ok();
                crate::trash::delete_path(&reg)?;
                freed += size;
                println!("[cargo] removed registry cache: {}", reg.display());
            }
        }

        let targets = Self::find_target_dirs(&self.home);
        if !dry_run && !targets.is_empty() {
            reporter.progress_init(self.name(), targets.len());
        }
        for (i, (path, size)) in targets.iter().enumerate() {
            if dry_run {
                println!(
                    "[dry-run] [cargo] would remove target dir: {} ({})",
                    path.display(),
                    crate::format::format_bytes(*size)
                );
            } else {
                reporter.progress_tick(path, i + 1, *size);
                self.runner
                    .run("chflags", &["-R", "nouchg", &path.to_string_lossy()])
                    .ok();
                crate::trash::delete_path(path)?;
                freed += size;
                println!("[cargo] removed target dir: {}", path.display());
            }
        }
        if !dry_run && !targets.is_empty() {
            reporter.progress_finish();
        }

        Ok(CleanResult {
            name: self.name(),
            bytes_freed: freed,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::command::SystemCommandRunner;
    use std::fs;
    use tempfile::TempDir;

    #[test]
    fn detect_includes_primary_target_when_verbose() {
        let _guard = crate::context::TEST_LOCK.lock().unwrap();
        crate::context::set_verbose(true);
        let tmp = TempDir::new().unwrap();
        // Create registry cache so the cleaner reports Pruneable
        let reg = tmp.path().join(".cargo/registry/cache/pkg");
        fs::create_dir_all(&reg).unwrap();
        fs::write(reg.join("dummy.crate"), b"x").unwrap();

        let cleaner = CargoCleaner::new(tmp.path(), Box::new(SystemCommandRunner));
        let result = cleaner.detect();
        assert!(
            result.primary_target.is_some(),
            "primary_target should be set when verbose"
        );
        assert!(
            result
                .primary_target
                .as_deref()
                .unwrap()
                .contains(".cargo/registry/cache"),
            "target should point to registry cache"
        );
        crate::context::set_verbose(false);
    }

    #[test]
    fn detect_omits_primary_target_when_not_verbose() {
        let _guard = crate::context::TEST_LOCK.lock().unwrap();
        crate::context::set_verbose(false);
        let tmp = TempDir::new().unwrap();
        let reg = tmp.path().join(".cargo/registry/cache/pkg");
        fs::create_dir_all(&reg).unwrap();
        fs::write(reg.join("dummy.crate"), b"x").unwrap();

        let cleaner = CargoCleaner::new(tmp.path(), Box::new(SystemCommandRunner));
        let result = cleaner.detect();
        assert!(result.primary_target.is_none());
    }
}