Skip to main content

diskforge_core/rules/
system_junk.rs

1use std::path::{Path, PathBuf};
2use std::sync::atomic::{AtomicU64, Ordering};
3use std::time::SystemTime;
4
5use ignore::WalkBuilder;
6use walkdir::WalkDir;
7
8use crate::sizing;
9use crate::types::{Category, CleanableItem, Risk, SystemJunkKind};
10
11// ---------------------------------------------------------------------------
12// Umbrella function — called from scanner.rs
13// ---------------------------------------------------------------------------
14
15/// Run all system junk sub-scanners and return combined results.
16pub fn scan_system_junk(home: &str) -> Vec<CleanableItem> {
17    let mut items = Vec::new();
18    items.extend(scan_ds_store(home));
19    items.extend(scan_crash_reports(home));
20    items.extend(scan_device_support(home));
21    items.extend(scan_broken_symlinks(home));
22    items.extend(scan_brew_cache(home));
23    items.extend(scan_language_packs());
24    items
25}
26
27// ---------------------------------------------------------------------------
28// 1. .DS_Store scanner
29// ---------------------------------------------------------------------------
30
31/// Recursively walk `home` collecting all .DS_Store files.
32/// Returns a single aggregated item with total count and size.
33pub fn scan_ds_store(home: &str) -> Vec<CleanableItem> {
34    let home_path = Path::new(home);
35    if !home_path.exists() {
36        return Vec::new();
37    }
38
39    let count = AtomicU64::new(0);
40    let total_size = AtomicU64::new(0);
41
42    WalkBuilder::new(home_path)
43        .hidden(false) // .DS_Store is a hidden file
44        .ignore(false)
45        .git_ignore(false)
46        .git_global(false)
47        .git_exclude(false)
48        .threads(rayon::current_num_threads().min(8))
49        .build_parallel()
50        .run(|| {
51            Box::new(|entry| {
52                if let Ok(entry) = entry
53                    && entry.file_type().is_some_and(|ft| ft.is_file())
54                    && entry.file_name() == ".DS_Store"
55                    && let Ok(meta) = entry.metadata()
56                {
57                    count.fetch_add(1, Ordering::Relaxed);
58                    total_size.fetch_add(meta.len(), Ordering::Relaxed);
59                }
60                ignore::WalkState::Continue
61            })
62        });
63
64    let count = count.load(Ordering::Relaxed);
65    let total_size = total_size.load(Ordering::Relaxed);
66
67    if count == 0 {
68        return Vec::new();
69    }
70
71    vec![CleanableItem {
72        category: Category::SystemJunk(SystemJunkKind::DsStore),
73        path: PathBuf::from(home),
74        size: total_size,
75        risk: Risk::None,
76        regenerates: true,
77        regeneration_hint: Some("Finder recreates on next folder visit".into()),
78        last_modified: None,
79        description: format!("{count} .DS_Store files"),
80        cleanup_command: Some(format!("find {home} -name .DS_Store -delete")),
81    }]
82}
83
84// ---------------------------------------------------------------------------
85// 2. Crash reports
86// ---------------------------------------------------------------------------
87
88/// Scan ~/Library/Logs/DiagnosticReports/ for crash reports older than 30 days.
89pub fn scan_crash_reports(home: &str) -> Vec<CleanableItem> {
90    let diag_dir = PathBuf::from(home).join("Library/Logs/DiagnosticReports");
91    if !diag_dir.exists() {
92        return Vec::new();
93    }
94
95    let now = SystemTime::now();
96    let thirty_days_secs: u64 = 30 * 24 * 60 * 60;
97
98    let extensions = ["crash", "ips", "diag", "hang"];
99    let mut total_size: u64 = 0;
100    let mut count: u64 = 0;
101    let mut latest_modified: Option<SystemTime> = None;
102
103    for entry in WalkDir::new(&diag_dir).into_iter().filter_map(|e| e.ok()) {
104        if !entry.file_type().is_file() {
105            continue;
106        }
107        let path = entry.path();
108        let ext_matches = path
109            .extension()
110            .and_then(|e| e.to_str())
111            .is_some_and(|ext| extensions.contains(&ext));
112        if !ext_matches {
113            continue;
114        }
115        let Ok(meta) = entry.metadata() else {
116            continue;
117        };
118        // Filter by age: only include reports older than 30 days
119        if let Ok(mtime) = meta.modified() {
120            if let Ok(age) = now.duration_since(mtime)
121                && age.as_secs() < thirty_days_secs
122            {
123                continue;
124            }
125            latest_modified = Some(match latest_modified {
126                Some(cur) => cur.max(mtime),
127                None => mtime,
128            });
129        }
130        total_size += meta.len();
131        count += 1;
132    }
133
134    if count == 0 {
135        return Vec::new();
136    }
137
138    vec![CleanableItem {
139        category: Category::SystemJunk(SystemJunkKind::CrashReport),
140        path: diag_dir,
141        size: total_size,
142        risk: Risk::Low,
143        regenerates: false,
144        regeneration_hint: None,
145        last_modified: latest_modified,
146        description: format!("{count} crash reports (>30 days old)"),
147        cleanup_command: None,
148    }]
149}
150
151// ---------------------------------------------------------------------------
152// 3. Xcode Device Support
153// ---------------------------------------------------------------------------
154
155/// Scan ~/Library/Developer/Xcode/{iOS,watchOS,tvOS} DeviceSupport/ for old
156/// version directories.
157pub fn scan_device_support(home: &str) -> Vec<CleanableItem> {
158    let base = PathBuf::from(home).join("Library/Developer/Xcode");
159    if !base.exists() {
160        return Vec::new();
161    }
162
163    let subdirs = [
164        "iOS DeviceSupport",
165        "watchOS DeviceSupport",
166        "tvOS DeviceSupport",
167    ];
168
169    let mut items = Vec::new();
170    for subdir in &subdirs {
171        let dir = base.join(subdir);
172        if !dir.exists() {
173            continue;
174        }
175        let Ok(entries) = std::fs::read_dir(&dir) else {
176            continue;
177        };
178        for entry in entries.filter_map(|e| e.ok()) {
179            let path = entry.path();
180            if !path.is_dir() {
181                continue;
182            }
183            let stats = sizing::dir_stats(&path);
184            if stats.size == 0 {
185                continue;
186            }
187            let name = path
188                .file_name()
189                .and_then(|n| n.to_str())
190                .unwrap_or("unknown")
191                .to_string();
192            items.push(CleanableItem {
193                category: Category::SystemJunk(SystemJunkKind::DeviceSupport),
194                path,
195                size: stats.size,
196                risk: Risk::Low,
197                regenerates: true,
198                regeneration_hint: Some("Re-downloaded on next device connection".into()),
199                last_modified: stats.last_modified,
200                description: format!("{subdir}: {name}"),
201                cleanup_command: None,
202            });
203        }
204    }
205
206    items
207}
208
209// ---------------------------------------------------------------------------
210// 4. Broken Symlinks
211// ---------------------------------------------------------------------------
212
213/// Scan key bin directories for symlinks whose targets no longer exist.
214pub fn scan_broken_symlinks(home: &str) -> Vec<CleanableItem> {
215    let dirs = vec![
216        PathBuf::from("/usr/local/bin"),
217        PathBuf::from("/opt/homebrew/bin"),
218        PathBuf::from(home).join("bin"),
219        PathBuf::from(home).join(".local/bin"),
220    ];
221    scan_broken_symlinks_in(&dirs)
222}
223
224/// Inner implementation that accepts arbitrary directories (testable).
225pub fn scan_broken_symlinks_in(dirs: &[PathBuf]) -> Vec<CleanableItem> {
226    let mut items = Vec::new();
227    for dir in dirs {
228        if !dir.exists() {
229            continue;
230        }
231        let Ok(entries) = std::fs::read_dir(dir) else {
232            continue;
233        };
234        for entry in entries.filter_map(|e| e.ok()) {
235            let path = entry.path();
236            // Check if it's a symlink
237            let Ok(meta) = std::fs::symlink_metadata(&path) else {
238                continue;
239            };
240            if !meta.file_type().is_symlink() {
241                continue;
242            }
243            // Check if target exists
244            let Ok(target) = std::fs::read_link(&path) else {
245                continue;
246            };
247            // Resolve relative targets against the symlink's parent
248            let resolved_target = if target.is_relative() {
249                if let Some(parent) = path.parent() {
250                    parent.join(&target)
251                } else {
252                    target.clone()
253                }
254            } else {
255                target.clone()
256            };
257            if resolved_target.exists() {
258                continue;
259            }
260            items.push(CleanableItem {
261                category: Category::SystemJunk(SystemJunkKind::BrokenSymlink),
262                path: path.clone(),
263                size: 0,
264                risk: Risk::None,
265                regenerates: false,
266                regeneration_hint: None,
267                last_modified: meta.modified().ok(),
268                description: format!("Broken symlink -> {}", target.display()),
269                cleanup_command: None,
270            });
271        }
272    }
273
274    items
275}
276
277// ---------------------------------------------------------------------------
278// 5. Homebrew Cache
279// ---------------------------------------------------------------------------
280
281/// Size the Homebrew cache directory and offer `brew cleanup` as the cleanup command.
282pub fn scan_brew_cache(home: &str) -> Vec<CleanableItem> {
283    // Try the standard Homebrew cache locations
284    let cache_dir = PathBuf::from(home).join("Library/Caches/Homebrew");
285
286    if !cache_dir.exists() {
287        return Vec::new();
288    }
289
290    let stats = sizing::dir_stats(&cache_dir);
291    if stats.size == 0 {
292        return Vec::new();
293    }
294
295    vec![CleanableItem {
296        category: Category::SystemJunk(SystemJunkKind::BrewCache),
297        path: cache_dir,
298        size: stats.size,
299        risk: Risk::Low,
300        regenerates: true,
301        regeneration_hint: Some("brew install re-downloads as needed".into()),
302        last_modified: stats.last_modified,
303        description: "Homebrew download cache".into(),
304        cleanup_command: Some("brew cleanup".into()),
305    }]
306}
307
308// ---------------------------------------------------------------------------
309// 6. Language Packs (.lproj)
310// ---------------------------------------------------------------------------
311
312/// Detect unused language packs (.lproj) in /Applications/*.app.
313/// Keeps user's preferred languages + English + Base.
314/// WARNING: Removing .lproj directories invalidates app code signatures.
315pub fn scan_language_packs() -> Vec<CleanableItem> {
316    scan_language_packs_in(Path::new("/Applications"))
317}
318
319/// Inner implementation that accepts an arbitrary apps directory (testable).
320pub fn scan_language_packs_in(apps_dir: &Path) -> Vec<CleanableItem> {
321    if !apps_dir.exists() {
322        return Vec::new();
323    }
324
325    let user_langs = detect_user_languages();
326
327    // Always keep these
328    let mut keep_set: Vec<String> = user_langs;
329    for lang in ["en", "en_US", "en_GB", "Base"] {
330        if !keep_set.iter().any(|l| l == lang) {
331            keep_set.push(lang.to_string());
332        }
333    }
334
335    let Ok(entries) = std::fs::read_dir(apps_dir) else {
336        return Vec::new();
337    };
338
339    let mut items = Vec::new();
340    for entry in entries.filter_map(|e| e.ok()) {
341        let app_path = entry.path();
342        if app_path.extension().is_none_or(|ext| ext != "app") {
343            continue;
344        }
345        let resources_dir = app_path.join("Contents/Resources");
346        if !resources_dir.exists() {
347            continue;
348        }
349
350        let Ok(resources) = std::fs::read_dir(&resources_dir) else {
351            continue;
352        };
353
354        let mut removable_size: u64 = 0;
355        let mut removable_count: u64 = 0;
356
357        for res_entry in resources.filter_map(|e| e.ok()) {
358            let lproj_path = res_entry.path();
359            if lproj_path.extension().is_none_or(|ext| ext != "lproj") {
360                continue;
361            }
362            // Get the language name (stem)
363            let lang_name = lproj_path
364                .file_stem()
365                .and_then(|s| s.to_str())
366                .unwrap_or("");
367
368            // Check if this language should be kept
369            let should_keep = keep_set.iter().any(|keep| {
370                lang_name == keep.as_str()
371                    || lang_name.starts_with(&format!("{keep}-"))
372                    || lang_name.starts_with(&format!("{keep}_"))
373            });
374
375            if should_keep {
376                continue;
377            }
378
379            let stats = sizing::dir_stats(&lproj_path);
380            removable_size += stats.size;
381            removable_count += 1;
382        }
383
384        if removable_count == 0 || removable_size == 0 {
385            continue;
386        }
387
388        let app_name = app_path
389            .file_stem()
390            .and_then(|s| s.to_str())
391            .unwrap_or("Unknown");
392
393        items.push(CleanableItem {
394            category: Category::SystemJunk(SystemJunkKind::LanguagePack),
395            path: resources_dir,
396            size: removable_size,
397            risk: Risk::Medium,
398            regenerates: false,
399            regeneration_hint: Some("Reinstall app to restore removed languages".into()),
400            last_modified: None,
401            description: format!(
402                "{app_name}: {removable_count} unused language packs (WARNING: removes code signature)"
403            ),
404            cleanup_command: None,
405        });
406    }
407
408    items
409}
410
411/// Detect user's preferred languages from macOS defaults.
412fn detect_user_languages() -> Vec<String> {
413    let output = std::process::Command::new("defaults")
414        .args(["read", "NSGlobalDomain", "AppleLanguages"])
415        .output();
416
417    match output {
418        Ok(out) if out.status.success() => {
419            let stdout = String::from_utf8_lossy(&out.stdout);
420            // Parse the plist-style array output:
421            //   (
422            //       "en-US",
423            //       "es-419",
424            //   )
425            stdout
426                .lines()
427                .map(|line| {
428                    line.trim()
429                        .trim_matches('"')
430                        .trim_end_matches(',')
431                        .to_string()
432                })
433                .filter(|s| !s.is_empty() && s != "(" && s != ")")
434                .map(|s| {
435                    // Convert "en-US" to "en" for matching lproj names
436                    s.split('-').next().unwrap_or(&s).to_string()
437                })
438                .collect()
439        }
440        _ => vec!["en".to_string()],
441    }
442}
443
444// ---------------------------------------------------------------------------
445// Tests
446// ---------------------------------------------------------------------------
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451    use std::fs;
452
453    #[test]
454    fn test_scan_ds_store_finds_files() {
455        let dir = std::env::temp_dir().join("diskforge_test_ds_store");
456        let _ = fs::remove_dir_all(&dir);
457        let sub = dir.join("sub");
458        fs::create_dir_all(&sub).unwrap();
459
460        // Create .DS_Store files
461        fs::write(dir.join(".DS_Store"), vec![0u8; 8192]).unwrap();
462        fs::write(sub.join(".DS_Store"), vec![0u8; 4096]).unwrap();
463        // Create a normal file (should be ignored)
464        fs::write(dir.join("readme.txt"), b"hello").unwrap();
465
466        let items = scan_ds_store(dir.to_str().unwrap());
467        assert_eq!(items.len(), 1, "Expected 1 aggregated item");
468        assert_eq!(items[0].size, 8192 + 4096);
469        assert!(items[0].description.contains("2 .DS_Store files"));
470        assert_eq!(items[0].risk, Risk::None);
471        assert!(items[0].regenerates);
472
473        fs::remove_dir_all(&dir).ok();
474    }
475
476    #[test]
477    fn test_scan_ds_store_empty() {
478        let dir = std::env::temp_dir().join("diskforge_test_ds_store_empty");
479        let _ = fs::remove_dir_all(&dir);
480        fs::create_dir_all(&dir).unwrap();
481        fs::write(dir.join("readme.txt"), b"hello").unwrap();
482
483        let items = scan_ds_store(dir.to_str().unwrap());
484        assert!(items.is_empty());
485
486        fs::remove_dir_all(&dir).ok();
487    }
488
489    #[test]
490    fn test_scan_crash_reports_filters_by_age() {
491        let dir = std::env::temp_dir().join("diskforge_test_crash_reports");
492        let _ = fs::remove_dir_all(&dir);
493        let diag = dir.join("Library/Logs/DiagnosticReports");
494        fs::create_dir_all(&diag).unwrap();
495
496        // Create crash files (will be recent, so should be filtered out by the 30-day rule)
497        fs::write(diag.join("recent.crash"), b"crash data").unwrap();
498        fs::write(diag.join("recent.ips"), b"ips data").unwrap();
499        // Non-crash file
500        fs::write(diag.join("readme.txt"), b"not a crash").unwrap();
501
502        let items = scan_crash_reports(dir.to_str().unwrap());
503        // All files are recent (just created), so should be filtered out
504        assert!(
505            items.is_empty(),
506            "Recent crash reports should be filtered out"
507        );
508
509        fs::remove_dir_all(&dir).ok();
510    }
511
512    #[test]
513    fn test_scan_device_support() {
514        let dir = std::env::temp_dir().join("diskforge_test_device_support");
515        let _ = fs::remove_dir_all(&dir);
516        let ios_ds = dir.join("Library/Developer/Xcode/iOS DeviceSupport/16.0 (20A362)");
517        fs::create_dir_all(&ios_ds).unwrap();
518        fs::write(ios_ds.join("Symbols"), vec![0u8; 1024]).unwrap();
519
520        let items = scan_device_support(dir.to_str().unwrap());
521        assert_eq!(items.len(), 1);
522        assert!(items[0].description.contains("16.0"));
523        assert_eq!(items[0].risk, Risk::Low);
524        assert!(items[0].regenerates);
525
526        fs::remove_dir_all(&dir).ok();
527    }
528
529    #[test]
530    fn test_scan_broken_symlinks() {
531        let dir = std::env::temp_dir().join("diskforge_test_broken_symlinks");
532        let _ = fs::remove_dir_all(&dir);
533        fs::create_dir_all(&dir).unwrap();
534
535        // Create a valid symlink target
536        let target = dir.join("real_target");
537        fs::write(&target, b"real").unwrap();
538
539        // Create a valid symlink
540        let valid_link = dir.join("valid_link");
541        std::os::unix::fs::symlink(&target, &valid_link).unwrap();
542
543        // Create a broken symlink (target doesn't exist)
544        let broken_link = dir.join("broken_link");
545        std::os::unix::fs::symlink("/nonexistent/path/xyz", &broken_link).unwrap();
546
547        // Now we can use scan_broken_symlinks_in with our temp dir
548        let items = scan_broken_symlinks_in(&[dir.clone()]);
549        assert_eq!(items.len(), 1, "Should find exactly 1 broken symlink");
550        assert!(items[0].description.contains("Broken symlink"));
551        assert!(items[0].description.contains("/nonexistent/path/xyz"));
552        assert_eq!(items[0].risk, Risk::None);
553
554        fs::remove_dir_all(&dir).ok();
555    }
556
557    #[test]
558    fn test_scan_brew_cache_nonexistent() {
559        let dir = std::env::temp_dir().join("diskforge_test_brew_cache_missing");
560        let _ = fs::remove_dir_all(&dir);
561        fs::create_dir_all(&dir).unwrap();
562
563        let items = scan_brew_cache(dir.to_str().unwrap());
564        assert!(items.is_empty(), "No brew cache should return empty");
565
566        fs::remove_dir_all(&dir).ok();
567    }
568
569    #[test]
570    fn test_scan_brew_cache_with_data() {
571        let dir = std::env::temp_dir().join("diskforge_test_brew_cache");
572        let _ = fs::remove_dir_all(&dir);
573        let cache = dir.join("Library/Caches/Homebrew");
574        fs::create_dir_all(&cache).unwrap();
575        fs::write(cache.join("some-package.tar.gz"), vec![0u8; 2048]).unwrap();
576
577        let items = scan_brew_cache(dir.to_str().unwrap());
578        assert_eq!(items.len(), 1);
579        assert_eq!(items[0].size, 2048);
580        assert_eq!(items[0].risk, Risk::Low);
581        assert_eq!(items[0].cleanup_command, Some("brew cleanup".into()));
582
583        fs::remove_dir_all(&dir).ok();
584    }
585
586    #[test]
587    fn test_scan_language_packs_mock() {
588        // Create a mock app bundle in a temp directory acting as /Applications
589        let dir = std::env::temp_dir().join("diskforge_test_lproj");
590        let _ = fs::remove_dir_all(&dir);
591        let resources = dir.join("TestApp.app/Contents/Resources");
592        fs::create_dir_all(&resources).unwrap();
593
594        // Create language pack directories
595        let en = resources.join("en.lproj");
596        fs::create_dir_all(&en).unwrap();
597        fs::write(en.join("Localizable.strings"), b"en strings").unwrap();
598
599        let fr = resources.join("fr.lproj");
600        fs::create_dir_all(&fr).unwrap();
601        fs::write(fr.join("Localizable.strings"), b"fr strings").unwrap();
602
603        let ja = resources.join("ja.lproj");
604        fs::create_dir_all(&ja).unwrap();
605        fs::write(ja.join("Localizable.strings"), b"ja strings").unwrap();
606
607        let base = resources.join("Base.lproj");
608        fs::create_dir_all(&base).unwrap();
609        fs::write(base.join("Main.storyboard"), b"base storyboard").unwrap();
610
611        // Use scan_language_packs_in with our mock directory
612        let items = scan_language_packs_in(&dir);
613        // Should find TestApp with removable language packs (fr, ja -- en and Base are kept)
614        assert_eq!(
615            items.len(),
616            1,
617            "Should find 1 app with removable lproj dirs"
618        );
619        assert!(
620            items[0].description.contains("TestApp"),
621            "Should mention the app name"
622        );
623        assert!(
624            items[0].description.contains("unused language packs"),
625            "Should describe unused packs"
626        );
627        assert_eq!(items[0].risk, Risk::Medium);
628
629        // Also test detect_user_languages helper still works
630        let langs = detect_user_languages();
631        assert!(!langs.is_empty(), "Should detect at least one language");
632
633        fs::remove_dir_all(&dir).ok();
634    }
635
636    #[test]
637    fn test_scan_system_junk_umbrella() {
638        // Test that the umbrella function doesn't panic on a clean temp dir
639        let dir = std::env::temp_dir().join("diskforge_test_system_junk_umbrella");
640        let _ = fs::remove_dir_all(&dir);
641        fs::create_dir_all(&dir).unwrap();
642
643        let items = scan_system_junk(dir.to_str().unwrap());
644        // May find .DS_Store or other items in temp, but should not panic
645        assert!(items.is_empty() || !items.is_empty()); // Just verify no panic
646
647        fs::remove_dir_all(&dir).ok();
648    }
649}