use crate::{analyzer, cleaner, config, health, history, scanner, ui};
use anyhow::Result;
use clap::{Parser, Subcommand};
use colored::Colorize;
use humansize::{format_size, DECIMAL};
use std::path::PathBuf;
#[derive(Parser)]
#[command(name = "diskr", version, about = "Save your disk space, without fear of deleting the wrong thing.")]
pub struct Cli {
#[command(subcommand)]
pub command: Option<Commands>,
}
#[derive(Subcommand)]
pub enum Commands {
Scan {
path: Option<PathBuf>,
#[arg(long)]
json: bool,
#[arg(long, default_value_t = 100)]
min_size_mb: u64,
},
Clean {
path: Option<PathBuf>,
#[arg(long)]
dry_run: bool,
#[arg(long)]
yes: bool,
},
Watch {
path: Option<PathBuf>,
},
Restore {
name: String,
},
Duplicates {
path: Option<PathBuf>,
#[arg(long, default_value_t = 4)]
min_size_kb: u64,
#[arg(long)]
include_system: bool,
},
Doctor {
path: Option<PathBuf>,
},
Health,
Logs {
#[arg(long)]
vacuum: Option<String>,
},
Apps {
#[arg(long, default_value_t = 90)]
min_idle_days: i64,
},
Sensitive {
path: Option<PathBuf>,
#[arg(long)]
json: bool,
},
Docker {
path: Option<PathBuf>,
#[arg(long)]
prune: bool,
#[arg(long)]
volumes: bool,
},
}
pub async fn run(cli: Cli) -> Result<()> {
match cli.command {
None => ui::app::launch(),
Some(Commands::Scan { path, json, min_size_mb }) => cmd_scan(path, json, min_size_mb),
Some(Commands::Clean { path, dry_run, yes }) => cmd_clean(path, dry_run, yes).await,
Some(Commands::Watch { path }) => cmd_watch(path).await,
Some(Commands::Restore { name }) => cmd_restore(name),
Some(Commands::Duplicates { path, min_size_kb, include_system }) => {
cmd_duplicates(path, min_size_kb, include_system)
}
Some(Commands::Doctor { path }) => cmd_doctor(path),
Some(Commands::Health) => cmd_health(),
Some(Commands::Logs { vacuum }) => cmd_logs(vacuum),
Some(Commands::Apps { min_idle_days }) => cmd_apps(min_idle_days),
Some(Commands::Sensitive { path, json }) => cmd_sensitive(path, json),
Some(Commands::Docker { path, prune, volumes }) => cmd_docker(path, prune, volumes),
}
}
fn resolve_path(path: Option<PathBuf>) -> PathBuf {
let target = path.unwrap_or_else(|| PathBuf::from("."));
std::fs::canonicalize(&target).unwrap_or(target)
}
fn section_header(title: &str) {
let mut c = title.chars();
let cap = match c.next() {
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
None => return,
};
println!("{}", cap.bold());
}
fn usage_bar(ratio: f64, width: usize) -> String {
let filled = ((ratio.clamp(0.0, 1.0)) * width as f64).round() as usize;
let filled = filled.min(width);
let bar = format!("{}{}", "█".repeat(filled), "░".repeat(width - filled));
let colored_bar = if ratio > 0.9 {
bar.red()
} else if ratio > 0.75 {
bar.yellow()
} else {
bar.green()
};
format!("[{colored_bar}] {:>3.0}%", ratio * 100.0)
}
fn cmd_scan(path: Option<PathBuf>, json: bool, min_size_mb: u64) -> Result<()> {
let target = resolve_path(path);
let opts = scanner::walker::WalkOptions { show_progress: !json, ..Default::default() };
let result = scanner::walker::scan_path(&target, &opts)?;
if json {
println!("{}", serde_json::to_string_pretty(&result)?);
return Ok(());
}
section_header("scan summary");
println!(" {} {}", "path:".dimmed(), target.display());
println!(" {} {}", "total size:".dimmed(), format_size(result.total_size, DECIMAL).bold());
println!(
" {} {} files, {} dirs {} {}ms",
"contents:".dimmed(),
result.file_count,
result.dir_count,
"scanned in".dimmed(),
result.scan_duration_ms
);
let big_files = analyzer::size::top_files(&result.root, min_size_mb * 1024 * 1024, 15);
if !big_files.is_empty() {
println!();
section_header("largest files");
let max = big_files.iter().map(|f| f.size).max().unwrap_or(1).max(1);
for f in &big_files {
let ratio = f.size as f64 / max as f64;
println!(
" {:>10} {} {}",
format_size(f.size, DECIMAL).bold(),
usage_bar(ratio, 16),
f.path.display()
);
}
}
let top_dirs = analyzer::size::top_dirs(&result.root, 10);
if !top_dirs.is_empty() {
println!();
section_header("largest directories");
let max = top_dirs.iter().map(|d| d.size).max().unwrap_or(1).max(1);
for d in &top_dirs {
let ratio = d.size as f64 / max as f64;
println!(
" {:>10} {} {}",
format_size(d.size, DECIMAL).bold(),
usage_bar(ratio, 16),
d.path.display()
);
}
}
Ok(())
}
async fn cmd_clean(path: Option<PathBuf>, dry_run: bool, yes: bool) -> Result<()> {
let target = resolve_path(path);
let cfg = config::load()?;
let opts = scanner::walker::WalkOptions::default();
let result = scanner::walker::scan_path(&target, &opts)?;
let stale = analyzer::age::stale_files(&result.root, cfg.stale_days, cfg.stale_min_size_mb * 1024 * 1024);
let mut caches = Vec::new();
analyzer::patterns::find_dev_caches(&result.root, &mut caches);
let candidates: Vec<(PathBuf, u64)> = stale.iter().map(|s| (s.path.clone(), s.size))
.chain(caches.iter().map(|c| (c.path.clone(), c.size)))
.filter(|(p, _)| !cleaner::safe::is_game_or_store_dir(p))
.collect();
let plan = cleaner::safe::build_plan(candidates);
section_header("clean plan");
println!(" {}", format!("{} items, {}", plan.remove.len(), format_size(plan.total_size, DECIMAL)).yellow().bold());
println!();
for p in plan.remove.iter().take(40) {
println!(" {} {}", "-".dimmed(), p.display());
}
if plan.remove.len() > 40 {
println!(" {} ({} more not shown)", "…".dimmed(), plan.remove.len() - 40);
}
if !plan.blocked.is_empty() {
println!("\n {} {} protected path(s) skipped", "note:".red().bold(), plan.blocked.len());
}
if dry_run {
println!("\n {}", "dry run, nothing was deleted".cyan());
return Ok(());
}
if !yes {
println!("\n {}", "pass --yes to actually delete these".yellow());
return Ok(());
}
let removed = cleaner::safe::execute_plan(&plan, false)?;
history::append(history::CleanEvent {
timestamp: chrono::Utc::now(),
paths: plan.remove.clone(),
bytes_freed: plan.total_size,
note: "diskr clean".into(),
})?;
println!(
"\n {}",
format!("{removed} item(s) moved to trash, {} freed", format_size(plan.total_size, DECIMAL)).green().bold()
);
let watcher = health::watcher::PostDeleteWatcher::new(plan.remove.clone());
tokio::spawn(watcher.run());
Ok(())
}
async fn cmd_watch(path: Option<PathBuf>) -> Result<()> {
let target = resolve_path(path);
section_header("watching");
println!(" {} {}", "path:".dimmed(), target.display());
println!(" {}", "checking every 10 minutes, ctrl-c to stop".dimmed());
let mut interval = tokio::time::interval(std::time::Duration::from_secs(600));
loop {
interval.tick().await;
let opts = scanner::walker::WalkOptions { show_progress: false, ..Default::default() };
if let Ok(result) = scanner::walker::scan_path(&target, &opts) {
let now = chrono::Local::now().format("%H:%M:%S");
println!(" [{now}] {} {}", "total size:".dimmed(), format_size(result.total_size, DECIMAL).bold());
}
}
}
fn cmd_restore(name: String) -> Result<()> {
let n = cleaner::trash::restore_from_trash(&name)?;
if n == 0 {
println!("{}", "nothing matching that name found in trash".yellow());
} else {
println!("{}", format!("{n} item(s) restored").green().bold());
}
Ok(())
}
fn cmd_duplicates(path: Option<PathBuf>, min_size_kb: u64, include_system: bool) -> Result<()> {
let target = resolve_path(path);
let cfg = config::load().unwrap_or_default();
let opts = scanner::walker::WalkOptions::default();
let result = scanner::walker::scan_path(&target, &opts)?;
let groups = analyzer::duplicates::find_duplicates_filtered(
&result.root,
min_size_kb * 1024,
&cfg.exclude_paths,
!include_system,
);
section_header("duplicate files");
if groups.is_empty() {
println!(" {}", "no duplicate files found".green());
if !include_system {
println!(
" {}",
"(flatpak, .var/app, Steam compatdata/shadercache and .cache were skipped — pass --include-system to check them too)"
.dimmed()
);
}
return Ok(());
}
let total_wasted: u64 = groups.iter().map(|g| g.wasted).sum();
let total_hardlinked: usize = groups.iter().map(|g| g.hardlinked).sum();
println!(
" {} duplicate groups {} reclaimable",
groups.len().to_string().bold(),
format_size(total_wasted, DECIMAL).yellow().bold()
);
if !include_system {
println!(" {}", "system/runtime dirs (flatpak, .cache, Steam compatdata) excluded by default".dimmed());
}
if total_hardlinked > 0 {
println!(
" {}",
format!(
"{total_hardlinked} additional copies are already hardlinks and cost 0 extra space (shown, not counted as waste)"
)
.dimmed()
);
}
println!();
for (idx, g) in groups.iter().take(30).enumerate() {
let hardlink_note = if g.hardlinked > 0 {
format!(" ({} hardlinked, already shared)", g.hardlinked).dimmed().to_string()
} else {
String::new()
};
println!(
"{}",
format!(
" {:>2}. {} each × {} copies → {} wasted{}",
idx + 1,
format_size(g.size, DECIMAL),
g.paths.len(),
format_size(g.wasted, DECIMAL),
hardlink_note
)
.cyan()
.bold()
);
for p in &g.paths {
println!(" {} {}", "•".dimmed(), p.display());
}
}
if groups.len() > 30 {
println!("\n {} ({} more groups not shown)", "…".dimmed(), groups.len() - 30);
}
Ok(())
}
fn cmd_doctor(path: Option<PathBuf>) -> Result<()> {
let target = resolve_path(path);
let cfg = config::load().unwrap_or_default();
let opts = scanner::walker::WalkOptions::default();
let result = scanner::walker::scan_path(&target, &opts)?;
section_header("diskr doctor");
println!(" {} {}", "scanned:".dimmed(), target.display());
let mut score: i64 = 100;
let mut notes: Vec<String> = Vec::new();
for d in analyzer::disk_health::list_disks() {
match d.status {
analyzer::disk_health::HealthStatus::Warning => {
score -= 15;
notes.push(format!("{} {} is reporting warning-level wear/temperature", "!".yellow(), d.device));
}
analyzer::disk_health::HealthStatus::Critical => {
score -= 40;
notes.push(format!("{} {} is reporting critical SMART health", "✗".red().bold(), d.device));
}
_ => {}
}
let used_ratio = 1.0
- (d.available_bytes as f64 / d.total_bytes.max(1) as f64);
if used_ratio > 0.95 {
score -= 10;
notes.push(format!("{} {} is {:.0}% full", "!".yellow(), d.device, used_ratio * 100.0));
}
}
let dup_groups =
analyzer::duplicates::find_duplicates_filtered(&result.root, 1024 * 1024, &cfg.exclude_paths, true);
let dup_wasted: u64 = dup_groups.iter().map(|g| g.wasted).sum();
let mut caches = Vec::new();
analyzer::patterns::find_dev_caches(&result.root, &mut caches);
let cache_total: u64 = caches.iter().map(|c| c.size).sum();
let stale = analyzer::age::stale_files(&result.root, cfg.stale_days, cfg.stale_min_size_mb * 1024 * 1024);
let stale_total: u64 = stale.iter().map(|s| s.size).sum();
let sensitive = analyzer::extra::scan_sensitive_files(&result.root);
if !sensitive.is_empty() {
score -= (sensitive.len() as i64 * 2).min(20);
notes.push(format!(
"{} {} potentially sensitive file(s) exposed — run `diskr sensitive`",
"!".yellow(),
sensitive.len()
));
}
score = score.clamp(0, 100);
let grade = match score {
90..=100 => "A".green().bold(),
75..=89 => "B".green().bold(),
60..=74 => "C".yellow().bold(),
40..=59 => "D".yellow().bold(),
_ => "F".red().bold(),
};
println!();
println!(" {} {} ({score}/100)", "grade:".dimmed(), grade);
if notes.is_empty() {
println!(" {}", "no issues found".green());
} else {
for n in ¬es {
println!(" {n}");
}
}
println!();
section_header("biggest win");
let mut wins: Vec<(String, u64, &str)> = vec![
("duplicate files".to_string(), dup_wasted, "diskr duplicates"),
("dev/build caches (node_modules, target, __pycache__, ...)".to_string(), cache_total, "diskr clean"),
(format!("files untouched for {}+ days", cfg.stale_days), stale_total, "diskr clean"),
];
wins.sort_by(|a, b| b.1.cmp(&a.1));
if let Some((label, size, cmd)) = wins.first() {
if *size == 0 {
println!(" {}", "nothing significant found to reclaim right now — nice and tidy".green());
} else {
println!(
" {} {} {}",
format_size(*size, DECIMAL).yellow().bold(),
"reclaimable in".dimmed(),
label
);
println!(" {} `{}`", "run:".dimmed(), cmd.cyan());
}
}
Ok(())
}
fn cmd_health() -> Result<()> {
let disks = analyzer::disk_health::list_disks();
section_header("disk health");
if disks.is_empty() {
println!(" {}", "no disks detected".yellow());
return Ok(());
}
let needs_root_note = disks.iter().any(|d| !d.smart_available);
for d in &disks {
let status_colored = match d.status {
analyzer::disk_health::HealthStatus::Good => "● good".green().bold(),
analyzer::disk_health::HealthStatus::Warning => "● warning".yellow().bold(),
analyzer::disk_health::HealthStatus::Critical => "● critical".red().bold(),
analyzer::disk_health::HealthStatus::Unknown => "● unknown".dimmed(),
};
println!();
println!(" {} {}", d.device.bold(), status_colored);
println!(" {}", d.model.dimmed());
let used = d.total_bytes.saturating_sub(d.available_bytes);
let ratio = used as f64 / d.total_bytes.max(1) as f64;
println!(
" {:<10} {} {} used of {}",
"usage:",
usage_bar(ratio, 24),
format_size(used, DECIMAL),
format_size(d.total_bytes, DECIMAL)
);
println!(" {:<10} {}", "type:", d.kind);
if d.smart_available {
if let Some(t) = d.temperature_c {
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() };
println!(" {:<10} {t_str}°C", "temp:");
}
if let Some(h) = d.power_on_hours {
println!(" {:<10} {h} hours ({:.1} days)", "power-on:", h as f64 / 24.0);
}
if let Some(s) = d.reallocated_sectors {
let s_str = if s > 0 { s.to_string().red().to_string() } else { s.to_string().green().to_string() };
println!(" {:<10} {s_str}", "bad sectors:");
}
if let Some(w) = d.wear_level_percent {
println!(" {:<10} {}", "wear:", usage_bar(w as f64 / 100.0, 24));
}
} else {
println!(
" {:<10} {}",
"SMART:",
"unavailable — status below is estimated from free space only".dimmed()
);
}
}
if needs_root_note {
println!();
println!(
" {}",
"tip: run `sudo diskr health` (or install smartmontools) for real temperature/wear data instead of the free-space estimate"
.dimmed()
);
}
Ok(())
}
fn cmd_logs(vacuum: Option<String>) -> Result<()> {
if let Some(keep) = vacuum {
health::logger::vacuum_journald(&keep)?;
println!("{} journald vacuumed, kept last {}", "done:".green().bold(), keep);
return Ok(());
}
let logs = health::logger::scan_logs();
section_header("log usage");
if logs.is_empty() {
println!(" {}", "no notable logs found".green());
return Ok(());
}
let max = logs.iter().map(|l| l.size_bytes).max().unwrap_or(1).max(1);
for l in &logs {
let ratio = l.size_bytes as f64 / max as f64;
println!(" {:>10} {} {}", format_size(l.size_bytes, DECIMAL).bold(), usage_bar(ratio, 16), l.label);
}
println!("\n {}", "tip: `diskr logs --vacuum 200M` (or a duration like `3d`) shrinks journald".dimmed());
Ok(())
}
fn cmd_apps(min_idle_days: i64) -> Result<()> {
let apps = analyzer::extra::find_unused_apps(min_idle_days);
section_header("idle applications");
let (mut confirmed, unknown): (Vec<_>, Vec<_>) =
apps.into_iter().partition(|a| a.last_used_days.is_some());
confirmed.sort_by(|a, b| b.last_used_days.unwrap_or(0).cmp(&a.last_used_days.unwrap_or(0)));
if confirmed.is_empty() && unknown.is_empty() {
println!(" {}", "no candidates found".green());
return Ok(());
}
if confirmed.is_empty() {
println!(" {}", format!("no apps confirmed idle for {min_idle_days}+ days").green());
} else {
println!(" {}", format!("{} app(s) confirmed idle for {min_idle_days}+ days", confirmed.len()).yellow().bold());
println!();
for a in &confirmed {
println!(
" {:<6} {:<28} {} {} days",
a.package_manager.dimmed(),
a.name.bold(),
"idle".dimmed(),
a.last_used_days.unwrap_or(0)
);
}
}
if !unknown.is_empty() {
println!();
println!(
" {}",
format!("{} app(s) with usage unknown — not confirmed idle, worth a manual look", unknown.len()).dimmed()
);
println!();
for a in &unknown {
println!(" {:<6} {:<28} {}", a.package_manager.dimmed(), a.name.bold(), "usage unknown".dimmed());
}
}
Ok(())
}
fn cmd_sensitive(path: Option<PathBuf>, json: bool) -> Result<()> {
let target = resolve_path(path);
let opts = scanner::walker::WalkOptions::default();
let result = scanner::walker::scan_path(&target, &opts)?;
let findings = analyzer::extra::scan_sensitive_files(&result.root);
if json {
println!("{}", serde_json::to_string_pretty(&findings)?);
return Ok(());
}
section_header("sensitive files");
if findings.is_empty() {
println!(" {}", "no obviously exposed credential files found".green());
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());
return Ok(());
}
println!(
" {} {}",
format!("{} potentially sensitive file(s) found", findings.len()).red().bold(),
"— review before sharing this machine or a backup of it".dimmed()
);
println!();
for f in findings.iter().take(25) {
println!(" {} {}", "•".yellow(), f.path.display());
println!(" {}", f.reason.dimmed());
}
if findings.len() > 25 {
println!(
"\n {} ({} more not shown — pass --json to see the full list)",
"…".dimmed(),
findings.len() - 25
);
}
Ok(())
}
fn cmd_docker(path: Option<PathBuf>, prune: bool, volumes: bool) -> Result<()> {
section_header("docker storage");
if !analyzer::extra::docker_prune_suggested() {
println!(" {}", "docker is not running or not installed".yellow());
return Ok(());
}
let target = resolve_path(path);
let opts = scanner::walker::WalkOptions::default();
let result = scanner::walker::scan_path(&target, &opts)?;
let findings = analyzer::extra::find_docker_bloat(&result.root);
let total: u64 = findings.iter().map(|f| f.size).sum();
if findings.is_empty() {
println!(" {}", "no docker storage bloat found".green());
return Ok(());
}
println!(" {}", format!("{} entries, {} total", findings.len(), format_size(total, DECIMAL)).bold());
println!();
for f in &findings {
println!(" {:>10} {} {}", format_size(f.size, DECIMAL).bold(), format!("({})", f.label).dimmed(), f.path.display());
}
if prune {
println!("\n {}", "running docker system prune...".cyan());
let output = analyzer::extra::run_docker_prune(volumes)?;
println!("{output}");
} else {
println!("\n {}", "pass --prune to run `docker system prune -f` (add --volumes to include volumes)".dimmed());
}
Ok(())
}