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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ 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` |
| the tree as text, for `--cli` | `crates/disktree-core/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
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,29 @@ disktree ~/src # or any directory
disktree --help # options: apparent size, follow links, skip hidden, …
```

### In the terminal

`--cli` scans the same way and prints the tree instead of opening a window,
for an SSH session, a script, or a coding agent that can only read output:

```text
$ disktree --cli --depth 2 ~/work
38 GiB /home/me/work
├── 21 GiB 55% builds/
│ ├── 12 GiB 57% release/ …
│ └── 9.0 GiB 43% debug/ …
├── 9.4 GiB 25% node_modules/ …
└── 7.6 GiB 20% datasets/ …

38 GiB in 512.0k files, 48.0k directories — 3 unreadable
```

Largest first, each row with its share of its parent. `--depth` is how many
levels print; a directory the limit stops at ends in `…`. Past 96 children,
the number the mosaic keeps, the rest share one `… N more` row. Every scan
option applies, and `--metric files` ranks and prints file counts. A name
holding a control character is escaped, so it cannot read as two rows.

### The screen

- **Top:** the trail from `/`, then what is measured — **Size**, **Files** or
Expand Down
42 changes: 42 additions & 0 deletions crates/disktree-app/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ struct Args {
options: ScanOptions,
depth: u32,
power: Option<power::PowerEfficiency>,
/// Print the tree to standard output instead of opening a window.
cli: bool,
}

const USAGE: &str = "\
Expand Down Expand Up @@ -78,6 +80,8 @@ options:
--thread-system-cpu-percent N
best-effort host CPU budget; 0 disables it (80)
--metric files rank by file count instead of bytes
--cli print the tree to standard output, --depth levels
deep, instead of opening a window
-h, --help show this help
";

Expand All @@ -101,6 +105,9 @@ fn run() -> Result<()> {
power::PowerEfficiency::default()
});
let args = parse_args_with_power(std::env::args_os().skip(1), saved)?;
if args.cli {
return print_tree(&args);
}

// When the app executable is reached through the command-line symlink,
// cmux sends SIGTERM to its foreground process group as AppKit takes
Expand Down Expand Up @@ -203,6 +210,29 @@ fn run() -> Result<()> {
Ok(())
}

/// `--cli`: scan as the window would, and print the tree instead of
/// drawing it, for a caller that can only read standard output.
fn print_tree(args: &Args) -> Result<()> {
use std::io::Write as _;

let tree = disktree_core::scan::scan(&args.root, args.options.clone())
.with_context(|| format!("cannot scan {}", args.root.display()))?;
let text = disktree_core::report::text_tree(
&args.root.display().to_string(),
&tree,
args.options.metric,
args.depth,
);
match std::io::stdout().lock().write_all(text.as_bytes()) {
// `disktree --cli | head` closes the pipe once it has its lines;
// that is the reader having had enough, not a failure.
Err(error) if error.kind() != std::io::ErrorKind::BrokenPipe => {
Err(error).context("cannot write to standard output")
}
_ => Ok(()),
}
}

/// Read the command line, program name already skipped.
#[cfg(test)]
fn parse_args(args: impl Iterator<Item = std::ffi::OsString>) -> Result<Args> {
Expand All @@ -221,6 +251,7 @@ fn parse_args_with_power(
let mut power = Some(preset);
let mut depth = 3_u32;
let mut disk = false;
let mut cli = false;
// `std::env::args` panics on a name that is not Unicode, and a path is
// any name: a restart as administrator hands the root back exactly as
// it was, so the caller passes `args_os`.
Expand All @@ -244,6 +275,7 @@ fn parse_args_with_power(
"-x" | "--one-filesystem" => options.one_filesystem = true,
"-X" | "--cross-filesystems" => options.one_filesystem = false,
"-D" | "--disk" => disk = true,
"--cli" => cli = true,
"-d" | "--depth" => {
let value = text(args.next(), "--depth needs a number")?;
depth = value.parse().context("--depth needs a number")?;
Expand Down Expand Up @@ -357,6 +389,7 @@ fn parse_args_with_power(
options,
depth: depth.clamp(1, 6),
power,
cli,
})
}

Expand Down Expand Up @@ -431,6 +464,15 @@ mod argument_tests {
assert_eq!(args.options.threads.system_cpu_limit, None);
}

#[test]
fn the_cli_flag_asks_for_text_instead_of_a_window() {
assert!(!parse_args(arguments(&[])).expect("window").cli);
let args =
parse_args(arguments(&["--cli", "--depth", "2"])).expect("text");
assert!(args.cli);
assert_eq!(args.depth, 2);
}

#[test]
fn saved_power_is_loaded_before_cli_overrides() {
use power::PowerEfficiency as Power;
Expand Down
1 change: 1 addition & 0 deletions crates/disktree-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ mod macos;
#[cfg(windows)]
mod mft;
pub mod removal;
pub mod report;
pub mod scan;
pub mod scan_threads;
pub mod size;
Expand Down
242 changes: 242 additions & 0 deletions crates/disktree-core/src/report.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
//! The scanned tree as text, for a terminal: `disktree --cli`.
//!
//! The same tree the window draws, largest first, for a caller that can
//! only read standard output: an agent, an SSH session, a script. Each row
//! is one entry with its value and its share of its parent, so the answer
//! to "what is eating this directory" is the first few lines.
//!
//! Something downstream reads this line by line, so a name is written
//! through the same rule as the delete list in [`crate::export`]: one that
//! holds a control character is escaped, and can never read as two rows.

use std::fmt::Write as _;

use crate::size::{human_bytes, human_count, share};
use crate::tree::{Metric, Node};

/// Children a level prints before the rest share one row. The treemap
/// keeps the same number, `LayoutOptions::max_children`, so the terminal
/// and the window agree on where a directory's tail begins.
pub const CHILDREN_KEPT: usize = 96;

/// Columns a value is right-aligned in: `1023 MiB` is the widest size, and
/// two more keep the column apart from the branch glyphs.
const VALUE_COLUMNS: usize = 10;

/// `tree` as an indented listing, `depth` levels below the root, which is
/// named `label`. Children are in the order [`crate::tree::aggregate`] left
/// them: ranked by `metric`, descending.
pub fn text_tree(
label: &str,
tree: &Node,
metric: Metric,
depth: u32,
) -> String {
let mut out = String::new();
let _ = writeln!(out, "{} {}", value(tree, metric), one_line(label));
rows(&mut out, tree, metric, depth.max(1), "");
let _ = write!(
out,
"\n{} in {} files, {} directories",
human_bytes(tree.bytes),
human_count(tree.files),
human_count(u64::from(tree.dirs)),
);
let unreadable = unreadable(tree);
if unreadable > 0 {
let _ = write!(out, " \u{2014} {unreadable} unreadable");
}
out.push('\n');
out
}

/// The rows for `node`'s children, and theirs while `depth` lasts.
fn rows(out: &mut String, node: &Node, metric: Metric, depth: u32, lead: &str) {
let total = node.value(metric);
let kept = node.children.len().min(CHILDREN_KEPT);
let (shown, rest) = node.children.split_at(kept);
for (index, child) in shown.iter().enumerate() {
let last = rest.is_empty() && index + 1 == shown.len();
let (branch, below) = if last {
("\u{2514}\u{2500}\u{2500} ", " ")
} else {
("\u{251c}\u{2500}\u{2500} ", "\u{2502} ")
};
// A directory the depth limit stops at is not empty, and must not
// read as if it were.
let cut = depth <= 1 && !child.children.is_empty();
let _ = writeln!(
out,
"{lead}{branch}{:>VALUE_COLUMNS$} {:>3.0}% {}{}{}",
value(child, metric),
share(child.value(metric), total),
one_line(&child.name),
if child.is_dir() { "/" } else { "" },
if cut { " \u{2026}" } else { "" },
);
if depth > 1 && !child.children.is_empty() {
rows(out, child, metric, depth - 1, &format!("{lead}{below}"));
}
}
if rest.is_empty() {
return;
}
let merged = rest
.iter()
.fold(0_u64, |sum, child| sum.saturating_add(child.value(metric)));
let _ = writeln!(
out,
"{lead}\u{2514}\u{2500}\u{2500} {:>VALUE_COLUMNS$} {:>3.0}% \
\u{2026} {} more",
amount(merged, metric),
share(merged, total),
rest.len(),
);
}

fn value(node: &Node, metric: Metric) -> String {
amount(node.value(metric), metric)
}

fn amount(value: u64, metric: Metric) -> String {
match metric {
Metric::Bytes => human_bytes(value),
Metric::Files => human_count(value),
}
}

/// Directories that could not be read, anywhere in the tree. Iterative: a
/// tree is as deep as the deepest path on the disk.
fn unreadable(tree: &Node) -> u64 {
let mut count = 0;
let mut pending = vec![tree];
while let Some(node) = pending.pop() {
count += u64::from(node.read_error);
pending.extend(&node.children);
}
count
}

/// `name` with every control character escaped, so it stays on its row.
fn one_line(name: &str) -> String {
if !name.chars().any(char::is_control) {
return name.to_owned();
}
name.chars()
.map(|char| {
if char.is_control() {
char.escape_default().to_string()
} else {
char.to_string()
}
})
.collect()
}

#[cfg(test)]
mod tests {
use super::*;
use crate::tree::NodeKind;

fn file(name: &str, bytes: u64) -> Node {
Node::entry(name, NodeKind::File, bytes)
}

fn dir(name: &str, children: Vec<Node>, metric: Metric) -> Node {
let mut node = Node::directory(name);
node.children = children;
crate::tree::aggregate(&mut node, metric);
node
}

const GIB: u64 = 1 << 30;

fn sample(metric: Metric) -> Node {
dir(
"work",
vec![
file("small.txt", GIB),
dir(
"builds",
vec![file("debug", 3 * GIB), file("release", 9 * GIB)],
metric,
),
file("data.bin", 4 * GIB),
],
metric,
)
}

#[test]
fn rows_are_largest_first_with_their_share_of_the_parent() {
let text = text_tree(
"/home/me/work",
&sample(Metric::Bytes),
Metric::Bytes,
3,
);
let expected = "\
17 GiB /home/me/work
\u{251c}\u{2500}\u{2500} 12 GiB 71% builds/
\u{2502} \u{251c}\u{2500}\u{2500} 9.0 GiB 75% release
\u{2502} \u{2514}\u{2500}\u{2500} 3.0 GiB 25% debug
\u{251c}\u{2500}\u{2500} 4.0 GiB 24% data.bin
\u{2514}\u{2500}\u{2500} 1.0 GiB 6% small.txt

17 GiB in 4 files, 2 directories
";
assert_eq!(text, expected);
}

#[test]
fn a_directory_past_the_depth_limit_is_marked_not_shown_empty() {
let text = text_tree("work", &sample(Metric::Bytes), Metric::Bytes, 1);
assert!(text.contains("builds/ \u{2026}"), "{text}");
assert!(!text.contains("release"), "{text}");
}

#[test]
fn the_file_metric_ranks_and_prints_counts() {
let text = text_tree("work", &sample(Metric::Files), Metric::Files, 1);
let first = text.lines().nth(1).expect("a row");
assert!(first.contains("builds/"), "{text}");
assert!(first.contains(" 2 50%"), "{text}");
}

#[test]
fn the_tail_past_the_kept_children_is_one_summed_row() {
let children = (0..CHILDREN_KEPT + 4)
.map(|index| file(&format!("f{index}"), 1024))
.collect();
let text = text_tree(
"many",
&dir("many", children, Metric::Bytes),
Metric::Bytes,
1,
);
// The root line, the kept rows, the merged row, a blank, the total.
assert_eq!(text.lines().count(), CHILDREN_KEPT + 4);
assert!(text.contains("4.0 KiB 4% \u{2026} 4 more"), "{text}");
}

#[test]
fn a_name_with_a_line_break_stays_on_its_row() {
let tree = dir(
"root",
vec![file("one\ntwo", 10), file("plain", 5)],
Metric::Bytes,
);
let text = text_tree("root", &tree, Metric::Bytes, 1);
assert!(text.contains("one\\ntwo"), "{text}");
assert_eq!(text.lines().count(), 5, "{text}");
}

#[test]
fn unreadable_directories_are_counted_in_the_total_line() {
let mut locked = Node::directory("locked");
locked.read_error = true;
let tree = dir("root", vec![locked, file("a", 10)], Metric::Bytes);
let text = text_tree("root", &tree, Metric::Bytes, 2);
assert!(text.ends_with("\u{2014} 1 unreadable\n"), "{text}");
}
}