Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ and `cargo build --release` directly; CI runs the gate on both systems.
| what a node is, or a derived total | `crates/disktree-core/src/tree.rs` |
| tile geometry, nesting, the merged tail | `crates/disktree-core/src/treemap.rs` |
| anything that deletes, or refuses to | `crates/disktree-core/src/removal.rs` |
| what `disktree report` prints, as text or JSON | `crates/disktree-core/src/report.rs` |
| running a report without a window | `crates/disktree-app/src/report.rs` |
| free space and projections | `crates/disktree-core/src/space.rs` |
| what macOS lists and measures in bulk | `crates/disktree-core/src/macos.rs` — `unsafe` for `getattrlistbulk` |
| what Windows lists, measures and compares differently | `crates/disktree-core/src/windows.rs` — `unsafe` for Win32 |
Expand Down
55 changes: 55 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,61 @@ disktree ~/src # or any directory
disktree --help # options: apparent size, follow links, skip hidden, …
```

### In a terminal

`disktree report` scans without opening a window and prints what fills the
tree: the largest entries, the scan by kind of data, what can be had back,
and the *Worth a look* list, from the same classification the window draws.
Like any explicit scan it needs a PATH or `--disk`, and it never removes
anything.

```text
$ disktree report ~/src --depth 2
~/src 36 GiB · 99 files · 79 dirs
/dev/nvme0n1p2 412 GiB free of 931 GiB · 56% used

size share files
21 GiB 56% 65 disktree/ (git)
19 GiB 52% 2 target/ (build output)
1.5 GiB 4% 2 .git/
2.3 MiB <1% 61 … 2 others
8.9 GiB 24% 32 webapp/ (cache)
5.9 GiB 16% 30 node_modules/ (reinstallable)
3.0 GiB 8% 1 public/ (documents)
700 B <1% 1 … 1 other
7.0 GiB 19% 2 datasets/
7.0 GiB 19% 1 imagenet-mini.tar
88 KiB <1% 1 … 1 other

By kind
21 GiB 56% Git
7.0 GiB 19% Other
5.9 GiB 16% Cache
3.0 GiB 8% Media
2.3 MiB <1% Code

Can be had back: 25 GiB, 68% of the scan (hatched in the window)
19 GiB build output
5.9 GiB reinstallable

Worth a look
19 GiB build output ~/src/disktree/target
5.9 GiB reinstallable ~/src/webapp/node_modules
```

Every scan option applies: `-a`, `-H`, `-X`, `--metric files`, and the
power and thread flags. `--depth` sets how many levels are listed, with no
six-level cap. `--top N` (10) and `--min SIZE` (`500M`, `2G`, or a share
such as the default `1%`) decide what each directory lists; what they leave
out is summed on an `… N others` row, so no total loses anything.
`--findings N` (20) sets how long *Worth a look* is.

`--json` prints one document instead, for scripts and coding agents: the
totals, the volume's space, the unreadable paths, `kinds`, `reclaimable` by
reason, `findings`, and the listed `tree`, every entry with its absolute
path, its kind of data and any reason it can be had back. Progress goes to
stderr, and only when that is a terminal.

### Servers over SSH (read-only)

```sh
Expand Down
134 changes: 124 additions & 10 deletions crates/disktree-app/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ mod git;
mod marks;
mod palette;
mod power;
mod report;
mod state;
#[cfg(test)]
mod tests;
Expand Down Expand Up @@ -49,12 +50,15 @@ struct Args {
options: ScanOptions,
depth: u32,
power: Option<power::PowerEfficiency>,
/// `disktree report`: print the scan instead of opening a window.
report: Option<report::Settings>,
}

const USAGE: &str = "\
disktree — a treemap of what is using your disk

usage: disktree [OPTIONS] [PATH]
disktree report [OPTIONS] PATH
disktree ssh HOST [OPTIONS] [PATH]
disktree extract-scanner ARCH OUTPUT

Expand All @@ -73,6 +77,11 @@ No scan starts before explicit acceptance. Nested mounts are excluded.
With PATH the window scans it directly, largest first. Space marks the
selected tile, Enter opens it, c reviews the marked list, ? lists every key.

report scans PATH (or --disk) without a window and prints the largest
entries as a tree, the scan by kind of data, what can be had back, and what
is worth a look, then exits. It never removes anything. Progress goes to
stderr when it is a terminal; the report goes to stdout.

options:
-a, --apparent-size measure apparent length instead of allocated blocks
-l, --follow-links follow symlinks (local scans only)
Expand All @@ -94,6 +103,15 @@ options:
best-effort host CPU budget; 0 disables it (80)
--metric files rank by file count instead of bytes
-h, --help show this help

