1use super::{calculate_dir_size, get_mtime, CleanableItem, SafetyLevel};
10use crate::error::Result;
11use std::collections::HashSet;
12use std::path::PathBuf;
13
14#[cfg(target_os = "macos")]
16pub struct MacOsCleaner {
17 home: PathBuf,
18}
19
20#[cfg(target_os = "macos")]
21impl MacOsCleaner {
22 pub fn new() -> Option<Self> {
24 let home = dirs::home_dir()?;
25 Some(Self { home })
26 }
27
28 pub fn detect(&self) -> Result<Vec<CleanableItem>> {
30 let mut items = Vec::new();
31
32 items.extend(self.detect_orphaned_containers()?);
34
35 items.extend(self.detect_library_caches()?);
37
38 items.extend(self.detect_app_support_remnants()?);
40
41 items.extend(self.detect_font_caches()?);
43
44 Ok(items)
45 }
46
47 fn get_installed_apps(&self) -> HashSet<String> {
49 let mut apps = HashSet::new();
50
51 let app_dirs = [
52 PathBuf::from("/Applications"),
53 self.home.join("Applications"),
54 ];
55
56 for app_dir in app_dirs {
57 if let Ok(entries) = std::fs::read_dir(&app_dir) {
58 for entry in entries.filter_map(|e| e.ok()) {
59 let name = entry.file_name().to_string_lossy().to_string();
60 if name.ends_with(".app") {
61 let app_name = name.trim_end_matches(".app").to_lowercase();
62 apps.insert(app_name);
63
64 let plist_path = entry.path().join("Contents/Info.plist");
66 if let Ok(content) = std::fs::read_to_string(&plist_path) {
67 if let Some(start) = content.find("<key>CFBundleIdentifier</key>") {
69 if let Some(value_start) = content[start..].find("<string>") {
70 let rest = &content[start + value_start + 8..];
71 if let Some(value_end) = rest.find("</string>") {
72 let bundle_id = rest[..value_end].to_lowercase();
73 apps.insert(bundle_id);
74 }
75 }
76 }
77 }
78 }
79 }
80 }
81 }
82
83 apps
84 }
85
86 fn detect_orphaned_containers(&self) -> Result<Vec<CleanableItem>> {
88 let mut items = Vec::new();
89
90 let containers_path = self.home.join("Library/Containers");
91 if !containers_path.exists() {
92 return Ok(items);
93 }
94
95 let installed_apps = self.get_installed_apps();
96
97 if let Ok(entries) = std::fs::read_dir(&containers_path) {
98 for entry in entries.filter_map(|e| e.ok()) {
99 let path = entry.path();
100 if !path.is_dir() {
101 continue;
102 }
103
104 let container_name = path.file_name()
105 .map(|n| n.to_string_lossy().to_string())
106 .unwrap_or_default();
107
108 if container_name.starts_with("com.apple.") {
110 continue;
111 }
112
113 let is_orphaned = !installed_apps.iter().any(|app| {
115 container_name.to_lowercase().contains(app)
116 });
117
118 if !is_orphaned {
119 continue;
120 }
121
122 let (size, file_count) = calculate_dir_size(&path)?;
123 if size < 50_000_000 {
124 continue;
125 }
126
127 items.push(CleanableItem {
128 name: format!("Orphaned: {}", container_name),
129 category: "macOS System".to_string(),
130 subcategory: "Containers".to_string(),
131 icon: "📦",
132 path,
133 size,
134 file_count: Some(file_count),
135 last_modified: get_mtime(&entry.path()),
136 description: "Container data for possibly uninstalled app. Verify before deleting.",
137 safe_to_delete: SafetyLevel::Caution,
138 clean_command: None,
139 });
140 }
141 }
142
143 Ok(items)
144 }
145
146 fn detect_library_caches(&self) -> Result<Vec<CleanableItem>> {
148 let mut items = Vec::new();
149
150 let caches_path = self.home.join("Library/Caches");
151 if !caches_path.exists() {
152 return Ok(items);
153 }
154
155 let skip_patterns = [
157 "com.apple.",
158 "homebrew",
159 "cocoapods",
160 "carthage",
161 "JetBrains",
162 "com.microsoft.VSCode",
163 "Google",
164 "Firefox",
165 "Spotify",
166 "Slack",
167 "Discord",
168 ];
169
170 if let Ok(entries) = std::fs::read_dir(&caches_path) {
171 for entry in entries.filter_map(|e| e.ok()) {
172 let path = entry.path();
173 let name = path.file_name()
174 .map(|n| n.to_string_lossy().to_string())
175 .unwrap_or_default();
176
177 let should_skip = skip_patterns.iter().any(|p| {
179 name.to_lowercase().contains(&p.to_lowercase())
180 });
181
182 if should_skip {
183 continue;
184 }
185
186 let (size, file_count) = if path.is_dir() {
187 calculate_dir_size(&path)?
188 } else {
189 (std::fs::metadata(&path)?.len(), 1)
190 };
191
192 if size < 100_000_000 {
193 continue;
194 }
195
196 items.push(CleanableItem {
197 name: format!("Cache: {}", name),
198 category: "macOS System".to_string(),
199 subcategory: "Caches".to_string(),
200 icon: "🗄️",
201 path,
202 size,
203 file_count: Some(file_count),
204 last_modified: get_mtime(&entry.path()),
205 description: "Application cache. Usually safe to delete.",
206 safe_to_delete: SafetyLevel::SafeWithCost,
207 clean_command: None,
208 });
209 }
210 }
211
212 Ok(items)
213 }
214
215 fn detect_app_support_remnants(&self) -> Result<Vec<CleanableItem>> {
217 let mut items = Vec::new();
218
219 let app_support = self.home.join("Library/Application Support");
220 if !app_support.exists() {
221 return Ok(items);
222 }
223
224 let installed_apps = self.get_installed_apps();
225
226 let skip_patterns = [
228 "com.apple.",
229 "Apple",
230 "Code",
231 "JetBrains",
232 "Google",
233 "Microsoft",
234 "Slack",
235 "Discord",
236 "Spotify",
237 "AddressBook",
238 "Dock",
239 "iCloud",
240 ];
241
242 if let Ok(entries) = std::fs::read_dir(&app_support) {
243 for entry in entries.filter_map(|e| e.ok()) {
244 let path = entry.path();
245 if !path.is_dir() {
246 continue;
247 }
248
249 let name = path.file_name()
250 .map(|n| n.to_string_lossy().to_string())
251 .unwrap_or_default();
252
253 let should_skip = skip_patterns.iter().any(|p| {
255 name.to_lowercase().contains(&p.to_lowercase())
256 });
257
258 if should_skip {
259 continue;
260 }
261
262 let is_orphaned = !installed_apps.iter().any(|app| {
264 name.to_lowercase().contains(app) || app.contains(&name.to_lowercase())
265 });
266
267 if !is_orphaned {
268 continue;
269 }
270
271 let (size, file_count) = calculate_dir_size(&path)?;
272 if size < 100_000_000 {
273 continue;
274 }
275
276 items.push(CleanableItem {
277 name: format!("App Support: {}", name),
278 category: "macOS System".to_string(),
279 subcategory: "Application Support".to_string(),
280 icon: "📁",
281 path,
282 size,
283 file_count: Some(file_count),
284 last_modified: get_mtime(&entry.path()),
285 description: "Application data for possibly uninstalled app.",
286 safe_to_delete: SafetyLevel::Caution,
287 clean_command: None,
288 });
289 }
290 }
291
292 Ok(items)
293 }
294
295 fn detect_font_caches(&self) -> Result<Vec<CleanableItem>> {
297 let mut items = Vec::new();
298
299 let font_cache_paths = [
300 "Library/Caches/com.apple.FontRegistry",
301 "Library/Caches/FontExplorer X",
302 ];
303
304 for rel_path in font_cache_paths {
305 let path = self.home.join(rel_path);
306 if !path.exists() {
307 continue;
308 }
309
310 let (size, file_count) = calculate_dir_size(&path)?;
311 if size < 50_000_000 {
312 continue;
313 }
314
315 items.push(CleanableItem {
316 name: "Font Cache".to_string(),
317 category: "macOS System".to_string(),
318 subcategory: "Fonts".to_string(),
319 icon: "🔤",
320 path,
321 size,
322 file_count: Some(file_count),
323 last_modified: None,
324 description: "Font rendering cache. Will be rebuilt.",
325 safe_to_delete: SafetyLevel::Safe,
326 clean_command: Some("sudo atsutil databases -remove".to_string()),
327 });
328 }
329
330 Ok(items)
331 }
332}
333
334#[cfg(target_os = "macos")]
335impl Default for MacOsCleaner {
336 fn default() -> Self {
337 Self::new().expect("MacOsCleaner requires home directory")
338 }
339}
340
341#[cfg(not(target_os = "macos"))]
343pub struct MacOsCleaner;
344
345#[cfg(not(target_os = "macos"))]
346impl MacOsCleaner {
347 pub fn new() -> Option<Self> {
348 Some(Self)
349 }
350
351 pub fn detect(&self) -> Result<Vec<CleanableItem>> {
352 Ok(vec![])
353 }
354}
355
356#[cfg(not(target_os = "macos"))]
357impl Default for MacOsCleaner {
358 fn default() -> Self {
359 Self
360 }
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366
367 #[test]
368 fn test_macos_cleaner_creation() {
369 let cleaner = MacOsCleaner::new();
370 assert!(cleaner.is_some());
371 }
372
373 #[test]
374 #[cfg(target_os = "macos")]
375 fn test_macos_detection() {
376 if let Some(cleaner) = MacOsCleaner::new() {
377 let items = cleaner.detect().unwrap();
378 println!("Found {} macOS items", items.len());
379 for item in &items {
380 println!(" {} {} ({} bytes)", item.icon, item.name, item.size);
381 }
382 }
383 }
384}