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
11pub 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
27pub 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) .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
84pub 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 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
151pub 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
209pub 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
224pub 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 let Ok(meta) = std::fs::symlink_metadata(&path) else {
238 continue;
239 };
240 if !meta.file_type().is_symlink() {
241 continue;
242 }
243 let Ok(target) = std::fs::read_link(&path) else {
245 continue;
246 };
247 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
277pub fn scan_brew_cache(home: &str) -> Vec<CleanableItem> {
283 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
308pub fn scan_language_packs() -> Vec<CleanableItem> {
316 scan_language_packs_in(Path::new("/Applications"))
317}
318
319pub 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 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 let lang_name = lproj_path
364 .file_stem()
365 .and_then(|s| s.to_str())
366 .unwrap_or("");
367
368 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
411fn 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 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 s.split('-').next().unwrap_or(&s).to_string()
437 })
438 .collect()
439 }
440 _ => vec!["en".to_string()],
441 }
442}
443
444#[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 fs::write(dir.join(".DS_Store"), vec![0u8; 8192]).unwrap();
462 fs::write(sub.join(".DS_Store"), vec![0u8; 4096]).unwrap();
463 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 fs::write(diag.join("recent.crash"), b"crash data").unwrap();
498 fs::write(diag.join("recent.ips"), b"ips data").unwrap();
499 fs::write(diag.join("readme.txt"), b"not a crash").unwrap();
501
502 let items = scan_crash_reports(dir.to_str().unwrap());
503 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 let target = dir.join("real_target");
537 fs::write(&target, b"real").unwrap();
538
539 let valid_link = dir.join("valid_link");
541 std::os::unix::fs::symlink(&target, &valid_link).unwrap();
542
543 let broken_link = dir.join("broken_link");
545 std::os::unix::fs::symlink("/nonexistent/path/xyz", &broken_link).unwrap();
546
547 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 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 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 let items = scan_language_packs_in(&dir);
613 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 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 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 assert!(items.is_empty() || !items.is_empty()); fs::remove_dir_all(&dir).ok();
648 }
649}