Skip to main content

diskr/
cli.rs

1use crate::{analyzer, cleaner, config, health, history, scanner, ui};
2use anyhow::Result;
3use clap::{Parser, Subcommand};
4use colored::Colorize;
5use humansize::{format_size, DECIMAL};
6use std::path::PathBuf;
7
8#[derive(Parser)]
9#[command(name = "diskr", version, about = "Save your disk space, without fear of deleting the wrong thing.")]
10pub struct Cli {
11    #[command(subcommand)]
12    pub command: Option<Commands>,
13}
14
15#[derive(Subcommand)]
16pub enum Commands {
17    Scan {
18        path: Option<PathBuf>,
19        #[arg(long)]
20        json: bool,
21        #[arg(long, default_value_t = 100)]
22        min_size_mb: u64,
23    },
24    Clean {
25        path: Option<PathBuf>,
26        #[arg(long)]
27        dry_run: bool,
28        #[arg(long)]
29        yes: bool,
30    },
31    Watch {
32        path: Option<PathBuf>,
33    },
34    Restore {
35        name: String,
36    },
37    Duplicates {
38        path: Option<PathBuf>,
39        #[arg(long, default_value_t = 4)]
40        min_size_kb: u64,
41        #[arg(long)]
42        include_system: bool,
43    },
44    Doctor {
45        path: Option<PathBuf>,
46    },
47    Health,
48    Logs {
49        #[arg(long)]
50        vacuum: Option<String>,
51    },
52    Apps {
53        #[arg(long, default_value_t = 90)]
54        min_idle_days: i64,
55    },
56    Sensitive {
57        path: Option<PathBuf>,
58        #[arg(long)]
59        json: bool,
60    },
61    Docker {
62        path: Option<PathBuf>,
63        #[arg(long)]
64        prune: bool,
65        #[arg(long)]
66        volumes: bool,
67    },
68}
69
70pub async fn run(cli: Cli) -> Result<()> {
71    match cli.command {
72        None => ui::app::launch(),
73        Some(Commands::Scan { path, json, min_size_mb }) => cmd_scan(path, json, min_size_mb),
74        Some(Commands::Clean { path, dry_run, yes }) => cmd_clean(path, dry_run, yes).await,
75        Some(Commands::Watch { path }) => cmd_watch(path).await,
76        Some(Commands::Restore { name }) => cmd_restore(name),
77        Some(Commands::Duplicates { path, min_size_kb, include_system }) => {
78            cmd_duplicates(path, min_size_kb, include_system)
79        }
80        Some(Commands::Doctor { path }) => cmd_doctor(path),
81        Some(Commands::Health) => cmd_health(),
82        Some(Commands::Logs { vacuum }) => cmd_logs(vacuum),
83        Some(Commands::Apps { min_idle_days }) => cmd_apps(min_idle_days),
84        Some(Commands::Sensitive { path, json }) => cmd_sensitive(path, json),
85        Some(Commands::Docker { path, prune, volumes }) => cmd_docker(path, prune, volumes),
86    }
87}
88
89fn resolve_path(path: Option<PathBuf>) -> PathBuf {
90    let target = path.unwrap_or_else(|| PathBuf::from("."));
91    std::fs::canonicalize(&target).unwrap_or(target)
92}
93
94fn section_header(title: &str) {
95    let mut c = title.chars();
96    let cap = match c.next() {
97        Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
98        None => return,
99    };
100    println!("{}", cap.bold());
101}
102
103fn usage_bar(ratio: f64, width: usize) -> String {
104    let filled = ((ratio.clamp(0.0, 1.0)) * width as f64).round() as usize;
105    let filled = filled.min(width);
106    let bar = format!("{}{}", "█".repeat(filled), "░".repeat(width - filled));
107    let colored_bar = if ratio > 0.9 {
108        bar.red()
109    } else if ratio > 0.75 {
110        bar.yellow()
111    } else {
112        bar.green()
113    };
114    format!("[{colored_bar}] {:>3.0}%", ratio * 100.0)
115}
116
117fn cmd_scan(path: Option<PathBuf>, json: bool, min_size_mb: u64) -> Result<()> {
118    let target = resolve_path(path);
119    let opts = scanner::walker::WalkOptions { show_progress: !json, ..Default::default() };
120    let result = scanner::walker::scan_path(&target, &opts)?;
121
122    if json {
123        println!("{}", serde_json::to_string_pretty(&result)?);
124        return Ok(());
125    }
126
127    section_header("scan summary");
128    println!("  {} {}", "path:".dimmed(), target.display());
129    println!("  {} {}", "total size:".dimmed(), format_size(result.total_size, DECIMAL).bold());
130    println!(
131        "  {} {} files, {} dirs   {} {}ms",
132        "contents:".dimmed(),
133        result.file_count,
134        result.dir_count,
135        "scanned in".dimmed(),
136        result.scan_duration_ms
137    );
138
139    let big_files = analyzer::size::top_files(&result.root, min_size_mb * 1024 * 1024, 15);
140    if !big_files.is_empty() {
141        println!();
142        section_header("largest files");
143        let max = big_files.iter().map(|f| f.size).max().unwrap_or(1).max(1);
144        for f in &big_files {
145            let ratio = f.size as f64 / max as f64;
146            println!(
147                "  {:>10}  {}  {}",
148                format_size(f.size, DECIMAL).bold(),
149                usage_bar(ratio, 16),
150                f.path.display()
151            );
152        }
153    }
154
155    let top_dirs = analyzer::size::top_dirs(&result.root, 10);
156    if !top_dirs.is_empty() {
157        println!();
158        section_header("largest directories");
159        let max = top_dirs.iter().map(|d| d.size).max().unwrap_or(1).max(1);
160        for d in &top_dirs {
161            let ratio = d.size as f64 / max as f64;
162            println!(
163                "  {:>10}  {}  {}",
164                format_size(d.size, DECIMAL).bold(),
165                usage_bar(ratio, 16),
166                d.path.display()
167            );
168        }
169    }
170
171    Ok(())
172}
173
174async fn cmd_clean(path: Option<PathBuf>, dry_run: bool, yes: bool) -> Result<()> {
175    let target = resolve_path(path);
176    let cfg = config::load()?;
177    let opts = scanner::walker::WalkOptions::default();
178    let result = scanner::walker::scan_path(&target, &opts)?;
179
180    let stale = analyzer::age::stale_files(&result.root, cfg.stale_days, cfg.stale_min_size_mb * 1024 * 1024);
181    let mut caches = Vec::new();
182    analyzer::patterns::find_dev_caches(&result.root, &mut caches);
183
184    let candidates: Vec<(PathBuf, u64)> = stale.iter().map(|s| (s.path.clone(), s.size))
185        .chain(caches.iter().map(|c| (c.path.clone(), c.size)))
186        .filter(|(p, _)| !cleaner::safe::is_game_or_store_dir(p))
187        .collect();
188
189    let plan = cleaner::safe::build_plan(candidates);
190
191    section_header("clean plan");
192    println!("  {}", format!("{} items, {}", plan.remove.len(), format_size(plan.total_size, DECIMAL)).yellow().bold());
193    println!();
194    for p in plan.remove.iter().take(40) {
195        println!("  {} {}", "-".dimmed(), p.display());
196    }
197    if plan.remove.len() > 40 {
198        println!("  {} ({} more not shown)", "…".dimmed(), plan.remove.len() - 40);
199    }
200    if !plan.blocked.is_empty() {
201        println!("\n  {} {} protected path(s) skipped", "note:".red().bold(), plan.blocked.len());
202    }
203    if !plan.at_risk.is_empty() {
204        println!(
205            "\n  {} {} item(s) skipped — removing them could break something running right now:",
206            "⚠".yellow().bold(),
207            plan.at_risk.len()
208        );
209        for a in &plan.at_risk {
210            println!("    {} {}", a.path.display(), format!("({})", a.reason).dimmed());
211        }
212    }
213
214    if dry_run {
215        println!("\n  {}", "dry run, nothing was deleted".cyan());
216        return Ok(());
217    }
218    if !yes {
219        println!("\n  {}", "pass --yes to actually delete these".yellow());
220        return Ok(());
221    }
222
223    let removed = cleaner::safe::execute_plan(&plan, false)?;
224    history::append(history::CleanEvent {
225        timestamp: chrono::Utc::now(),
226        paths: plan.remove.clone(),
227        bytes_freed: plan.total_size,
228        note: "diskr clean".into(),
229    })?;
230    println!(
231        "\n  {}",
232        format!("{removed} item(s) moved to trash, {} freed", format_size(plan.total_size, DECIMAL)).green().bold()
233    );
234
235    let watcher = health::watcher::PostDeleteWatcher::new(plan.remove.clone());
236    tokio::spawn(watcher.run());
237    Ok(())
238}
239
240async fn cmd_watch(path: Option<PathBuf>) -> Result<()> {
241    let target = resolve_path(path);
242    section_header("watching");
243    println!("  {} {}", "path:".dimmed(), target.display());
244    println!("  {}", "checking every 10 minutes, ctrl-c to stop".dimmed());
245    let mut interval = tokio::time::interval(std::time::Duration::from_secs(600));
246    loop {
247        interval.tick().await;
248        let opts = scanner::walker::WalkOptions { show_progress: false, ..Default::default() };
249        if let Ok(result) = scanner::walker::scan_path(&target, &opts) {
250            let now = chrono::Local::now().format("%H:%M:%S");
251            println!("  [{now}] {} {}", "total size:".dimmed(), format_size(result.total_size, DECIMAL).bold());
252        }
253    }
254}
255
256fn cmd_restore(name: String) -> Result<()> {
257    let n = cleaner::trash::restore_from_trash(&name)?;
258    if n == 0 {
259        println!("{}", "nothing matching that name found in trash".yellow());
260    } else {
261        println!("{}", format!("{n} item(s) restored").green().bold());
262    }
263    Ok(())
264}
265
266fn cmd_duplicates(path: Option<PathBuf>, min_size_kb: u64, include_system: bool) -> Result<()> {
267    let target = resolve_path(path);
268    let cfg = config::load().unwrap_or_default();
269    let opts = scanner::walker::WalkOptions::default();
270    let result = scanner::walker::scan_path(&target, &opts)?;
271    let groups = analyzer::duplicates::find_duplicates_filtered(
272        &result.root,
273        min_size_kb * 1024,
274        &cfg.exclude_paths,
275        !include_system,
276    );
277
278    section_header("duplicate files");
279
280    if groups.is_empty() {
281        println!("  {}", "no duplicate files found".green());
282        if !include_system {
283            println!(
284                "  {}",
285                "(flatpak, .var/app, Steam compatdata/shadercache and .cache were skipped — pass --include-system to check them too)"
286                    .dimmed()
287            );
288        }
289        return Ok(());
290    }
291
292    let total_wasted: u64 = groups.iter().map(|g| g.wasted).sum();
293    let total_hardlinked: usize = groups.iter().map(|g| g.hardlinked).sum();
294
295    println!(
296        "  {} duplicate groups   {} reclaimable",
297        groups.len().to_string().bold(),
298        format_size(total_wasted, DECIMAL).yellow().bold()
299    );
300    if !include_system {
301        println!("  {}", "system/runtime dirs (flatpak, .cache, Steam compatdata) excluded by default".dimmed());
302    }
303    if total_hardlinked > 0 {
304        println!(
305            "  {}",
306            format!(
307                "{total_hardlinked} additional copies are already hardlinks and cost 0 extra space (shown, not counted as waste)"
308            )
309            .dimmed()
310        );
311    }
312    println!();
313
314    for (idx, g) in groups.iter().take(30).enumerate() {
315        let hardlink_note = if g.hardlinked > 0 {
316            format!("  ({} hardlinked, already shared)", g.hardlinked).dimmed().to_string()
317        } else {
318            String::new()
319        };
320        println!(
321            "{}",
322            format!(
323                "  {:>2}. {} each × {} copies → {} wasted{}",
324                idx + 1,
325                format_size(g.size, DECIMAL),
326                g.paths.len(),
327                format_size(g.wasted, DECIMAL),
328                hardlink_note
329            )
330            .cyan()
331            .bold()
332        );
333        for p in &g.paths {
334            println!("      {} {}", "•".dimmed(), p.display());
335        }
336    }
337
338    if groups.len() > 30 {
339        println!("\n  {} ({} more groups not shown)", "…".dimmed(), groups.len() - 30);
340    }
341
342    Ok(())
343}
344
345fn cmd_doctor(path: Option<PathBuf>) -> Result<()> {
346    let target = resolve_path(path);
347    let cfg = config::load().unwrap_or_default();
348    let opts = scanner::walker::WalkOptions::default();
349    let result = scanner::walker::scan_path(&target, &opts)?;
350
351    section_header("diskr doctor");
352    println!("  {} {}", "scanned:".dimmed(), target.display());
353
354    let mut score: i64 = 100;
355    let mut notes: Vec<String> = Vec::new();
356
357    // disk health
358    for d in analyzer::disk_health::list_disks() {
359        match d.status {
360            analyzer::disk_health::HealthStatus::Warning => {
361                score -= 15;
362                notes.push(format!("{} {} is reporting warning-level wear/temperature", "!".yellow(), d.device));
363            }
364            analyzer::disk_health::HealthStatus::Critical => {
365                score -= 40;
366                notes.push(format!("{} {} is reporting critical SMART health", "✗".red().bold(), d.device));
367            }
368            _ => {}
369        }
370        let used_ratio = 1.0
371            - (d.available_bytes as f64 / d.total_bytes.max(1) as f64);
372        if used_ratio > 0.95 {
373            score -= 10;
374            notes.push(format!("{} {} is {:.0}% full", "!".yellow(), d.device, used_ratio * 100.0));
375        }
376    }
377
378    let dup_groups =
379        analyzer::duplicates::find_duplicates_filtered(&result.root, 1024 * 1024, &cfg.exclude_paths, true);
380    let dup_wasted: u64 = dup_groups.iter().map(|g| g.wasted).sum();
381
382    let mut caches = Vec::new();
383    analyzer::patterns::find_dev_caches(&result.root, &mut caches);
384    let cache_total: u64 = caches.iter().map(|c| c.size).sum();
385
386    let stale = analyzer::age::stale_files(&result.root, cfg.stale_days, cfg.stale_min_size_mb * 1024 * 1024);
387    let stale_total: u64 = stale.iter().map(|s| s.size).sum();
388
389    let sensitive = analyzer::extra::scan_sensitive_files(&result.root);
390    if !sensitive.is_empty() {
391        score -= (sensitive.len() as i64 * 2).min(20);
392        notes.push(format!(
393            "{} {} potentially sensitive file(s) exposed — run `diskr sensitive`",
394            "!".yellow(),
395            sensitive.len()
396        ));
397    }
398
399    score = score.clamp(0, 100);
400    let grade = match score {
401        90..=100 => "A".green().bold(),
402        75..=89 => "B".green().bold(),
403        60..=74 => "C".yellow().bold(),
404        40..=59 => "D".yellow().bold(),
405        _ => "F".red().bold(),
406    };
407
408    println!();
409    println!("  {}  {}  ({score}/100)", "grade:".dimmed(), grade);
410
411    if notes.is_empty() {
412        println!("  {}", "no issues found".green());
413    } else {
414        for n in &notes {
415            println!("  {n}");
416        }
417    }
418
419    println!();
420    section_header("biggest win");
421    let mut wins: Vec<(String, u64, &str)> = vec![
422        ("duplicate files".to_string(), dup_wasted, "diskr duplicates"),
423        ("dev/build caches (node_modules, target, __pycache__, ...)".to_string(), cache_total, "diskr clean"),
424        (format!("files untouched for {}+ days", cfg.stale_days), stale_total, "diskr clean"),
425    ];
426    wins.sort_by(|a, b| b.1.cmp(&a.1));
427
428    if let Some((label, size, cmd)) = wins.first() {
429        if *size == 0 {
430            println!("  {}", "nothing significant found to reclaim right now — nice and tidy".green());
431        } else {
432            println!(
433                "  {} {} {}",
434                format_size(*size, DECIMAL).yellow().bold(),
435                "reclaimable in".dimmed(),
436                label
437            );
438            println!("  {} `{}`", "run:".dimmed(), cmd.cyan());
439        }
440    }
441
442    Ok(())
443}
444
445fn cmd_health() -> Result<()> {
446    let disks = analyzer::disk_health::list_disks();
447
448    section_header("disk health");
449
450    if disks.is_empty() {
451        println!("  {}", "no disks detected".yellow());
452        return Ok(());
453    }
454
455    let needs_root_note = disks.iter().any(|d| !d.smart_available);
456
457    for d in &disks {
458        let status_colored = match d.status {
459            analyzer::disk_health::HealthStatus::Good => "●  good".green().bold(),
460            analyzer::disk_health::HealthStatus::Warning => "●  warning".yellow().bold(),
461            analyzer::disk_health::HealthStatus::Critical => "●  critical".red().bold(),
462            analyzer::disk_health::HealthStatus::Unknown => "●  unknown".dimmed(),
463        };
464
465        println!();
466        println!("  {}  {}", d.device.bold(), status_colored);
467        println!("  {}", d.model.dimmed());
468
469        let used = d.total_bytes.saturating_sub(d.available_bytes);
470        let ratio = used as f64 / d.total_bytes.max(1) as f64;
471        println!(
472            "  {:<10} {}   {} used of {}",
473            "usage:",
474            usage_bar(ratio, 24),
475            format_size(used, DECIMAL),
476            format_size(d.total_bytes, DECIMAL)
477        );
478        println!("  {:<10} {}", "type:", d.kind);
479
480        if d.smart_available {
481            if let Some(t) = d.temperature_c {
482                let t_str = if t > 65 { t.to_string().red().to_string() } else if t > 55 { t.to_string().yellow().to_string() } else { t.to_string().green().to_string() };
483                println!("  {:<10} {t_str}°C", "temp:");
484            }
485            if let Some(h) = d.power_on_hours {
486                println!("  {:<10} {h} hours ({:.1} days)", "power-on:", h as f64 / 24.0);
487            }
488            if let Some(s) = d.reallocated_sectors {
489                let s_str = if s > 0 { s.to_string().red().to_string() } else { s.to_string().green().to_string() };
490                println!("  {:<10} {s_str}", "bad sectors:");
491            }
492            if let Some(w) = d.wear_level_percent {
493                println!("  {:<10} {}", "wear:", usage_bar(w as f64 / 100.0, 24));
494            }
495        } else {
496            println!(
497                "  {:<10} {}",
498                "SMART:",
499                "unavailable — status below is estimated from free space only".dimmed()
500            );
501        }
502    }
503
504    if needs_root_note {
505        println!();
506        println!(
507            "  {}",
508            "tip: run `sudo diskr health` (or install smartmontools) for real temperature/wear data instead of the free-space estimate"
509                .dimmed()
510        );
511    }
512
513    Ok(())
514}
515
516fn cmd_logs(vacuum: Option<String>) -> Result<()> {
517    if let Some(keep) = vacuum {
518        health::logger::vacuum_journald(&keep)?;
519        println!("{} journald vacuumed, kept last {}", "done:".green().bold(), keep);
520        return Ok(());
521    }
522    let logs = health::logger::scan_logs();
523    section_header("log usage");
524    if logs.is_empty() {
525        println!("  {}", "no notable logs found".green());
526        return Ok(());
527    }
528    let max = logs.iter().map(|l| l.size_bytes).max().unwrap_or(1).max(1);
529    for l in &logs {
530        let ratio = l.size_bytes as f64 / max as f64;
531        println!("  {:>10}  {}  {}", format_size(l.size_bytes, DECIMAL).bold(), usage_bar(ratio, 16), l.label);
532    }
533    println!("\n  {}", "tip: `diskr logs --vacuum 200M` (or a duration like `3d`) shrinks journald".dimmed());
534    Ok(())
535}
536
537fn cmd_apps(min_idle_days: i64) -> Result<()> {
538    let apps = analyzer::extra::find_unused_apps(min_idle_days);
539    section_header("idle applications");
540
541    let (mut confirmed, unknown): (Vec<_>, Vec<_>) =
542        apps.into_iter().partition(|a| a.last_used_days.is_some());
543    confirmed.sort_by(|a, b| b.last_used_days.unwrap_or(0).cmp(&a.last_used_days.unwrap_or(0)));
544
545    if confirmed.is_empty() && unknown.is_empty() {
546        println!("  {}", "no candidates found".green());
547        return Ok(());
548    }
549
550    if confirmed.is_empty() {
551        println!("  {}", format!("no apps confirmed idle for {min_idle_days}+ days").green());
552    } else {
553        println!("  {}", format!("{} app(s) confirmed idle for {min_idle_days}+ days", confirmed.len()).yellow().bold());
554        println!();
555        for a in &confirmed {
556            println!(
557                "  {:<6} {:<28} {} {} days",
558                a.package_manager.dimmed(),
559                a.name.bold(),
560                "idle".dimmed(),
561                a.last_used_days.unwrap_or(0)
562            );
563        }
564    }
565
566    if !unknown.is_empty() {
567        println!();
568        println!(
569            "  {}",
570            format!("{} app(s) with usage unknown — not confirmed idle, worth a manual look", unknown.len()).dimmed()
571        );
572        println!();
573        for a in &unknown {
574            println!("  {:<6} {:<28} {}", a.package_manager.dimmed(), a.name.bold(), "usage unknown".dimmed());
575        }
576    }
577    Ok(())
578}
579
580fn cmd_sensitive(path: Option<PathBuf>, json: bool) -> Result<()> {
581    let target = resolve_path(path);
582    let opts = scanner::walker::WalkOptions::default();
583    let result = scanner::walker::scan_path(&target, &opts)?;
584    let findings = analyzer::extra::scan_sensitive_files(&result.root);
585
586    if json {
587        println!("{}", serde_json::to_string_pretty(&findings)?);
588        return Ok(());
589    }
590
591    section_header("sensitive files");
592
593    if findings.is_empty() {
594        println!("  {}", "no obviously exposed credential files found".green());
595        println!("  {}", "(vendored dirs like venv, node_modules, Steam and .cache are skipped, and .pem files are only flagged if they actually contain a private key)".dimmed());
596        return Ok(());
597    }
598
599    println!(
600        "  {} {}",
601        format!("{} potentially sensitive file(s) found", findings.len()).red().bold(),
602        "— review before sharing this machine or a backup of it".dimmed()
603    );
604    println!();
605
606    for f in findings.iter().take(25) {
607        println!("  {} {}", "•".yellow(), f.path.display());
608        println!("    {}", f.reason.dimmed());
609    }
610
611    if findings.len() > 25 {
612        println!(
613            "\n  {} ({} more not shown — pass --json to see the full list)",
614            "…".dimmed(),
615            findings.len() - 25
616        );
617    }
618
619    Ok(())
620}
621
622fn cmd_docker(path: Option<PathBuf>, prune: bool, volumes: bool) -> Result<()> {
623    section_header("docker storage");
624    if !analyzer::extra::docker_prune_suggested() {
625        println!("  {}", "docker is not running or not installed".yellow());
626        return Ok(());
627    }
628    let target = resolve_path(path);
629    let opts = scanner::walker::WalkOptions::default();
630    let result = scanner::walker::scan_path(&target, &opts)?;
631    let findings = analyzer::extra::find_docker_bloat(&result.root);
632    let total: u64 = findings.iter().map(|f| f.size).sum();
633
634    if findings.is_empty() {
635        println!("  {}", "no docker storage bloat found".green());
636        return Ok(());
637    }
638
639    println!("  {}", format!("{} entries, {} total", findings.len(), format_size(total, DECIMAL)).bold());
640    println!();
641    for f in &findings {
642        println!("  {:>10}  {}  {}", format_size(f.size, DECIMAL).bold(), format!("({})", f.label).dimmed(), f.path.display());
643    }
644
645    if prune {
646        println!("\n  {}", "running docker system prune...".cyan());
647        let output = analyzer::extra::run_docker_prune(volumes)?;
648        println!("{output}");
649    } else {
650        println!("\n  {}", "pass --prune to run `docker system prune -f` (add --volumes to include volumes)".dimmed());
651    }
652    Ok(())
653}