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