Skip to main content

diskforge_core/
dashboard.rs

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
13// ---------------------------------------------------------------------------
14// Options
15// ---------------------------------------------------------------------------
16
17/// Options for the unified smart scan.
18pub struct DashboardOptions {
19    /// Directories to scan for dev artifacts and system caches.
20    pub scan_paths: Vec<PathBuf>,
21    /// Minimum size for scan items (bytes).
22    pub scan_min_size: u64,
23    /// Only report scan items older than this many seconds.
24    pub scan_older_than: Option<u64>,
25    /// Directories to search for large/old files.
26    pub find_paths: Vec<PathBuf>,
27    /// Minimum file size for the finder (bytes).
28    pub find_min_size: u64,
29    /// Maximum number of large-file results.
30    pub find_top: usize,
31    /// Skip duplicate detection entirely (faster scan).
32    pub skip_dupes: bool,
33    /// Minimum size for duplicate detection (bytes).
34    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, // 1 MB
45            scan_older_than: None,
46            find_paths: vec![downloads, desktop],
47            find_min_size: 50 * 1024 * 1024, // 50 MB
48            find_top: 50,
49            skip_dupes: false,
50            dupe_min_size: 1024 * 1024, // 1 MB
51        }
52    }
53}
54
55// ---------------------------------------------------------------------------
56// Progress
57// ---------------------------------------------------------------------------
58
59/// Phases of the smart scan.
60#[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
93/// Thread-safe progress tracking across scan phases.
94pub struct DashboardProgress {
95    phase: AtomicU8,
96    /// Total phases (2 without dupes, 3 with dupes).
97    pub total_phases: u8,
98    /// Per-engine progress — populated for find and dupes phases.
99    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// ---------------------------------------------------------------------------
123// Result + Categories
124// ---------------------------------------------------------------------------
125
126/// Categories for the dashboard breakdown.
127#[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    /// Map a core Category to a DashboardCategory.
156    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/// A single entry in the category breakdown.
174#[derive(Debug, Clone)]
175pub struct CategoryBreakdown {
176    pub category: DashboardCategory,
177    pub item_count: usize,
178    pub total_size: u64,
179}
180
181/// Unified result from the smart scan.
182pub 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    /// Total reclaimable bytes across all scan types.
193    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    /// Break down results by dashboard category.
205    pub fn category_breakdown(&self) -> Vec<CategoryBreakdown> {
206        let mut map: HashMap<DashboardCategory, (usize, u64)> = HashMap::new();
207
208        // Scan items
209        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        // Found files
217        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        // Duplicates
224        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        // Sort by size descending
242        breakdown.sort_by(|a, b| b.total_size.cmp(&a.total_size));
243        breakdown
244    }
245}
246
247// ---------------------------------------------------------------------------
248// Orchestrator
249// ---------------------------------------------------------------------------
250
251/// Run the unified smart scan: scan + find + (optionally) dupes.
252///
253/// Scan and find run in parallel threads (both are I/O-bound and independent).
254/// Dupes run after both complete (it benefits from the scan's directory discovery).
255pub 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    // Get disk info
263    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    // --- Run scan + find in parallel ---
270    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        // Start find in parallel
290        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    // --- Phase 3: Duplicate detection (optional, sequential after scan+find) ---
303    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// ---------------------------------------------------------------------------
336// Tests
337// ---------------------------------------------------------------------------
338
339#[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        // 500 (scan) + 1000 (find) + 100 (dupes wasted) = 1600
431        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        // Dev artifacts should be first (500 bytes total)
459        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}