Skip to main content

null_e/cleaners/
homebrew.rs

1//! Homebrew cleanup module
2//!
3//! Handles cleanup of Homebrew caches:
4//! - Downloaded formula archives
5//! - Old package versions
6//! - Outdated casks
7
8use super::{calculate_dir_size, get_mtime, CleanableItem, SafetyLevel};
9use crate::error::Result;
10use std::path::PathBuf;
11use std::process::Command;
12
13/// Homebrew cleaner
14pub struct HomebrewCleaner {
15    #[allow(dead_code)]
16    home: PathBuf,
17    cache_path: PathBuf,
18}
19
20impl HomebrewCleaner {
21    /// Create a new Homebrew cleaner
22    pub fn new() -> Option<Self> {
23        let home = dirs::home_dir()?;
24
25        // Homebrew cache location
26        let cache_path = home.join("Library/Caches/Homebrew");
27
28        Some(Self { home, cache_path })
29    }
30
31    /// Check if Homebrew is installed
32    pub fn is_available(&self) -> bool {
33        Command::new("brew")
34            .arg("--version")
35            .output()
36            .map(|o| o.status.success())
37            .unwrap_or(false)
38    }
39
40    /// Detect all Homebrew cleanable items
41    pub fn detect(&self) -> Result<Vec<CleanableItem>> {
42        let mut items = Vec::new();
43
44        // Main cache directory
45        if self.cache_path.exists() {
46            items.extend(self.detect_cache()?);
47        }
48
49        // Downloads subdirectory
50        let downloads_path = self.cache_path.join("downloads");
51        if downloads_path.exists() {
52            let (size, file_count) = calculate_dir_size(&downloads_path)?;
53            if size > 50_000_000 {
54                items.push(CleanableItem {
55                    name: "Homebrew Downloads".to_string(),
56                    category: "Package Manager".to_string(),
57                    subcategory: "Homebrew".to_string(),
58                    icon: "🍺",
59                    path: downloads_path,
60                    size,
61                    file_count: Some(file_count),
62                    last_modified: None,
63                    description: "Downloaded formula archives. Safe to delete.",
64                    safe_to_delete: SafetyLevel::Safe,
65                    clean_command: Some("brew cleanup".to_string()),
66                });
67            }
68        }
69
70        // Cask downloads
71        let cask_path = self.cache_path.join("Cask");
72        if cask_path.exists() {
73            let (size, file_count) = calculate_dir_size(&cask_path)?;
74            if size > 50_000_000 {
75                items.push(CleanableItem {
76                    name: "Homebrew Cask Downloads".to_string(),
77                    category: "Package Manager".to_string(),
78                    subcategory: "Homebrew".to_string(),
79                    icon: "🍺",
80                    path: cask_path,
81                    size,
82                    file_count: Some(file_count),
83                    last_modified: None,
84                    description: "Downloaded cask application archives. Safe to delete.",
85                    safe_to_delete: SafetyLevel::Safe,
86                    clean_command: Some("brew cleanup --cask".to_string()),
87                });
88            }
89        }
90
91        // Old formula versions in Cellar
92        items.extend(self.detect_old_versions()?);
93
94        Ok(items)
95    }
96
97    /// Detect cache files
98    fn detect_cache(&self) -> Result<Vec<CleanableItem>> {
99        let mut items = Vec::new();
100
101        if let Ok(entries) = std::fs::read_dir(&self.cache_path) {
102            for entry in entries.filter_map(|e| e.ok()) {
103                let path = entry.path();
104
105                // Skip subdirectories we handle separately
106                let name = path.file_name()
107                    .map(|n| n.to_string_lossy().to_string())
108                    .unwrap_or_default();
109
110                if name == "downloads" || name == "Cask" || name == "api" {
111                    continue;
112                }
113
114                // Only look at files/directories that are cached packages
115                if path.is_file() && (name.ends_with(".tar.gz") || name.ends_with(".bottle.tar.gz")) {
116                    let size = std::fs::metadata(&path)?.len();
117                    if size > 10_000_000 {
118                        items.push(CleanableItem {
119                            name: format!("Cached: {}", name),
120                            category: "Package Manager".to_string(),
121                            subcategory: "Homebrew".to_string(),
122                            icon: "🍺",
123                            path,
124                            size,
125                            file_count: Some(1),
126                            last_modified: get_mtime(&entry.path()),
127                            description: "Cached package archive. Safe to delete.",
128                            safe_to_delete: SafetyLevel::Safe,
129                            clean_command: Some("brew cleanup".to_string()),
130                        });
131                    }
132                }
133            }
134        }
135
136        Ok(items)
137    }
138
139    /// Detect old formula versions
140    fn detect_old_versions(&self) -> Result<Vec<CleanableItem>> {
141        let mut items = Vec::new();
142
143        // Check Cellar for old versions
144        let cellar_paths = [
145            PathBuf::from("/usr/local/Cellar"),
146            PathBuf::from("/opt/homebrew/Cellar"),
147        ];
148
149        for cellar in cellar_paths {
150            if !cellar.exists() {
151                continue;
152            }
153
154            if let Ok(formulas) = std::fs::read_dir(&cellar) {
155                for formula in formulas.filter_map(|e| e.ok()) {
156                    let formula_path = formula.path();
157                    if !formula_path.is_dir() {
158                        continue;
159                    }
160
161                    // Count versions
162                    let versions: Vec<_> = std::fs::read_dir(&formula_path)
163                        .ok()
164                        .map(|entries| entries.filter_map(|e| e.ok()).collect())
165                        .unwrap_or_default();
166
167                    if versions.len() <= 1 {
168                        continue;
169                    }
170
171                    // Calculate size of old versions (all but the latest)
172                    let mut old_versions: Vec<_> = versions;
173                    old_versions.sort_by(|a, b| {
174                        let a_time = a.metadata().and_then(|m| m.modified()).ok();
175                        let b_time = b.metadata().and_then(|m| m.modified()).ok();
176                        b_time.cmp(&a_time)
177                    });
178
179                    // Skip the newest, sum the rest
180                    let mut total_size = 0u64;
181                    let mut total_files = 0u64;
182                    for old_version in old_versions.iter().skip(1) {
183                        if let Ok((size, count)) = calculate_dir_size(&old_version.path()) {
184                            total_size += size;
185                            total_files += count;
186                        }
187                    }
188
189                    if total_size > 50_000_000 {
190                        let formula_name = formula_path.file_name()
191                            .map(|n| n.to_string_lossy().to_string())
192                            .unwrap_or_default();
193
194                        items.push(CleanableItem {
195                            name: format!("Old versions: {}", formula_name),
196                            category: "Package Manager".to_string(),
197                            subcategory: "Homebrew".to_string(),
198                            icon: "🍺",
199                            path: formula_path,
200                            size: total_size,
201                            file_count: Some(total_files),
202                            last_modified: None,
203                            description: "Old formula versions. Use 'brew cleanup' to remove.",
204                            safe_to_delete: SafetyLevel::Safe,
205                            clean_command: Some(format!("brew cleanup {}", formula_name)),
206                        });
207                    }
208                }
209            }
210        }
211
212        Ok(items)
213    }
214
215    /// Run brew cleanup command
216    pub fn clean_all(&self, scrub: bool) -> Result<u64> {
217        let mut cmd = Command::new("brew");
218        cmd.arg("cleanup");
219
220        if scrub {
221            cmd.arg("-s"); // Scrub cache, even latest versions
222        }
223
224        cmd.arg("--prune=all");
225
226        let output = cmd.output()?;
227
228        if !output.status.success() {
229            let stderr = String::from_utf8_lossy(&output.stderr);
230            return Err(crate::error::DevSweepError::Other(
231                format!("brew cleanup failed: {}", stderr)
232            ));
233        }
234
235        // Estimate freed space (brew doesn't report exact bytes)
236        Ok(0)
237    }
238}
239
240impl Default for HomebrewCleaner {
241    fn default() -> Self {
242        Self::new().expect("HomebrewCleaner requires home directory")
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    #[test]
251    fn test_homebrew_cleaner_creation() {
252        let cleaner = HomebrewCleaner::new();
253        assert!(cleaner.is_some());
254    }
255
256    #[test]
257    fn test_homebrew_detection() {
258        if let Some(cleaner) = HomebrewCleaner::new() {
259            if cleaner.is_available() {
260                let items = cleaner.detect().unwrap();
261                println!("Found {} Homebrew items", items.len());
262                for item in &items {
263                    println!("  {} {} ({} bytes)", item.icon, item.name, item.size);
264                }
265            } else {
266                println!("Homebrew not installed");
267            }
268        }
269    }
270}