From 18fc3696f053a7ba44af31bc3224de94757c93e5 Mon Sep 17 00:00:00 2001 From: Mohamad Yahia Date: Sun, 25 Oct 2026 17:12:28 +0400 Subject: [PATCH] app: print a scan with disktree report, without a window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `disktree report PATH` (or `--disk`) scans through the existing pipeline and prints what fills the tree, then exits: the largest entries as an indented tree, the scan by kind of data, what can be had back by reason, and the window's "Worth a look" list. `--json` prints the same as one document, with absolute paths, for scripts and coding agents. The report lives in disktree-core (`report.rs`) and reads the tree the window draws, `classify` and `insights` included, so the terminal and the treemap never disagree about what is reclaimable. The app only parses the command, drives a ScanHandle with a progress line on stderr when that is a terminal, and returns before any GPUI setup. - Every scan option applies; `--depth` has no six-level cap here. - `--top N` and `--min SIZE|N%` decide what each directory lists; the rest is summed on an "… N others" row, so no total loses anything. - No PATH is an error rather than an implicit home scan. - Unreadable paths come from ScanProgress, files included, with the macOS Full Disk Access hint when access is known to be missing. - Names with control characters are escaped in text; a closed pipe is not an error. - Category and Reclaim serialise as snake_case for the JSON. --- AGENTS.md | 2 + README.md | 55 ++ crates/disktree-app/src/main.rs | 134 ++++- crates/disktree-app/src/report.rs | 100 ++++ crates/disktree-core/src/classify.rs | 8 +- crates/disktree-core/src/lib.rs | 1 + crates/disktree-core/src/report.rs | 852 +++++++++++++++++++++++++++ crates/disktree-core/src/size.rs | 37 ++ 8 files changed, 1177 insertions(+), 12 deletions(-) create mode 100644 crates/disktree-app/src/report.rs create mode 100644 crates/disktree-core/src/report.rs diff --git a/AGENTS.md b/AGENTS.md index 0342b30c..f94523e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 | diff --git a/README.md b/README.md index bf9bc21f..62d4a046 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/crates/disktree-app/src/main.rs b/crates/disktree-app/src/main.rs index 904a9319..ddcd4610 100644 --- a/crates/disktree-app/src/main.rs +++ b/crates/disktree-app/src/main.rs @@ -17,6 +17,7 @@ mod git; mod marks; mod palette; mod power; +mod report; mod state; #[cfg(test)] mod tests; @@ -49,12 +50,15 @@ struct Args { options: ScanOptions, depth: u32, power: Option, + /// `disktree report`: print the scan instead of opening a window. + report: Option, } 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 @@ -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) @@ -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<()> { @@ -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 @@ -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() { @@ -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")?; @@ -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(), @@ -447,6 +516,7 @@ fn parse_args_with_power( power, options, depth, + report: None, }); } let home = std::env::home_dir(); @@ -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, + 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)] @@ -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 [ diff --git a/crates/disktree-app/src/report.rs b/crates/disktree-app/src/report.rs new file mode 100644 index 00000000..1630e149 --- /dev/null +++ b/crates/disktree-app/src/report.rs @@ -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 = 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"), + } +} diff --git a/crates/disktree-core/src/classify.rs b/crates/disktree-core/src/classify.rs index bfe24a7f..8aeb5aff 100644 --- a/crates/disktree-core/src/classify.rs +++ b/crates/disktree-core/src/classify.rs @@ -18,7 +18,10 @@ use rayon::prelude::*; use crate::tree::Node; /// A kind of data, for colour. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +#[derive( + Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, +)] +#[serde(rename_all = "snake_case")] pub enum Category { /// Source code and checkouts. Code, @@ -70,7 +73,8 @@ impl Category { } /// Why a directory's space can be had back. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize)] +#[serde(rename_all = "snake_case")] pub enum Reclaim { /// A cache: whatever wrote it will write it again. Regenerable, diff --git a/crates/disktree-core/src/lib.rs b/crates/disktree-core/src/lib.rs index 0f58c7ba..52674b41 100644 --- a/crates/disktree-core/src/lib.rs +++ b/crates/disktree-core/src/lib.rs @@ -22,6 +22,7 @@ pub mod remote_progress; #[cfg(unix)] mod remote_walk; pub mod removal; +pub mod report; pub mod scan; pub mod scan_preview; pub mod scan_threads; diff --git a/crates/disktree-core/src/report.rs b/crates/disktree-core/src/report.rs new file mode 100644 index 00000000..bcb4d330 --- /dev/null +++ b/crates/disktree-core/src/report.rs @@ -0,0 +1,852 @@ +//! A finished scan as a report for a terminal or another program. +//! +//! The largest entries as an indented tree, what kind of data the scan holds +//! and how much of it can be had back, and what is worth a look, with the +//! volume's free space beside them. +//! +//! It reads the classification and findings the window draws, so +//! `disktree report` and the treemap never disagree about what is +//! reclaimable. Nothing here touches the filesystem: a report is a walk over +//! a tree that is already finished. + +use std::borrow::Cow; +use std::fmt::Write as _; +use std::path::{Path, PathBuf}; + +use serde::Serialize; + +use crate::classify::{Category, Reclaim}; +use crate::insights::{self, Finding, STALE_DAYS}; +use crate::size::{human_bytes, human_count, parse_bytes, share}; +use crate::space::SpaceInfo; +use crate::tree::{Metric, Node, NodeKind, path_of}; + +/// The smallest entry the tree lists. Smaller ones are summed into their +/// directory's remainder line, so no total loses anything. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Threshold { + /// At least this many bytes. + Bytes(u64), + /// At least this percentage of the whole scan, by the ranked measure. + Share(f64), +} + +impl Threshold { + /// `512M`, `2G`, `1.5GiB`, a plain byte count, or a share such as `1%`. + pub fn parse(text: &str) -> Option { + let text = text.trim(); + if let Some(number) = text.strip_suffix('%') { + let percent: f64 = number.trim_end().parse().ok()?; + // `contains` is false for NaN too. + return (0.0..=100.0) + .contains(&percent) + .then_some(Self::Share(percent)); + } + parse_bytes(text).map(Self::Bytes) + } + + fn admits(self, node: &Node, total: u64, metric: Metric) -> bool { + match self { + Self::Bytes(bytes) => node.bytes >= bytes, + Self::Share(percent) => { + node.value(metric) as f64 >= total as f64 * percent / 100.0 + } + } + } +} + +/// How much of the scan a report lists. +#[derive(Clone, Copy, Debug)] +pub struct Limits { + /// Levels of the tree below the root. + pub depth: usize, + /// Entries listed in each directory, largest first. + pub top: usize, + pub min: Threshold, + /// "Worth a look" findings listed, largest first. + pub findings: usize, +} + +impl Default for Limits { + /// Three levels, like the window draws at first. Ten entries and a + /// one percent floor keep a home directory to a screen or two; the + /// remainder lines say how much was folded away. + fn default() -> Self { + Self { + depth: 3, + top: 10, + min: Threshold::Share(1.0), + findings: 20, + } + } +} + +/// What the caller measured besides the tree. +#[derive(Clone, Debug, Default)] +pub struct Scanned { + /// The scanned root, absolute. + pub root: PathBuf, + /// Written as `~` in text, so paths stay short. + pub home: Option, + pub metric: Metric, + pub apparent_size: bool, + /// Paths the walk could not read, and the first of them verbatim. + pub unreadable: u64, + pub unreadable_paths: Vec, + /// The device the root is mounted from, and its space, when readable. + pub device: Option, + pub space: Option, + /// `Some(false)` when macOS is known to be hiding folders. + pub full_disk_access: Option, + /// Unix seconds, for how old a finding is; passed in to be testable. + pub now: i64, +} + +/// A scan, summarised. Serialises as the `--json` document. +#[derive(Debug, Serialize)] +pub struct Report { + /// The scanned root, absolute. + pub root: String, + /// `"disk usage"`, the blocks deleting gives back, or + /// `"apparent size"`, the length `ls -l` shows. + pub measure: &'static str, + /// `"size"` or `"files"`: what the tree is ordered and cut by. + pub ranked_by: &'static str, + pub bytes: u64, + pub files: u64, + pub dirs: u64, + /// Paths the walk could not read; what they hold is not counted. + pub unreadable: u64, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub unreadable_paths: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub full_disk_access: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub volume: Option, + /// The whole scan by kind of data, largest first. + pub kinds: Vec, + pub reclaimable: Reclaimable, + /// The window's "worth a look" list. + pub findings: Vec, + pub tree: Entry, + #[serde(skip)] + home: Option, + #[serde(skip)] + metric: Metric, +} + +/// The volume the root is on. +#[derive(Debug, Serialize)] +pub struct Volume { + #[serde(skip_serializing_if = "Option::is_none")] + pub device: Option, + #[serde(flatten)] + pub space: SpaceInfo, +} + +/// One kind of data, over the whole scan. +#[derive(Debug, Serialize)] +pub struct KindTotal { + pub category: Category, + pub label: &'static str, + pub bytes: u64, + pub files: u64, +} + +/// Space the window hatches: caches, build output, package stores and the +/// rest of [`Reclaim`], over the whole scan. +#[derive(Debug, Default, Serialize)] +pub struct Reclaimable { + pub bytes: u64, + /// By reason, largest first. + pub reasons: Vec, +} + +#[derive(Debug, Serialize)] +pub struct ReasonTotal { + pub reclaim: Reclaim, + pub label: &'static str, + pub bytes: u64, +} + +/// One line of "worth a look". +#[derive(Debug, Serialize)] +pub struct Suggestion { + pub path: String, + /// What clearing it frees. For stale experiments, only the stale ones. + pub bytes: u64, + /// `"reclaimable"`, `"worktrees"` or `"stale_experiments"`. + pub finding: &'static str, + /// Why it is on the list, as the window says it. + pub why: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub reclaim: Option, +} + +/// One entry of the tree, with the largest of its children. +#[derive(Debug, Serialize)] +pub struct Entry { + pub name: String, + pub path: String, + /// `"directory"`, `"file"`, `"symlink"` or `"other"`. + pub kind: &'static str, + pub bytes: u64, + pub files: u64, + pub dirs: u32, + /// Newest write at or beneath it, in Unix seconds; `0` when unknown. + pub modified: i64, + pub category: Category, + #[serde(skip_serializing_if = "Option::is_none")] + pub reclaim: Option, + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub unreadable: bool, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub children: Vec, + /// What the limits left out of `children`, summed. Absent at the depth + /// limit, where no child is listed and the entry's totals say it all. + #[serde(skip_serializing_if = "Option::is_none")] + pub rest: Option, +} + +/// Entries a directory holds that its listing leaves out. +#[derive(Debug, Default, Serialize)] +pub struct Rest { + pub entries: u64, + pub bytes: u64, + pub files: u64, +} + +impl Report { + pub fn new(tree: &Node, scanned: Scanned, limits: Limits) -> Self { + let total = tree.value(scanned.metric); + let admits = + |node: &Node| limits.min.admits(node, total, scanned.metric); + let mut outline = Outline { + depth: limits.depth, + top: limits.top, + admits: &admits, + } + .entry(tree, &scanned.root); + outline.name = outline.path.clone(); + let (kinds, reclaimable) = tally(tree); + let findings = + insights::worth_a_look(tree, scanned.now, limits.findings) + .into_iter() + .map(|candidate| Suggestion { + path: path_of(&scanned.root, tree, &candidate.crumbs) + .to_string_lossy() + .into_owned(), + bytes: candidate.bytes, + finding: match candidate.finding { + Finding::Reclaimable(_) => "reclaimable", + Finding::Worktrees { .. } => "worktrees", + Finding::StaleExperiments { .. } => "stale_experiments", + }, + why: why(&candidate.finding), + reclaim: match candidate.finding { + Finding::Reclaimable(reason) => Some(reason), + _ => None, + }, + }) + .collect(); + Self { + root: scanned.root.to_string_lossy().into_owned(), + measure: if scanned.apparent_size { + "apparent size" + } else { + "disk usage" + }, + ranked_by: scanned.metric.label(), + bytes: tree.bytes, + files: tree.files, + dirs: u64::from(tree.dirs), + unreadable: scanned.unreadable, + unreadable_paths: scanned.unreadable_paths, + full_disk_access: scanned.full_disk_access, + volume: scanned.space.map(|space| Volume { + device: scanned.device, + space, + }), + kinds, + reclaimable, + findings, + tree: outline, + home: scanned.home, + metric: scanned.metric, + } + } + + /// The `--json` document. + pub fn to_json(&self) -> serde_json::Result { + serde_json::to_string_pretty(self) + } + + /// The report for a terminal: totals, the tree, the kinds of data, and + /// what is worth a look. + pub fn to_text(&self) -> String { + let mut out = String::new(); + let measure = if self.measure == "disk usage" { + String::new() + } else { + format!(" ({})", self.measure) + }; + let _ = write!( + out, + "{} {}{measure} · {} files · {} dirs", + self.shown(&self.root), + human_bytes(self.bytes), + human_count(self.files), + human_count(self.dirs), + ); + if self.unreadable > 0 { + let _ = + write!(out, " · {} unreadable", human_count(self.unreadable)); + } + out.push('\n'); + if let Some(volume) = &self.volume { + let _ = writeln!( + out, + "{} {} free of {} · {:.0}% used", + volume.device.as_deref().unwrap_or("volume"), + human_bytes(volume.space.available), + human_bytes(volume.space.total), + volume.space.used_fraction() * 100.0, + ); + } + + out.push_str("\n size share files\n"); + let total = self.ranked(self.tree.bytes, self.tree.files); + self.rows(&mut out, &self.tree, 0, total); + + if !self.kinds.is_empty() { + out.push_str("\nBy kind\n"); + for kind in &self.kinds { + let _ = writeln!( + out, + "{:>8} {:>5} {}", + human_bytes(kind.bytes), + percent(kind.bytes, self.bytes), + kind.label, + ); + } + } + if self.reclaimable.bytes > 0 { + let _ = writeln!( + out, + "\nCan be had back: {}, {} of the scan (hatched in the \ + window)", + human_bytes(self.reclaimable.bytes), + percent(self.reclaimable.bytes, self.bytes).trim_start(), + ); + for reason in &self.reclaimable.reasons { + let _ = writeln!( + out, + "{:>8} {}", + human_bytes(reason.bytes), + reason.label + ); + } + } + if !self.findings.is_empty() { + out.push_str("\nWorth a look\n"); + let width = self + .findings + .iter() + .map(|found| found.why.chars().count()) + .max() + .unwrap_or(0); + for found in &self.findings { + let _ = writeln!( + out, + "{:>8} {: 0 { + let noun = if self.unreadable == 1 { + "path" + } else { + "paths" + }; + let _ = writeln!( + out, + "\n{} {noun} could not be read; what they hold is not \ + counted.", + human_count(self.unreadable), + ); + if self.full_disk_access == Some(false) { + out.push_str( + "macOS keeps them from this terminal. Give it Full Disk \ + Access in System Settings › Privacy & Security, then \ + run this again.\n", + ); + } + } + out + } + + /// The tree's rows beneath `entry`, `level` deep. + fn rows(&self, out: &mut String, entry: &Entry, level: usize, total: u64) { + let indent = " ".repeat(level); + for child in &entry.children { + let slash = if child.kind == "directory" { "/" } else { "" }; + let _ = writeln!( + out, + "{:>8} {:>5} {:>7} {indent}{}{slash}{}", + human_bytes(child.bytes), + percent(self.ranked(child.bytes, child.files), total), + human_count(child.files), + printable(&child.name), + tag(entry, child), + ); + self.rows(out, child, level + 1, total); + } + if let Some(rest) = &entry.rest { + let noun = if rest.entries == 1 { "other" } else { "others" }; + let _ = writeln!( + out, + "{:>8} {:>5} {:>7} {indent}… {} {noun}", + human_bytes(rest.bytes), + percent(self.ranked(rest.bytes, rest.files), total), + human_count(rest.files), + human_count(rest.entries), + ); + } + } + + /// Of an entry's two totals, the one the tree is ranked by. + const fn ranked(&self, bytes: u64, files: u64) -> u64 { + match self.metric { + Metric::Bytes => bytes, + Metric::Files => files, + } + } + + /// `path` for reading: under the home directory as `~`, and with any + /// control character escaped so a name cannot forge another line. + fn shown(&self, path: &str) -> String { + let path = Path::new(path); + let shown = match self + .home + .as_deref() + .and_then(|home| path.strip_prefix(home).ok()) + { + Some(rest) if rest.as_os_str().is_empty() => "~".to_owned(), + Some(rest) => { + format!("~{}{}", std::path::MAIN_SEPARATOR, rest.display()) + } + None => path.display().to_string(), + }; + printable(&shown).into_owned() + } +} + +/// Builds the listed tree. +struct Outline<'a> { + depth: usize, + top: usize, + admits: &'a dyn Fn(&Node) -> bool, +} + +impl Outline<'_> { + /// `node`, at `path`, with its largest children down to the depth + /// limit. Children arrive ordered by the ranked measure, largest first. + fn entry(&self, node: &Node, path: &Path) -> Entry { + let mut entry = Entry { + name: node.name.to_string(), + path: path.to_string_lossy().into_owned(), + kind: match node.kind { + NodeKind::Directory => "directory", + NodeKind::File => "file", + NodeKind::Symlink => "symlink", + NodeKind::Other => "other", + }, + bytes: node.bytes, + files: node.files, + dirs: node.dirs, + modified: node.modified, + category: node.category, + reclaim: node.reclaim, + unreadable: node.read_error, + children: Vec::new(), + rest: None, + }; + if !node.is_dir() || self.depth == 0 { + return entry; + } + let below = Self { + depth: self.depth - 1, + ..*self + }; + let mut rest = Rest::default(); + for child in &node.children { + if entry.children.len() < self.top && (self.admits)(child) { + entry + .children + .push(below.entry(child, &path.join(&*child.name))); + } else { + rest.entries += 1; + rest.bytes = rest.bytes.saturating_add(child.bytes); + rest.files += child.files; + } + } + entry.rest = (rest.entries > 0).then_some(rest); + entry + } +} + +/// Every byte in the scan by kind, and what of it can be had back. A leaf +/// carries its directory's kind and reclaim reason, so summing the leaves +/// counts each byte once, hardlinks included: a second name weighs nothing. +fn tally(root: &Node) -> (Vec, Reclaimable) { + let mut kinds: Vec = Vec::new(); + let mut reclaimable = Reclaimable::default(); + // A stack rather than recursion: a tree can be deeper than a thread's + // stack is tall. + let mut stack = vec![root]; + while let Some(node) = stack.pop() { + if node.is_dir() { + stack.extend(&node.children); + continue; + } + match kinds.iter_mut().find(|kind| kind.category == node.category) { + Some(kind) => { + kind.bytes = kind.bytes.saturating_add(node.bytes); + kind.files += node.files; + } + None => kinds.push(KindTotal { + category: node.category, + label: node.category.label(), + bytes: node.bytes, + files: node.files, + }), + } + let Some(reason) = node.reclaim else { + continue; + }; + reclaimable.bytes = reclaimable.bytes.saturating_add(node.bytes); + match reclaimable + .reasons + .iter_mut() + .find(|total| total.reclaim == reason) + { + Some(total) => total.bytes = total.bytes.saturating_add(node.bytes), + None => reclaimable.reasons.push(ReasonTotal { + reclaim: reason, + label: reason.label(), + bytes: node.bytes, + }), + } + } + kinds.retain(|kind| kind.bytes > 0 || kind.files > 0); + kinds.sort_by_key(|kind| std::cmp::Reverse(kind.bytes)); + reclaimable.reasons.retain(|total| total.bytes > 0); + reclaimable + .reasons + .sort_by_key(|total| std::cmp::Reverse(total.bytes)); + (kinds, reclaimable) +} + +/// Why a finding is on the list, in the window's words. +fn why(finding: &Finding) -> String { + let plural = |count: usize| if count == 1 { "" } else { "s" }; + match finding { + Finding::Reclaimable(reason) => reason.label().to_owned(), + Finding::Worktrees { count, oldest_days } => format!( + "{count} worktree{} · oldest {oldest_days} d", + plural(*count) + ), + Finding::StaleExperiments { count } => format!( + "{count} experiment{} untouched > {STALE_DAYS} d", + plural(*count) + ), + } +} + +/// What a row adds about its entry: its kind where that differs from its +/// directory's, and why it can be had back where that starts. Everything +/// beneath inherits both, so repeating them would only be noise. +fn tag(parent: &Entry, child: &Entry) -> String { + let mut parts: Vec> = Vec::new(); + if child.category != parent.category && child.category != Category::Other { + parts.push(Cow::Owned(child.category.label().to_lowercase())); + } + if let Some(reason) = child.reclaim + && parent.reclaim.is_none() + { + parts.push(Cow::Borrowed(reason.label())); + } + if child.unreadable { + parts.push(Cow::Borrowed("unreadable")); + } + if parts.is_empty() { + String::new() + } else { + format!(" ({})", parts.join(", ")) + } +} + +/// A share as a short column: `43%`, or `<1%` for a sliver that is there. +fn percent(part: u64, total: u64) -> String { + let value = share(part, total); + if part > 0 && value < 0.5 { + "<1%".to_owned() + } else { + format!("{value:.0}%") + } +} + +/// `text` with its control characters escaped. +fn printable(text: &str) -> Cow<'_, str> { + if text.chars().any(char::is_control) { + Cow::Owned( + text.chars() + .map(|char| { + if char.is_control() { + char.escape_default().to_string() + } else { + char.to_string() + } + }) + .collect(), + ) + } else { + Cow::Borrowed(text) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::classify::classify; + use crate::scan::{ScanOptions, scan}; + use crate::tree::aggregate; + use std::fs; + use tempfile::TempDir; + + const MIB: u64 = 1024 * 1024; + const DAY: i64 = 86_400; + const NOW: i64 = 1_800_000_000; + + fn file(name: &str, bytes: u64) -> Node { + let mut node = Node::entry(name, NodeKind::File, bytes); + node.modified = NOW - DAY; + node + } + + fn dir(name: &str, children: Vec) -> Node { + let mut node = Node::directory(name); + node.children = children; + node + } + + fn tree(mut root: Node) -> Node { + aggregate(&mut root, Metric::Bytes); + classify(&mut root); + root + } + + fn scanned() -> Scanned { + Scanned { + root: PathBuf::from("/home/tobi"), + home: Some(PathBuf::from("/home/tobi")), + now: NOW, + ..Scanned::default() + } + } + + fn home() -> Node { + tree(dir( + "tobi", + vec![ + dir( + "src", + vec![ + file("a", 40), + file("b", 30), + file("c", 20), + file("d", 9), + file("e", 1), + ], + ), + dir(".cache", vec![dir("kache", vec![file("blob", 300)])]), + dir( + "deep", + vec![dir("er", vec![dir("est", vec![file("f", 50)])])], + ), + ], + )) + } + + fn limits(depth: usize, top: usize, min: Threshold) -> Limits { + Limits { + depth, + top, + min, + findings: 10, + } + } + + fn named<'a>(entry: &'a Entry, name: &str) -> &'a Entry { + entry + .children + .iter() + .find(|child| child.name == name) + .unwrap_or_else(|| panic!("no {name} under {}", entry.name)) + } + + #[test] + fn lists_the_largest_first_and_folds_the_rest() { + let report = + Report::new(&home(), scanned(), limits(2, 2, Threshold::Bytes(0))); + let src = named(&report.tree, "src"); + let names: Vec<&str> = + src.children.iter().map(|child| &*child.name).collect(); + assert_eq!(names, ["a", "b"]); + let rest = src.rest.as_ref().expect("a remainder"); + assert_eq!((rest.entries, rest.bytes, rest.files), (3, 30, 3)); + let top: Vec<&str> = report + .tree + .children + .iter() + .map(|child| &*child.name) + .collect(); + assert_eq!(top, [".cache", "src"], "largest first"); + assert_eq!(report.tree.rest.as_ref().map(|rest| rest.bytes), Some(50)); + } + + #[test] + fn depth_stops_the_listing_without_a_remainder() { + let report = + Report::new(&home(), scanned(), limits(2, 10, Threshold::Bytes(0))); + let er = named(named(&report.tree, "deep"), "er"); + assert!(er.children.is_empty()); + assert!(er.rest.is_none(), "the totals say it at the depth limit"); + assert_eq!(er.bytes, 50); + assert_eq!(er.path, "/home/tobi/deep/er"); + } + + #[test] + fn a_share_floor_folds_small_entries_into_the_remainder() { + // 450 bytes in all, so five percent is 22.5: `c` at 20 is out. + let report = Report::new( + &home(), + scanned(), + limits(2, 10, Threshold::Share(5.0)), + ); + let src = named(&report.tree, "src"); + let names: Vec<&str> = + src.children.iter().map(|child| &*child.name).collect(); + assert_eq!(names, ["a", "b"]); + assert_eq!(src.rest.as_ref().map(|rest| rest.bytes), Some(30)); + } + + #[test] + fn kinds_count_every_byte_once() { + let root = home(); + let report = Report::new(&root, scanned(), Limits::default()); + let sum: u64 = report.kinds.iter().map(|kind| kind.bytes).sum(); + assert_eq!(sum, root.bytes); + assert_eq!(report.kinds[0].category, Category::Cache); + assert_eq!(report.reclaimable.bytes, 300); + assert_eq!(report.reclaimable.reasons[0].reclaim, Reclaim::Regenerable); + } + + #[test] + fn thresholds_read_sizes_and_shares() { + assert_eq!(Threshold::parse("1%"), Some(Threshold::Share(1.0))); + assert_eq!(Threshold::parse(" 0.5 %"), Some(Threshold::Share(0.5))); + assert_eq!(Threshold::parse("512M"), Some(Threshold::Bytes(512 * MIB))); + assert_eq!(Threshold::parse("4096"), Some(Threshold::Bytes(4096))); + for bad in ["", "%", "101%", "-1%", "nan%", "lots", "1Q"] { + assert_eq!(Threshold::parse(bad), None, "{bad:?}"); + } + } + + #[test] + fn text_shortens_home_and_escapes_control_characters() { + let root = tree(dir( + "tobi", + vec![dir("odd\nname", vec![file("x", 10)]), file("y", 5)], + )); + let text = Report::new(&root, scanned(), Limits::default()).to_text(); + assert!(text.starts_with("~ 15 B · 2 files · 2 dirs\n"), "{text}"); + assert!(text.contains("odd\\nname/"), "{text}"); + assert!(!text.contains("odd\nname"), "{text}"); + } + + #[test] + fn unreadable_paths_are_counted_and_explained() { + let text = Report::new( + &home(), + Scanned { + unreadable: 2, + full_disk_access: Some(false), + ..scanned() + }, + Limits::default(), + ) + .to_text(); + assert!(text.contains("· 2 unreadable"), "{text}"); + assert!(text.contains("2 paths could not be read"), "{text}"); + assert!(text.contains("Full Disk Access"), "{text}"); + } + + /// End to end, over a real tree: a build directory beside its manifest + /// is a finding with its absolute path, and the JSON says the same. + #[test] + fn a_real_scan_reports_build_output_as_worth_a_look() { + let temp = TempDir::new().expect("tempdir"); + let project = temp.path().join("rust-thing"); + fs::create_dir_all(project.join("target/debug")).expect("mkdir"); + fs::write(project.join("Cargo.toml"), "[package]\n").expect("write"); + // Sparse, and measured by apparent size: past the findings' floor + // without writing eighty megabytes. + fs::File::create(project.join("target/debug/app")) + .and_then(|file| file.set_len(80 * MIB)) + .expect("sparse file"); + fs::write(temp.path().join("notes.txt"), "hello").expect("write"); + + let root = temp.path().to_path_buf(); + let options = ScanOptions { + apparent_size: true, + ..ScanOptions::default() + }; + let tree = scan(&root, options).expect("scan"); + let report = Report::new( + &tree, + Scanned { + root: root.clone(), + apparent_size: true, + now: NOW, + ..Scanned::default() + }, + Limits::default(), + ); + + let target = root.join("rust-thing").join("target"); + let found = report.findings.first().expect("a finding"); + assert_eq!(found.path, target.to_string_lossy()); + assert_eq!(found.finding, "reclaimable"); + assert_eq!(found.reclaim, Some(Reclaim::BuildOutput)); + assert!(found.bytes >= 80 * MIB); + + let json: serde_json::Value = + serde_json::from_str(&report.to_json().expect("json")) + .expect("one document"); + assert_eq!(json["measure"], "apparent size"); + assert_eq!(json["findings"][0]["reclaim"], "build_output"); + let first = &json["tree"]["children"][0]; + assert_eq!(first["name"], "rust-thing"); + assert_eq!(first["category"], "other"); + assert_eq!( + first["children"][0]["path"].as_str(), + target.to_str(), + "paths are absolute" + ); + assert_eq!(json["bytes"], tree.bytes); + } +} diff --git a/crates/disktree-core/src/size.rs b/crates/disktree-core/src/size.rs index 69cbe624..c5aec5c1 100644 --- a/crates/disktree-core/src/size.rs +++ b/crates/disktree-core/src/size.rs @@ -66,6 +66,30 @@ pub fn human_count(count: u64) -> String { } } +/// A size as typed on a command line: `512M`, `2G`, `1.5GiB`, `800k`. +/// +/// A plain number is bytes. Units are binary however they are spelled, +/// because every size here is printed in them: `1G` is what `1.0 GiB` means. +pub fn parse_bytes(text: &str) -> Option { + let text = text.trim(); + let split = text + .find(|char: char| !(char.is_ascii_digit() || char == '.')) + .unwrap_or(text.len()); + let (number, unit) = text.split_at(split); + let number: f64 = number.parse().ok()?; + let power = match unit.trim().to_ascii_lowercase().as_str() { + "" | "b" => 0, + "k" | "kb" | "kib" => 1, + "m" | "mb" | "mib" => 2, + "g" | "gb" | "gib" => 3, + "t" | "tb" | "tib" => 4, + "p" | "pb" | "pib" => 5, + _ => return None, + }; + let bytes = number * 1024_f64.powi(power); + (bytes < u64::MAX as f64).then(|| bytes.round() as u64) +} + /// Share of `total` as a whole percentage, saturating instead of dividing by /// zero. pub fn share(part: u64, total: u64) -> f64 { @@ -135,6 +159,19 @@ mod tests { assert_eq!(human_count(999_999_999), "1.0G"); } + #[test] + fn typed_sizes_are_binary_whatever_the_spelling() { + assert_eq!(parse_bytes("4096"), Some(4096)); + assert_eq!(parse_bytes("1k"), Some(1024)); + assert_eq!(parse_bytes("512M"), Some(512 * 1024 * 1024)); + assert_eq!(parse_bytes("512 MiB"), parse_bytes("512mb")); + assert_eq!(parse_bytes("1.5G"), Some(3 * 512 * 1024 * 1024)); + assert_eq!(parse_bytes("2TB"), Some(2 << 40)); + for bad in ["", "G", "1.2.3G", "-1G", "1 lots", "1e3", "99999999P"] { + assert_eq!(parse_bytes(bad), None, "{bad:?}"); + } + } + #[test] fn share_survives_a_zero_total() { assert!((share(5, 0) - 0.0).abs() < f64::EPSILON);