1use super::{calculate_dir_size, get_mtime, CleanableItem, SafetyLevel};
11use crate::error::Result;
12use std::path::PathBuf;
13use std::time::{Duration, SystemTime};
14
15pub struct SystemCleaner {
17 home: PathBuf,
18}
19
20impl SystemCleaner {
21 pub fn new() -> Option<Self> {
23 let home = dirs::home_dir()?;
24 Some(Self { home })
25 }
26
27 pub fn detect(&self) -> Result<Vec<CleanableItem>> {
29 let mut items = Vec::new();
30
31 items.extend(self.detect_trash()?);
33
34 items.extend(self.detect_downloads()?);
36
37 items.extend(self.detect_temp()?);
39
40 #[cfg(target_os = "macos")]
42 items.extend(self.detect_time_machine()?);
43
44 items.extend(self.detect_system_caches()?);
46
47 Ok(items)
48 }
49
50 fn detect_trash(&self) -> Result<Vec<CleanableItem>> {
52 let mut items = Vec::new();
53
54 #[cfg(target_os = "macos")]
56 let trash_paths = vec![
57 self.home.join(".Trash"),
58 ];
59
60 #[cfg(target_os = "linux")]
61 let trash_paths = vec![
62 self.home.join(".local/share/Trash/files"),
63 self.home.join(".Trash"),
64 ];
65
66 #[cfg(target_os = "windows")]
67 let trash_paths = vec![
68 PathBuf::from("C:\\$Recycle.Bin"),
71 ];
72
73 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
74 let trash_paths: Vec<PathBuf> = vec![];
75
76 for trash_path in trash_paths {
77 if !trash_path.exists() {
78 continue;
79 }
80
81 let (size, file_count) = match calculate_dir_size(&trash_path) {
83 Ok(result) => result,
84 Err(_) => continue, };
86
87 if size < 1_000_000 {
89 continue;
90 }
91
92 items.push(CleanableItem {
93 name: "Trash".to_string(),
94 category: "System".to_string(),
95 subcategory: "Trash".to_string(),
96 icon: "🗑️",
97 path: trash_path,
98 size,
99 file_count: Some(file_count),
100 last_modified: None,
101 description: "Files in trash. Permanently deleted when cleaned.",
102 safe_to_delete: SafetyLevel::Caution,
103 #[cfg(target_os = "macos")]
104 clean_command: Some("rm -rf ~/.Trash/*".to_string()),
105 #[cfg(target_os = "linux")]
106 clean_command: Some("trash-empty".to_string()),
107 #[cfg(target_os = "windows")]
108 clean_command: Some("Clear-RecycleBin -Force".to_string()),
109 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
110 clean_command: None,
111 });
112 }
113
114 Ok(items)
115 }
116
117 fn detect_downloads(&self) -> Result<Vec<CleanableItem>> {
119 let mut items = Vec::new();
120
121 let downloads = dirs::download_dir()
122 .unwrap_or_else(|| self.home.join("Downloads"));
123
124 if !downloads.exists() {
125 return Ok(items);
126 }
127
128 let archive_extensions = [
130 "zip", "tar", "tar.gz", "tgz", "tar.bz2", "tar.xz", "7z", "rar",
131 "dmg", "iso", "pkg", "deb", "rpm", "msi", "exe",
132 ];
133
134 let age_threshold = Duration::from_secs(30 * 24 * 60 * 60);
136 let now = SystemTime::now();
137
138 let mut total_size = 0u64;
139 let mut file_count = 0u64;
140 let mut old_archives: Vec<PathBuf> = Vec::new();
141
142 if let Ok(entries) = std::fs::read_dir(&downloads) {
143 for entry in entries.filter_map(|e| e.ok()) {
144 let path = entry.path();
145 if !path.is_file() {
146 continue;
147 }
148
149 let ext = path.extension()
151 .and_then(|e| e.to_str())
152 .unwrap_or("")
153 .to_lowercase();
154
155 let is_archive = archive_extensions.contains(&ext.as_str()) ||
157 path.to_string_lossy().ends_with(".tar.gz") ||
158 path.to_string_lossy().ends_with(".tar.bz2") ||
159 path.to_string_lossy().ends_with(".tar.xz");
160
161 if !is_archive {
162 continue;
163 }
164
165 if let Ok(metadata) = path.metadata() {
167 if let Ok(modified) = metadata.modified() {
168 if let Ok(age) = now.duration_since(modified) {
169 if age > age_threshold {
170 total_size += metadata.len();
171 file_count += 1;
172 old_archives.push(path);
173 }
174 }
175 }
176 }
177 }
178 }
179
180 if total_size > 100_000_000 && file_count > 0 { items.push(CleanableItem {
182 name: format!("Old Downloads ({} files)", file_count),
183 category: "System".to_string(),
184 subcategory: "Downloads".to_string(),
185 icon: "📥",
186 path: downloads,
187 size: total_size,
188 file_count: Some(file_count),
189 last_modified: None,
190 description: "Archive files older than 30 days in Downloads folder.",
191 safe_to_delete: SafetyLevel::Caution,
192 clean_command: None,
193 });
194 }
195
196 Ok(items)
197 }
198
199 fn detect_temp(&self) -> Result<Vec<CleanableItem>> {
201 let mut items = Vec::new();
202
203 #[cfg(target_os = "macos")]
205 let temp_paths = vec![
206 self.home.join("Library/Caches/TemporaryItems"),
207 PathBuf::from("/private/var/folders"),
208 ];
209
210 #[cfg(target_os = "linux")]
211 let temp_paths = vec![
212 PathBuf::from("/tmp"),
213 PathBuf::from("/var/tmp"),
214 self.home.join(".cache"),
215 ];
216
217 #[cfg(target_os = "windows")]
218 let temp_paths = vec![
219 std::env::temp_dir(),
220 self.home.join("AppData/Local/Temp"),
221 ];
222
223 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
224 let temp_paths: Vec<PathBuf> = vec![];
225
226 for temp_path in temp_paths {
227 if !temp_path.exists() {
228 continue;
229 }
230
231 if std::fs::read_dir(&temp_path).is_err() {
233 continue;
234 }
235
236 let (size, file_count) = calculate_dir_size(&temp_path)?;
237 if size < 500_000_000 { continue;
239 }
240
241 #[cfg(target_os = "linux")]
243 if temp_path == self.home.join(".cache") {
244 continue;
245 }
246
247 items.push(CleanableItem {
248 name: format!("Temp Files ({})", temp_path.file_name()
249 .map(|n| n.to_string_lossy().to_string())
250 .unwrap_or_else(|| "tmp".to_string())),
251 category: "System".to_string(),
252 subcategory: "Temp".to_string(),
253 icon: "🔥",
254 path: temp_path,
255 size,
256 file_count: Some(file_count),
257 last_modified: None,
258 description: "Temporary files. May contain files in use.",
259 safe_to_delete: SafetyLevel::Caution,
260 clean_command: None,
261 });
262 }
263
264 Ok(items)
265 }
266
267 #[cfg(target_os = "macos")]
269 fn detect_time_machine(&self) -> Result<Vec<CleanableItem>> {
270 let mut items = Vec::new();
271
272 let output = std::process::Command::new("tmutil")
274 .args(["listlocalsnapshotdates", "/"])
275 .output();
276
277 if let Ok(output) = output {
278 if output.status.success() {
279 let stdout = String::from_utf8_lossy(&output.stdout);
280 let snapshot_count = stdout.lines().count().saturating_sub(1); if snapshot_count > 0 {
283 let estimated_size = (snapshot_count as u64) * 2_000_000_000; items.push(CleanableItem {
288 name: format!("Time Machine Snapshots ({} snapshots)", snapshot_count),
289 category: "System".to_string(),
290 subcategory: "Time Machine".to_string(),
291 icon: "⏰",
292 path: PathBuf::from("/"),
293 size: estimated_size,
294 file_count: Some(snapshot_count as u64),
295 last_modified: None,
296 description: "Local Time Machine snapshots. Deleting frees space.",
297 safe_to_delete: SafetyLevel::Caution,
298 clean_command: Some("tmutil deletelocalsnapshots /".to_string()),
299 });
300 }
301 }
302 }
303
304 Ok(items)
305 }
306
307 fn detect_system_caches(&self) -> Result<Vec<CleanableItem>> {
309 let mut items = Vec::new();
310
311 #[cfg(target_os = "macos")]
312 {
313 let user_cache = self.home.join("Library/Caches");
315 if user_cache.exists() {
316 let (size, file_count) = calculate_dir_size(&user_cache)?;
317 if size > 1_000_000_000 { items.push(CleanableItem {
319 name: "User Caches".to_string(),
320 category: "System".to_string(),
321 subcategory: "Caches".to_string(),
322 icon: "🗄️",
323 path: user_cache,
324 size,
325 file_count: Some(file_count),
326 last_modified: None,
327 description: "Application caches. Apps will rebuild them.",
328 safe_to_delete: SafetyLevel::SafeWithCost,
329 clean_command: None,
330 });
331 }
332 }
333
334 let font_caches = [
336 self.home.join("Library/Caches/com.apple.FontRegistry"),
337 PathBuf::from("/System/Library/Caches/com.apple.IntlDataCache.le*"),
338 ];
339
340 for font_cache in font_caches {
341 if !font_cache.exists() {
342 continue;
343 }
344 let (size, file_count) = calculate_dir_size(&font_cache)?;
345 if size > 50_000_000 {
346 items.push(CleanableItem {
347 name: "Font Caches".to_string(),
348 category: "System".to_string(),
349 subcategory: "Fonts".to_string(),
350 icon: "🔤",
351 path: font_cache,
352 size,
353 file_count: Some(file_count),
354 last_modified: None,
355 description: "Font caches. System will rebuild on restart.",
356 safe_to_delete: SafetyLevel::SafeWithCost,
357 clean_command: Some("sudo atsutil databases -remove".to_string()),
358 });
359 }
360 }
361 }
362
363 #[cfg(target_os = "linux")]
364 {
365 let thumbs = self.home.join(".cache/thumbnails");
367 if thumbs.exists() {
368 let (size, file_count) = calculate_dir_size(&thumbs)?;
369 if size > 500_000_000 {
370 items.push(CleanableItem {
371 name: "Thumbnail Cache".to_string(),
372 category: "System".to_string(),
373 subcategory: "Thumbnails".to_string(),
374 icon: "🖼️",
375 path: thumbs,
376 size,
377 file_count: Some(file_count),
378 last_modified: None,
379 description: "Image thumbnails. Will be regenerated when needed.",
380 safe_to_delete: SafetyLevel::Safe,
381 clean_command: None,
382 });
383 }
384 }
385
386 let journal = PathBuf::from("/var/log/journal");
388 if journal.exists() {
389 if let Ok((size, file_count)) = calculate_dir_size(&journal) {
390 if size > 1_000_000_000 { items.push(CleanableItem {
392 name: "Journal Logs".to_string(),
393 category: "System".to_string(),
394 subcategory: "Logs".to_string(),
395 icon: "📋",
396 path: journal,
397 size,
398 file_count: Some(file_count),
399 last_modified: None,
400 description: "Systemd journal logs. Can be vacuumed.",
401 safe_to_delete: SafetyLevel::SafeWithCost,
402 clean_command: Some("sudo journalctl --vacuum-size=500M".to_string()),
403 });
404 }
405 }
406 }
407 }
408
409 #[cfg(target_os = "windows")]
410 {
411 let wu_cache = PathBuf::from("C:\\Windows\\SoftwareDistribution\\Download");
413 if wu_cache.exists() {
414 if let Ok((size, file_count)) = calculate_dir_size(&wu_cache) {
415 if size > 1_000_000_000 {
416 items.push(CleanableItem {
417 name: "Windows Update Cache".to_string(),
418 category: "System".to_string(),
419 subcategory: "Windows Update".to_string(),
420 icon: "🪟",
421 path: wu_cache,
422 size,
423 file_count: Some(file_count),
424 last_modified: None,
425 description: "Windows Update download cache.",
426 safe_to_delete: SafetyLevel::SafeWithCost,
427 clean_command: None,
428 });
429 }
430 }
431 }
432
433 let prefetch = PathBuf::from("C:\\Windows\\Prefetch");
435 if prefetch.exists() {
436 if let Ok((size, file_count)) = calculate_dir_size(&prefetch) {
437 if size > 200_000_000 {
438 items.push(CleanableItem {
439 name: "Prefetch Files".to_string(),
440 category: "System".to_string(),
441 subcategory: "Prefetch".to_string(),
442 icon: "⚡",
443 path: prefetch,
444 size,
445 file_count: Some(file_count),
446 last_modified: None,
447 description: "Windows prefetch data. May slow first app launches.",
448 safe_to_delete: SafetyLevel::SafeWithCost,
449 clean_command: None,
450 });
451 }
452 }
453 }
454 }
455
456 Ok(items)
457 }
458}
459
460pub fn find_big_files(min_size_mb: u64) -> Result<Vec<CleanableItem>> {
462 let mut items = Vec::new();
463 let min_size = min_size_mb * 1_000_000;
464
465 let home = dirs::home_dir().ok_or_else(|| {
466 crate::error::DevSweepError::Config("Could not find home directory".into())
467 })?;
468
469 let skip_paths = [
471 ".git", "node_modules", "target", ".cargo", ".npm",
472 ".gradle", "venv", ".venv", "__pycache__",
473 "Library/Caches", "AppData/Local", ".cache",
474 ];
475
476 let walker = walkdir::WalkDir::new(&home)
477 .max_depth(5) .into_iter()
479 .filter_entry(|e| {
480 let path = e.path();
481 !skip_paths.iter().any(|skip| path.to_string_lossy().contains(skip))
483 });
484
485 for entry in walker.filter_map(|e| e.ok()) {
486 let path = entry.path();
487
488 if !path.is_file() {
489 continue;
490 }
491
492 if let Ok(metadata) = path.metadata() {
493 let size = metadata.len();
494 if size >= min_size {
495 let name = path.file_name()
496 .map(|n| n.to_string_lossy().to_string())
497 .unwrap_or_else(|| "Unknown".to_string());
498
499 items.push(CleanableItem {
500 name,
501 category: "Big Files".to_string(),
502 subcategory: "Files".to_string(),
503 icon: "📄",
504 path: path.to_path_buf(),
505 size,
506 file_count: Some(1),
507 last_modified: get_mtime(path),
508 description: "Large file found in home directory.",
509 safe_to_delete: SafetyLevel::Caution,
510 clean_command: None,
511 });
512 }
513 }
514 }
515
516 items.sort_by(|a, b| b.size.cmp(&a.size));
518
519 items.truncate(50);
521
522 Ok(items)
523}