sasurahime 0.1.10

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::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};

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

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

    fn find_old_caches(caches_dir: &Path) -> Vec<PathBuf> {
        let entries = match fs::read_dir(caches_dir) {
            Ok(e) => e,
            Err(_) => return vec![],
        };

        let mut versions: Vec<(Vec<u32>, PathBuf)> = entries
            .filter_map(|e| e.ok())
            .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
            .filter_map(|e| {
                let name = e.file_name().to_string_lossy().to_string();
                let key: Vec<u32> = name
                    .split(|c: char| !c.is_ascii_digit())
                    .filter_map(|s| s.parse().ok())
                    .collect();
                if key.is_empty() {
                    return None;
                }
                Some((key, e.path()))
            })
            .collect();

        if versions.len() <= 1 {
            return vec![];
        }

        let max_key = versions.iter().map(|(k, _)| k.clone()).max().unwrap();
        versions.retain(|(k, _)| *k != max_key);
        versions.into_iter().map(|(_, p)| p).collect()
    }
}

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

    fn detect(&self) -> ScanResult {
        let caches = self.home.join(".gradle/caches");
        if !caches.exists() {
            return ScanResult {
                name: self.name(),
                status: ScanStatus::NotFound,
            };
        }
        let old = Self::find_old_caches(&caches);
        let bytes: u64 = old.iter().map(|p| dir_size(p)).sum();
        ScanResult {
            name: self.name(),
            status: if bytes > 0 {
                ScanStatus::Pruneable(bytes)
            } else {
                ScanStatus::Clean
            },
        }
    }

    fn clean(&self, dry_run: bool, _reporter: &dyn ProgressReporter) -> Result<CleanResult> {
        let caches = self.home.join(".gradle/caches");
        if !caches.exists() {
            return Ok(CleanResult {
                name: self.name(),
                bytes_freed: 0,
            });
        }
        let old = Self::find_old_caches(&caches);
        let mut freed: u64 = 0;
        for path in &old {
            let size = dir_size(path);
            if dry_run {
                println!(
                    "[dry-run] [gradle] would remove: {} ({})",
                    path.display(),
                    crate::format::format_bytes(size)
                );
            } else {
                self.runner
                    .run("chflags", &["-R", "nouchg", &path.to_string_lossy()])
                    .ok();
                fs::remove_dir_all(path)?;
                freed += size;
                println!("[gradle] removed: {}", path.display());
            }
        }
        Ok(CleanResult {
            name: self.name(),
            bytes_freed: freed,
        })
    }
}

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

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

    fn find_old_caches(jetbrains_dir: &Path) -> Vec<PathBuf> {
        let entries = match fs::read_dir(jetbrains_dir) {
            Ok(e) => e,
            Err(_) => return vec![],
        };

        let mut by_ide: HashMap<String, Vec<(Vec<u32>, PathBuf)>> = HashMap::new();

        for entry in entries.filter_map(|e| e.ok()) {
            if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
                continue;
            }
            let name = entry.file_name().to_string_lossy().to_string();
            let ide_name: String = name
                .chars()
                .take_while(|c| c.is_ascii_alphabetic())
                .collect();
            if ide_name.is_empty() {
                continue;
            }
            let key: Vec<u32> = name[ide_name.len()..]
                .split(|c: char| !c.is_ascii_digit())
                .filter_map(|s| s.parse().ok())
                .collect();
            if key.is_empty() {
                continue;
            }
            by_ide
                .entry(ide_name)
                .or_default()
                .push((key, entry.path()));
        }

        let mut old = vec![];
        for versions in by_ide.values() {
            if versions.len() <= 1 {
                continue;
            }
            let max_key = versions.iter().map(|(k, _)| k.clone()).max().unwrap();
            for (k, p) in versions {
                if *k != max_key {
                    old.push(p.clone());
                }
            }
        }
        old
    }
}

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

    fn detect(&self) -> ScanResult {
        let dir = self.home.join("Library/Caches/JetBrains");
        if !dir.exists() {
            return ScanResult {
                name: self.name(),
                status: ScanStatus::NotFound,
            };
        }
        let old = Self::find_old_caches(&dir);
        let bytes: u64 = old.iter().map(|p| dir_size(p)).sum();
        ScanResult {
            name: self.name(),
            status: if bytes > 0 {
                ScanStatus::Pruneable(bytes)
            } else {
                ScanStatus::Clean
            },
        }
    }

    fn clean(&self, dry_run: bool, _reporter: &dyn ProgressReporter) -> Result<CleanResult> {
        let dir = self.home.join("Library/Caches/JetBrains");
        if !dir.exists() {
            return Ok(CleanResult {
                name: self.name(),
                bytes_freed: 0,
            });
        }
        let old = Self::find_old_caches(&dir);
        let mut freed: u64 = 0;
        for path in &old {
            let size = dir_size(path);
            if dry_run {
                println!(
                    "[dry-run] [jetbrains] would remove: {} ({})",
                    path.display(),
                    crate::format::format_bytes(size)
                );
            } else {
                self.runner
                    .run("chflags", &["-R", "nouchg", &path.to_string_lossy()])
                    .ok();
                fs::remove_dir_all(path)?;
                freed += size;
                println!("[jetbrains] removed: {}", path.display());
            }
        }
        Ok(CleanResult {
            name: self.name(),
            bytes_freed: freed,
        })
    }
}