report options:
-d, --depth N levels of the tree to list (default 3, no limit)
--top N entries listed per directory (default 10)
--min SIZE fold entries smaller than SIZE into a remainder
line: 500M, 2G, or a share of the scan such as 1%
(default 1%)
--findings N \"worth a look\" findings to list (default 20)
--json one JSON document instead of text
";

fn main() -> Result<()> {
Expand All @@ -120,6 +138,10 @@ fn run() -> Result<()> {
});
let args = parse_args_with_power(std::env::args_os().skip(1), saved)?;

if let Some(settings) = args.report {
return report::run(&args.root, args.options, settings);
}

if args.check {
let host = args
.remote
Expand Down Expand Up @@ -322,15 +344,22 @@ fn parse_args_with_power(
};
let mut check = false;
let mut args = args.peekable();
let remote = if args.peek().is_some_and(|arg| arg == "ssh") {
let mut report = if args.peek().is_some_and(|arg| arg == "report") {
args.next();
let host =
text(args.next(), "ssh needs a host, e.g. disktree ssh dev")?;
disktree_core::remote::validate_host(&host)?;
Some(host)
Some(report::Settings::default())
} else {
None
};
let remote =
if report.is_none() && args.peek().is_some_and(|arg| arg == "ssh") {
args.next();
let host =
text(args.next(), "ssh needs a host, e.g. disktree ssh dev")?;
disktree_core::remote::validate_host(&host)?;
Some(host)
} else {
None
};

while let Some(arg) = args.next() {
match arg.to_str().unwrap_or_default() {
Expand All @@ -350,11 +379,41 @@ fn parse_args_with_power(
"-d" | "--depth" => {
let value = text(args.next(), "--depth needs a number")?;
depth = value.parse().context("--depth needs a number")?;
anyhow::ensure!(
(1..=6).contains(&depth),
"--depth must be 1 to 6"
);
// Six levels is what a window can draw legibly; printed
// text has no such ceiling.
if report.is_some() {
anyhow::ensure!(depth >= 1, "--depth must be at least 1");
} else {
anyhow::ensure!(
(1..=6).contains(&depth),
"--depth must be 1 to 6"
);
}
}
"--top" => {
let settings = report_option(&mut report, "--top")?;
let value = text(args.next(), "--top needs a number")?;
let top: usize =
value.parse().context("--top needs a positive integer")?;
anyhow::ensure!(top > 0, "--top must be positive");
settings.limits.top = top;
}
"--min" => {
let settings = report_option(&mut report, "--min")?;
let value = text(args.next(), "--min needs a size")?;
settings.limits.min =
disktree_core::report::Threshold::parse(&value)
.with_context(|| {
format!("--min {value}: try 500M, 2G or 1%")
})?;
}
"--findings" => {
let settings = report_option(&mut report, "--findings")?;
let value = text(args.next(), "--findings needs a number")?;
settings.limits.findings =
value.parse().context("--findings needs a number")?;
}
"--json" => report_option(&mut report, "--json")?.json = true,
"--power-efficiency" => {
let value =
text(args.next(), "--power-efficiency needs a preset")?;
Expand Down Expand Up @@ -436,7 +495,17 @@ fn parse_args_with_power(
!(disk && root.is_some()),
"--disk and a PATH cannot be combined"
);
let choose_root = root.is_none() && !disk && !check;
if let Some(report) = &mut report {
// A report is asked for on purpose, so it scans nothing implicitly
// either: no PATH is not taken to mean the home directory.
anyhow::ensure!(
root.is_some() || disk,
"disktree report needs a PATH or --disk, e.g. disktree report ~"
);
anyhow::ensure!(!check, "--check only applies to disktree ssh HOST");
report.limits.depth = depth as usize;
}
let choose_root = root.is_none() && !disk && !check && report.is_none();
if remote.is_some() {
return Ok(Args {
root: root.unwrap_or_default(),
Expand All @@ -447,6 +516,7 @@ fn parse_args_with_power(
power,
options,
depth,
report: None,
});
}
let home = std::env::home_dir();
Expand Down Expand Up @@ -486,9 +556,20 @@ fn parse_args_with_power(
options,
depth: depth.clamp(1, 6),
power,
report,
})
}

/// The settings an option only `report` takes; anywhere else it is a typo.
fn report_option<'a>(
report: &'a mut Option<report::Settings>,
option: &str,
) -> Result<&'a mut report::Settings> {
report
.as_mut()
.with_context(|| format!("{option} only applies to disktree report"))
}

/// The console of the terminal disktree was started from, if any: a
/// windowed program on Windows gets none of its own.
#[cfg(windows)]
Expand Down Expand Up @@ -663,6 +744,39 @@ mod argument_tests {
assert_eq!(args.options.threads.max_threads, 4);
}

#[test]
fn report_needs_an_explicit_root_and_keeps_its_own_options() {
let parse = |args: &[&str]| {
parse_args(args.iter().map(std::ffi::OsString::from))
};
let args = parse(&[
"report", "--depth", "9", "--top", "4", "--min", "2G", "--json",
"-H", ".",
])
.expect("report");
let report = args.report.expect("report settings");
assert_eq!(report.limits.depth, 9, "no window, no six-level cap");
assert_eq!(report.limits.top, 4);
assert_eq!(
report.limits.min,
disktree_core::report::Threshold::Bytes(2 << 30)
);
assert!(report.json);
assert!(!args.options.include_hidden, "scan options still apply");
assert!(!args.choose_root);
assert!(parse(&["report", "--disk"]).expect("disk").report.is_some());

assert!(parse(&["report"]).is_err(), "no implicit home scan");
assert!(parse(&["report", "--top", "0", "."]).is_err());
assert!(parse(&["report", "--min", "lots", "."]).is_err());
assert!(parse(&["report", "--check", "."]).is_err());
for option in ["--json", "--top", "--min", "--findings"] {
assert!(parse(&[option, "1", "."]).is_err(), "{option} alone");
}
assert!(parse(&["--depth", "9", "."]).is_err(), "the window caps");
assert!(parse(&["."]).expect("window").report.is_none());
}

#[test]
fn invalid_worker_budgets_are_rejected() {
for extra in [
Expand Down
100 changes: 100 additions & 0 deletions crates/disktree-app/src/report.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
//! `disktree report`: scan, print what fills the tree, and exit, without a
//! window. For a terminal, a script, or a coding agent asked to free space.

use std::io::{IsTerminal as _, Write as _};
use std::path::Path;
use std::time::{Duration, Instant};

use anyhow::{Context as _, Result};
use disktree_core::report::{Limits, Report, Scanned};
use disktree_core::scan::{ScanHandle, ScanOptions};
use disktree_core::size::{human_bytes, human_count};
use disktree_core::{access, space};

/// What `disktree report` was asked for.
#[derive(Clone, Copy, Debug, Default)]
pub struct Settings {
pub limits: Limits,
/// One JSON document on stdout instead of text.
pub json: bool,
}

/// How often the progress line is redrawn: often enough to look alive,
/// rarely enough not to cost the scan anything.
const PROGRESS_EVERY: Duration = Duration::from_millis(250);

pub fn run(
root: &Path,
options: ScanOptions,
settings: Settings,
) -> Result<()> {
let metric = options.metric;
let apparent_size = options.apparent_size;
let scan = ScanHandle::spawn(root.to_path_buf(), options);
// Progress goes to stderr, and only to a terminal: stdout is the report,
// and a log or a pipe has no use for carriage returns.
let live = std::io::stderr().is_terminal();
let started = Instant::now();
let mut drawn: Option<Instant> = None;
let tree = loop {
if let Some(outcome) = scan.poll() {
break outcome
.with_context(|| format!("cannot scan {}", root.display()))?;
}
if live && drawn.is_none_or(|at| at.elapsed() >= PROGRESS_EVERY) {
let progress = scan.progress.snapshot();
eprint!(
"\r\x1b[2Kscanning {} · {} · {} files · {:.0} s",
root.display(),
human_bytes(progress.bytes),
human_count(progress.files),
started.elapsed().as_secs_f64(),
);
drawn = Some(Instant::now());
}
std::thread::sleep(Duration::from_millis(20));
};
if live {
eprint!("\r\x1b[2K");
}

let progress = scan.progress.snapshot();
// Canonical, like the root it is compared with: a home reached through
// a symlink would otherwise never shorten to `~`.
let home = std::env::home_dir()
.map(|home| dunce::canonicalize(&home).unwrap_or(home));
let report = Report::new(
&tree,
Scanned {
root: root.to_path_buf(),
full_disk_access: home
.as_deref()
.and_then(access::full_disk_access),
home,
metric,
apparent_size,
unreadable: progress.errors,
unreadable_paths: progress.messages,
device: space::device_for(root),
space: space::space_info(root).ok(),
now: crate::state::now_seconds(),
},
settings.limits,
);
let text = if settings.json {
let mut json = report.to_json().context("write the report")?;
json.push('\n');
json
} else {
report.to_text()
};
let mut stdout = std::io::stdout().lock();
match stdout
.write_all(text.as_bytes())
.and_then(|()| stdout.flush())
{
// `disktree report ~ | head` closing the pipe early is not a failure.
Err(error) if error.kind() == std::io::ErrorKind::BrokenPipe => Ok(()),
outcome => outcome.context("write the report"),
}
}
Loading