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
204    if dry_run {
205        println!("\n  {}", "dry run, nothing was deleted".cyan());
206        return Ok(());
207    }
208    if !yes {
209        println!("\n  {}", "pass --yes to actually delete these".yellow());
210        return Ok(());
211    }
212
213    let removed = cleaner::safe::execute_plan(&plan, false)?;
214    history::append(history::CleanEvent {
215        timestamp: chrono::Utc::now(),
216        paths: plan.remove.clone(),
217        bytes_freed: plan.total_size,
218        note: "diskr clean".into(),
219    })?;
220    println!(
221        "\n  {}",
222        format!("{removed} item(s) moved to trash, {} freed", format_size(plan.total_size, DECIMAL)).green().bold()
223    );
224
225    let watcher = health::watcher::PostDeleteWatcher::new(plan.remove.clone());
226    tokio::spawn(watcher.run());
227    Ok(())
228}
229
230async fn cmd_watch(path: Option<PathBuf>) -> Result<()> {
231    let target = resolve_path(path);
232    section_header("watching");
233    println!("  {} {}", "path:".dimmed(), target.display());
234    println!("  {}", "checking every 10 minutes, ctrl-c to stop".dimmed());
235    let mut interval = tokio::time::interval(std::time::Duration::from_secs(600));
236    loop {
237        interval.tick().await;
238        let opts = scanner::walker::WalkOptions { show_progress: false, ..Default::default() };
239        if let Ok(result) = scanner::walker::scan_path(&target, &opts) {
240            let now = chrono::Local::now().format("%H:%M:%S");
241            println!("  [{now}] {} {}", "total size:".dimmed(), format_size(result.total_size, DECIMAL).bold());
242        }
243    }
244}
245
246fn cmd_restore(name: String) -> Result<()> {
247    let n = cleaner::trash::restore_from_trash(&name)?;
248    if n == 0 {
249        println!("{}", "nothing matching that name found in trash".yellow());
250    } else {
251        println!("{}", format!("{n} item(s) restored").green().bold());
252    }
253    Ok(())
254}
255
256fn cmd_duplicates(path: Option<PathBuf>, min_size_kb: u64, include_system: bool) -> Result<()> {
257    let target = resolve_path(path);
258    let cfg = config::load().unwrap_or_default();
259    let opts = scanner::walker::WalkOptions::default();
260    let result = scanner::walker::scan_path(&target, &opts)?;
261    let groups = analyzer::duplicates::find_duplicates_filtered(
262        &result.root,
263        min_size_kb * 1024,
264        &cfg.exclude_paths,
265        !include_system,
266    );
267
268    section_header("duplicate files");
269
270    if groups.is_empty() {
271        println!("  {}", "no duplicate files found".green());
272        if !include_system {
273            println!(
274                "  {}",
275                "(flatpak, .var/app, Steam compatdata/shadercache and .cache were skipped — pass --include-system to check them too)"
276                    .dimmed()
277            );
278        }
279        return Ok(());
280    }
281
282    let total_wasted: u64 = groups.iter().map(|g| g.wasted).sum();
283    let total_hardlinked: usize = groups.iter().map(|g| g.hardlinked).sum();
284
285    println!(
286        "  {} duplicate groups   {} reclaimable",
287        groups.len().to_string().bold(),
288        format_size(total_wasted, DECIMAL).yellow().bold()
289    );
290    if !include_system {
291        println!("  {}", "system/runtime dirs (flatpak, .cache, Steam compatdata) excluded by default".dimmed());
292    }
293    if total_hardlinked > 0 {
294        println!(
295            "  {}",
296            format!(
297                "{total_hardlinked} additional copies are already hardlinks and cost 0 extra space (shown, not counted as waste)"
298            )
299            .dimmed()
300        );
301    }
302    println!();
303
304    for (idx, g) in groups.iter().take(30).enumerate() {
305        let hardlink_note = if g.hardlinked > 0 {
306            format!("  ({} hardlinked, already shared)", g.hardlinked).dimmed().to_string()
307        } else {
308            String::new()
309        };
310        println!(
311            "{}",
312            format!(
313                "  {:>2}. {} each × {} copies → {} wasted{}",
314                idx + 1,
315                format_size(g.size, DECIMAL),
316                g.paths.len(),
317                format_size(g.wasted, DECIMAL),
318                hardlink_note
319            )
320            .cyan()
321            .bold()
322        );
323        for p in &g.paths {
324            println!("      {} {}", "•".dimmed(), p.display());
325        }
326    }
327
328    if groups.len() > 30 {
329        println!("\n  {} ({} more groups not shown)", "…".dimmed(), groups.len() - 30);
330    }
331
332    Ok(())
333}
334
335fn cmd_doctor(path: Option<PathBuf>) -> Result<()> {
336    let target = resolve_path(path);
337    let cfg = config::load().unwrap_or_default();
338    let opts = scanner::walker::WalkOptions::default();
339    let result = scanner::walker::scan_path(&target, &opts)?;
340
341    section_header("diskr doctor");
342    println!("  {} {}", "scanned:".dimmed(), target.display());
343
344    let mut score: i64 = 100;
345    let mut notes: Vec<String> = Vec::new();
346
347    // disk health
348    for d in analyzer::disk_health::list_disks() {
349        match d.status {
350            analyzer::disk_health::HealthStatus::Warning => {
351                score -= 15;
352                notes.push(format!("{} {} is reporting warning-level wear/temperature", "!".yellow(), d.device));
353            }
354            analyzer::disk_health::HealthStatus::Critical => {
355                score -= 40;
356                notes.push(format!("{} {} is reporting critical SMART health", "✗".red().bold(), d.device));
357            }
358            _ => {}
359        }
360        let used_ratio = 1.0
361            - (d.available_bytes as f64 / d.total_bytes.max(1) as f64);
362        if used_ratio > 0.95 {
363            score -= 10;
364            notes.push(format!("{} {} is {:.0}% full", "!".yellow(), d.device, used_ratio * 100.0));
365        }
366    }
367
368    let dup_groups =
369        analyzer::duplicates::find_duplicates_filtered(&result.root, 1024 * 1024, &cfg.exclude_paths, true);
370    let dup_wasted: u64 = dup_groups.iter().map(|g| g.wasted).sum();
371
372    let mut caches = Vec::new();
373    analyzer::patterns::find_dev_caches(&result.root, &mut caches);
374    let cache_total: u64 = caches.iter().map(|c| c.size).sum();
375
376    let stale = analyzer::age::stale_files(&result.root, cfg.stale_days, cfg.stale_min_size_mb * 1024 * 1024);
377    let stale_total: u64 = stale.iter().map(|s| s.size).sum();
378
379    let sensitive = analyzer::extra::scan_sensitive_files(&result.root);
380    if !sensitive.is_empty() {
381        score -= (sensitive.len() as i64 * 2).min(20);
382        notes.push(format!(
383            "{} {} potentially sensitive file(s) exposed — run `diskr sensitive`",
384            "!".yellow(),
385            sensitive.len()
386        ));
387    }
388
389    score = score.clamp(0, 100);
390    let grade = match score {
391        90..=100 => "A".green().bold(),
392        75..=89 => "B".green().bold(),
393        60..=74 => "C".yellow().bold(),
394        40..=59 => "D".yellow().bold(),
395        _ => "F".red().bold(),
396    };
397
398    println!();
399    println!("  {}  {}  ({score}/100)", "grade:".dimmed(), grade);
400
401    if notes.is_empty() {
402        println!("  {}", "no issues found".green());
403    } else {
404        for n in &notes {
405            println!("  {n}");
406        }
407    }
408
409    println!();
410    section_header("biggest win");
411    let mut wins: Vec<(String, u64, &str)> = vec![
412        ("duplicate files".to_string(), dup_wasted, "diskr duplicates"),
413        ("dev/build caches (node_modules, target, __pycache__, ...)".to_string(), cache_total, "diskr clean"),
414        (format!("files untouched for {}+ days", cfg.stale_days), stale_total, "diskr clean"),
415    ];
416    wins.sort_by(|a, b| b.1.cmp(&a.1));
417
418    if let Some((label, size, cmd)) = wins.first() {
419        if *size == 0 {
420            println!("  {}", "nothing significant found to reclaim right now — nice and tidy".green());
421        } else {
422            println!(
423                "  {} {} {}",
424                format_size(*size, DECIMAL).yellow().bold(),
425                "reclaimable in".dimmed(),
426                label
427            );
428            println!("  {} `{}`", "run:".dimmed(), cmd.cyan());
429        }
430    }
431
432    Ok(())
433}
434
435fn cmd_health() -> Result<()> {
436    let disks = analyzer::disk_health::list_disks();
437
438    section_header("disk health");
439
440    if disks.is_empty() {
441        println!("  {}", "no disks detected".yellow());
442        return Ok(());
443    }
444
445    let needs_root_note = disks.iter().any(|d| !d.smart_available);
446
447    for d in &disks {
448        let status_colored = match d.status {
449            analyzer::disk_health::HealthStatus::Good => "●  good".green().bold(),
450            analyzer::disk_health::HealthStatus::Warning => "●  warning".yellow().bold(),
451            analyzer::disk_health::HealthStatus::Critical => "●  critical".red().bold(),
452            analyzer::disk_health::HealthStatus::Unknown => "●  unknown".dimmed(),
453        };
454
455        println!();
456        println!("  {}  {}", d.device.bold(), status_colored);
457        println!("  {}", d.model.dimmed());
458
459        let used = d.total_bytes.saturating_sub(d.available_bytes);
460        let ratio = used as f64 / d.total_bytes.max(1) as f64;
461        println!(
462            "  {:<10} {}   {} used of {}",
463            "usage:",
464            usage_bar(ratio, 24),
465            format_size(used, DECIMAL),
466            format_size(d.total_bytes, DECIMAL)
467        );
468        println!("  {:<10} {}", "type:", d.kind);
469
470        if d.smart_available {
471            if let Some(t) = d.temperature_c {
472                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() };
473                println!("  {:<10} {t_str}°C", "temp:");
474            }
475            if let Some(h) = d.power_on_hours {
476                println!("  {:<10} {h} hours ({:.1} days)", "power-on:", h as f64 / 24.0);
477            }
478            if let Some(s) = d.reallocated_sectors {
479                let s_str = if s > 0 { s.to_string().red().to_string() } else { s.to_string().green().to_string() };
480                println!("  {:<10} {s_str}", "bad sectors:");
481            }
482            if let Some(w) = d.wear_level_percent {
483                println!("  {:<10} {}", "wear:", usage_bar(w as f64 / 100.0, 24));
484            }
485        } else {
486            println!(
487                "  {:<10} {}",
488                "SMART:",
489                "unavailable — status below is estimated from free space only".dimmed()
490            );
491        }
492    }
493
494    if needs_root_note {
495        println!();
496        println!(
497            "  {}",
498            "tip: run `sudo diskr health` (or install smartmontools) for real temperature/wear data instead of the free-space estimate"
499                .dimmed()
500        );
501    }
502
503    Ok(())
504}
505
506fn cmd_logs(vacuum: Option<String>) -> Result<()> {
507    if let Some(keep) = vacuum {
508        health::logger::vacuum_journald(&keep)?;
509        println!("{} journald vacuumed, kept last {}", "done:".green().bold(), keep);
510        return Ok(());
511    }
512    let logs = health::logger::scan_logs();
513    section_header("log usage");
514    if logs.is_empty() {
515        println!("  {}", "no notable logs found".green());
516        return Ok(());
517    }
518    let max = logs.iter().map(|l| l.size_bytes).max().unwrap_or(1).max(1);
519    for l in &logs {
520        let ratio = l.size_bytes as f64 / max as f64;
521        println!("  {:>10}  {}  {}", format_size(l.size_bytes, DECIMAL).bold(), usage_bar(ratio, 16), l.label);
522    }
523    println!("\n  {}", "tip: `diskr logs --vacuum 200M` (or a duration like `3d`) shrinks journald".dimmed());
524    Ok(())
525}
526
527fn cmd_apps(min_idle_days: i64) -> Result<()> {
528    let apps = analyzer::extra::find_unused_apps(min_idle_days);
529    section_header("idle applications");
530
531    let (mut confirmed, unknown): (Vec<_>, Vec<_>) =
532        apps.into_iter().partition(|a| a.last_used_days.is_some());
533    confirmed.sort_by(|a, b| b.last_used_days.unwrap_or(0).cmp(&a.last_used_days.unwrap_or(0)));
534
535    if confirmed.is_empty() && unknown.is_empty() {
536        println!("  {}", "no candidates found".green());
537        return Ok(());
538    }
539
540    if confirmed.is_empty() {
541        println!("  {}", format!("no apps confirmed idle for {min_idle_days}+ days").green());
542    } else {
543        println!("  {}", format!("{} app(s) confirmed idle for {min_idle_days}+ days", confirmed.len()).yellow().bold());
544        println!();
545        for a in &confirmed {
546            println!(
547                "  {:<6} {:<28} {} {} days",
548                a.package_manager.dimmed(),
549                a.name.bold(),
550                "idle".dimmed(),
551                a.last_used_days.unwrap_or(0)
552            );
553        }
554    }
555
556    if !unknown.is_empty() {
557        println!();
558        println!(
559            "  {}",
560            format!("{} app(s) with usage unknown — not confirmed idle, worth a manual look", unknown.len()).dimmed()
561        );
562        println!();
563        for a in &unknown {
564            println!("  {:<6} {:<28} {}", a.package_manager.dimmed(), a.name.bold(), "usage unknown".dimmed());
565        }
566    }
567    Ok(())
568}
569
570fn cmd_sensitive(path: Option<PathBuf>, json: bool) -> Result<()> {
571    let target = resolve_path(path);
572    let opts = scanner::walker::WalkOptions::default();
573    let result = scanner::walker::scan_path(&target, &opts)?;
574    let findings = analyzer::extra::scan_sensitive_files(&result.root);
575
576    if json {
577        println!("{}", serde_json::to_string_pretty(&findings)?);
578        return Ok(());
579    }
580
581    section_header("sensitive files");
582
583    if findings.is_empty() {
584        println!("  {}", "no obviously exposed credential files found".green());
585        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());
586        return Ok(());
587    }
588
589    println!(
590        "  {} {}",
591        format!("{} potentially sensitive file(s) found", findings.len()).red().bold(),
592        "— review before sharing this machine or a backup of it".dimmed()
593    );
594    println!();
595
596    for f in findings.iter().take(25) {
597        println!("  {} {}", "•".yellow(), f.path.display());
598        println!("    {}", f.reason.dimmed());
599    }
600
601    if findings.len() > 25 {
602        println!(
603            "\n  {} ({} more not shown — pass --json to see the full list)",
604            "…".dimmed(),
605            findings.len() - 25
606        );
607    }
608
609    Ok(())
610}
611
612fn cmd_docker(path: Option<PathBuf>, prune: bool, volumes: bool) -> Result<()> {
613    section_header("docker storage");
614    if !analyzer::extra::docker_prune_suggested() {
615        println!("  {}", "docker is not running or not installed".yellow());
616        return Ok(());
617    }
618    let target = resolve_path(path);
619    let opts = scanner::walker::WalkOptions::default();
620    let result = scanner::walker::scan_path(&target, &opts)?;
621    let findings = analyzer::extra::find_docker_bloat(&result.root);
622    let total: u64 = findings.iter().map(|f| f.size).sum();
623
624    if findings.is_empty() {
625        println!("  {}", "no docker storage bloat found".green());
626        return Ok(());
627    }
628
629    println!("  {}", format!("{} entries, {} total", findings.len(), format_size(total, DECIMAL)).bold());
630    println!();
631    for f in &findings {
632        println!("  {:>10}  {}  {}", format_size(f.size, DECIMAL).bold(), format!("({})", f.label).dimmed(), f.path.display());
633    }
634
635    if prune {
636        println!("\n  {}", "running docker system prune...".cyan());
637        let output = analyzer::extra::run_docker_prune(volumes)?;
638        println!("{output}");
639    } else {
640        println!("\n  {}", "pass --prune to run `docker system prune -f` (add --volumes to include volumes)".dimmed());
641    }
642    Ok(())
643}