Skip to main content

null_e/cleaners/
system.rs

1//! System cleanup module
2//!
3//! Handles cleanup of system-level items:
4//! - Trash/Recycle Bin
5//! - Downloads folder (old archives)
6//! - Temporary files
7//! - Time Machine local snapshots (macOS)
8//! - Windows temp files
9
10use super::{calculate_dir_size, get_mtime, CleanableItem, SafetyLevel};
11use crate::error::Result;
12use std::path::PathBuf;
13use std::time::{Duration, SystemTime};
14
15/// System cleaner
16pub struct SystemCleaner {
17    home: PathBuf,
18}
19
20impl SystemCleaner {
21    /// Create a new system cleaner
22    pub fn new() -> Option<Self> {
23        let home = dirs::home_dir()?;
24        Some(Self { home })
25    }
26
27    /// Detect all cleanable items
28    pub fn detect(&self) -> Result<Vec<CleanableItem>> {
29        let mut items = Vec::new();
30
31        // Trash
32        items.extend(self.detect_trash()?);
33
34        // Downloads (old archives)
35        items.extend(self.detect_downloads()?);
36
37        // Temp files
38        items.extend(self.detect_temp()?);
39
40        // Time Machine local snapshots (macOS)
41        #[cfg(target_os = "macos")]
42        items.extend(self.detect_time_machine()?);
43
44        // System caches
45        items.extend(self.detect_system_caches()?);
46
47        Ok(items)
48    }
49
50    /// Detect Trash contents
51    fn detect_trash(&self) -> Result<Vec<CleanableItem>> {
52        let mut items = Vec::new();
53
54        // Trash locations per OS
55        #[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            // Windows Recycle Bin is more complex to access
69            // Using SHQueryRecycleBin API would be better
70            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            // Try to calculate size, skip if permission denied
82            let (size, file_count) = match calculate_dir_size(&trash_path) {
83                Ok(result) => result,
84                Err(_) => continue, // Permission denied or other error
85            };
86
87            // Show if at least 1MB
88            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    /// Detect old archives in Downloads
118    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        // Archive extensions to look for
129        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        // Threshold: files older than 30 days
135        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                // Check extension
150                let ext = path.extension()
151                    .and_then(|e| e.to_str())
152                    .unwrap_or("")
153                    .to_lowercase();
154
155                // Handle .tar.* extensions
156                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                // Check age
166                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 { // 100MB minimum
181            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    /// Detect temporary files
200    fn detect_temp(&self) -> Result<Vec<CleanableItem>> {
201        let mut items = Vec::new();
202
203        // Temp locations
204        #[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            // Skip if not readable (permissions)
232            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 { // 500MB minimum for temp
238                continue;
239            }
240
241            // Don't suggest cleaning main system cache on Linux
242            #[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    /// Detect Time Machine local snapshots (macOS only)
268    #[cfg(target_os = "macos")]
269    fn detect_time_machine(&self) -> Result<Vec<CleanableItem>> {
270        let mut items = Vec::new();
271
272        // Check if tmutil is available and get snapshot info
273        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); // First line is header
281
282                if snapshot_count > 0 {
283                    // Estimate size (typically 1-10GB per snapshot)
284                    // We can't easily get exact size without parsing more output
285                    let estimated_size = (snapshot_count as u64) * 2_000_000_000; // ~2GB per snapshot
286
287                    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    /// Detect system caches
308    fn detect_system_caches(&self) -> Result<Vec<CleanableItem>> {
309        let mut items = Vec::new();
310
311        #[cfg(target_os = "macos")]
312        {
313            // User caches
314            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 { // 1GB
318                    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            // Font caches
335            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            // Thumbnail cache
366            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            // Journal logs
387            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 { // 1GB
391                        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            // Windows Update cache
412            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            // Prefetch
434            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
460/// Find big files in a directory
461pub 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    // Walk home directory, but skip certain paths
470    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) // Don't go too deep
478        .into_iter()
479        .filter_entry(|e| {
480            let path = e.path();
481            // Skip hidden system directories and known cache locations
482            !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    // Sort by size (largest first)
517    items.sort_by(|a, b| b.size.cmp(&a.size));
518
519    // Return top 50
520    items.truncate(50);
521
522    Ok(items)
523}