null_e/cleaners/
xcode.rs

1//! Xcode cleanup module
2//!
3//! Handles cleanup of Xcode-related files:
4//! - DerivedData (build artifacts)
5//! - Archives (old app builds)
6//! - iOS DeviceSupport (debug symbols)
7//! - Simulators (iOS/watchOS/tvOS)
8//! - Xcode caches
9
10use super::{calculate_dir_size, get_mtime, CleanableItem, SafetyLevel};
11use crate::error::Result;
12use std::path::PathBuf;
13
14/// Xcode cleaner
15pub struct XcodeCleaner {
16    home: PathBuf,
17}
18
19impl XcodeCleaner {
20    /// Create a new Xcode cleaner
21    pub fn new() -> Option<Self> {
22        let home = dirs::home_dir()?;
23
24        // Only available on macOS
25        #[cfg(not(target_os = "macos"))]
26        return None;
27
28        #[cfg(target_os = "macos")]
29        Some(Self { home })
30    }
31
32    /// Detect all Xcode cleanable items
33    pub fn detect(&self) -> Result<Vec<CleanableItem>> {
34        let mut items = Vec::new();
35
36        // DerivedData
37        items.extend(self.detect_derived_data()?);
38
39        // Archives
40        items.extend(self.detect_archives()?);
41
42        // iOS DeviceSupport
43        items.extend(self.detect_device_support()?);
44
45        // Simulators
46        items.extend(self.detect_simulators()?);
47
48        // Xcode Caches
49        items.extend(self.detect_caches()?);
50
51        // Documentation cache
52        items.extend(self.detect_documentation()?);
53
54        Ok(items)
55    }
56
57    /// Detect DerivedData folders
58    fn detect_derived_data(&self) -> Result<Vec<CleanableItem>> {
59        let derived_data = self.home
60            .join("Library/Developer/Xcode/DerivedData");
61
62        if !derived_data.exists() {
63            return Ok(vec![]);
64        }
65
66        let mut items = Vec::new();
67
68        // Each subdirectory is a project's derived data
69        if let Ok(entries) = std::fs::read_dir(&derived_data) {
70            for entry in entries.filter_map(|e| e.ok()) {
71                let path = entry.path();
72                if path.is_dir() {
73                    let name = path.file_name()
74                        .map(|n| n.to_string_lossy().to_string())
75                        .unwrap_or_else(|| "Unknown".to_string());
76
77                    // Skip the ModuleCache as it's shared
78                    if name == "ModuleCache" || name == "ModuleCache.noindex" {
79                        continue;
80                    }
81
82                    let (size, file_count) = calculate_dir_size(&path)?;
83                    if size == 0 {
84                        continue;
85                    }
86
87                    items.push(CleanableItem {
88                        name: format!("DerivedData: {}", name.split('-').next().unwrap_or(&name)),
89                        category: "Xcode".to_string(),
90                        subcategory: "DerivedData".to_string(),
91                        icon: "🔨",
92                        path,
93                        size,
94                        file_count: Some(file_count),
95                        last_modified: get_mtime(&entry.path()),
96                        description: "Build artifacts, indexes, logs. Safe to delete.",
97                        safe_to_delete: SafetyLevel::Safe,
98                        clean_command: None,
99                    });
100                }
101            }
102        }
103
104        Ok(items)
105    }
106
107    /// Detect Archives
108    fn detect_archives(&self) -> Result<Vec<CleanableItem>> {
109        let archives = self.home
110            .join("Library/Developer/Xcode/Archives");
111
112        if !archives.exists() {
113            return Ok(vec![]);
114        }
115
116        let mut items = Vec::new();
117
118        // Archives are organized by date
119        if let Ok(date_dirs) = std::fs::read_dir(&archives) {
120            for date_dir in date_dirs.filter_map(|e| e.ok()) {
121                let date_path = date_dir.path();
122                if !date_path.is_dir() {
123                    continue;
124                }
125
126                if let Ok(archive_files) = std::fs::read_dir(&date_path) {
127                    for archive in archive_files.filter_map(|e| e.ok()) {
128                        let path = archive.path();
129                        if path.extension().map(|e| e == "xcarchive").unwrap_or(false) {
130                            let name = path.file_stem()
131                                .map(|n| n.to_string_lossy().to_string())
132                                .unwrap_or_else(|| "Unknown".to_string());
133
134                            let (size, file_count) = calculate_dir_size(&path)?;
135
136                            items.push(CleanableItem {
137                                name: format!("Archive: {}", name),
138                                category: "Xcode".to_string(),
139                                subcategory: "Archives".to_string(),
140                                icon: "📦",
141                                path,
142                                size,
143                                file_count: Some(file_count),
144                                last_modified: get_mtime(&archive.path()),
145                                description: "App archive with dSYM. Keep if you need crash logs.",
146                                safe_to_delete: SafetyLevel::Caution,
147                                clean_command: None,
148                            });
149                        }
150                    }
151                }
152            }
153        }
154
155        Ok(items)
156    }
157
158    /// Detect iOS DeviceSupport
159    fn detect_device_support(&self) -> Result<Vec<CleanableItem>> {
160        let device_support_paths = [
161            "Library/Developer/Xcode/iOS DeviceSupport",
162            "Library/Developer/Xcode/watchOS DeviceSupport",
163            "Library/Developer/Xcode/tvOS DeviceSupport",
164        ];
165
166        let mut items = Vec::new();
167
168        for support_path in device_support_paths {
169            let path = self.home.join(support_path);
170            if !path.exists() {
171                continue;
172            }
173
174            let platform = support_path.split('/').last().unwrap_or("Device");
175
176            if let Ok(entries) = std::fs::read_dir(&path) {
177                for entry in entries.filter_map(|e| e.ok()) {
178                    let entry_path = entry.path();
179                    if !entry_path.is_dir() {
180                        continue;
181                    }
182
183                    let version = entry_path.file_name()
184                        .map(|n| n.to_string_lossy().to_string())
185                        .unwrap_or_else(|| "Unknown".to_string());
186
187                    let (size, file_count) = calculate_dir_size(&entry_path)?;
188                    if size == 0 {
189                        continue;
190                    }
191
192                    items.push(CleanableItem {
193                        name: format!("{}: {}", platform.replace(" DeviceSupport", ""), version),
194                        category: "Xcode".to_string(),
195                        subcategory: "DeviceSupport".to_string(),
196                        icon: "📱",
197                        path: entry_path,
198                        size,
199                        file_count: Some(file_count),
200                        last_modified: get_mtime(&entry.path()),
201                        description: "Debug symbols for iOS version. Safe to delete old versions.",
202                        safe_to_delete: SafetyLevel::SafeWithCost,
203                        clean_command: None,
204                    });
205                }
206            }
207        }
208
209        Ok(items)
210    }
211
212    /// Detect Simulators
213    fn detect_simulators(&self) -> Result<Vec<CleanableItem>> {
214        let simulators = self.home
215            .join("Library/Developer/CoreSimulator/Devices");
216
217        if !simulators.exists() {
218            return Ok(vec![]);
219        }
220
221        let mut items = Vec::new();
222
223        // Each UUID directory is a simulator
224        if let Ok(entries) = std::fs::read_dir(&simulators) {
225            for entry in entries.filter_map(|e| e.ok()) {
226                let path = entry.path();
227                if !path.is_dir() {
228                    continue;
229                }
230
231                // Read device.plist to get simulator info
232                let plist_path = path.join("device.plist");
233                let name = if plist_path.exists() {
234                    // Try to parse plist for name
235                    std::fs::read_to_string(&plist_path)
236                        .ok()
237                        .and_then(|content| {
238                            // Simple regex-like extraction for name
239                            content.find("<key>name</key>")
240                                .and_then(|idx| {
241                                    let after = &content[idx..];
242                                    after.find("<string>")
243                                        .and_then(|start| {
244                                            let s = &after[start + 8..];
245                                            s.find("</string>").map(|end| s[..end].to_string())
246                                        })
247                                })
248                        })
249                        .unwrap_or_else(|| "Unknown Simulator".to_string())
250                } else {
251                    "Unknown Simulator".to_string()
252                };
253
254                let (size, file_count) = calculate_dir_size(&path)?;
255                if size < 1_000_000 {
256                    // Skip tiny simulators (likely just metadata)
257                    continue;
258                }
259
260                items.push(CleanableItem {
261                    name: format!("Simulator: {}", name),
262                    category: "Xcode".to_string(),
263                    subcategory: "Simulators".to_string(),
264                    icon: "📲",
265                    path,
266                    size,
267                    file_count: Some(file_count),
268                    last_modified: get_mtime(&entry.path()),
269                    description: "iOS Simulator with apps and data.",
270                    safe_to_delete: SafetyLevel::SafeWithCost,
271                    clean_command: Some("xcrun simctl delete".to_string()),
272                });
273            }
274        }
275
276        Ok(items)
277    }
278
279    /// Detect Xcode caches
280    fn detect_caches(&self) -> Result<Vec<CleanableItem>> {
281        let cache_paths = [
282            ("Xcode Cache", "Library/Caches/com.apple.dt.Xcode"),
283            ("Instruments Cache", "Library/Caches/com.apple.dt.instruments"),
284            ("Swift Package Cache", "Library/Caches/org.swift.swiftpm"),
285            ("Playgrounds Cache", "Library/Developer/XCPGDevices"),
286        ];
287
288        let mut items = Vec::new();
289
290        for (name, rel_path) in cache_paths {
291            let path = self.home.join(rel_path);
292            if !path.exists() {
293                continue;
294            }
295
296            let (size, file_count) = calculate_dir_size(&path)?;
297            if size == 0 {
298                continue;
299            }
300
301            items.push(CleanableItem {
302                name: name.to_string(),
303                category: "Xcode".to_string(),
304                subcategory: "Caches".to_string(),
305                icon: "🗂️",
306                path,
307                size,
308                file_count: Some(file_count),
309                last_modified: None,
310                description: "Xcode cache files. Safe to delete.",
311                safe_to_delete: SafetyLevel::Safe,
312                clean_command: None,
313            });
314        }
315
316        Ok(items)
317    }
318
319    /// Detect documentation downloads
320    fn detect_documentation(&self) -> Result<Vec<CleanableItem>> {
321        let doc_path = self.home
322            .join("Library/Developer/Shared/Documentation/DocSets");
323
324        if !doc_path.exists() {
325            return Ok(vec![]);
326        }
327
328        let (size, file_count) = calculate_dir_size(&doc_path)?;
329        if size == 0 {
330            return Ok(vec![]);
331        }
332
333        Ok(vec![CleanableItem {
334            name: "Documentation Sets".to_string(),
335            category: "Xcode".to_string(),
336            subcategory: "Documentation".to_string(),
337            icon: "📚",
338            path: doc_path,
339            size,
340            file_count: Some(file_count),
341            last_modified: None,
342            description: "Offline documentation. Can be re-downloaded.",
343            safe_to_delete: SafetyLevel::SafeWithCost,
344            clean_command: None,
345        }])
346    }
347}
348
349impl Default for XcodeCleaner {
350    fn default() -> Self {
351        Self::new().expect("XcodeCleaner requires home directory")
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    #[test]
360    #[cfg(target_os = "macos")]
361    fn test_xcode_cleaner_creation() {
362        let cleaner = XcodeCleaner::new();
363        assert!(cleaner.is_some());
364    }
365
366    #[test]
367    #[cfg(target_os = "macos")]
368    fn test_xcode_detection() {
369        if let Some(cleaner) = XcodeCleaner::new() {
370            let items = cleaner.detect().unwrap();
371            println!("Found {} Xcode items", items.len());
372            for item in &items {
373                println!("  {} {} ({} bytes)", item.icon, item.name, item.size);
374            }
375        }
376    }
377}