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 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 ¬es {
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}