Skip to main content

null_e/cleaners/
macos.rs

1//! macOS-specific cleanup module
2//!
3//! Handles cleanup of macOS-specific files:
4//! - Orphaned app containers (~/Library/Containers)
5//! - Group containers
6//! - Application support remnants
7//! - System caches
8
9use super::{calculate_dir_size, get_mtime, CleanableItem, SafetyLevel};
10use crate::error::Result;
11use std::collections::HashSet;
12use std::path::PathBuf;
13
14/// macOS system cleaner
15#[cfg(target_os = "macos")]
16pub struct MacOsCleaner {
17    home: PathBuf,
18}
19
20#[cfg(target_os = "macos")]
21impl MacOsCleaner {
22    /// Create a new macOS cleaner
23    pub fn new() -> Option<Self> {
24        let home = dirs::home_dir()?;
25        Some(Self { home })
26    }
27
28    /// Detect all macOS cleanable items
29    pub fn detect(&self) -> Result<Vec<CleanableItem>> {
30        let mut items = Vec::new();
31
32        // Orphaned containers
33        items.extend(self.detect_orphaned_containers()?);
34
35        // Large Library caches
36        items.extend(self.detect_library_caches()?);
37
38        // Application support remnants
39        items.extend(self.detect_app_support_remnants()?);
40
41        // Font caches
42        items.extend(self.detect_font_caches()?);
43
44        Ok(items)
45    }
46
47    /// Get list of installed applications
48    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                        // Also extract bundle identifier if possible
65                        let plist_path = entry.path().join("Contents/Info.plist");
66                        if let Ok(content) = std::fs::read_to_string(&plist_path) {
67                            // Simple extraction of bundle identifier
68                            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    /// Detect orphaned containers (sandboxed app data for uninstalled apps)
87    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                // Skip system containers
109                if container_name.starts_with("com.apple.") {
110                    continue;
111                }
112
113                // Check if this container belongs to an installed app
114                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    /// Detect large Library caches
147    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        // Skip patterns (we handle these in other cleaners)
156        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                // Skip known patterns
178                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    /// Detect Application Support remnants
216    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        // Skip patterns (system and known apps)
227        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                // Skip known patterns
254                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                // Check if app is installed
263                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    /// Detect font caches
296    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// Stub for non-macOS platforms
342#[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}