1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::atomic::{AtomicU8, Ordering};
4
5use anyhow::Result;
6
7use crate::disk;
8use crate::duplicate::{self, DupeOptions, DupeProgress, DupeResult};
9use crate::file_finder::{self, FindOptions, FoundFile, ScanProgress};
10use crate::scanner::{self, ScanOptions};
11use crate::types::{Category, ScanResult};
12
13pub struct DashboardOptions {
19 pub scan_paths: Vec<PathBuf>,
21 pub scan_min_size: u64,
23 pub scan_older_than: Option<u64>,
25 pub find_paths: Vec<PathBuf>,
27 pub find_min_size: u64,
29 pub find_top: usize,
31 pub skip_dupes: bool,
33 pub dupe_min_size: u64,
35}
36
37impl Default for DashboardOptions {
38 fn default() -> Self {
39 let home = std::env::var("HOME").unwrap_or_else(|_| "/Users".into());
40 let downloads = PathBuf::from(&home).join("Downloads");
41 let desktop = PathBuf::from(&home).join("Desktop");
42 Self {
43 scan_paths: vec![PathBuf::from(&home)],
44 scan_min_size: 1024 * 1024, scan_older_than: None,
46 find_paths: vec![downloads, desktop],
47 find_min_size: 50 * 1024 * 1024, find_top: 50,
49 skip_dupes: false,
50 dupe_min_size: 1024 * 1024, }
52 }
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61#[repr(u8)]
62pub enum ScanPhase {
63 Scanning = 0,
64 FindingFiles = 1,
65 CheckingDuplicates = 2,
66 Done = 3,
67}
68
69impl ScanPhase {
70 pub fn label(&self) -> &'static str {
71 match self {
72 Self::Scanning => "Scanning known paths",
73 Self::FindingFiles => "Finding large files",
74 Self::CheckingDuplicates => "Checking duplicates",
75 Self::Done => "Done",
76 }
77 }
78
79 pub fn number(&self) -> u8 {
80 *self as u8 + 1
81 }
82
83 fn from_u8(v: u8) -> Self {
84 match v {
85 0 => Self::Scanning,
86 1 => Self::FindingFiles,
87 2 => Self::CheckingDuplicates,
88 _ => Self::Done,
89 }
90 }
91}
92
93pub struct DashboardProgress {
95 phase: AtomicU8,
96 pub total_phases: u8,
98 pub find_progress: ScanProgress,
100 pub dupe_progress: DupeProgress,
101}
102
103impl DashboardProgress {
104 pub fn new(skip_dupes: bool) -> Self {
105 Self {
106 phase: AtomicU8::new(0),
107 total_phases: if skip_dupes { 2 } else { 3 },
108 find_progress: ScanProgress::new(),
109 dupe_progress: DupeProgress::new(),
110 }
111 }
112
113 pub fn phase(&self) -> ScanPhase {
114 ScanPhase::from_u8(self.phase.load(Ordering::Relaxed))
115 }
116
117 pub fn set_phase(&self, phase: ScanPhase) {
118 self.phase.store(phase as u8, Ordering::Relaxed);
119 }
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Hash)]
128pub enum DashboardCategory {
129 DevArtifacts,
130 SystemCaches,
131 PackageManagers,
132 Docker,
133 Simulators,
134 SystemJunk,
135 LargeFiles,
136 Duplicates,
137 Other,
138}
139
140impl DashboardCategory {
141 pub fn label(&self) -> &'static str {
142 match self {
143 Self::DevArtifacts => "Dev Artifacts",
144 Self::SystemCaches => "System Caches",
145 Self::PackageManagers => "Package Managers",
146 Self::Docker => "Docker",
147 Self::Simulators => "Simulators",
148 Self::SystemJunk => "System Junk",
149 Self::LargeFiles => "Large Files",
150 Self::Duplicates => "Duplicates",
151 Self::Other => "Other",
152 }
153 }
154
155 pub fn from_scan_category(cat: &Category) -> Self {
157 match cat {
158 Category::DevArtifact(_) => Self::DevArtifacts,
159 Category::SystemCache => Self::SystemCaches,
160 Category::BrowserCache(_) => Self::SystemCaches,
161 Category::IdeCache(_) => Self::SystemCaches,
162 Category::AppCache(_) => Self::SystemCaches,
163 Category::PackageManager(_) => Self::PackageManagers,
164 Category::Docker => Self::Docker,
165 Category::Simulator => Self::Simulators,
166 Category::Android => Self::DevArtifacts,
167 Category::Personal(_) => Self::Other,
168 Category::SystemJunk(_) => Self::SystemJunk,
169 }
170 }
171}
172
173#[derive(Debug, Clone)]
175pub struct CategoryBreakdown {
176 pub category: DashboardCategory,
177 pub item_count: usize,
178 pub total_size: u64,
179}
180
181pub struct DashboardResult {
183 pub scan_result: ScanResult,
184 pub found_files: Vec<FoundFile>,
185 pub dupe_result: Option<DupeResult>,
186 pub disk_total: u64,
187 pub disk_used: u64,
188 pub disk_free: u64,
189}
190
191impl DashboardResult {
192 pub fn total_reclaimable(&self) -> u64 {
194 let scan_total: u64 = self.scan_result.items.iter().map(|i| i.size).sum();
195 let find_total: u64 = self.found_files.iter().map(|f| f.size).sum();
196 let dupe_total: u64 = self
197 .dupe_result
198 .as_ref()
199 .map(|d| d.total_wasted)
200 .unwrap_or(0);
201 scan_total + find_total + dupe_total
202 }
203
204 pub fn category_breakdown(&self) -> Vec<CategoryBreakdown> {
206 let mut map: HashMap<DashboardCategory, (usize, u64)> = HashMap::new();
207
208 for item in &self.scan_result.items {
210 let cat = DashboardCategory::from_scan_category(&item.category);
211 let entry = map.entry(cat).or_insert((0, 0));
212 entry.0 += 1;
213 entry.1 += item.size;
214 }
215
216 if !self.found_files.is_empty() {
218 let entry = map.entry(DashboardCategory::LargeFiles).or_insert((0, 0));
219 entry.0 += self.found_files.len();
220 entry.1 += self.found_files.iter().map(|f| f.size).sum::<u64>();
221 }
222
223 if let Some(ref dupe) = self.dupe_result
225 && !dupe.groups.is_empty()
226 {
227 let entry = map.entry(DashboardCategory::Duplicates).or_insert((0, 0));
228 entry.0 += dupe.groups.len();
229 entry.1 += dupe.total_wasted;
230 }
231
232 let mut breakdown: Vec<CategoryBreakdown> = map
233 .into_iter()
234 .map(|(category, (item_count, total_size))| CategoryBreakdown {
235 category,
236 item_count,
237 total_size,
238 })
239 .collect();
240
241 breakdown.sort_by(|a, b| b.total_size.cmp(&a.total_size));
243 breakdown
244 }
245}
246
247pub fn run_smart_scan(
256 options: &DashboardOptions,
257 progress: Option<&DashboardProgress>,
258) -> Result<DashboardResult> {
259 let home = std::env::var("HOME")?;
260 let home_path = Path::new(&home);
261
262 let disk_info = disk::get_disk_info(home_path)?;
264
265 if let Some(p) = progress {
266 p.set_phase(ScanPhase::Scanning);
267 }
268
269 let scan_options = ScanOptions {
271 min_size: options.scan_min_size,
272 older_than: options.scan_older_than,
273 project_dirs: options.scan_paths.clone(),
274 };
275
276 let find_options = FindOptions {
277 root_paths: options.find_paths.clone(),
278 min_size: Some(options.find_min_size),
279 older_than: None,
280 file_types: None,
281 max_results: Some(options.find_top),
282 validate_types: true,
283 extra_exclusions: Vec::new(),
284 };
285
286 let (scan_result, found_files) = std::thread::scope(|s| {
287 let scan_handle = s.spawn(|| scanner::scan(&scan_options));
288
289 if let Some(p) = progress {
291 p.set_phase(ScanPhase::FindingFiles);
292 }
293 let find_handle =
294 s.spawn(|| file_finder::find_files(&find_options, progress.map(|p| &p.find_progress)));
295
296 let scan_result = scan_handle.join().expect("scan thread panicked")?;
297 let found_files = find_handle.join().expect("find thread panicked");
298
299 Ok::<_, anyhow::Error>((scan_result, found_files))
300 })?;
301
302 let dupe_result = if options.skip_dupes {
304 None
305 } else {
306 if let Some(p) = progress {
307 p.set_phase(ScanPhase::CheckingDuplicates);
308 }
309
310 let dupe_options = DupeOptions {
311 root_paths: options.scan_paths.clone(),
312 min_size: Some(options.dupe_min_size),
313 min_group_size: None,
314 };
315 Some(duplicate::find_duplicates(
316 &dupe_options,
317 progress.map(|p| &p.dupe_progress),
318 )?)
319 };
320
321 if let Some(p) = progress {
322 p.set_phase(ScanPhase::Done);
323 }
324
325 Ok(DashboardResult {
326 scan_result,
327 found_files,
328 dupe_result,
329 disk_total: disk_info.total,
330 disk_used: disk_info.used,
331 disk_free: disk_info.free,
332 })
333}
334
335#[cfg(test)]
340mod tests {
341 use super::*;
342 use crate::types::{Category, CleanableItem, ProjectType, Risk};
343 use std::time::SystemTime;
344
345 fn make_item(category: Category, size: u64) -> CleanableItem {
346 CleanableItem {
347 category,
348 path: PathBuf::from("/tmp/test"),
349 size,
350 risk: Risk::Low,
351 regenerates: true,
352 regeneration_hint: None,
353 last_modified: Some(SystemTime::now()),
354 description: "test".to_string(),
355 cleanup_command: None,
356 }
357 }
358
359 #[test]
360 fn test_total_reclaimable_scan_only() {
361 let result = DashboardResult {
362 scan_result: ScanResult {
363 items: vec![
364 make_item(Category::DevArtifact(ProjectType::Node), 100),
365 make_item(Category::SystemCache, 200),
366 ],
367 disk_total: 1000,
368 disk_used: 800,
369 disk_free: 200,
370 },
371 found_files: vec![],
372 dupe_result: None,
373 disk_total: 1000,
374 disk_used: 800,
375 disk_free: 200,
376 };
377 assert_eq!(result.total_reclaimable(), 300);
378 }
379
380 #[test]
381 fn test_total_reclaimable_with_files_and_dupes() {
382 use crate::duplicate::{DupeResult, DuplicateFile, DuplicateGroup};
383 use crate::file_finder::{FileCategory, FoundFile};
384
385 let result = DashboardResult {
386 scan_result: ScanResult {
387 items: vec![make_item(Category::Docker, 500)],
388 disk_total: 1000,
389 disk_used: 800,
390 disk_free: 200,
391 },
392 found_files: vec![FoundFile {
393 path: PathBuf::from("/tmp/big.dmg"),
394 size: 1000,
395 mtime: None,
396 category: FileCategory::DiskImage,
397 is_downloaded: false,
398 download_source: None,
399 }],
400 dupe_result: Some(DupeResult {
401 groups: vec![DuplicateGroup {
402 hash: "abc".to_string(),
403 size: 100,
404 files: vec![
405 DuplicateFile {
406 path: PathBuf::from("/a"),
407 size: 100,
408 mtime: None,
409 inode: 1,
410 device: 1,
411 },
412 DuplicateFile {
413 path: PathBuf::from("/b"),
414 size: 100,
415 mtime: None,
416 inode: 2,
417 device: 1,
418 },
419 ],
420 is_clone_group: false,
421 }],
422 total_wasted: 100,
423 files_scanned: 10,
424 empty_files_skipped: 0,
425 }),
426 disk_total: 1000,
427 disk_used: 800,
428 disk_free: 200,
429 };
430 assert_eq!(result.total_reclaimable(), 1600);
432 }
433
434 #[test]
435 fn test_category_breakdown() {
436 let result = DashboardResult {
437 scan_result: ScanResult {
438 items: vec![
439 make_item(Category::DevArtifact(ProjectType::Rust), 300),
440 make_item(Category::DevArtifact(ProjectType::Node), 200),
441 make_item(Category::SystemCache, 100),
442 make_item(Category::PackageManager("npm".into()), 50),
443 ],
444 disk_total: 1000,
445 disk_used: 800,
446 disk_free: 200,
447 },
448 found_files: vec![],
449 dupe_result: None,
450 disk_total: 1000,
451 disk_used: 800,
452 disk_free: 200,
453 };
454
455 let breakdown = result.category_breakdown();
456 assert!(!breakdown.is_empty());
457
458 assert_eq!(breakdown[0].category, DashboardCategory::DevArtifacts);
460 assert_eq!(breakdown[0].item_count, 2);
461 assert_eq!(breakdown[0].total_size, 500);
462 }
463
464 #[test]
465 fn test_dashboard_category_mapping() {
466 assert_eq!(
467 DashboardCategory::from_scan_category(&Category::DevArtifact(ProjectType::Rust)),
468 DashboardCategory::DevArtifacts
469 );
470 assert_eq!(
471 DashboardCategory::from_scan_category(&Category::BrowserCache("Chrome".into())),
472 DashboardCategory::SystemCaches
473 );
474 assert_eq!(
475 DashboardCategory::from_scan_category(&Category::Docker),
476 DashboardCategory::Docker
477 );
478 assert_eq!(
479 DashboardCategory::from_scan_category(&Category::Simulator),
480 DashboardCategory::Simulators
481 );
482 }
483
484 #[test]
485 fn test_scan_phase_labels() {
486 assert_eq!(ScanPhase::Scanning.label(), "Scanning known paths");
487 assert_eq!(ScanPhase::FindingFiles.label(), "Finding large files");
488 assert_eq!(ScanPhase::CheckingDuplicates.label(), "Checking duplicates");
489 assert_eq!(ScanPhase::Done.label(), "Done");
490 }
491
492 #[test]
493 fn test_dashboard_progress_phases() {
494 let progress = DashboardProgress::new(false);
495 assert_eq!(progress.phase(), ScanPhase::Scanning);
496 assert_eq!(progress.total_phases, 3);
497
498 progress.set_phase(ScanPhase::FindingFiles);
499 assert_eq!(progress.phase(), ScanPhase::FindingFiles);
500
501 let progress_no_dupes = DashboardProgress::new(true);
502 assert_eq!(progress_no_dupes.total_phases, 2);
503 }
504
505 #[test]
506 fn test_default_options() {
507 let opts = DashboardOptions::default();
508 assert_eq!(opts.scan_min_size, 1024 * 1024);
509 assert_eq!(opts.find_min_size, 50 * 1024 * 1024);
510 assert_eq!(opts.find_top, 50);
511 assert!(!opts.skip_dupes);
512 }
513}