From 84397cda920e95813d21cf478213dcd010f8a9e6 Mon Sep 17 00:00:00 2001 From: armada45-pixel Date: Tue, 13 Oct 2026 06:59:44 +0700 Subject: [PATCH] Add --cli: print the scanned tree to standard output For a caller that can only read output: an agent, an SSH session, a script. The scan is the window's own; `report::text_tree` prints it largest first, each row with its share of its parent, `--depth` levels deep. A directory the depth limit stops at ends in an ellipsis instead of reading as empty, the tail past the 96 children the mosaic keeps shares one row, and a name with a control character is escaped so it stays on its row. A closed pipe is the reader having had enough, not an error. Closes #74 Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 1 + README.md | 23 +++ crates/disktree-app/src/main.rs | 42 +++++ crates/disktree-core/src/lib.rs | 1 + crates/disktree-core/src/report.rs | 242 +++++++++++++++++++++++++++++ 5 files changed, 309 insertions(+) create mode 100644 crates/disktree-core/src/report.rs diff --git a/AGENTS.md b/AGENTS.md index a567ce80..a1c7d0b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 | diff --git a/README.md b/README.md index 80442695..881a216a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/crates/disktree-app/src/main.rs b/crates/disktree-app/src/main.rs index 92fa2f76..d2bab06d 100644 --- a/crates/disktree-app/src/main.rs +++ b/crates/disktree-app/src/main.rs @@ -45,6 +45,8 @@ struct Args { options: ScanOptions, depth: u32, power: Option, + /// Print the tree to standard output instead of opening a window. + cli: bool, } const USAGE: &str = "\ @@ -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 "; @@ -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 @@ -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) -> Result { @@ -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`. @@ -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")?; @@ -357,6 +389,7 @@ fn parse_args_with_power( options, depth: depth.clamp(1, 6), power, + cli, }) } @@ -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; diff --git a/crates/disktree-core/src/lib.rs b/crates/disktree-core/src/lib.rs index a620d802..59bc4928 100644 --- a/crates/disktree-core/src/lib.rs +++ b/crates/disktree-core/src/lib.rs @@ -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; diff --git a/crates/disktree-core/src/report.rs b/crates/disktree-core/src/report.rs new file mode 100644 index 00000000..265763aa --- /dev/null +++ b/crates/disktree-core/src/report.rs @@ -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, 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}"); + } +}