1use super::{calculate_dir_size, get_mtime, CleanableItem, SafetyLevel};
11use crate::error::Result;
12use std::path::PathBuf;
13
14pub struct XcodeCleaner {
16 home: PathBuf,
17}
18
19impl XcodeCleaner {
20 pub fn new() -> Option<Self> {
22 let home = dirs::home_dir()?;
23
24 #[cfg(not(target_os = "macos"))]
26 return None;
27
28 #[cfg(target_os = "macos")]
29 Some(Self { home })
30 }
31
32 pub fn detect(&self) -> Result<Vec<CleanableItem>> {
34 let mut items = Vec::new();
35
36 items.extend(self.detect_derived_data()?);
38
39 items.extend(self.detect_archives()?);
41
42 items.extend(self.detect_device_support()?);
44
45 items.extend(self.detect_simulators()?);
47
48 items.extend(self.detect_caches()?);
50
51 items.extend(self.detect_documentation()?);
53
54 Ok(items)
55 }
56
57 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 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 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 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 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 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 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 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 let plist_path = path.join("device.plist");
233 let name = if plist_path.exists() {
234 std::fs::read_to_string(&plist_path)
236 .ok()
237 .and_then(|content| {
238 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 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 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 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}