null_e/cleaners/
homebrew.rs1use super::{calculate_dir_size, get_mtime, CleanableItem, SafetyLevel};
9use crate::error::Result;
10use std::path::PathBuf;
11use std::process::Command;
12
13pub struct HomebrewCleaner {
15 #[allow(dead_code)]
16 home: PathBuf,
17 cache_path: PathBuf,
18}
19
20impl HomebrewCleaner {
21 pub fn new() -> Option<Self> {
23 let home = dirs::home_dir()?;
24
25 let cache_path = home.join("Library/Caches/Homebrew");
27
28 Some(Self { home, cache_path })
29 }
30
31 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 pub fn detect(&self) -> Result<Vec<CleanableItem>> {
42 let mut items = Vec::new();
43
44 if self.cache_path.exists() {
46 items.extend(self.detect_cache()?);
47 }
48
49 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 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 items.extend(self.detect_old_versions()?);
93
94 Ok(items)
95 }
96
97 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 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 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 fn detect_old_versions(&self) -> Result<Vec<CleanableItem>> {
141 let mut items = Vec::new();
142
143 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 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 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 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 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"); }
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 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}