mod app;
mod bulkstat;
mod fs_ops;
mod history;
mod packages;
mod reclaim;
mod scanner;
mod space;
mod terminal_backend;
mod ui;
use anyhow::{bail, Context, Result};
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui_core::terminal::Terminal;
use std::{
ffi::OsString,
io::{self, IsTerminal},
path::{Path, PathBuf},
process::{Command, Stdio},
time::Duration,
};
use app::{human, App, Focus};
use terminal_backend::CrosstermBackend;
fn main() -> Result<()> {
match parse_args(std::env::args_os().skip(1))? {
CliAction::Help => {
print_help();
Ok(())
}
CliAction::Version => {
println!("diskr {}", env!("CARGO_PKG_VERSION"));
Ok(())
}
CliAction::Top { path, limit, json } => print_top(path, limit, json),
CliAction::Reclaim { path, json } => print_reclaim(path, json),
CliAction::Save { path, json } => save_baseline(path, json),
CliAction::Diff { path, json } => print_diff(path, json),
CliAction::Space { path, json } => print_space(path, json),
CliAction::Packages { path, json } => print_packages(path, json),
CliAction::ThinSnapshots {
path,
bytes,
confirmed,
} => thin_snapshots(path, bytes, confirmed),
CliAction::Run(start) => run_app(start),
}
}
fn run_app(start: PathBuf) -> Result<()> {
if !start.exists() {
bail!("path does not exist: {}", start.display());
}
if !start.is_dir() {
bail!("path is not a directory: {}", start.display());
}
if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
bail!("diskr requires an interactive terminal");
}
let mut app = App::new(start)?;
let _terminal_guard = TerminalGuard::enter()?;
let backend = CrosstermBackend::new(io::stdout());
let mut terminal = Terminal::new(backend)?;
let res = run(&mut terminal, &mut app);
let cursor_res = terminal.show_cursor();
res?;
cursor_res?;
Ok(())
}
enum CliAction {
Run(PathBuf),
Top {
path: PathBuf,
limit: usize,
json: bool,
},
Reclaim {
path: PathBuf,
json: bool,
},
Save {
path: PathBuf,
json: bool,
},
Diff {
path: PathBuf,
json: bool,
},
Space {
path: PathBuf,
json: bool,
},
Packages {
path: PathBuf,
json: bool,
},
ThinSnapshots {
path: PathBuf,
bytes: u64,
confirmed: bool,
},
Help,
Version,
}
fn parse_args(args: impl IntoIterator<Item = OsString>) -> Result<CliAction> {
let mut args = args.into_iter();
let mut path: Option<PathBuf> = None;
let mut top_limit: Option<usize> = None;
let mut reclaim_report = false;
let mut save_baseline = false;
let mut diff_baseline = false;
let mut space_report = false;
let mut packages_report = false;
let mut thin_snapshots: Option<u64> = None;
let mut confirmed = false;
let mut json = false;
let mut separator_seen = false;
while let Some(arg) = args.next() {
if separator_seen {
set_cli_path(&mut path, arg)?;
continue;
}
let arg_text = arg.to_string_lossy().into_owned();
match arg_text.as_str() {
"-h" | "--help" => return Ok(CliAction::Help),
"-V" | "--version" => return Ok(CliAction::Version),
"--" => {
separator_seen = true;
}
"--json" => {
json = true;
}
"--yes" => {
confirmed = true;
}
"--reclaim" => {
reclaim_report = true;
}
"--save" => {
save_baseline = true;
}
"--diff" => {
diff_baseline = true;
}
"--space" => {
space_report = true;
}
"--packages" => {
packages_report = true;
}
"--thin-snapshots" => {
let Some(size) = args.next() else {
bail!("usage: diskr --thin-snapshots SIZE [--yes] [PATH]");
};
thin_snapshots = Some(space::parse_byte_size(&size.to_string_lossy())?);
}
_ if arg_text.starts_with("--thin-snapshots=") => {
let size = arg_text.trim_start_matches("--thin-snapshots=");
thin_snapshots = Some(space::parse_byte_size(size)?);
}
"--top" => {
let Some(limit) = args.next() else {
bail!("usage: diskr --top N [--json] [PATH]");
};
top_limit = Some(parse_top_limit(&limit.to_string_lossy())?);
}
_ if arg_text.starts_with("--top=") => {
let limit = arg_text.trim_start_matches("--top=");
top_limit = Some(parse_top_limit(limit)?);
}
_ => {
set_cli_path(&mut path, arg)?;
}
}
}
if json
&& top_limit.is_none()
&& !reclaim_report
&& !save_baseline
&& !diff_baseline
&& !space_report
&& !packages_report
{
bail!("--json requires --top, --reclaim, --save, --diff, --space, or --packages");
}
if confirmed && thin_snapshots.is_none() {
bail!("--yes requires --thin-snapshots");
}
if separator_seen && path.is_none() {
bail!("usage: diskr [PATH]");
}
let mode_count = usize::from(top_limit.is_some())
+ usize::from(reclaim_report)
+ usize::from(save_baseline)
+ usize::from(diff_baseline)
+ usize::from(space_report)
+ usize::from(packages_report)
+ usize::from(thin_snapshots.is_some());
if mode_count > 1 {
bail!("choose only one of --top, --reclaim, --save, --diff, --space, --packages, or --thin-snapshots");
}
match top_limit {
Some(limit) => Ok(CliAction::Top {
path: path.unwrap_or_else(dirs_home),
limit,
json,
}),
None if reclaim_report => Ok(CliAction::Reclaim {
path: path.unwrap_or_else(dirs_home),
json,
}),
None if save_baseline => Ok(CliAction::Save {
path: path.unwrap_or_else(dirs_home),
json,
}),
None if diff_baseline => Ok(CliAction::Diff {
path: path.unwrap_or_else(dirs_home),
json,
}),
None if space_report => Ok(CliAction::Space {
path: path.unwrap_or_else(dirs_home),
json,
}),
None if packages_report => Ok(CliAction::Packages {
path: path.unwrap_or_else(dirs_home),
json,
}),
None if thin_snapshots.is_some() => Ok(CliAction::ThinSnapshots {
path: path.unwrap_or_else(dirs_home),
bytes: thin_snapshots.unwrap_or_default(),
confirmed,
}),
None => Ok(CliAction::Run(path.unwrap_or_else(dirs_home))),
}
}
fn set_cli_path(path: &mut Option<PathBuf>, value: OsString) -> Result<()> {
if path.replace(PathBuf::from(value)).is_some() {
bail!("usage: diskr [PATH]");
}
Ok(())
}
fn parse_top_limit(value: &str) -> Result<usize> {
let limit = value
.parse::<usize>()
.with_context(|| format!("invalid --top value: {value}"))?;
if limit == 0 {
bail!("--top must be greater than zero");
}
Ok(limit)
}
fn print_help() {
println!(
"\
diskr {}
Lightweight terminal file explorer and disk/storage manager for macOS.
Usage:
diskr [PATH]
diskr -- PATH
diskr --top N [--json] [PATH]
diskr --reclaim [--json] [PATH]
diskr --save [--json] [PATH]
diskr --diff [--json] [PATH]
diskr --space [--json] [PATH]
diskr --packages [--json] [PATH]
diskr --thin-snapshots SIZE [--yes] [PATH]
Keys:
Up/Down, j/k Move selection
PageUp/PageDown Move by a page
Home/End Jump to first or last item
Enter Open selected directory or disk/package path
Backspace Go to parent directory
/ Search files or filter packages
i Show package details (deps, reverse deps)
u Toggle showing only removable packages
x Uninstall selected package via its manager
Left/Right, h/l Switch pane or package view
Space Quick Look selected item
f Reveal selected item in Finder
O Open selected item with default app
r Refresh view and scan nearby directory sizes
o Cycle sort mode
p Open packages pane / switch package view
. Toggle hidden files
d Move selected item to Trash
Tab Switch files/disks/packages pane
q, Esc Quit
",
env!("CARGO_PKG_VERSION")
);
}
fn print_top(path: PathBuf, limit: usize, json: bool) -> Result<()> {
if !path.exists() {
bail!("path does not exist: {}", path.display());
}
if !path.is_dir() {
bail!("path is not a directory: {}", path.display());
}
let scan = bulkstat::scan_dir(&path, limit);
if json {
let files: Vec<_> = scan
.largest_files
.iter()
.map(|file| {
serde_json::json!({
"path": file.path.to_string_lossy(),
"logical": file.size.logical,
"allocated": file.size.allocated,
})
})
.collect();
let report = serde_json::json!({
"path": path.to_string_lossy(),
"limit": limit,
"total_logical": scan.size.logical,
"total_allocated": scan.size.allocated,
"files": files,
});
println!("{}", serde_json::to_string_pretty(&report)?);
return Ok(());
}
println!(
"Top {} files by allocated size under {}",
limit,
path.display()
);
println!(
"Total: {} disk / {} apparent",
human(scan.size.allocated),
human(scan.size.logical)
);
if scan.largest_files.is_empty() {
println!("No regular files found.");
return Ok(());
}
for (index, file) in scan.largest_files.iter().enumerate() {
println!(
"{:>2}. {:>22} {}",
index + 1,
top_size_label(file.size),
file.path.display()
);
}
Ok(())
}
fn top_size_label(size: bulkstat::SizeInfo) -> String {
if size.allocated == size.logical {
human(size.logical)
} else {
format!(
"{} disk / {} apparent",
human(size.allocated),
human(size.logical)
)
}
}
fn print_reclaim(path: PathBuf, json: bool) -> Result<()> {
if !path.exists() {
bail!("path does not exist: {}", path.display());
}
if !path.is_dir() {
bail!("path is not a directory: {}", path.display());
}
let report = reclaim::report(&path);
if json {
let findings: Vec<_> = report
.findings
.iter()
.map(|finding| {
serde_json::json!({
"label": finding.label,
"class": finding.class.label(),
"count": finding.count,
"logical": finding.size.logical,
"allocated": finding.size.allocated,
"note": finding.note,
"paths": finding
.paths
.iter()
.map(|p| p.to_string_lossy())
.collect::<Vec<_>>(),
})
})
.collect();
let value = serde_json::json!({
"root": report.root.to_string_lossy(),
"total_logical": report.total.logical,
"total_allocated": report.total.allocated,
"findings": findings,
});
println!("{}", serde_json::to_string_pretty(&value)?);
return Ok(());
}
println!("Reclaimable space under {}", report.root.display());
println!(
"Total: {} disk / {} apparent",
human(report.total.allocated),
human(report.total.logical)
);
if report.findings.is_empty() {
println!("No known reclaimable caches or build artifacts found.");
return Ok(());
}
for finding in &report.findings {
let count = if finding.count > 1 {
format!(" (x{})", finding.count)
} else {
String::new()
};
println!(
"{:>22} [{:^11}] {}{}",
top_size_label(finding.size),
finding.class.label(),
finding.label,
count
);
println!(" {}", finding.note);
}
println!("\nThis is a report only; diskr does not delete anything here.");
Ok(())
}
fn save_baseline(path: PathBuf, json: bool) -> Result<()> {
let record = history::save(&path)?;
let total = record.total();
if json {
let children: Vec<_> = record
.children
.iter()
.map(|child| {
serde_json::json!({
"name": child.name,
"is_dir": child.is_dir,
"logical": child.size.logical,
"allocated": child.size.allocated,
})
})
.collect();
let value = serde_json::json!({
"path": record.path.to_string_lossy(),
"timestamp": record.timestamp,
"total_logical": total.logical,
"total_allocated": total.allocated,
"children": children,
});
println!("{}", serde_json::to_string_pretty(&value)?);
return Ok(());
}
println!("Saved baseline for {}", record.path.display());
println!(
"Total: {} disk / {} apparent across {} entries",
human(total.allocated),
human(total.logical),
record.children.len()
);
println!(
"Compare later with `diskr --diff {}`.",
record.path.display()
);
Ok(())
}
fn print_diff(path: PathBuf, json: bool) -> Result<()> {
let report = history::diff(&path)?;
if json {
let changes: Vec<_> = report
.changes
.iter()
.map(|change| {
serde_json::json!({
"name": change.name,
"status": change_status(change),
"before_logical": change.before.map(|s| s.logical),
"before_allocated": change.before.map(|s| s.allocated),
"after_logical": change.after.map(|s| s.logical),
"after_allocated": change.after.map(|s| s.allocated),
"delta_logical": change.delta_logical().to_string(),
"delta_allocated": change.delta_allocated().to_string(),
})
})
.collect();
let value = serde_json::json!({
"path": report.path.to_string_lossy(),
"baseline_timestamp": report.baseline_timestamp,
"current_timestamp": report.current_timestamp,
"before_total_logical": report.before_total.logical,
"before_total_allocated": report.before_total.allocated,
"after_total_logical": report.after_total.logical,
"after_total_allocated": report.after_total.allocated,
"total_delta_logical": report.total_delta_logical().to_string(),
"total_delta_allocated": report.total_delta_allocated().to_string(),
"changes": changes,
});
println!("{}", serde_json::to_string_pretty(&value)?);
return Ok(());
}
println!("Diff for {}", report.path.display());
println!(
"Baseline captured {}",
format_elapsed(
report
.current_timestamp
.saturating_sub(report.baseline_timestamp)
)
);
println!(
"Total: {} disk → {} disk ({})",
human(report.before_total.allocated),
human(report.after_total.allocated),
format_signed_bytes(report.total_delta_allocated())
);
if report.changes.is_empty() {
println!("No changes since the baseline.");
return Ok(());
}
for change in &report.changes {
println!(
"{:>12} {:<9} {}",
format_signed_bytes(change.delta_allocated()),
change_status(change),
change.name
);
}
Ok(())
}
fn change_status(change: &history::ChildChange) -> &'static str {
match (change.before, change.after) {
(None, Some(_)) => "new",
(Some(_), None) => "removed",
_ if change.delta_allocated() >= 0 => "grew",
_ => "shrank",
}
}
fn format_signed_bytes(delta: i128) -> String {
let magnitude = delta.unsigned_abs().min(u128::from(u64::MAX)) as u64;
let sign = if delta < 0 { "-" } else { "+" };
format!("{sign}{}", human(magnitude))
}
fn format_elapsed(secs: u64) -> String {
if secs == 0 {
return String::from("just now");
}
let days = secs / 86_400;
let hours = (secs % 86_400) / 3_600;
let minutes = (secs % 3_600) / 60;
if days > 0 {
return format!("{days}d {hours}h ago");
}
if hours > 0 {
return format!("{hours}h {minutes}m ago");
}
if minutes > 0 {
return format!("{minutes}m ago");
}
format!("{secs}s ago")
}
fn print_space(path: PathBuf, json: bool) -> Result<()> {
let report = space::report_for_path(&path)?;
if json {
let snapshots: Vec<_> = report
.local_snapshots
.names
.iter()
.map(|name| serde_json::json!({ "name": name }))
.collect();
let apfs_container = report.apfs_container.as_ref().map(|container| {
serde_json::json!({
"reference": container.reference,
"size": container.size,
"free": container.free,
})
});
let value = serde_json::json!({
"path": report.path.to_string_lossy(),
"mount": report.mount.to_string_lossy(),
"device": report.device,
"filesystem": report.fs_type,
"total": report.total,
"used": report.used,
"free": report.free,
"available": report.available,
"free_not_user_available": report.unavailable_free(),
"apfs_container": apfs_container,
"local_snapshots": snapshots,
"local_snapshot_error": report.local_snapshots.error,
});
println!("{}", serde_json::to_string_pretty(&value)?);
return Ok(());
}
println!("Space report for {}", report.path.display());
println!(
"Mount: {} ({}, {})",
report.mount.display(),
report.fs_type,
report.device
);
println!("Total: {}", human(report.total));
println!("Used: {}", human(report.used));
println!("Free blocks: {}", human(report.free));
println!("Available to user: {}", human(report.available));
let unavailable = report.unavailable_free();
if unavailable > 0 {
println!("Free but not user-available: {}", human(unavailable));
}
if let Some(container) = &report.apfs_container {
println!(
"APFS container {}: {} total / {} free",
container.reference,
human(container.size),
human(container.free)
);
}
match &report.local_snapshots.error {
Some(error) if !error.is_empty() => {
println!("Local snapshots: unavailable ({error})");
}
_ if report.local_snapshots.names.is_empty() => {
println!("Local snapshots: none reported by tmutil");
}
_ => {
println!("Local snapshots: {}", report.local_snapshots.names.len());
for name in &report.local_snapshots.names {
println!(" {name}");
}
println!(
"Snapshot sizes are not reported by tmutil. To request reclamation, first preview with `diskr --thin-snapshots 10G {}`.",
report.path.display()
);
}
}
Ok(())
}
fn print_packages(path: PathBuf, json: bool) -> Result<()> {
let reports = packages::scan_managers();
let project_deps = packages::find_project_deps(&path, 5);
if json {
let managers: Vec<_> = reports
.iter()
.filter(|r| r.available)
.map(|r| {
let pkgs: Vec<_> = r
.packages
.iter()
.map(|p| {
serde_json::json!({
"name": p.name,
"version": p.version,
"logical": p.size.map(|s| s.logical),
"allocated": p.size.map(|s| s.allocated),
"path": p.path.as_ref().map(|p| p.to_string_lossy().into_owned()),
})
})
.collect();
serde_json::json!({
"manager": r.manager.label(),
"count": r.packages.len(),
"total_logical": r.total_size.logical,
"total_allocated": r.total_size.allocated,
"packages": pkgs,
})
})
.collect();
let projects: Vec<_> = project_deps
.iter()
.map(|d| {
serde_json::json!({
"path": d.path.to_string_lossy(),
"manager": d.manager_label,
"manifest": d.manifest,
"dep_count": d.dep_count,
"deps_logical": d.deps_size.map(|s| s.logical),
"deps_allocated": d.deps_size.map(|s| s.allocated),
})
})
.collect();
let value = serde_json::json!({
"system_packages": managers,
"project_dependencies": projects,
});
println!("{}", serde_json::to_string_pretty(&value)?);
return Ok(());
}
println!("Package report");
println!();
let mut any_manager = false;
for report in &reports {
if !report.available {
continue;
}
any_manager = true;
println!(
"{}: {} packages · {}",
report.manager.label(),
report.packages.len(),
human(report.total_size.allocated)
);
let mut sorted: Vec<_> = report.packages.iter().collect();
sorted.sort_by(|a, b| {
let a_size = a.size.map(|s| s.allocated).unwrap_or(0);
let b_size = b.size.map(|s| s.allocated).unwrap_or(0);
b_size.cmp(&a_size)
});
for pkg in sorted.iter().take(10) {
let size_str = pkg
.size
.map(|s| human(s.allocated))
.unwrap_or_else(|| String::from("?"));
println!(" {:>10} {} {}", size_str, pkg.name, pkg.version);
}
if report.packages.len() > 10 {
println!(" … and {} more", report.packages.len() - 10);
}
println!();
}
if !any_manager {
println!("No supported package managers found.");
println!();
}
if !project_deps.is_empty() {
println!("Project dependencies under {}", path.display());
for dep in &project_deps {
let size_str = dep
.deps_size
.map(|s| human(s.allocated))
.unwrap_or_else(|| String::from("—"));
println!(
" {:>10} {} ({}, {} deps)",
size_str,
dep.path.display(),
dep.manager_label,
dep.dep_count
);
}
}
Ok(())
}
fn thin_snapshots(path: PathBuf, bytes: u64, confirmed: bool) -> Result<()> {
if !path.exists() {
bail!("path does not exist: {}", path.display());
}
if !confirmed {
println!(
"Would run: tmutil thinlocalsnapshots {} {} 4",
path.display(),
bytes
);
println!(
"Re-run with --yes to request {} of local snapshot reclamation.",
human(bytes)
);
return Ok(());
}
let result = space::thin_local_snapshots(&path, bytes)?;
println!(
"Requested {} of local snapshot reclamation for {}.",
human(result.requested_bytes),
result.path.display()
);
if !result.stdout.is_empty() {
println!("{}", result.stdout);
}
if !result.stderr.is_empty() {
eprintln!("{}", result.stderr);
}
Ok(())
}
#[derive(Clone, Copy)]
enum ExternalAction {
QuickLook,
RevealInFinder,
Open,
}
impl ExternalAction {
fn status_label(self) -> &'static str {
match self {
ExternalAction::QuickLook => "quick look",
ExternalAction::RevealInFinder => "reveal in Finder",
ExternalAction::Open => "open",
}
}
}
fn launch_external_action(app: &mut App, action: ExternalAction) {
let Some((path, label)) = selected_action_target(app) else {
app.status = String::from("nothing selected");
return;
};
let result = match action {
ExternalAction::QuickLook => spawn_quick_look(&path),
ExternalAction::RevealInFinder => spawn_reveal_in_finder(&path),
ExternalAction::Open => spawn_open(&path),
};
app.status = match result {
Ok(()) => format!("{}: {label}", action.status_label()),
Err(err) => format!("{} failed: {err}", action.status_label()),
};
}
fn selected_action_target(app: &App) -> Option<(PathBuf, String)> {
match app.focus {
Focus::Files => app
.visible_entry(app.selected)
.map(|entry| (entry.path.clone(), entry.name.clone())),
Focus::Disks => app.disks.get(app.selected_disk).map(|disk| {
let label = if disk.name.is_empty() {
disk.mount.display().to_string()
} else {
disk.name.clone()
};
(disk.mount.clone(), label)
}),
Focus::Packages => {
let real_idx = app.pkg_visible_index(app.selected_pkg)?;
match app.pkg_view {
app::PkgView::SystemManagers => app.flat_packages().get(real_idx).and_then(
|(package, manager): &(packages::Package, packages::Manager)| {
package.path.as_ref().map(|path| {
(
path.clone(),
format!("{} {}", manager.label(), package.name),
)
})
},
),
app::PkgView::ProjectDeps => app.project_deps.get(real_idx).map(|dep| {
let path = dep.deps_dir.as_ref().unwrap_or(&dep.path).clone();
(
path,
format!("{} {}", dep.manager_label, dep.path.display()),
)
}),
}
}
}
}
fn spawn_quick_look(path: &Path) -> Result<()> {
let mut command = Command::new("qlmanage");
command.arg("-p").arg(path);
spawn_detached(&mut command)
}
fn spawn_reveal_in_finder(path: &Path) -> Result<()> {
let mut command = Command::new("open");
command.arg("-R").arg(path);
spawn_detached(&mut command)
}
fn spawn_open(path: &Path) -> Result<()> {
let mut command = Command::new("open");
command.arg(path);
spawn_detached(&mut command)
}
fn spawn_detached(command: &mut Command) -> Result<()> {
command
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.context("spawn macOS command")?;
Ok(())
}
struct TerminalGuard;
impl TerminalGuard {
fn enter() -> Result<Self> {
enable_raw_mode()?;
let mut stdout = io::stdout();
if let Err(err) = execute!(stdout, EnterAlternateScreen, EnableMouseCapture) {
let _ = disable_raw_mode();
return Err(err.into());
}
Ok(Self)
}
}
impl Drop for TerminalGuard {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture);
}
}
fn dirs_home() -> std::path::PathBuf {
std::env::var_os("HOME")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::PathBuf::from("/"))
}
fn run<B>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()>
where
B: ratatui_core::backend::Backend<Error = io::Error>,
{
let mut needs_draw = true;
loop {
if app.drain_scan_results() {
needs_draw = true;
}
if needs_draw {
terminal.draw(|f| ui::draw(f, app))?;
needs_draw = false;
}
let timeout = if app.has_pending_scan_work() {
Duration::from_millis(50)
} else {
Duration::from_secs(1)
};
if event::poll(timeout)? {
match event::read()? {
Event::Resize(_, _) => {
needs_draw = true;
}
Event::Key(key) => {
if key.kind != KeyEventKind::Press {
continue;
}
if app.confirming_delete {
let handled = match key.code {
KeyCode::Char('y') => {
app.confirm_delete()?;
true
}
KeyCode::Char('n') | KeyCode::Esc => {
app.cancel_delete();
true
}
_ => false,
};
if handled {
needs_draw = true;
}
continue;
}
if app.confirming_uninstall {
let handled = match key.code {
KeyCode::Char('y') => {
app.confirm_uninstall();
true
}
KeyCode::Char('n') | KeyCode::Esc => {
app.cancel_uninstall();
true
}
_ => false,
};
if handled {
needs_draw = true;
}
continue;
}
if app.pkg_detail {
let handled = match key.code {
KeyCode::Esc | KeyCode::Char('i') | KeyCode::Char('q') => {
app.close_pkg_detail();
true
}
KeyCode::Char('x') => {
app.close_pkg_detail();
app.request_uninstall();
true
}
KeyCode::Char('d') => {
app.close_pkg_detail();
app.request_delete();
true
}
_ => false,
};
if handled {
needs_draw = true;
}
continue;
}
if app.search_mode {
let handled = match key.code {
KeyCode::Esc => {
app.exit_search();
true
}
KeyCode::Enter => {
app.exit_search();
true
}
KeyCode::Backspace => {
app.search_pop();
true
}
KeyCode::Down => {
app.move_cursor(1);
true
}
KeyCode::Up => {
app.move_cursor(-1);
true
}
KeyCode::PageDown => {
app.page_move(1);
true
}
KeyCode::PageUp => {
app.page_move(-1);
true
}
KeyCode::Home => {
app.move_to_start();
true
}
KeyCode::End => {
app.move_to_end();
true
}
KeyCode::Char(ch) => {
app.search_push(ch);
true
}
_ => false,
};
if handled {
needs_draw = true;
}
continue;
}
if app.pkg_search_mode {
let handled = match key.code {
KeyCode::Esc => {
app.exit_pkg_search();
true
}
KeyCode::Enter => {
app.exit_pkg_search();
true
}
KeyCode::Backspace => {
app.pkg_search_pop();
true
}
KeyCode::Down | KeyCode::Char('j') => {
app.move_cursor(1);
true
}
KeyCode::Up | KeyCode::Char('k') => {
app.move_cursor(-1);
true
}
KeyCode::PageDown => {
app.page_move(1);
true
}
KeyCode::PageUp => {
app.page_move(-1);
true
}
KeyCode::Home => {
app.move_to_start();
true
}
KeyCode::End => {
app.move_to_end();
true
}
KeyCode::Char(ch) => {
app.pkg_search_push(ch);
true
}
_ => false,
};
if handled {
needs_draw = true;
}
continue;
}
let handled = match key.code {
KeyCode::Char('q') => return Ok(()),
KeyCode::Esc => {
if app.focus != Focus::Files {
app.focus = Focus::Files;
true
} else {
return Ok(());
}
}
KeyCode::Down | KeyCode::Char('j') => {
app.move_cursor(1);
true
}
KeyCode::Up | KeyCode::Char('k') => {
app.move_cursor(-1);
true
}
KeyCode::PageDown => {
app.page_move(1);
true
}
KeyCode::PageUp => {
app.page_move(-1);
true
}
KeyCode::Home => {
app.move_to_start();
true
}
KeyCode::End => {
app.move_to_end();
true
}
KeyCode::Enter => {
if app.focus == Focus::Packages && app.packages_loaded {
app.open_pkg_detail();
} else {
app.enter()?;
}
true
}
KeyCode::Backspace => {
app.go_up()?;
true
}
KeyCode::Char(' ') => {
launch_external_action(app, ExternalAction::QuickLook);
true
}
KeyCode::Char('f') => {
launch_external_action(app, ExternalAction::RevealInFinder);
true
}
KeyCode::Char('O') => {
launch_external_action(app, ExternalAction::Open);
true
}
KeyCode::Char('r') => {
if app.focus == Focus::Packages {
app.refresh_packages();
} else {
app.force_rescan();
}
true
}
KeyCode::Char('d') => {
app.request_delete();
true
}
KeyCode::Char('o') => {
app.cycle_sort();
true
}
KeyCode::Char('p') => {
app.focus = Focus::Packages;
app.load_packages();
true
}
KeyCode::Char('/') if app.focus == Focus::Files => {
app.enter_search();
true
}
KeyCode::Char('/')
if app.focus == Focus::Packages && app.packages_loaded =>
{
app.enter_pkg_search();
true
}
KeyCode::Char('i')
if app.focus == Focus::Packages && app.packages_loaded =>
{
app.open_pkg_detail();
true
}
KeyCode::Char('u')
if app.focus == Focus::Packages && app.packages_loaded =>
{
app.toggle_unused_filter();
true
}
KeyCode::Char('x')
if app.focus == Focus::Packages && app.packages_loaded =>
{
app.request_uninstall();
true
}
KeyCode::Left | KeyCode::Char('h') => {
if app.focus == Focus::Packages
&& app.pkg_view == app::PkgView::ProjectDeps
{
app.toggle_pkg_view();
true
} else {
focus_previous(app);
true
}
}
KeyCode::Right | KeyCode::Char('l') => {
if app.focus == Focus::Packages
&& app.pkg_view == app::PkgView::SystemManagers
{
app.toggle_pkg_view();
true
} else {
focus_next(app);
true
}
}
KeyCode::Char('.') => {
app.toggle_hidden()?;
true
}
KeyCode::BackTab => {
focus_previous(app);
true
}
KeyCode::Tab => {
focus_next(app);
true
}
_ => false,
};
if handled {
needs_draw = true;
}
}
_ => {}
}
}
}
}
fn focus_next(app: &mut App) {
let next = match app.focus {
Focus::Files => Focus::Disks,
Focus::Disks => Focus::Packages,
Focus::Packages => Focus::Files,
};
set_focus(app, next);
}
fn focus_previous(app: &mut App) {
let previous = match app.focus {
Focus::Files => Focus::Packages,
Focus::Disks => Focus::Files,
Focus::Packages => Focus::Disks,
};
set_focus(app, previous);
}
fn set_focus(app: &mut App, focus: Focus) {
if app.focus != focus {
app.status.clear();
}
app.focus = focus;
if app.focus == Focus::Packages {
app.load_packages();
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn parse(parts: &[&str]) -> Result<CliAction> {
parse_args(parts.iter().map(OsString::from))
}
#[test]
fn defaults_to_home_without_args() {
assert!(matches!(parse(&[]).unwrap(), CliAction::Run(_)));
}
#[test]
fn accepts_one_path() {
let action = parse(&["/tmp"]).unwrap();
assert!(matches!(action, CliAction::Run(path) if path == std::path::Path::new("/tmp")));
}
#[test]
fn accepts_dash_prefixed_path_after_separator() {
let action = parse(&["--", "-cache"]).unwrap();
assert!(matches!(action, CliAction::Run(path) if path == std::path::Path::new("-cache")));
}
#[test]
fn parses_top_report() {
let action = parse(&["--top", "20", "--json", "/tmp"]).unwrap();
assert!(matches!(
action,
CliAction::Top {
path,
limit: 20,
json: true
} if path == std::path::Path::new("/tmp")
));
}
#[test]
fn parses_top_equals_report_with_default_path() {
let action = parse(&["--top=5"]).unwrap();
assert!(matches!(
action,
CliAction::Top {
limit: 5,
json: false,
..
}
));
}
#[test]
fn parses_save_baseline() {
let action = parse(&["--save", "/tmp"]).unwrap();
assert!(matches!(
action,
CliAction::Save { path, json: false } if path == std::path::Path::new("/tmp")
));
}
#[test]
fn parses_diff_report() {
let action = parse(&["--diff", "--json", "/tmp"]).unwrap();
assert!(matches!(
action,
CliAction::Diff { path, json: true } if path == std::path::Path::new("/tmp")
));
}
#[test]
fn parses_space_report() {
let action = parse(&["--space", "--json", "/tmp"]).unwrap();
assert!(matches!(
action,
CliAction::Space {
path,
json: true
} if path == std::path::Path::new("/tmp")
));
}
#[test]
fn parses_packages_report() {
let action = parse(&["--packages", "--json", "/tmp"]).unwrap();
assert!(matches!(
action,
CliAction::Packages {
path,
json: true
} if path == std::path::Path::new("/tmp")
));
}
#[test]
fn parses_reclaim_report() {
let action = parse(&["--reclaim", "--json", "/tmp"]).unwrap();
assert!(matches!(
action,
CliAction::Reclaim {
path,
json: true
} if path == std::path::Path::new("/tmp")
));
}
#[test]
fn parses_snapshot_thin_dry_run() {
let action = parse(&["--thin-snapshots", "1.5G", "/tmp"]).unwrap();
assert!(matches!(
action,
CliAction::ThinSnapshots {
path,
bytes: 1_610_612_736,
confirmed: false
} if path == std::path::Path::new("/tmp")
));
}
#[test]
fn parses_snapshot_thin_confirmation() {
let action = parse(&["--thin-snapshots=2G", "--yes"]).unwrap();
assert!(matches!(
action,
CliAction::ThinSnapshots {
bytes: 2_147_483_648,
confirmed: true,
..
}
));
}
#[test]
fn top_rejects_zero_limit() {
let err = parse(&["--top", "0"]).err().unwrap();
assert!(err.to_string().contains("--top must be greater than zero"));
}
#[test]
fn json_requires_top_report() {
let err = parse(&["--json"]).err().unwrap();
assert!(err
.to_string()
.contains("--json requires --top, --reclaim, --save, --diff, --space, or --packages"));
}
#[test]
fn yes_requires_snapshot_thinning() {
let err = parse(&["--yes"]).err().unwrap();
assert!(err.to_string().contains("--yes requires --thin-snapshots"));
}
#[test]
fn report_modes_are_mutually_exclusive() {
let err = parse(&["--top", "5", "--space"]).err().unwrap();
assert!(err.to_string().contains(
"choose only one of --top, --reclaim, --save, --diff, --space, --packages, or --thin-snapshots"
));
}
#[test]
fn external_action_target_uses_selected_file_disk_or_package() {
let root = test_root("external_target");
let file = root.join("visible.txt");
fs::create_dir_all(&root).unwrap();
fs::write(&file, b"visible").unwrap();
let mut app = App::new(root.clone()).unwrap();
let target = selected_action_target(&app).unwrap();
assert_eq!(target.0, file);
assert_eq!(target.1, "visible.txt");
let mount = root.join("mount");
fs::create_dir_all(&mount).unwrap();
app.disks = vec![app::DiskInfo {
name: String::from("External"),
mount: mount.clone(),
total: 100,
available: 50,
}];
app.focus = Focus::Disks;
let target = selected_action_target(&app).unwrap();
assert_eq!(target.0, mount);
assert_eq!(target.1, "External");
let pkg_path = root.join("pkg-bin");
fs::write(&pkg_path, b"binary").unwrap();
app.pkg_reports = vec![packages::ManagerReport {
manager: packages::Manager::Cargo,
packages: vec![packages::Package {
name: String::from("diskr"),
version: String::from("0.1.5"),
size: None,
path: Some(pkg_path.clone()),
}],
total_size: crate::bulkstat::SizeInfo::default(),
available: true,
}];
app.project_deps = vec![packages::ProjectDeps {
path: root.clone(),
manager_label: "cargo",
manifest: "Cargo.toml",
dep_count: 3,
deps_size: None,
deps_dir: Some(root.join("target")),
}];
app.packages_loaded = true;
app.rebuild_flat_packages();
app.focus = Focus::Packages;
app.pkg_view = app::PkgView::SystemManagers;
app.selected_pkg = 0;
let target = selected_action_target(&app).unwrap();
assert_eq!(target.0, pkg_path);
assert_eq!(target.1, "cargo diskr");
app.pkg_view = app::PkgView::ProjectDeps;
let target = selected_action_target(&app).unwrap();
assert_eq!(target.0, root.join("target"));
assert_eq!(target.1, format!("cargo {}", root.display()));
fs::remove_dir_all(root).unwrap();
}
#[test]
fn separator_requires_path() {
let err = parse(&["--"]).err().unwrap();
assert!(err.to_string().contains("usage: diskr [PATH]"));
}
#[test]
fn parses_help_and_version_flags() {
assert!(matches!(parse(&["--help"]).unwrap(), CliAction::Help));
assert!(matches!(parse(&["-h"]).unwrap(), CliAction::Help));
assert!(matches!(parse(&["--version"]).unwrap(), CliAction::Version));
assert!(matches!(parse(&["-V"]).unwrap(), CliAction::Version));
}
#[test]
fn rejects_extra_args() {
assert!(parse(&["/tmp", "/var"]).is_err());
assert!(parse(&["--", "/tmp", "/var"]).is_err());
}
fn test_root(name: &str) -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!(
"diskr_main_{name}_{}_{}",
std::process::id(),
nanos
))
}
}