From 81365100d957928d80a55da1c33018441363d7fa Mon Sep 17 00:00:00 2001 From: Blotto Date: Sun, 11 Oct 2026 13:26:21 +0700 Subject: [PATCH] Add headless JSON reports, a read-only MCP server, and agent help in the window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI agents and scripts can now ask disktree where the space went, through the scanner the window already uses, with no way to change anything. Headless, read-only: - `disktree scan [PATH] [--json]` prints a documented report (docs/agent-json.md, versioned by `schema_version`) built for agents: every size as bytes, human size and percent of the total, every time also as `age_days`; the volume's free space; three breakdowns (top-level folders, kind of data, age) that each sum to the total exactly; a reclaimable summary counted once at the outermost folder, by reason, with the free space it would give back; and the largest directories (marked `nested_in` when already counted) and files. Options for top-N, listing depth, apparent size, excludes, one filesystem and `--only` sections; filters for minimum size, extension, age, name, category and reclaim reason. - `disktree summary [PATH]` gives a volume's capacity and free space. - `disktree mcp` serves six read-only tools over stdio (scan_path, reclaimable_space, largest_dirs, largest_files, list_files, disk_summary) returning the same JSON, with a five-minute scan cache, progress notifications and cancellation. The new crate `disktree-agent` never names `removal` or `export`; a test reads its source and fails on any call that could write, move or delete a file. Report categories leave out the window's guess for unrecognised top-level folders, so `~/Library` is not counted as cache. In the window: - An "AI agent…" button, `a` and File > Use with an AI Agent open a panel with a prompt to paste into Claude, Codex or Cursor, written for this machine, plus the MCP settings and the Claude Code command. Paths are single-quoted so a folder named `$(…)` stays a name. - "Export JSON…", `e`/Cmd-E and File > Export Report as JSON save the scan on screen as the same report, built off the UI thread. - Both sit in the top bar and step aside at narrow widths so the view settings never move; About disktree, `--version` and the keyboard overlay show the version. In disktree-core, `ScanOptions::exclude` leaves matching names or paths out of the walk; duplicate-mount suppression and the MFT reader stand aside when patterns are set. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 12 + Cargo.toml | 8 +- README.md | 161 +++- crates/disktree-agent/Cargo.toml | 19 + crates/disktree-agent/src/cli.rs | 362 +++++++++ crates/disktree-agent/src/guide.rs | 146 ++++ crates/disktree-agent/src/lib.rs | 25 + crates/disktree-agent/src/mcp.rs | 890 ++++++++++++++++++++ crates/disktree-agent/src/query.rs | 523 ++++++++++++ crates/disktree-agent/src/report.rs | 983 +++++++++++++++++++++++ crates/disktree-agent/tests/guide.rs | 53 ++ crates/disktree-agent/tests/mcp.rs | 536 ++++++++++++ crates/disktree-agent/tests/read_only.rs | 70 ++ crates/disktree-agent/tests/report.rs | 493 ++++++++++++ crates/disktree-app/Cargo.toml | 1 + crates/disktree-app/src/app_menu.rs | 18 +- crates/disktree-app/src/main.rs | 24 +- crates/disktree-app/src/state.rs | 166 +++- crates/disktree-app/src/tests.rs | 127 +++ crates/disktree-app/src/ui.rs | 4 + crates/disktree-app/src/views.rs | 377 ++++++++- crates/disktree-core/src/exclude.rs | 272 +++++++ crates/disktree-core/src/lib.rs | 1 + crates/disktree-core/src/mft.rs | 5 + crates/disktree-core/src/scan.rs | 57 +- docs/agent-json.md | 243 ++++++ 26 files changed, 5559 insertions(+), 17 deletions(-) create mode 100644 crates/disktree-agent/Cargo.toml create mode 100644 crates/disktree-agent/src/cli.rs create mode 100644 crates/disktree-agent/src/guide.rs create mode 100644 crates/disktree-agent/src/lib.rs create mode 100644 crates/disktree-agent/src/mcp.rs create mode 100644 crates/disktree-agent/src/query.rs create mode 100644 crates/disktree-agent/src/report.rs create mode 100644 crates/disktree-agent/tests/guide.rs create mode 100644 crates/disktree-agent/tests/mcp.rs create mode 100644 crates/disktree-agent/tests/read_only.rs create mode 100644 crates/disktree-agent/tests/report.rs create mode 100644 crates/disktree-core/src/exclude.rs create mode 100644 docs/agent-json.md diff --git a/Cargo.lock b/Cargo.lock index 71482b5..5810f59 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1418,11 +1418,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "disktree-agent" +version = "0.11.0" +dependencies = [ + "chrono", + "disktree-core", + "dunce", + "serde_json", + "tempfile", +] + [[package]] name = "disktree-app" version = "0.11.0" dependencies = [ "anyhow", + "disktree-agent", "disktree-core", "dunce", "gpui-kit", diff --git a/Cargo.toml b/Cargo.toml index 30dfed0..cf88632 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,11 @@ [workspace] resolver = "3" -members = ["crates/disktree-app", "crates/disktree-core", "xtask"] +members = [ + "crates/disktree-agent", + "crates/disktree-app", + "crates/disktree-core", + "xtask", +] [workspace.package] version = "0.11.0" @@ -10,6 +15,7 @@ license = "MIT" repository = "https://github.com/tobi/disktree" [workspace.dependencies] +disktree-agent = { path = "crates/disktree-agent" } disktree-core = { path = "crates/disktree-core" } anyhow = "1" rayon = "1" diff --git a/README.md b/README.md index 8044269..a23b8d4 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,41 @@ disktree ~/src # or any directory disktree --help # options: apparent size, follow links, skip hidden, … ``` +### Ask your AI agent + +You don't have to read the treemap alone. Claude, Codex, Cursor or any agent +that can run commands can use disktree to measure your disk and suggest what +to clean up — and disktree itself never deletes anything. + +In the window, click **AI agent…** in the top bar (or press `a`, or choose +**File › Use with an AI Agent…**). It shows a prompt written for your machine, +with the full path to your copy of disktree already filled in. Click **Copy +prompt**, paste it into your agent, and it will scan, read the report, and come +back with a plan that asks before removing anything. The prompt looks like +this: + +```text +I want to free up disk space on my Mac. I have disktree, a read-only disk +usage tool, installed at: + '/Applications/disktree.app/Contents/MacOS/disktree' + +Please: +1. Run: '/Applications/disktree.app/Contents/MacOS/disktree' scan '/Users/you' --json --only overview +… +4. Give me a short plan, biggest wins first: what to remove, how much space + each frees, how safe it is and why, and the exact command you would use. + +Do not delete, move or change anything until I approve each item. +``` + +To hand over a snapshot instead, click **Export JSON…** beside it (or press +`e`, ⌘E, or **File › Export Report as JSON…**) and attach the saved file to a chat: it is the same report +`disktree scan --json` prints, made from the scan on screen without scanning +again. To make disktree a tool your agent can call whenever you ask about disk +space, the same panel copies the MCP settings for Claude Desktop and Cursor and +the one-line command for Claude Code; see [For scripts and AI +agents](#for-scripts-and-ai-agents). + ### The screen - **Top:** the trail from `/`, then what is measured — **Size**, **Files** or @@ -229,12 +264,14 @@ and shows how much free space was actually gained. | `g` | the whole disk | | `p` | show or hide the selection line | | `o` | show it in Finder, File Explorer or the file manager | +| `a` | use disktree from your AI agent: a prompt to copy, and the MCP settings | +| `e` (`⌘E` / `ctrl e`) | save this scan as a JSON report | | right-click | show that tile in Finder, File Explorer or the file manager | | `?` | every key | | `q` | quit | -On macOS the menu bar also has ⌘⇧R to show the selection in Finder, ⌘R to -rescan, ⌘[ and ⌘] for back and forward, and ⌘Q, ⌘H and ⌘W (closing the +On macOS the menu bar also has ⌘⇧R to show the selection in Finder, ⌘E to +export the JSON report, ⌘R to rescan, ⌘[ and ⌘] for back and forward, and ⌘Q, ⌘H and ⌘W (closing the window quits); other ⌘ chords are left to the system. On Linux and Windows the same work with ctrl, with F5 to rescan too. @@ -372,6 +409,123 @@ tested: fsmonitor, hooks and pager off, and a checkout that defines its own filter drivers is not asked for its status at all ("changes unknown"). +## For scripts and AI agents + +The window's **AI agent…** button (`a`) is the quick way in; this is +the detail behind it. The same scanner runs without a window, and only ever +**reads**: `disktree +scan`, `disktree summary` and `disktree mcp` list files and directories and +nothing else. They cannot delete, move, trash or change anything — the removal +code is not reachable from them, and a test fails the build if their source +ever names it. + +```sh +disktree scan ~ --json # overview, breakdowns, reclaimable, 20 largest +disktree scan ~ --json --only reclaim # just what can likely be freed, and how +disktree scan ~ --json --only dirs --depth 2 # where the space goes, two levels down +disktree scan ~ --json --only files --min-size 1G # files over 1 GiB +disktree scan ~ --json --only files --ext iso,dmg --older-than 6mo +disktree scan ~ --json --only dirs --category cache # cache folders, outermost only +disktree scan ~/src --json --reclaim build-output # target/, dist/ and friends +disktree scan / -X --json --exclude /System --exclude '**/node_modules' +disktree summary ~ --json # capacity and free space, no scan +``` + +The report does the arithmetic so an agent does not have to. Every size comes +as exact bytes, human text and a percent of the total; every time also as an +age in days. It splits the total three ways — by top-level folder, by kind of +data and by age — and each split adds up to the total exactly. Reclaimable +space (caches, build output, dependencies, trash) is totalled by reason, with +what each reason means, counted once at its outermost folder, alongside the +volume's free space before and after. Directory lists mark entries that sit +inside another listed entry, so they are never added twice. + +Without `--json` the same report prints as a short table. Options: + +| option | meaning | +| --- | --- | +| `-n, --top N` | entries per list (default 20, at most 10000) | +| `--only PARTS` | comma list of `overview` (volume, breakdowns, reclaimable), `reclaim`, `dirs`, `files`, or `all` (the default) | +| `-d, --depth N` | list only entries at most N levels below PATH (deeper files too are left out of the lists); sizes still count everything beneath | +| `-a, --apparent-size` | apparent length instead of on-disk allocation | +| `-x, --one-filesystem` / `-X, --cross-filesystems` | stay on PATH's volume (default) or cross into others | +| `-H, --no-hidden`, `-l, --follow-links` | as for the window | +| `-e, --exclude PATTERN` | never enter or count matching names (`node_modules`, `*.vmdk`), or paths if the pattern has a `/`; repeatable | +| `--min-size SIZE` | at least SIZE: `500M`, `1G`, `1.5GiB` (binary units however spelled) | +| `--ext EXT[,EXT]` | files with these extensions | +| `--older-than AGE` | last written before `30d`, `6mo`, `1y`, … ago, or a date `2025-01-31` | +| `--name PATTERN` | name (or, with a `/`, path) matches | +| `--category KIND` | `code`, `agent-scratch`, `toolchain`, `synced`, `git`, `media`, `documents`, `cache`, `other` | +| `--reclaim REASON` | what the window hatches as reclaimable: `any`, `regenerable`, `build-output`, `package-store`, `trash`, … | + +Exit status is 0 on success, 1 if the scan failed and 2 for a bad command line. +The JSON is documented field by field, with its stability promise, in +[docs/agent-json.md](docs/agent-json.md). A directory literally named `scan`, +`summary` or `mcp` still opens in the window as `disktree ./scan`. + +### MCP server + +`disktree mcp` speaks the [Model Context Protocol](https://modelcontextprotocol.io) +over stdio, so an agent can ask about disk usage directly. Its tools return the +same JSON as `disktree scan --json`, and all of them are annotated read-only: + +| tool | returns | +| --- | --- | +| `scan_path` | the full report: volume, breakdowns, reclaimable, largest directories and files | +| `largest_files` | the largest files, with the filters | +| `largest_dirs` | the largest directories; `depth: 1` for an overview, `category: "cache"` for cache folders | +| `list_files` | files matching filters (`min_size`, `extensions`, `older_than`, `name`, `category`, `reclaim`), 100 by default | +| `reclaimable_space` | what can likely be freed: totals by reason, the largest places, free space before and after | +| `disk_summary` | capacity, used, free and available space; nothing is scanned | + +The scanning tools take `path` (absolute, or `~/…`), `top`, `depth`, +`apparent_size`, `one_filesystem`, `include_hidden`, `follow_links`, `exclude` +and the filters. A scan is reused for five minutes by any call with the same +path and scan options, so following `scan_path` with `list_files` costs +nothing; `refresh: true` walks again. Long scans send progress notifications +when the client asks for them, and a cancelled call stops its walk. + +Point the client at the binary by absolute path; MCP clients do not expand +`~`. After `make install` on macOS that is `~/.local/bin/disktree` (a link +into `~/Applications/disktree.app`); for the release download it is +`/Applications/disktree.app/Contents/MacOS/disktree`. On Linux it is +`~/.local/bin/disktree`; on Windows, `disktree.exe` wherever you put it. + +**Claude Desktop** — Settings › Developer › Edit Config opens +`claude_desktop_config.json` (`~/Library/Application Support/Claude/` on +macOS, `%APPDATA%\Claude\` on Windows). Add the server and restart Claude: + +```json +{ + "mcpServers": { + "disktree": { + "command": "/Users/you/.local/bin/disktree", + "args": ["mcp"] + } + } +} +``` + +**Cursor** — the same block in `~/.cursor/mcp.json` for every project, or +`.cursor/mcp.json` in one: + +```json +{ + "mcpServers": { + "disktree": { + "command": "/Users/you/.local/bin/disktree", + "args": ["mcp"] + } + } +} +``` + +**Claude Code** — `claude mcp add disktree -- /Users/you/.local/bin/disktree mcp`. + +On macOS, a scan of your home directory skips what macOS protects (Mail, +Messages, Safari and so on) unless the program that starts disktree has Full +Disk Access; the report's `errors` count says how much was refused. + ## On Hyprland Hyprland tiles new windows, so disktree opens into whatever tile it is given. @@ -388,7 +542,7 @@ windowrule = size 1400 900, class:^(disktree)$ ```sh make run # release build, scanning $HOME make lint # rustfmt --check, then clippy with every warning an error -make test # scanner, layout and removal tests, plus window-harness tests +make test # scanner, layout, removal and agent tests, plus window-harness tests make ci # lint, then test ``` @@ -401,6 +555,7 @@ gone while their neighbours are not. | path | what lives there | | --- | --- | | `crates/disktree-core` | scanning, the tree, the squarified layout, free space and removal — no UI | +| `crates/disktree-agent` | `disktree scan`, `summary` and `mcp`: read-only JSON reports and the MCP server — no UI, no removal | | `crates/disktree-app/src/state.rs` | every action the interface can take, and the key map | | `crates/disktree-app/src/views.rs` | the screens | | `crates/disktree-app/src/treemap_view.rs` | painting the mosaic and its labels | diff --git a/crates/disktree-agent/Cargo.toml b/crates/disktree-agent/Cargo.toml new file mode 100644 index 0000000..2c008ff --- /dev/null +++ b/crates/disktree-agent/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "disktree-agent" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Read-only, headless disktree for scripts and AI agents: JSON reports and an MCP server" + +[lints] +workspace = true + +[dependencies] +disktree-core = { workspace = true } +chrono = { version = "0.4.45", default-features = false, features = ["clock", "std"] } +dunce = "1.0.5" +serde_json = { version = "1", features = ["preserve_order"] } + +[dev-dependencies] +tempfile = "3" diff --git a/crates/disktree-agent/src/cli.rs b/crates/disktree-agent/src/cli.rs new file mode 100644 index 0000000..28788a4 --- /dev/null +++ b/crates/disktree-agent/src/cli.rs @@ -0,0 +1,362 @@ +//! `disktree scan`, `disktree summary` and `disktree mcp`: the headless +//! subcommands. The window's own binary hands them over before it opens +//! anything, so none of them needs a display. + +use std::ffi::OsString; +use std::io::Write; +use std::path::PathBuf; + +use crate::query::{ + self, Query, Sections, check_depth, check_top, normalize_extension, + parse_category, parse_older_than, parse_reclaim, parse_size, +}; +use crate::report; + +/// The subcommands this crate answers to. +pub const SUBCOMMANDS: [&str; 3] = ["scan", "summary", "mcp"]; + +pub const USAGE: &str = "\ +disktree scan — measure a directory without opening a window (read-only) + +usage: disktree scan [OPTIONS] [PATH] + disktree summary [--json] [PATH] + disktree mcp + +arguments: + PATH directory to scan (default: the home directory) + +output: + --json print the JSON report (see docs/agent-json.md) + -n, --top N entries per list (default 20, at most 10000) + --only WHAT comma list of overview, reclaim, dirs, files or + all (default all) + -d, --depth N list only entries at most N levels below PATH; + deeper ones are not listed, though sizes still + count everything beneath + +measuring: + -a, --apparent-size apparent length instead of allocated blocks + -x, --one-filesystem stay on PATH's volume (the default) + -X, --cross-filesystems + also measure other volumes mounted below PATH + -H, --no-hidden skip dotfiles and dot-directories + -l, --follow-links follow symlinks + -e, --exclude PATTERN leave out matching names, or paths if PATTERN has + a `/` (`*`, `**`, `?`); repeatable + +filters (every one given must pass): + --min-size SIZE at least SIZE: 500M, 1G, 1.5GiB (units are binary) + --ext EXT[,EXT] files with one of these extensions; repeatable + --older-than AGE last written before AGE ago (30d, 6mo, 1y) or a + date (2025-01-31); for a directory, nothing + beneath it was written since + --name PATTERN name, or path if PATTERN has a `/`; repeatable + --category KIND code, agent-scratch, toolchain, synced, git, media, + documents, cache or other; lists only the + outermost matching directory + --reclaim REASON space disktree calls reclaimable: any, regenerable, + sync-history, package-store, build-output, + reinstallable, sandbox-layers, snapshots, trash or + temporary; outermost directory only + -h, --help show this help + +Nothing is ever deleted, moved or changed: these commands only list. +"; + +/// Run a headless subcommand if `args` (program name skipped) starts with +/// one; `None` means the window should open as usual. +pub fn dispatch(args: &[OsString]) -> Option { + let first = args.first()?.to_str()?; + if !SUBCOMMANDS.contains(&first) { + return None; + } + let rest = &args[1..]; + let stdout = std::io::stdout(); + let stderr = std::io::stderr(); + Some(match first { + "mcp" => { + if rest.is_empty() { + crate::mcp::serve_stdio() + } else { + let _ = + writeln!(stderr.lock(), "disktree mcp takes no options"); + 2 + } + } + command => run(command, rest, &mut stdout.lock(), &mut stderr.lock()), + }) +} + +/// `disktree scan` or `disktree summary`, writing to the given streams. +/// Exit status: 0 done, 1 the scan failed, 2 the command line was wrong. +pub fn run( + command: &str, + args: &[OsString], + out: &mut dyn Write, + err: &mut dyn Write, +) -> i32 { + let parsed = match parse(args) { + Ok(Parsed::Help) => { + let _ = write!(out, "{USAGE}"); + return 0; + } + Ok(Parsed::Run(parsed)) => parsed, + Err(message) => { + let _ = writeln!(err, "disktree {command}: {message}"); + let _ = writeln!(err, "try `disktree {command} --help`"); + return 2; + } + }; + let outcome = match command { + "summary" => report::summary(&parsed.query.root), + _ => report::scan_quietly(&parsed.query.root, &parsed.query.settings) + .map_err(|error| { + format!("cannot scan {}: {error}", parsed.query.root.display()) + }) + .and_then(|scanned| { + report::report( + &scanned, + &parsed.query, + chrono::Utc::now().timestamp(), + ) + }), + }; + let value = match outcome { + Ok(value) => value, + Err(message) => { + let _ = writeln!(err, "disktree {command}: {message}"); + return 1; + } + }; + let written = if parsed.json { + write!(out, "{}", report::json_text(&value)) + } else if command == "summary" { + writeln!(out, "{}", summary_text(&value)) + } else { + write!(out, "{}", report::text(&value)) + }; + // A closed pipe (`| head`) is not a failure worth a message. + i32::from(written.is_err()) +} + +fn summary_text(value: &serde_json::Value) -> String { + format!( + "{} {} used of {} ({}%) · {} available", + value["volume_root"] + .as_str() + .or_else(|| value["path"].as_str()) + .unwrap_or_default(), + value["used"]["size"].as_str().unwrap_or_default(), + value["capacity"]["size"].as_str().unwrap_or_default(), + value["used_percent"], + value["available"]["size"].as_str().unwrap_or_default(), + ) +} + +/// A parsed command line. +#[derive(Debug)] +pub struct Options { + pub query: Query, + pub json: bool, +} + +#[derive(Debug)] +enum Parsed { + Help, + Run(Options), +} + +fn parse(args: &[OsString]) -> Result { + let mut root: Option = None; + let mut query = Query::new(PathBuf::new()); + let mut json = false; + // Reports write paths as JSON strings, so a path that is not Unicode + // could not be reported faithfully anyway. + let args: Vec = args + .iter() + .map(|arg| { + arg.to_str().map(str::to_owned).ok_or_else(|| { + format!("{} is not valid Unicode", arg.display()) + }) + }) + .collect::>()?; + let mut args = args.into_iter(); + let mut value = |flag: &str| -> Result { + args.next().ok_or_else(|| format!("{flag} needs a value")) + }; + // `value` borrows `args`, so the loop takes each argument through it too. + while let Ok(arg) = value("") { + let flag = arg.as_str(); + match flag { + "-h" | "--help" => return Ok(Parsed::Help), + "--json" => json = true, + "-a" | "--apparent-size" => query.settings.apparent_size = true, + "-x" | "--one-filesystem" => query.settings.one_filesystem = true, + "-X" | "--cross-filesystems" => { + query.settings.one_filesystem = false; + } + "-H" | "--no-hidden" => query.settings.include_hidden = false, + "-l" | "--follow-links" => query.settings.follow_links = true, + "-e" | "--exclude" => query.settings.exclude.push(value(flag)?), + "-n" | "--top" => { + query.top = check_top(number(flag, &value(flag)?)?)?; + } + "-d" | "--depth" => { + query.depth = Some(check_depth(number(flag, &value(flag)?)?)?); + } + "--only" => query.sections = Sections::parse(&value(flag)?)?, + "--min-size" => { + query.filters.min_size = Some(parse_size(&value(flag)?)?); + } + "--ext" => query.filters.extensions.extend( + value(flag)?.split(',').filter_map(normalize_extension), + ), + "--older-than" => { + query.filters.older_than = + Some(parse_older_than(&value(flag)?)?); + } + "--name" => query.filters.names.push(value(flag)?), + "--category" => { + query.filters.category = Some(parse_category(&value(flag)?)?); + } + "--reclaim" => { + query.filters.reclaim = Some(parse_reclaim(&value(flag)?)?); + } + other if other.starts_with('-') && other.len() > 1 => { + return Err(format!("unknown option {other}")); + } + path => { + if root.is_some() { + return Err("only one path can be scanned".to_owned()); + } + root = Some(query::expand_home(path)); + } + } + } + let root = match root { + Some(root) => root, + None => { + std::env::home_dir().ok_or("no path given and no home directory")? + } + }; + query.root = query::resolve_root(&root)?; + Ok(Parsed::Run(Options { query, json })) +} + +fn number(flag: &str, text: &str) -> Result { + text.parse() + .map_err(|_| format!("{flag} needs a whole number, not {text:?}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn os(args: &[&str]) -> Vec { + args.iter().map(OsString::from).collect() + } + + fn options(args: &[&str]) -> Options { + match parse(&os(args)).expect("parses") { + Parsed::Run(options) => options, + Parsed::Help => panic!("help"), + } + } + + #[test] + fn subcommands_are_recognised_and_paths_are_left_to_the_window() { + assert_eq!(dispatch(&os(&[])), None); + assert_eq!(dispatch(&os(&["/tmp"])), None); + assert_eq!(dispatch(&os(&["--depth", "2"])), None); + assert_eq!(dispatch(&os(&["./scan"])), None); + assert_eq!(dispatch(&os(&["mcp", "--stray"])), Some(2)); + } + + #[test] + fn every_option_is_read() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().to_str().expect("utf-8 temp path"); + let parsed = options(&[ + path, + "--json", + "-a", + "-X", + "-H", + "-l", + "-e", + "node_modules", + "--exclude", + "*.tmp", + "-n", + "5", + "-d", + "3", + "--only", + "files", + "--min-size", + "1G", + "--ext", + ".ISO,dmg", + "--ext", + "zip", + "--older-than", + "30d", + "--name", + "*backup*", + "--category", + "cache", + "--reclaim", + "any", + ]); + let query = parsed.query; + assert!(parsed.json); + assert_eq!( + query.root, + dunce::canonicalize(temp.path()).expect("canonical") + ); + assert!(query.settings.apparent_size); + assert!(!query.settings.one_filesystem); + assert!(!query.settings.include_hidden); + assert!(query.settings.follow_links); + assert_eq!(query.settings.exclude, ["node_modules", "*.tmp"]); + assert_eq!(query.top, 5); + assert_eq!(query.depth, Some(3)); + assert_eq!(query.sections, Sections::FILES); + assert_eq!(query.filters.min_size, Some(1 << 30)); + assert_eq!(query.filters.extensions, ["iso", "dmg", "zip"]); + assert!(query.filters.older_than.is_some()); + assert_eq!(query.filters.names, ["*backup*"]); + assert!(query.filters.category.is_some()); + assert!(query.filters.reclaim.is_some()); + } + + #[test] + fn mistakes_are_usage_errors() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().to_str().expect("utf-8 temp path"); + for bad in [ + vec![path, "--top"], + vec![path, "--top", "0"], + vec![path, "--depth", "x"], + vec![path, "--min-size", "lots"], + vec![path, "--category", "junk"], + vec![path, "--only", "neither"], + vec![path, "--frobnicate"], + vec![path, path], + vec!["/no/such/disktree/path"], + ] { + let mut out = Vec::new(); + let mut err = Vec::new(); + assert_eq!( + run("scan", &os(&bad), &mut out, &mut err), + 2, + "{bad:?}" + ); + assert!(out.is_empty()); + assert!(!err.is_empty()); + } + let mut out = Vec::new(); + assert_eq!(run("scan", &os(&["--help"]), &mut out, &mut Vec::new()), 0); + assert!(String::from_utf8_lossy(&out).contains("read-only")); + } +} diff --git a/crates/disktree-agent/src/guide.rs b/crates/disktree-agent/src/guide.rs new file mode 100644 index 0000000..814b7a1 --- /dev/null +++ b/crates/disktree-agent/src/guide.rs @@ -0,0 +1,146 @@ +//! Help for driving disktree from an AI agent. +//! +//! What the window hands a person who wants their agent to do this: a +//! prompt to paste into Claude, Codex, Cursor or any agent that can run +//! commands, and the settings that make disktree one of its tools. +//! +//! Every command is spelled with this machine's own path to disktree, so +//! it can be pasted as it is: someone who downloaded the app has no +//! `disktree` on their `PATH`. Paths are quoted so a shell reads them as +//! one literal word: a folder named `$(rm -rf ~)` stays a name. + +use std::path::Path; + +/// The operating system, as a person would name it. +const fn platform() -> &'static str { + if cfg!(target_os = "macos") { + "Mac" + } else if cfg!(windows) { + "Windows PC" + } else { + "Linux machine" + } +} + +/// A path as one literal shell word. Single quotes, inside which POSIX +/// shells and PowerShell expand nothing: no `$`, no backticks, no `$(…)`. +/// A single quote in the path is the one character to escape, as `'\''` +/// for a POSIX shell and `''` for PowerShell, the shell agents run on +/// Windows. +fn quoted(path: &Path) -> String { + let text = path.to_string_lossy(); + if cfg!(windows) { + format!("'{}'", text.replace('\'', "''")) + } else { + format!("'{}'", text.replace('\'', "'\\''")) + } +} + +/// The executable as the first word of a command. PowerShell reads a +/// quoted first word as a string, not a program, unless `&` calls it. +fn program(exe: &Path) -> String { + if cfg!(windows) { + format!("& {}", quoted(exe)) + } else { + quoted(exe) + } +} + +/// A prompt asking an agent to work out, with disktree and without +/// deleting anything, how to free space under `root`. +pub fn agent_prompt(exe: &Path, root: &Path) -> String { + let location = quoted(exe); + let exe = program(exe); + let root = quoted(root); + format!( + "\ +I want to free up disk space on my {platform}. I have disktree, a read-only \ +disk usage tool, installed at: + {location} + +Please: +1. Run: {exe} scan {root} --json --only overview + It prints a JSON report and changes nothing. A large folder can take a \ +minute or two. +2. Read the report. Sizes, percentages and ages are already calculated, so \ +there is no need to add anything up. \"breakdown\" splits the total by \ +top-level folder, by kind of data and by age. \"reclaimable\" lists caches, \ +build output, dependencies and other space that can usually be recreated, \ +with what each reason means. In \"largest_dirs\", \"nested_in\" marks a \ +folder already counted inside another one. +3. Look closer where it helps, for example: + {exe} scan {root} --json --only files --min-size 1G + {exe} scan {root} --json --only dirs --depth 2 + {exe} scan {root} --json --only dirs --reclaim any --older-than 3mo + ({exe} scan --help lists every option.) +4. Give me a short plan, biggest wins first: what to remove, how much \ +space each frees, how safe it is and why, and the exact command you would \ +use. Prefer the owning tool's own cleanup (for example npm cache clean, \ +Xcode's DerivedData, or a project's clean command) over deleting by hand. + +Do not delete, move or change anything until I approve each item.", + platform = platform(), + ) +} + +/// The MCP settings for Claude Desktop, Cursor and other clients that +/// read an `mcpServers` block. +pub fn mcp_config(exe: &Path) -> String { + let command = serde_json::Value::from(exe.to_string_lossy().into_owned()); + format!( + "{{\n \"mcpServers\": {{\n \"disktree\": {{\n \"command\": \ + {command},\n \"args\": [\"mcp\"]\n }}\n }}\n}}" + ) +} + +/// The one line that adds disktree to Claude Code. +pub fn claude_code_command(exe: &Path) -> String { + format!("claude mcp add disktree -- {} mcp", quoted(exe)) +} + +#[cfg(test)] +mod tests { + #[cfg(not(windows))] + use std::path::PathBuf; + + use super::*; + + #[cfg(not(windows))] + fn exe() -> PathBuf { + PathBuf::from("/Applications/disktree.app/Contents/MacOS/disktree") + } + + #[cfg(not(windows))] + #[test] + fn the_prompt_names_this_disktree_and_asks_before_deleting() { + let prompt = agent_prompt(&exe(), Path::new("/Users/me")); + assert!(prompt.contains( + "'/Applications/disktree.app/Contents/MacOS/disktree' scan \ + '/Users/me' --json --only overview" + )); + assert!(prompt.contains("read-only")); + assert!(prompt.contains("until I approve each item")); + assert!(prompt.contains("--min-size 1G")); + } + + #[test] + fn the_mcp_config_is_valid_json_with_this_path() { + let config: serde_json::Value = serde_json::from_str(&mcp_config( + Path::new("/opt/a \"b\"/disktree"), + )) + .expect("valid JSON"); + let server = &config["mcpServers"]["disktree"]; + assert_eq!(server["command"], "/opt/a \"b\"/disktree"); + assert_eq!(server["args"][0], "mcp"); + } + + #[cfg(not(windows))] + #[test] + fn claude_code_gets_one_command() { + assert_eq!( + claude_code_command(&exe()), + "claude mcp add disktree -- \ + '/Applications/disktree.app/Contents/MacOS/disktree' mcp" + ); + } +} diff --git a/crates/disktree-agent/src/lib.rs b/crates/disktree-agent/src/lib.rs new file mode 100644 index 0000000..b9e67c2 --- /dev/null +++ b/crates/disktree-agent/src/lib.rs @@ -0,0 +1,25 @@ +//! `disktree-agent`: disktree without a window, for scripts and AI agents. +//! +//! Two front ends share one report: `disktree scan PATH --json` prints it, +//! and `disktree mcp` serves it over the Model Context Protocol on stdio. +//! Both walk the tree with [`disktree_core::scan`], the scanner the window +//! uses, and both are **read-only by construction**: nothing in this crate +//! deletes, moves, trashes, renames or writes a file. It never names +//! [`disktree_core::removal`] or [`disktree_core::export`], and +//! `tests/read_only.rs` fails the build if it ever starts to. +//! +//! The JSON is described in `docs/agent-json.md` and versioned by its +//! `schema_version` field. + +pub mod cli; +pub mod guide; +pub mod mcp; +pub mod query; +pub mod report; + +/// The `schema_version` every report carries. Raised only when a field is +/// removed or changes meaning; new fields are added without raising it. +pub const SCHEMA_VERSION: u32 = 1; + +/// This build's version, as reports and the MCP handshake give it. +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/crates/disktree-agent/src/mcp.rs b/crates/disktree-agent/src/mcp.rs new file mode 100644 index 0000000..2e8b3d3 --- /dev/null +++ b/crates/disktree-agent/src/mcp.rs @@ -0,0 +1,890 @@ +//! `disktree mcp`: the reports as Model Context Protocol tools, over stdio. +//! +//! Newline-delimited JSON-RPC 2.0 on stdin and stdout; stdout carries +//! protocol messages and nothing else, and diagnostics go to stderr. Every +//! tool is read-only and says so in its annotations: the server lists +//! files and directories and can do nothing else to them. +//! +//! A scan of a home directory takes a while, so the last one is kept for +//! [`CACHE_FOR`] and reused by any tool asking the same question of the +//! same tree (`refresh` forces a new walk). While a scan runs the server +//! still reads stdin, so a client's cancellation stops the walk, and a +//! client that sent a progress token is told how far it has got. + +use std::collections::{HashSet, VecDeque}; +use std::io::{BufRead as _, Write as _}; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, TryRecvError}; +use std::time::{Duration, Instant}; + +use serde_json::{Map, Value, json}; + +use crate::VERSION; +use crate::query::{ + self, DEFAULT_TOP, MAX_TOP, Query, ScanSettings, Sections, check_depth, + check_top, normalize_extension, parse_category, parse_older_than, + parse_reclaim, parse_size, +}; +use crate::report::{self, Scanned}; + +/// How long a finished scan answers further calls. +pub const CACHE_FOR: Duration = Duration::from_mins(5); + +/// Entries `list_files` returns unless asked otherwise: it is for filtered +/// lists, which are often longer than a top 20. +const LIST_FILES_TOP: usize = 100; + +/// Protocol revisions this server speaks, newest first. +const PROTOCOL_VERSIONS: [&str; 4] = + ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"]; + +const INSTRUCTIONS: &str = "\ +disktree measures disk usage and is strictly read-only: these tools list \ +files and directories with their sizes and never delete, move or change \ +anything. Every size comes as bytes and human text, with its percent of the \ +scanned total and ages in days, so no arithmetic is needed. Start with \ +scan_path for an overview or reclaimable_space for what can be freed, then \ +largest_dirs, largest_files or list_files with filters (min_size, \ +extensions, older_than, category, reclaim) for specifics. In largest_dirs, \ +nested_in names a listed folder that already contains an entry: do not add \ +those together. Scans of large trees take time; results are cached for \ +five minutes per path and options, and refresh=true rescans. Sizes are \ +on-disk allocation unless apparent_size=true."; + +/// JSON-RPC error codes. +const PARSE_ERROR: i64 = -32700; +const INVALID_REQUEST: i64 = -32600; +const METHOD_NOT_FOUND: i64 = -32601; +const INVALID_PARAMS: i64 = -32602; + +/// Serve on stdin and stdout until stdin closes. Returns the exit status. +pub fn serve_stdio() -> i32 { + let (sender, inbox) = mpsc::channel(); + let reader = std::thread::Builder::new() + .name("disktree-mcp-stdin".into()) + .spawn(move || { + for line in std::io::stdin().lock().lines() { + let Ok(line) = line else { break }; + if sender.send(line).is_err() { + break; + } + } + }); + if let Err(error) = reader { + eprintln!("disktree mcp: cannot read stdin: {error}"); + return 1; + } + eprintln!("disktree mcp {VERSION}: read-only disk usage tools on stdio"); + let stdout = std::io::stdout(); + let mut failed = false; + let mut server = Server::new(Some(inbox)); + server.run(&mut |message| { + let mut out = stdout.lock(); + let written = serde_json::to_writer(&mut out, message) + .map_err(std::io::Error::other) + .and_then(|()| out.write_all(b"\n")) + .and_then(|()| out.flush()); + failed |= written.is_err(); + }); + i32::from(failed) +} + +/// A scan kept for later calls. +#[derive(Debug)] +struct Cached { + root: PathBuf, + settings: ScanSettings, + at: Instant, + scan: Arc, +} + +/// The protocol state: the inbox, messages read while a scan ran, and the +/// last scan. +#[derive(Debug)] +pub struct Server { + inbox: Option>, + queued: VecDeque, + cache: Option, + cache_for: Duration, + /// Requests cancelled before they were handled, by their id as JSON + /// text: a cancellation can arrive while the request waits its turn. + cancelled: HashSet, + /// The request being handled was cancelled: send it no reply. + abandoned: bool, +} + +impl Server { + /// A server reading `inbox`; `None` handles only what it is given. + pub fn new(inbox: Option>) -> Self { + Self { + inbox, + queued: VecDeque::new(), + cancelled: HashSet::new(), + abandoned: false, + cache: None, + cache_for: CACHE_FOR, + } + } + + /// Keep scans for `cache_for` instead of [`CACHE_FOR`]. + #[must_use] + pub const fn with_cache_for(mut self, cache_for: Duration) -> Self { + self.cache_for = cache_for; + self + } + + /// Whether a finished scan is being kept. + pub const fn has_cached_scan(&self) -> bool { + self.cache.is_some() + } + + /// Handle messages until the inbox closes. A cached scan is let go + /// once it expires, even if no call comes to replace it: a home + /// directory's tree is the better part of a gigabyte, and a client can + /// keep the server running for days. + pub fn run(&mut self, out: &mut dyn FnMut(&Value)) { + loop { + let line = if let Some(line) = self.queued.pop_front() { + line + } else { + let Some(inbox) = &self.inbox else { return }; + let expires = self.cache.as_ref().map(|cached| { + self.cache_for.saturating_sub(cached.at.elapsed()) + }); + let received = match expires { + Some(wait) => inbox.recv_timeout(wait), + None => { + inbox.recv().map_err(|_| RecvTimeoutError::Disconnected) + } + }; + match received { + Ok(line) => line, + Err(RecvTimeoutError::Timeout) => { + self.cache = None; + continue; + } + Err(RecvTimeoutError::Disconnected) => return, + } + }; + self.handle_line(&line, out); + } + } + + /// Handle one line of input, sending any replies to `out`. + pub fn handle_line(&mut self, line: &str, out: &mut dyn FnMut(&Value)) { + if line.trim().is_empty() { + return; + } + match serde_json::from_str::(line) { + Ok(message) => self.handle(&message, out), + Err(error) => out(&failure( + &Value::Null, + PARSE_ERROR, + &format!("not JSON: {error}"), + )), + } + } + + fn handle(&mut self, message: &Value, out: &mut dyn FnMut(&Value)) { + let Some(object) = message.as_object() else { + out(&failure( + &Value::Null, + INVALID_REQUEST, + "expected an object", + )); + return; + }; + let id = object.get("id"); + // An id is a string or a number; anything else cannot be echoed. + if id.is_some_and(|id| !(id.is_string() || id.is_number())) { + out(&failure( + &Value::Null, + INVALID_REQUEST, + "id must be a string or a number", + )); + return; + } + // A response to a request this server never sent. + if object.get("method").is_none() + && (object.contains_key("result") || object.contains_key("error")) + { + return; + } + let Some(method) = object.get("method").and_then(Value::as_str) else { + out(&failure( + id.unwrap_or(&Value::Null), + INVALID_REQUEST, + "no method", + )); + return; + }; + if object.get("jsonrpc").and_then(Value::as_str) != Some("2.0") { + if let Some(id) = id { + out(&failure(id, INVALID_REQUEST, "jsonrpc must be \"2.0\"")); + } + return; + } + // Notifications (no id) are never answered; a cancellation is + // remembered for a request that has not been handled yet. + let Some(id) = id else { + if method == "notifications/cancelled" + && let Some(request) = message["params"].get("requestId") + { + self.cancelled.insert(request.to_string()); + } + return; + }; + if self.cancelled.remove(&id.to_string()) { + return; + } + let empty = Value::Object(Map::new()); + let params = object.get("params").unwrap_or(&empty); + let reply = match method { + "initialize" => Ok(initialize(params)), + "ping" => Ok(json!({})), + "tools/list" => Ok(json!({ "tools": tools() })), + "tools/call" => self.call(id, params, out), + other => Err((METHOD_NOT_FOUND, format!("no method {other}"))), + }; + // A request cancelled while it ran gets no reply at all. + if std::mem::take(&mut self.abandoned) { + return; + } + out(&match reply { + Ok(result) => { + json!({ "jsonrpc": "2.0", "id": id, "result": result }) + } + Err((code, message)) => failure(id, code, &message), + }); + } + + fn call( + &mut self, + id: &Value, + params: &Value, + out: &mut dyn FnMut(&Value), + ) -> Result { + let name = + params.get("name").and_then(Value::as_str).ok_or_else(|| { + (INVALID_PARAMS, "tools/call needs a tool name".to_owned()) + })?; + let Some(tool) = Tool::named(name) else { + return Err((INVALID_PARAMS, format!("no tool named {name}"))); + }; + let empty = Value::Object(Map::new()); + let arguments = params.get("arguments").unwrap_or(&empty); + let progress_token = params + .get("_meta") + .and_then(|meta| meta.get("progressToken")); + let outcome = self.run_tool(tool, arguments, id, progress_token, out); + Ok(match outcome { + Ok(value) => { + let text = serde_json::to_string_pretty(&value).unwrap_or_else( + |error| format!("{{\"error\": \"{error}\"}}"), + ); + json!({ + "content": [{ "type": "text", "text": text }], + "structuredContent": value, + "isError": false, + }) + } + Err(message) => json!({ + "content": [{ "type": "text", "text": message }], + "isError": true, + }), + }) + } + + fn run_tool( + &mut self, + tool: Tool, + arguments: &Value, + id: &Value, + progress_token: Option<&Value>, + out: &mut dyn FnMut(&Value), + ) -> Result { + let arguments = + arguments.as_object().ok_or("arguments must be an object")?; + let allowed = tool.arguments(); + if let Some(unknown) = arguments + .keys() + .find(|key| !allowed.contains(&key.as_str())) + { + return Err(format!( + "{} takes no argument {unknown:?}; it takes {}", + tool.name(), + allowed.join(", ") + )); + } + let args = Arguments(arguments); + if tool == Tool::DiskSummary { + let path = args.path()?.map_or_else(home, Ok)?; + return report::summary(&query::resolve_root(&path)?); + } + let query = query_from(tool, &args)?; + let refresh = args.flag("refresh")?.unwrap_or(false); + let scanned = self.scanned(&query, refresh, id, progress_token, out)?; + report::report(&scanned, &query, chrono::Utc::now().timestamp()) + } + + /// The scan `query` needs: the cached one when it fits, else a new one. + fn scanned( + &mut self, + query: &Query, + refresh: bool, + id: &Value, + progress_token: Option<&Value>, + out: &mut dyn FnMut(&Value), + ) -> Result, String> { + if !refresh + && let Some(cached) = &self.cache + && cached.root == query.root + && cached.settings == query.settings + && cached.at.elapsed() < self.cache_for + { + return Ok(Arc::clone(&cached.scan)); + } + // Let the old tree go before walking a new one: a disk's worth of + // nodes is not small. + self.cache = None; + let mut sent = 0_u64; + let mut progress = |snapshot: &disktree_core::scan::ScanSnapshot| { + let Some(token) = progress_token else { return }; + let entries = snapshot.files + snapshot.dirs; + if entries <= sent { + return; + } + sent = entries; + out(&json!({ + "jsonrpc": "2.0", + "method": "notifications/progress", + "params": { + "progressToken": token, + "progress": entries, + "message": format!( + "scanned {} files in {} directories, {}", + snapshot.files, + snapshot.dirs, + disktree_core::size::human_bytes(snapshot.bytes), + ), + }, + })); + }; + let mut cancelled = || self.cancelled(id); + let scanned = report::scan( + &query.root, + &query.settings, + &mut progress, + &mut cancelled, + ) + .map_err(|error| { + format!("cannot scan {}: {error}", query.root.display()) + })?; + let scanned = Arc::new(scanned); + self.cache = Some(Cached { + root: query.root.clone(), + settings: query.settings.clone(), + at: Instant::now(), + scan: Arc::clone(&scanned), + }); + Ok(scanned) + } + + /// Read what has arrived while a scan runs: a cancellation of request + /// `id` stops it, and anything else waits its turn. + fn cancelled(&mut self, id: &Value) -> bool { + let Some(inbox) = &self.inbox else { + return false; + }; + loop { + match inbox.try_recv() { + Ok(line) => { + let message = serde_json::from_str::(&line).ok(); + let cancels = message.as_ref().and_then(|message| { + (message["method"] == "notifications/cancelled") + .then(|| message["params"]["requestId"].clone()) + }); + match cancels { + Some(request) if request == *id => { + self.abandoned = true; + return true; + } + // For a request still waiting: skip it when its + // turn comes. + Some(request) => { + self.cancelled.insert(request.to_string()); + } + None => self.queued.push_back(line), + } + } + // A closed stdin means no more requests, not that the ones + // already sent are unwanted: `echo … | disktree mcp` still + // gets its answers. + Err(TryRecvError::Empty | TryRecvError::Disconnected) => { + return false; + } + } + } + } +} + +fn failure(id: &Value, code: i64, message: &str) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id, + "error": { "code": code, "message": message }, + }) +} + +fn initialize(params: &Value) -> Value { + let asked = params.get("protocolVersion").and_then(Value::as_str); + let version = asked + .filter(|asked| PROTOCOL_VERSIONS.contains(asked)) + .unwrap_or(PROTOCOL_VERSIONS[0]); + json!({ + "protocolVersion": version, + "capabilities": { "tools": { "listChanged": false } }, + "serverInfo": { + "name": "disktree", + "title": "disktree (read-only disk usage)", + "version": VERSION, + }, + "instructions": INSTRUCTIONS, + }) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Tool { + ScanPath, + LargestFiles, + LargestDirs, + ListFiles, + ReclaimableSpace, + DiskSummary, +} + +const TOOLS: [Tool; 6] = [ + Tool::ScanPath, + Tool::LargestFiles, + Tool::LargestDirs, + Tool::ListFiles, + Tool::ReclaimableSpace, + Tool::DiskSummary, +]; + +/// Arguments every scanning tool takes. +const SCAN_ARGUMENTS: [&str; 9] = [ + "path", + "top", + "depth", + "apparent_size", + "one_filesystem", + "include_hidden", + "follow_links", + "exclude", + "refresh", +]; + +/// Filters, by whether they apply to files only. +const FILTER_ARGUMENTS: [&str; 5] = + ["min_size", "older_than", "name", "category", "reclaim"]; + +impl Tool { + fn named(name: &str) -> Option { + TOOLS.into_iter().find(|tool| tool.name() == name) + } + + const fn name(self) -> &'static str { + match self { + Self::ScanPath => "scan_path", + Self::LargestFiles => "largest_files", + Self::LargestDirs => "largest_dirs", + Self::ListFiles => "list_files", + Self::ReclaimableSpace => "reclaimable_space", + Self::DiskSummary => "disk_summary", + } + } + + const fn sections(self) -> Sections { + match self { + Self::ScanPath | Self::DiskSummary => Sections::ALL, + Self::ReclaimableSpace => Sections::RECLAIM, + Self::LargestDirs => Sections::DIRS, + Self::LargestFiles | Self::ListFiles => Sections::FILES, + } + } + + const fn default_top(self) -> usize { + match self { + Self::ListFiles => LIST_FILES_TOP, + _ => DEFAULT_TOP, + } + } + + fn arguments(self) -> Vec<&'static str> { + if self == Self::DiskSummary { + return vec!["path"]; + } + // The reclaimable summary describes the whole tree: no listing + // depth, no filters. + if self == Self::ReclaimableSpace { + return SCAN_ARGUMENTS + .into_iter() + .filter(|name| *name != "depth") + .collect(); + } + let mut names: Vec<&str> = SCAN_ARGUMENTS + .iter() + .chain(&FILTER_ARGUMENTS) + .copied() + .collect(); + if self.sections().files { + names.push("extensions"); + } + names + } + + const fn description(self) -> &'static str { + match self { + Self::ScanPath => { + "Full overview of a directory (read-only): total, the \ + volume's free space, breakdowns by top-level folder, by \ + kind of data and by age (each sums to the total), the \ + reclaimable summary, and the largest directories and \ + files. Sizes, percentages and ages are precomputed. \ + Filters and depth narrow only the two lists." + } + Self::LargestFiles => { + "The largest files beneath a directory, biggest first \ + (read-only). Filters narrow the list, e.g. min_size=\"1G\" \ + or extensions=[\"iso\",\"dmg\"]." + } + Self::LargestDirs => { + "The largest directories beneath a directory, biggest first \ + (read-only). Use depth=1 or 2 for an overview; nested \ + directories otherwise repeat their parents' space. \ + category=\"cache\" or reclaim=\"any\" lists the outermost \ + matching folders, e.g. cache folders." + } + Self::ListFiles => { + "Files matching filters, biggest first, up to top (default \ + 100) (read-only): min_size, extensions, older_than, name \ + pattern, category, reclaim. The matched totals count every \ + match, not just those listed." + } + Self::ReclaimableSpace => { + "Space that can likely be freed under a directory \ + (read-only): caches, build output, reinstallable \ + dependencies, trash, temporary files. Totals by reason \ + with what each means, the largest such folders (counted \ + once, no overlap), and the volume's free space before \ + and after. Start here for \"what can I clean up\"." + } + Self::DiskSummary => { + "Capacity, used, free and available space of the volume a \ + path is on. Fast: nothing is scanned (read-only)." + } + } + } + + fn schema(self) -> Value { + let mut properties = Map::new(); + let mut add = |name: &str, schema: Value| { + properties.insert(name.to_owned(), schema); + }; + let strings = |description: &str| { + json!({ + "anyOf": [ + { "type": "array", "items": { "type": "string" } }, + { "type": "string" }, + ], + "description": description, + }) + }; + for name in self.arguments() { + match name { + "path" => add( + name, + json!({ + "type": "string", + "description": "Absolute directory path; ~ and ~/… are the \ + home directory. Defaults to the home directory.", + }), + ), + "top" => add( + name, + json!({ + "type": "integer", "minimum": 1, "maximum": MAX_TOP, + "description": format!( + "Entries per list (default {}).", self.default_top() + ), + }), + ), + "depth" => add( + name, + json!({ + "type": "integer", "minimum": 1, + "description": "List only entries at most this many \ + levels below path; deeper files and directories \ + are not listed at all, though sizes still include \ + them. Leave unset to find the largest files \ + anywhere.", + }), + ), + "apparent_size" => add( + name, + json!({ + "type": "boolean", + "description": "Apparent length instead of on-disk \ + allocation (default false).", + }), + ), + "one_filesystem" => add( + name, + json!({ + "type": "boolean", + "description": "Stay on path's volume (default true).", + }), + ), + "include_hidden" => add( + name, + json!({ + "type": "boolean", + "description": "Include dotfiles and dot-directories \ + (default true).", + }), + ), + "follow_links" => add( + name, + json!({ + "type": "boolean", + "description": "Follow symlinks (default false).", + }), + ), + "exclude" => add( + name, + strings( + "Leave these out of the scan: a name pattern \ + (node_modules, *.vmdk) or, with a /, a path pattern. \ + * stays within a path component, ** crosses them.", + ), + ), + "refresh" => add( + name, + json!({ + "type": "boolean", + "description": "Rescan even if a scan of the same path \ + and options from the last five minutes is cached.", + }), + ), + "min_size" => add( + name, + json!({ + "type": ["string", "integer"], + "description": "At least this size: bytes, or 500M, 1G, \ + 1.5GiB (units are binary).", + }), + ), + "extensions" => add( + name, + strings( + "Files with one of these extensions (iso, .dmg, tar.gz).", + ), + ), + "older_than" => add( + name, + json!({ + "type": "string", + "description": "Last modified before this long ago (30d, \ + 6mo, 1y, 12h) or a date (2025-01-31). For a \ + directory: nothing beneath it changed since.", + }), + ), + "name" => add( + name, + strings( + "Name pattern (*backup*, *.log) or, with a /, path pattern.", + ), + ), + "category" => add( + name, + json!({ + "type": "string", + "enum": query::category_slugs(), + "description": "Kind of data, as disktree classifies it. \ + Directories: outermost match only.", + }), + ), + "reclaim" => add( + name, + json!({ + "type": "string", + "enum": query::reclaim_slugs(), + "description": "Space disktree considers reclaimable \ + (caches, build output, trash...), for this reason or \ + any. Directories: outermost match only.", + }), + ), + _ => {} + } + } + let required: Vec<&str> = if self == Self::DiskSummary { + Vec::new() + } else { + vec!["path"] + }; + json!({ + "type": "object", + "properties": properties, + "required": required, + "additionalProperties": false, + }) + } +} + +fn tools() -> Vec { + TOOLS + .iter() + .map(|tool| { + json!({ + "name": tool.name(), + "description": tool.description(), + "inputSchema": tool.schema(), + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + }, + }) + }) + .collect() +} + +fn home() -> Result { + std::env::home_dir() + .ok_or_else(|| "no path given and no home directory".to_owned()) +} + +/// A tool's arguments, read with type checks. +struct Arguments<'a>(&'a Map); + +impl Arguments<'_> { + fn get(&self, name: &str) -> Option<&Value> { + self.0.get(name).filter(|value| !value.is_null()) + } + + fn path(&self) -> Result, String> { + let Some(value) = self.get("path") else { + return Ok(None); + }; + let text = value.as_str().ok_or("path must be a string")?; + let path = query::expand_home(text); + if !path.is_absolute() { + return Err(format!( + "path must be absolute or start with ~, not {text:?}" + )); + } + Ok(Some(path)) + } + + fn flag(&self, name: &str) -> Result, String> { + self.get(name) + .map(|value| { + value + .as_bool() + .ok_or_else(|| format!("{name} must be true or false")) + }) + .transpose() + } + + fn whole(&self, name: &str) -> Result, String> { + self.get(name) + .map(|value| { + value + .as_u64() + .ok_or_else(|| format!("{name} must be a whole number")) + }) + .transpose() + } + + fn text(&self, name: &str) -> Result, String> { + self.get(name) + .map(|value| { + value + .as_str() + .ok_or_else(|| format!("{name} must be a string")) + }) + .transpose() + } + + /// A list of strings, or one string; for `extensions`, commas split it. + fn strings(&self, name: &str) -> Result, String> { + match self.get(name) { + None => Ok(Vec::new()), + Some(Value::String(text)) => Ok(vec![text.clone()]), + Some(Value::Array(items)) => items + .iter() + .map(|item| { + item.as_str() + .map(str::to_owned) + .ok_or_else(|| format!("{name} must be strings")) + }) + .collect(), + Some(_) => { + Err(format!("{name} must be a string or a list of strings")) + } + } + } +} + +fn query_from(tool: Tool, args: &Arguments<'_>) -> Result { + let path = args.path()?.ok_or("path is required")?; + let mut query = Query::new(query::resolve_root(&path)?); + query.sections = tool.sections(); + query.top = args + .whole("top")? + .map_or_else(|| Ok(tool.default_top()), check_top)?; + query.depth = args.whole("depth")?.map(check_depth).transpose()?; + let settings = &mut query.settings; + if let Some(value) = args.flag("apparent_size")? { + settings.apparent_size = value; + } + if let Some(value) = args.flag("one_filesystem")? { + settings.one_filesystem = value; + } + if let Some(value) = args.flag("include_hidden")? { + settings.include_hidden = value; + } + if let Some(value) = args.flag("follow_links")? { + settings.follow_links = value; + } + settings.exclude = args.strings("exclude")?; + let filters = &mut query.filters; + filters.min_size = match args.get("min_size") { + None => None, + Some(Value::Number(number)) => Some(number.as_u64().ok_or( + "min_size must be a whole number of bytes or a size like 1G", + )?), + Some(Value::String(text)) => Some(parse_size(text)?), + Some(_) => { + return Err( + "min_size must be a number or a size like 1G".to_owned() + ); + } + }; + filters.extensions = args + .strings("extensions")? + .iter() + .flat_map(|text| text.split(',')) + .filter_map(normalize_extension) + .collect(); + filters.older_than = + args.text("older_than")?.map(parse_older_than).transpose()?; + filters.names = args.strings("name")?; + filters.category = + args.text("category")?.map(parse_category).transpose()?; + filters.reclaim = args.text("reclaim")?.map(parse_reclaim).transpose()?; + Ok(query) +} diff --git a/crates/disktree-agent/src/query.rs b/crates/disktree-agent/src/query.rs new file mode 100644 index 0000000..9ef4a85 --- /dev/null +++ b/crates/disktree-agent/src/query.rs @@ -0,0 +1,523 @@ +//! What a caller asks for: the root, how to measure it, and which entries +//! to list. The command line and the MCP tools both build a [`Query`]. + +use std::path::{Path, PathBuf}; + +use disktree_core::classify::{Category, Reclaim}; +use disktree_core::exclude::Exclude; +use disktree_core::scan::ScanOptions; + +/// Entries listed per section unless asked otherwise. +pub const DEFAULT_TOP: usize = 20; +/// The most entries a section lists; a longer list is no use to an agent. +pub const MAX_TOP: usize = 10_000; + +/// How the tree is measured. Two queries with equal settings can share one +/// scan, which is what the MCP server's cache keys on. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ScanSettings { + /// Apparent length (`ls -l`) rather than allocated blocks (`du`). + pub apparent_size: bool, + /// Stay on the root's volume. On by default, as in the window. + pub one_filesystem: bool, + /// Include dotfiles and dot-directories. On by default. + pub include_hidden: bool, + /// Follow symlinks. Off by default. + pub follow_links: bool, + /// Patterns left out of the walk; see [`disktree_core::exclude`]. + pub exclude: Vec, +} + +impl Default for ScanSettings { + fn default() -> Self { + Self { + apparent_size: false, + one_filesystem: true, + include_hidden: true, + follow_links: false, + exclude: Vec::new(), + } + } +} + +impl ScanSettings { + /// The settings a scan was made with, as the window holds them. + pub fn from_options(options: &ScanOptions) -> Self { + Self { + apparent_size: options.apparent_size, + one_filesystem: options.one_filesystem, + include_hidden: options.include_hidden, + follow_links: options.follow_links, + exclude: options.exclude.patterns().map(str::to_owned).collect(), + } + } + + /// The core scanner's options for these settings. + pub fn options(&self) -> Result { + Ok(ScanOptions { + apparent_size: self.apparent_size, + one_filesystem: self.one_filesystem, + include_hidden: self.include_hidden, + follow_links: self.follow_links, + exclude: Exclude::new(&self.exclude)?, + ..ScanOptions::default() + }) + } + + pub const fn size_mode(&self) -> &'static str { + if self.apparent_size { + "apparent" + } else { + "on_disk" + } + } +} + +/// Which parts a report carries. The totals, options and errors always +/// come; the rest is asked for, so a tool that wants one list does not pay +/// for the others. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Sections { + /// The volume, the three breakdowns and the reclaimable summary. + pub overview: bool, + /// The volume and the reclaimable summary alone. + pub reclaim: bool, + pub dirs: bool, + pub files: bool, +} + +impl Sections { + pub const ALL: Self = Self { + overview: true, + reclaim: true, + dirs: true, + files: true, + }; + pub const NONE: Self = Self { + overview: false, + reclaim: false, + dirs: false, + files: false, + }; + pub const DIRS: Self = Self { + dirs: true, + ..Self::NONE + }; + pub const FILES: Self = Self { + files: true, + ..Self::NONE + }; + pub const RECLAIM: Self = Self { + reclaim: true, + ..Self::NONE + }; + + /// Whether the volume is measured. + pub const fn volume(self) -> bool { + self.overview || self.reclaim + } + + /// Whether the reclaimable summary is made. + pub const fn reclaimable(self) -> bool { + self.overview || self.reclaim + } + + /// `overview,dirs`: a comma list of `overview`, `reclaim`, `dirs`, + /// `files` and `all`. + pub fn parse(text: &str) -> Result { + let mut sections = Self::NONE; + for part in text.split(',').map(str::trim).filter(|p| !p.is_empty()) { + match part { + "all" => sections = Self::ALL, + "overview" | "summary" => sections.overview = true, + "reclaim" | "reclaimable" => sections.reclaim = true, + "dirs" | "directories" => sections.dirs = true, + "files" => sections.files = true, + other => { + return Err(format!( + "unknown section {other:?}; try overview, reclaim, \ + dirs, files or all" + )); + } + } + } + if sections == Self::NONE { + return Err("name at least one section".to_owned()); + } + Ok(sections) + } +} + +/// Before when an entry must have last been written. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum OlderThan { + /// This many seconds before the report is made. + Age(i64), + /// Before this Unix time. + Before(i64), +} + +impl OlderThan { + /// The cutoff in Unix seconds, for a report made at `now`. + pub const fn cutoff(self, now: i64) -> i64 { + match self { + Self::Age(seconds) => now - seconds, + Self::Before(time) => time, + } + } +} + +/// Which reclaim reasons pass. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ReclaimFilter { + /// Any reason at all. + Any, + One(Reclaim), +} + +/// What an entry must be to be listed. Every set filter must pass. +#[derive(Clone, Debug, Default)] +pub struct Filters { + /// At least this many bytes. + pub min_size: Option, + /// Files only: one of these extensions, lowercase, without the dot. + pub extensions: Vec, + /// Last written before this: for a directory, nothing beneath it was + /// written since. + pub older_than: Option, + /// The name (or, with a `/`, the path) matches one of these patterns, + /// in the exclude syntax. + pub names: Vec, + /// Of this kind, as the window colours it. + pub category: Option, + /// Space the window marks as reclaimable, for this reason or any. + pub reclaim: Option, +} + +impl Filters { + pub const fn is_empty(&self) -> bool { + self.min_size.is_none() + && self.extensions.is_empty() + && self.older_than.is_none() + && self.names.is_empty() + && self.category.is_none() + && self.reclaim.is_none() + } + + /// A category or reclaim filter lists only the outermost matching + /// directory: both are inherited, so every folder inside a cache is a + /// cache too, and listing them all says nothing new. + pub const fn outermost_dirs(&self) -> bool { + self.category.is_some() || self.reclaim.is_some() + } +} + +/// Everything a report needs to be made. +#[derive(Clone, Debug)] +pub struct Query { + pub root: PathBuf, + pub settings: ScanSettings, + /// Entries per section. + pub top: usize, + /// List entries at most this many levels below the root. Sizes still + /// count everything beneath. + pub depth: Option, + pub sections: Sections, + pub filters: Filters, +} + +impl Query { + pub fn new(root: PathBuf) -> Self { + Self { + root, + settings: ScanSettings::default(), + top: DEFAULT_TOP, + depth: None, + sections: Sections::ALL, + filters: Filters::default(), + } + } +} + +/// `~` and `~/…` against the home directory; anything else as given. +pub fn expand_home(text: &str) -> PathBuf { + let home = std::env::home_dir(); + match (text, home) { + ("~", Some(home)) => home, + (text, Some(home)) if text.starts_with("~/") => home.join(&text[2..]), + _ => PathBuf::from(text), + } +} + +/// The root as the scanner should see it: canonical, and a directory. +pub fn resolve_root(path: &Path) -> Result { + let root = dunce::canonicalize(path) + .map_err(|error| format!("cannot read {}: {error}", path.display()))?; + if !root.is_dir() { + return Err(format!("{} is not a directory", root.display())); + } + Ok(root) +} + +/// Entries per section: 1 to [`MAX_TOP`]. +pub fn check_top(top: u64) -> Result { + usize::try_from(top) + .ok() + .filter(|top| (1..=MAX_TOP).contains(top)) + .ok_or_else(|| format!("top must be 1 to {MAX_TOP}")) +} + +/// Levels below the root: at least 1. +pub fn check_depth(depth: u64) -> Result { + usize::try_from(depth) + .ok() + .filter(|depth| *depth >= 1) + .ok_or_else(|| "depth must be 1 or more".to_owned()) +} + +/// A size: `1500`, `500K`, `1.5G`, `2GB`, `2GiB`, `10 MB`. Units are +/// binary whichever way they are spelled, matching the `GiB` reports print: +/// `1GB` is 1024³ bytes. +pub fn parse_size(text: &str) -> Result { + let invalid = || { + format!("cannot read size {text:?}; try 1500, 500K, 1.5G, 2GB or 2GiB") + }; + let trimmed = text.trim(); + let split = trimmed + .find(|c: char| !(c.is_ascii_digit() || c == '.')) + .unwrap_or(trimmed.len()); + let (number, unit) = trimmed.split_at(split); + let value: f64 = number.parse().map_err(|_| invalid())?; + 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 Err(invalid()), + }; + let bytes = value * 1024_f64.powi(power); + if !bytes.is_finite() || bytes < 0.0 || bytes > u64::MAX as f64 { + return Err(invalid()); + } + Ok(bytes.round() as u64) +} + +/// An age or a date: `30d`, `12h`, `2w`, `6mo`, `1y`, `90min`, `45s`, or +/// `2025-01-31` (midnight UTC). A month is 30 days and a year 365. +pub fn parse_older_than(text: &str) -> Result { + let trimmed = text.trim(); + if let Ok(date) = chrono::NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") { + let time = date + .and_hms_opt(0, 0, 0) + .map(|time| time.and_utc().timestamp()) + .ok_or_else(|| format!("cannot read date {text:?}"))?; + return Ok(OlderThan::Before(time)); + } + let invalid = || { + format!( + "cannot read age {text:?}; try 30d, 12h, 2w, 6mo, 1y, 90min or \ + a date like 2025-01-31" + ) + }; + let split = trimmed + .find(|c: char| !c.is_ascii_digit()) + .ok_or_else(invalid)?; + let (number, unit) = trimmed.split_at(split); + let count: i64 = number.parse().map_err(|_| invalid())?; + let unit_seconds = match unit.trim().to_ascii_lowercase().as_str() { + "s" | "sec" | "secs" | "second" | "seconds" => 1, + "min" | "mins" | "minute" | "minutes" => 60, + "h" | "hr" | "hrs" | "hour" | "hours" => 3_600, + "d" | "day" | "days" => 86_400, + "w" | "wk" | "week" | "weeks" => 7 * 86_400, + "mo" | "month" | "months" => 30 * 86_400, + "y" | "yr" | "year" | "years" => 365 * 86_400, + _ => return Err(invalid()), + }; + count + .checked_mul(unit_seconds) + .map(OlderThan::Age) + .ok_or_else(invalid) +} + +/// `iso`, `.ISO` and `tar.gz` alike: lowercase, no leading dot. +pub fn normalize_extension(text: &str) -> Option { + let text = text.trim().trim_start_matches('.').to_ascii_lowercase(); + (!text.is_empty()).then_some(text) +} + +/// The slug a category is written as in JSON. +pub const fn category_slug(category: Category) -> &'static str { + match category { + Category::Code => "code", + Category::AgentScratch => "agent-scratch", + Category::Toolchain => "toolchain", + Category::Synced => "synced", + Category::Git => "git", + Category::Media => "media", + Category::Documents => "documents", + Category::Cache => "cache", + Category::Other => "other", + } +} + +/// The slug a reclaim reason is written as in JSON. +pub const fn reclaim_slug(reclaim: Reclaim) -> &'static str { + match reclaim { + Reclaim::Regenerable => "regenerable", + Reclaim::SyncHistory => "sync-history", + Reclaim::PackageStore => "package-store", + Reclaim::BuildOutput => "build-output", + Reclaim::Reinstallable => "reinstallable", + Reclaim::SandboxLayers => "sandbox-layers", + Reclaim::Snapshots => "snapshots", + Reclaim::Trash => "trash", + Reclaim::Temporary => "temporary", + } +} + +const CATEGORIES: [Category; 9] = [ + Category::Code, + Category::AgentScratch, + Category::Toolchain, + Category::Synced, + Category::Git, + Category::Media, + Category::Documents, + Category::Cache, + Category::Other, +]; + +const RECLAIMS: [Reclaim; 9] = [ + Reclaim::Regenerable, + Reclaim::SyncHistory, + Reclaim::PackageStore, + Reclaim::BuildOutput, + Reclaim::Reinstallable, + Reclaim::SandboxLayers, + Reclaim::Snapshots, + Reclaim::Trash, + Reclaim::Temporary, +]; + +/// Every category slug, for help text and the MCP schema. +pub fn category_slugs() -> Vec<&'static str> { + CATEGORIES.into_iter().map(category_slug).collect() +} + +/// Every reclaim slug, plus `any`. +pub fn reclaim_slugs() -> Vec<&'static str> { + std::iter::once("any") + .chain(RECLAIMS.into_iter().map(reclaim_slug)) + .collect() +} + +pub fn parse_category(text: &str) -> Result { + let wanted = text.trim().to_ascii_lowercase().replace('_', "-"); + CATEGORIES + .into_iter() + .find(|category| category_slug(*category) == wanted) + .ok_or_else(|| { + format!( + "unknown category {text:?}; try {}", + category_slugs().join(", ") + ) + }) +} + +pub fn parse_reclaim(text: &str) -> Result { + let wanted = text.trim().to_ascii_lowercase().replace('_', "-"); + if wanted == "any" { + return Ok(ReclaimFilter::Any); + } + RECLAIMS + .into_iter() + .find(|reclaim| reclaim_slug(*reclaim) == wanted) + .map(ReclaimFilter::One) + .ok_or_else(|| { + format!( + "unknown reclaim reason {text:?}; try {}", + reclaim_slugs().join(", ") + ) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const GIB: u64 = 1024 * 1024 * 1024; + + #[test] + fn sizes_are_binary_however_they_are_spelled() { + assert_eq!(parse_size("1500"), Ok(1500)); + assert_eq!(parse_size("1G"), Ok(GIB)); + assert_eq!(parse_size("1GB"), Ok(GIB)); + assert_eq!(parse_size("1 GiB"), Ok(GIB)); + assert_eq!(parse_size("1.5g"), Ok(GIB * 3 / 2)); + assert_eq!(parse_size("500K"), Ok(500 * 1024)); + assert_eq!(parse_size("2tb"), Ok(2 * 1024 * GIB)); + for bad in ["", "G", "1XB", "-1G", "1..5G", "99999999999P"] { + assert!(parse_size(bad).is_err(), "{bad:?}"); + } + } + + #[test] + fn ages_and_dates_are_read() { + assert_eq!(parse_older_than("30d"), Ok(OlderThan::Age(30 * 86_400))); + assert_eq!(parse_older_than("12h"), Ok(OlderThan::Age(12 * 3_600))); + assert_eq!(parse_older_than("6mo"), Ok(OlderThan::Age(180 * 86_400))); + assert_eq!(parse_older_than("1y"), Ok(OlderThan::Age(365 * 86_400))); + assert_eq!(parse_older_than("90min"), Ok(OlderThan::Age(5_400))); + assert_eq!( + parse_older_than("2025-01-31"), + Ok(OlderThan::Before(1_738_281_600)) + ); + // `m` would be minutes to some and months to others. + for bad in ["", "d", "30", "30m", "30x", "2025-13-01"] { + assert!(parse_older_than(bad).is_err(), "{bad:?}"); + } + assert_eq!(OlderThan::Age(10).cutoff(100), 90); + assert_eq!(OlderThan::Before(5).cutoff(100), 5); + } + + #[test] + fn extensions_categories_and_reasons_are_normalized() { + assert_eq!(normalize_extension(".ISO"), Some("iso".to_owned())); + assert_eq!(normalize_extension("tar.gz"), Some("tar.gz".to_owned())); + assert_eq!(normalize_extension(" . "), None); + assert_eq!(parse_category("Cache"), Ok(Category::Cache)); + assert_eq!(parse_category("agent_scratch"), Ok(Category::AgentScratch)); + assert!(parse_category("caches").is_err()); + assert_eq!(parse_reclaim("any"), Ok(ReclaimFilter::Any)); + assert_eq!( + parse_reclaim("build-output"), + Ok(ReclaimFilter::One(Reclaim::BuildOutput)) + ); + assert!(parse_reclaim("junk").is_err()); + for slug in category_slugs() { + assert_eq!(category_slug(parse_category(slug).expect(slug)), slug); + } + } + + #[test] + fn top_and_depth_are_bounded() { + assert_eq!(check_top(5), Ok(5)); + assert!(check_top(0).is_err()); + assert!(check_top(MAX_TOP as u64 + 1).is_err()); + assert_eq!(check_depth(1), Ok(1)); + assert!(check_depth(0).is_err()); + } + + #[test] + fn home_is_expanded_only_at_the_start() { + if let Some(home) = std::env::home_dir() { + assert_eq!(expand_home("~"), home); + assert_eq!(expand_home("~/x"), home.join("x")); + } + assert_eq!(expand_home("/a/~/b"), PathBuf::from("/a/~/b")); + assert_eq!(expand_home("~other"), PathBuf::from("~other")); + } +} diff --git a/crates/disktree-agent/src/report.rs b/crates/disktree-agent/src/report.rs new file mode 100644 index 0000000..1c5d8e5 --- /dev/null +++ b/crates/disktree-agent/src/report.rs @@ -0,0 +1,983 @@ +//! A scan, and the JSON report made from it. +//! +//! The walk is the core scanner's; this module only reads the finished +//! tree, once, and does every sum, share and age the report states, so a +//! reader never has to add sizes or subtract dates. Lists are kept to +//! their top entries as the tree is read, so a disk of millions of files +//! costs a few hundred paths, not millions. +//! +//! The breakdowns are built to be added up: each divides the root's bytes +//! into parts that do not overlap and sum to the total exactly. + +use std::cmp::{Ordering, Reverse}; +use std::collections::{BinaryHeap, HashSet}; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use disktree_core::classify::{ + Category, Reclaim, category_of_name, is_git_store, +}; +use disktree_core::exclude::Exclude; +use disktree_core::scan::{ScanHandle, ScanSnapshot}; +use disktree_core::size::{human_bytes, human_count}; +use disktree_core::space::{self, SpaceInfo}; +use disktree_core::tree::{Node, NodeKind}; +use serde_json::{Map, Value, json}; + +use crate::query::{ + Filters, Query, ReclaimFilter, ScanSettings, category_slug, reclaim_slug, +}; +use crate::{SCHEMA_VERSION, VERSION}; + +/// Unreadable paths quoted in a report; the count covers the rest. +const ERROR_EXAMPLES: usize = 10; + +/// How often a running scan reports progress. +const PROGRESS_EVERY: Duration = Duration::from_millis(500); + +const DAY: i64 = 86_400; + +/// The age buckets of `breakdown.by_age`: label and upper bound in days. +const AGES: [(&str, i64); 4] = [ + ("under_30_days", 30), + ("30_days_to_6_months", 182), + ("6_months_to_1_year", 365), + ("over_1_year", i64::MAX), +]; + +/// Every category, in the index order the walk tallies them by. +const CATEGORIES: [Category; 9] = [ + Category::Code, + Category::AgentScratch, + Category::Toolchain, + Category::Synced, + Category::Git, + Category::Media, + Category::Documents, + Category::Cache, + Category::Other, +]; + +/// Every reclaim reason, in the index order the walk tallies them by. +const RECLAIMS: [Reclaim; 9] = [ + Reclaim::Regenerable, + Reclaim::SyncHistory, + Reclaim::PackageStore, + Reclaim::BuildOutput, + Reclaim::Reinstallable, + Reclaim::SandboxLayers, + Reclaim::Snapshots, + Reclaim::Trash, + Reclaim::Temporary, +]; + +/// A finished scan, ready to be reported on any number of times. +#[derive(Debug)] +pub struct Scanned { + pub root: PathBuf, + /// Shared, so the window can report on the tree it already holds. + pub tree: Arc, + pub settings: ScanSettings, + /// Unix seconds when the scan finished. + pub scanned_at: i64, + pub elapsed: Duration, + pub errors: u64, + pub error_examples: Vec, +} + +/// Scan `root`, calling `progress` about twice a second while the walk +/// runs and `cancelled` as often, to stop it early. +pub fn scan( + root: &Path, + settings: &ScanSettings, + progress: &mut dyn FnMut(&ScanSnapshot), + cancelled: &mut dyn FnMut() -> bool, +) -> io::Result { + let options = settings.options().map_err(io::Error::other)?; + let started = Instant::now(); + let handle = ScanHandle::spawn(root.to_path_buf(), options); + let mut last_report = Instant::now(); + let tree = loop { + // Asked before the result is taken, so a cancellation already + // waiting is seen even by a scan that finishes at once. + if cancelled() { + handle.cancel(); + return Err(io::Error::new( + io::ErrorKind::Interrupted, + "the scan was cancelled", + )); + } + if let Some(outcome) = handle.poll() { + break outcome?; + } + if last_report.elapsed() >= PROGRESS_EVERY { + progress(&handle.progress.snapshot()); + last_report = Instant::now(); + } + std::thread::sleep(Duration::from_millis(20)); + }; + let snapshot = handle.progress.snapshot(); + Ok(Scanned { + root: root.to_path_buf(), + tree: Arc::new(tree), + settings: settings.clone(), + scanned_at: chrono::Utc::now().timestamp(), + elapsed: started.elapsed(), + errors: snapshot.errors, + error_examples: snapshot + .messages + .into_iter() + .take(ERROR_EXAMPLES) + .collect(), + }) +} + +/// A scan without progress or cancellation, for the command line. +pub fn scan_quietly( + root: &Path, + settings: &ScanSettings, +) -> io::Result { + scan(root, settings, &mut |_| {}, &mut || false) +} + +/// The report for `query` over `scanned`, as of `now` (Unix seconds). +pub fn report( + scanned: &Scanned, + query: &Query, + now: i64, +) -> Result { + let names = Exclude::new(&query.filters.names)?; + let tree = &scanned.tree; + let total = tree.bytes; + let mut walk = Walk::new(query, names, now, total); + let mut path = scanned.root.clone(); + walk.visit(tree, &mut path, 1, Category::Other, true); + + let sections = query.sections; + let volume = sections + .volume() + .then(|| space::space_info(&scanned.root).ok()) + .flatten(); + + let mut report = Map::new(); + let mut put = |key: &str, value: Value| { + report.insert(key.to_owned(), value); + }; + put("schema_version", SCHEMA_VERSION.into()); + put( + "generator", + json!({ "name": "disktree", "version": VERSION }), + ); + put("root", path_text(&scanned.root).into()); + put("scanned_at", time_text(scanned.scanned_at).into()); + put("scan_seconds", round1(scanned.elapsed.as_secs_f64()).into()); + put("options", options_json(&scanned.settings, query)); + put("filters", filters_json(&query.filters, now)); + put( + "total", + json!({ + "bytes": total, + "size": human_bytes(total), + "files": tree.files, + "directories": tree.dirs.saturating_sub(1), + }), + ); + put( + "errors", + json!({ + "unreadable_paths": scanned.errors, + "examples": scanned.error_examples, + "note": (scanned.errors > 0).then_some( + "These paths could not be read, so their contents are not \ + counted anywhere in this report." + ), + }), + ); + if sections.volume() { + put( + "volume", + volume.map_or(Value::Null, |info| volume_json(info, total)), + ); + } + if sections.overview { + put("breakdown", walk.breakdown_json(tree, &scanned.root, now)); + } + if sections.reclaimable() { + // Only on-disk bytes from this volume come back to it: apparent + // sizes overstate sparse files, and other volumes are reached by + // crossing filesystems or following links. + let settings = &scanned.settings; + let after = volume.filter(|_| { + settings.one_filesystem + && !settings.apparent_size + && !settings.follow_links + }); + put("reclaimable", walk.reclaimable_json(after, now)); + } + if sections.dirs { + let dirs = std::mem::replace(&mut walk.dirs, Top::new(0)); + put("largest_dirs", dirs_json(&dirs.into_sorted(), total, now)); + } + if sections.files { + let files = std::mem::replace(&mut walk.files, Top::new(0)); + let entries = files + .into_sorted() + .iter() + .map(|hit| hit.json(total, now)) + .collect(); + put("largest_files", Value::Array(entries)); + } + put("matched", walk.matched_json()); + Ok(Value::Object(report)) +} + +/// A report as the JSON text `disktree scan --json` prints: indented, with +/// a final newline. The window's export writes exactly this. +pub fn json_text(report: &Value) -> String { + let mut text = + serde_json::to_string_pretty(report).unwrap_or_else(|_| "{}".into()); + text.push('\n'); + text +} + +/// The volume a path is on, without scanning anything. +pub fn summary(path: &Path) -> Result { + let info = space::space_info(path).map_err(|error| { + format!("cannot measure {}: {error}", path.display()) + })?; + Ok(json!({ + "schema_version": SCHEMA_VERSION, + "generator": { "name": "disktree", "version": VERSION }, + "path": path_text(path), + "volume_root": space::volume_root_for(path).as_deref().map(path_text), + "device": space::device_for(path), + "capacity": amount(info.total), + "used": amount(info.used()), + "free": amount(info.free), + "available": amount(info.available), + "used_percent": percent(info.used(), info.total), + "available_percent": percent(info.available, info.total), + })) +} + +/// A report as text for a person at a terminal. +pub fn text(report: &Value) -> String { + use std::fmt::Write as _; + + let mut out = String::new(); + let total = &report["total"]; + let _ = writeln!( + out, + "{} {} {} · {} files · {} directories · {}s", + report["root"].as_str().unwrap_or_default(), + total["size"].as_str().unwrap_or_default(), + if report["options"]["size_mode"] == "apparent" { + "apparent" + } else { + "on disk" + }, + human_count(total["files"].as_u64().unwrap_or_default()), + human_count(total["directories"].as_u64().unwrap_or_default()), + report["scan_seconds"], + ); + let volume = &report["volume"]; + if volume.is_object() { + let _ = writeln!( + out, + "volume: {} available of {} ({}% used); this tree is {}% of \ + what is used", + volume["available"]["size"].as_str().unwrap_or_default(), + volume["capacity"]["size"].as_str().unwrap_or_default(), + volume["used_percent"], + volume["root_percent_of_used"], + ); + } + let row = |out: &mut String, entry: &Value, label: &str| { + let _ = writeln!( + out, + " {:>10} {:>5}% {}", + entry["size"].as_str().unwrap_or_default(), + entry["percent"], + label, + ); + }; + if let Some(categories) = report["breakdown"]["by_category"].as_array() { + let _ = writeln!(out, "\nBy kind"); + for entry in categories { + row( + &mut out, + entry, + entry["category"].as_str().unwrap_or_default(), + ); + } + } + let reclaimable = &report["reclaimable"]; + if reclaimable.is_object() { + let _ = writeln!( + out, + "\nReclaimable: {} ({}% of this tree)", + reclaimable["size"].as_str().unwrap_or_default(), + reclaimable["percent"], + ); + for entry in reclaimable["by_reason"].as_array().into_iter().flatten() { + let label = format!( + "{} ({} places)", + entry["reason"].as_str().unwrap_or_default(), + entry["places"], + ); + row(&mut out, entry, &label); + } + } + for (key, title) in [ + ("largest_dirs", "Largest directories"), + ("largest_files", "Largest files"), + ] { + let Some(entries) = report[key].as_array() else { + continue; + }; + let _ = writeln!(out, "\n{title}"); + if entries.is_empty() { + let _ = writeln!(out, " (none match)"); + } + for entry in entries { + let age = entry["age_days"] + .as_i64() + .map_or_else(String::new, |days| format!("{days}d")); + let _ = writeln!( + out, + " {:>10} {:>5}% {age:>6} {}", + entry["size"].as_str().unwrap_or_default(), + entry["percent"], + entry["path"].as_str().unwrap_or_default(), + ); + } + } + let errors = report["errors"]["unreadable_paths"] + .as_u64() + .unwrap_or_default(); + if errors > 0 { + let _ = writeln!( + out, + "\n{errors} paths could not be read; their contents are not \ + counted." + ); + } + out +} + +/// What each reclaim reason means for whoever acts on it, said once per +/// report rather than once per entry. +const fn reclaim_description(reclaim: Reclaim) -> &'static str { + match reclaim { + Reclaim::Regenerable => { + "Caches: whatever wrote them writes them again when needed." + } + Reclaim::SyncHistory => { + "A sync client's old versions of files; current files are kept \ + elsewhere." + } + Reclaim::PackageStore => { + "A package manager's shared store; packages download again when \ + a project needs them." + } + Reclaim::BuildOutput => { + "Compiler or bundler output; the next build recreates it, more \ + slowly." + } + Reclaim::Reinstallable => { + "Installed dependencies beside their manifest; reinstall with the \ + project's package manager." + } + Reclaim::SandboxLayers => { + "Container or sandbox image layers; pulled again when needed." + } + Reclaim::Snapshots => { + "Sandbox or VM snapshots; check none is still wanted first." + } + Reclaim::Trash => "Files already deleted, still on disk until emptied.", + Reclaim::Temporary => "Temporary files and logs.", + } +} + +/// `part` as a percentage of `whole`, one decimal; `0` of nothing. +fn percent(part: u64, whole: u64) -> f64 { + if whole == 0 { + 0.0 + } else { + round1(part as f64 * 100.0 / whole as f64) + } +} + +fn round1(value: f64) -> f64 { + (value * 10.0).round() / 10.0 +} + +fn amount(bytes: u64) -> Value { + json!({ "bytes": bytes, "size": human_bytes(bytes) }) +} + +/// Whole days since `modified`; `None` when unknown. Never negative: a +/// clock that has gone back makes a file new, not from the future. +fn age_days(modified: i64, now: i64) -> Option { + (modified != 0).then(|| (now - modified).max(0) / DAY) +} + +/// Unix seconds as RFC 3339 UTC; `None` for `0`, which the tree uses for +/// unknown. +fn time_text(seconds: i64) -> Option { + (seconds != 0) + .then(|| chrono::DateTime::from_timestamp(seconds, 0)) + .flatten() + .map(|time| time.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)) +} + +fn path_text(path: &Path) -> String { + path.to_string_lossy().into_owned() +} + +fn options_json(settings: &ScanSettings, query: &Query) -> Value { + json!({ + "size_mode": settings.size_mode(), + "one_filesystem": settings.one_filesystem, + "include_hidden": settings.include_hidden, + "follow_links": settings.follow_links, + "exclude": settings.exclude, + "top": query.top, + "depth": query.depth, + }) +} + +fn filters_json(filters: &Filters, now: i64) -> Value { + json!({ + "min_size_bytes": filters.min_size, + "extensions": filters.extensions, + "older_than": filters + .older_than + .and_then(|older| time_text(older.cutoff(now))), + "name": filters.names, + "category": filters.category.map(category_slug), + "reclaim": filters.reclaim.map(|reclaim| match reclaim { + ReclaimFilter::Any => "any", + ReclaimFilter::One(reason) => reclaim_slug(reason), + }), + }) +} + +fn volume_json(info: SpaceInfo, total: u64) -> Value { + json!({ + "capacity": amount(info.total), + "used": amount(info.used()), + "available": amount(info.available), + "used_percent": percent(info.used(), info.total), + "available_percent": percent(info.available, info.total), + "root_percent_of_used": percent(total, info.used()), + }) +} + +/// `largest_dirs`, each marked with the nearest listed directory holding +/// it, so overlapping entries are never added together by mistake. +fn dirs_json(hits: &[Hit], total: u64, now: i64) -> Value { + let listed: HashSet<&Path> = + hits.iter().map(|hit| hit.path.as_path()).collect(); + let entries = hits + .iter() + .map(|hit| { + let mut entry = hit.json(total, now); + let nested_in = hit + .path + .ancestors() + .skip(1) + .find(|ancestor| listed.contains(ancestor)) + .map(path_text); + if let Some(object) = entry.as_object_mut() { + object.insert("nested_in".into(), nested_in.into()); + } + entry + }) + .collect(); + Value::Array(entries) +} + +/// A byte and item tally. +#[derive(Clone, Copy, Debug, Default)] +struct Tally { + bytes: u64, + count: u64, +} + +impl Tally { + const fn add(&mut self, bytes: u64, count: u64) { + self.bytes += bytes; + self.count += count; + } +} + +/// One listed entry. +#[derive(Debug)] +struct Hit { + bytes: u64, + path: PathBuf, + kind: NodeKind, + modified: i64, + depth: usize, + files: u64, + unreadable: bool, + category: Category, + reclaim: Option, +} + +impl Hit { + fn new(node: &Node, path: &Path, depth: usize, category: Category) -> Self { + Self { + bytes: node.bytes, + path: path.to_path_buf(), + kind: node.kind, + modified: node.modified, + depth, + files: node.files, + unreadable: node.read_error, + category, + reclaim: node.reclaim, + } + } + + fn json(&self, total: u64, now: i64) -> Value { + let mut entry = json!({ + "path": path_text(&self.path), + "name": self + .path + .file_name() + .map(|name| name.to_string_lossy().into_owned()), + "type": if self.kind.is_dir() { "directory" } else { "file" }, + "bytes": self.bytes, + "size": human_bytes(self.bytes), + "percent": percent(self.bytes, total), + "modified": time_text(self.modified), + "age_days": age_days(self.modified, now), + "depth": self.depth, + "category": category_slug(self.category), + "reclaim": self.reclaim.map(reclaim_slug), + }); + if self.kind.is_dir() + && let Some(object) = entry.as_object_mut() + { + object.insert("files".into(), self.files.into()); + object.insert("unreadable".into(), self.unreadable.into()); + } + entry + } + + /// Larger first; equal sizes by path, so output is stable. + fn rank(&self, other: &Self) -> Ordering { + other + .bytes + .cmp(&self.bytes) + .then_with(|| self.path.cmp(&other.path)) + } +} + +impl PartialEq for Hit { + fn eq(&self, other: &Self) -> bool { + self.rank(other) == Ordering::Equal + } +} + +impl Eq for Hit {} + +impl PartialOrd for Hit { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Hit { + /// Better ranked is greater, so a max-heap of `Reverse` keeps the + /// worst on top, where it is evicted. + fn cmp(&self, other: &Self) -> Ordering { + other.rank(self) + } +} + +/// The best `limit` hits seen. +#[derive(Debug)] +struct Top { + limit: usize, + heap: BinaryHeap>, +} + +impl Top { + fn new(limit: usize) -> Self { + Self { + limit, + heap: BinaryHeap::with_capacity(limit + 1), + } + } + + /// Whether a hit of `bytes` could be kept, before its path is built. + fn wants(&self, bytes: u64) -> bool { + self.limit > 0 + && (self.heap.len() < self.limit + || self + .heap + .peek() + .is_some_and(|Reverse(worst)| bytes >= worst.bytes)) + } + + fn push(&mut self, hit: Hit) { + self.heap.push(Reverse(hit)); + if self.heap.len() > self.limit { + self.heap.pop(); + } + } + + fn into_sorted(self) -> Vec { + let mut hits: Vec = + self.heap.into_iter().map(|Reverse(hit)| hit).collect(); + hits.sort_by(Hit::rank); + hits + } +} + +/// The one walk over a finished tree: it fills the lists and every tally. +/// Lists honour the query's depth and filters; tallies describe the whole +/// root and honour neither. +#[derive(Debug)] +struct Walk<'a> { + query: &'a Query, + names: Exclude, + cutoff: Option, + now: i64, + total: u64, + dirs: Top, + files: Top, + matched_dirs: u64, + matched_files: Tally, + by_category: [Tally; CATEGORIES.len()], + /// Bytes and files by age, plus one for unknown, last. + by_age: [Tally; AGES.len() + 1], + reclaim_by: [Tally; RECLAIMS.len()], + reclaim_top: Top, +} + +impl<'a> Walk<'a> { + fn new(query: &'a Query, names: Exclude, now: i64, total: u64) -> Self { + let reclaim_top = if query.sections.reclaimable() { + query.top + } else { + 0 + }; + Self { + query, + names, + cutoff: query.filters.older_than.map(|older| older.cutoff(now)), + now, + total, + dirs: Top::new(if query.sections.dirs { query.top } else { 0 }), + files: Top::new(if query.sections.files { query.top } else { 0 }), + matched_dirs: 0, + matched_files: Tally::default(), + by_category: [Tally::default(); CATEGORIES.len()], + by_age: [Tally::default(); AGES.len() + 1], + reclaim_by: [Tally::default(); RECLAIMS.len()], + reclaim_top: Top::new(reclaim_top), + } + } + + /// Visit the children of `node`, which sits at `path` and is of kind + /// `category`; the children are at `depth` below the root. + /// `list_dirs` is off beneath a directory already listed as an + /// outermost match. + fn visit( + &mut self, + node: &Node, + path: &mut PathBuf, + depth: usize, + category: Category, + list_dirs: bool, + ) { + let listing = self.query.depth.is_none_or(|limit| depth <= limit); + for child in &node.children { + path.push(&*child.name); + if child.kind == NodeKind::Directory { + // The window's rule without its guess for unknown top-level + // names: a directory is what its own name says, or what + // holds it. The guess colours `~/Library` by its `Caches`, + // which is a fine colour and a wrong total. + let kind = category_of_name(&child.name) + .or_else(|| is_git_store(child).then_some(Category::Git)) + .unwrap_or(category); + // Reclaim reasons are inherited, so the outermost one is + // where the parent has none: those never overlap. + if let Some(reason) = child.reclaim + && node.reclaim.is_none() + { + self.reclaim_by[reclaim_index(reason)].add(child.bytes, 1); + if self.reclaim_top.wants(child.bytes) { + self.reclaim_top + .push(Hit::new(child, path, depth, kind)); + } + } + let listed = list_dirs + && listing + && self.passes(child, path, kind, false); + if listed { + self.matched_dirs += 1; + if self.dirs.wants(child.bytes) { + self.dirs.push(Hit::new(child, path, depth, kind)); + } + } + let below = list_dirs + && !(listed && self.query.filters.outermost_dirs()); + self.visit(child, path, depth + 1, kind, below); + } else { + self.tally_leaf(child, category); + if child.kind == NodeKind::File + && listing + && self.passes(child, path, category, true) + { + self.matched_files.add(child.bytes, 1); + if self.files.wants(child.bytes) { + self.files.push(Hit::new(child, path, depth, category)); + } + } + } + path.pop(); + } + } + + /// Count a file, link or special file into the breakdowns. Only leaves + /// hold bytes of their own, so these sum to the root's total. + fn tally_leaf(&mut self, leaf: &Node, category: Category) { + let files = u64::from(leaf.kind == NodeKind::File); + self.by_category[category_index(category)].add(leaf.bytes, files); + let age = + age_days(leaf.modified, self.now).map_or(AGES.len(), |days| { + AGES.iter() + .position(|(_, under)| days < *under) + .unwrap_or(AGES.len() - 1) + }); + self.by_age[age].add(leaf.bytes, files); + } + + fn passes( + &self, + node: &Node, + path: &Path, + category: Category, + file: bool, + ) -> bool { + let filters = &self.query.filters; + if filters.min_size.is_some_and(|min| node.bytes < min) { + return false; + } + if let Some(cutoff) = self.cutoff + && (node.modified == 0 || node.modified >= cutoff) + { + return false; + } + if file && !filters.extensions.is_empty() { + let name = node.name.to_ascii_lowercase(); + let matched = filters.extensions.iter().any(|extension| { + name.len() > extension.len() + 1 + && name.ends_with(extension.as_str()) + && name.as_bytes()[name.len() - extension.len() - 1] == b'.' + }); + if !matched { + return false; + } + } + if !self.names.is_empty() + && !self.names.matches(&node.name, || path.to_path_buf()) + { + return false; + } + if filters.category.is_some_and(|wanted| category != wanted) { + return false; + } + match filters.reclaim { + None => true, + Some(ReclaimFilter::Any) => node.reclaim.is_some(), + Some(ReclaimFilter::One(reason)) => node.reclaim == Some(reason), + } + } + + fn breakdown_json(&self, tree: &Node, root: &Path, now: i64) -> Value { + let total = self.total; + let row = |bytes: u64, files: u64| { + json!({ + "bytes": bytes, + "size": human_bytes(bytes), + "percent": percent(bytes, total), + "files": files, + }) + }; + let with = |key: &str, label: &str, mut value: Value| { + let mut object = Map::new(); + object.insert(key.to_owned(), label.into()); + if let Value::Object(rest) = value.take() { + object.extend(rest); + } + Value::Object(object) + }; + + // By bytes, whatever the tree is ordered by: the window ranks its + // tree by file count when asked to. + let mut by_bytes: Vec<&Node> = tree.children.iter().collect(); + by_bytes + .sort_by(|a, b| b.bytes.cmp(&a.bytes).then(a.name.cmp(&b.name))); + let shown = self.query.top.min(by_bytes.len()); + let (top, rest) = by_bytes.split_at(shown); + let mut children: Vec = top + .iter() + .map(|child| { + let kind = if child.is_dir() { + category_of_name(&child.name) + .or_else(|| { + is_git_store(child).then_some(Category::Git) + }) + .unwrap_or(Category::Other) + } else { + Category::Other + }; + Hit::new(child, &root.join(&*child.name), 1, kind) + .json(total, now) + }) + .collect(); + children.sort_by_key(|entry| Reverse(entry["bytes"].as_u64())); + let rest_bytes: u64 = rest.iter().map(|child| child.bytes).sum(); + let rest_files: u64 = rest.iter().map(|child| child.files).sum(); + + let mut by_category: Vec<(usize, Tally)> = + self.by_category.iter().copied().enumerate().collect(); + by_category.sort_by_key(|(_, tally)| Reverse(tally.bytes)); + let by_category: Vec = by_category + .into_iter() + .filter(|(_, tally)| tally.bytes > 0 || tally.count > 0) + .map(|(index, tally)| { + with( + "category", + category_slug(CATEGORIES[index]), + row(tally.bytes, tally.count), + ) + }) + .collect(); + + let labels = AGES.iter().map(|(label, _)| *label).chain(["unknown"]); + let by_age: Vec = labels + .zip(self.by_age) + .map(|(label, tally)| { + with("age", label, row(tally.bytes, tally.count)) + }) + .collect(); + + json!({ + "note": "Each list divides total.bytes into parts that do not \ + overlap; its bytes add up to the total.", + "children": children, + "children_rest": { + "count": rest.len(), + "bytes": rest_bytes, + "size": human_bytes(rest_bytes), + "percent": percent(rest_bytes, total), + "files": rest_files, + }, + "by_category": by_category, + "by_age": by_age, + }) + } + + fn reclaimable_json( + &mut self, + volume: Option, + now: i64, + ) -> Value { + let total = self.total; + let bytes: u64 = self.reclaim_by.iter().map(|tally| tally.bytes).sum(); + let places: u64 = self.reclaim_by.iter().map(|tally| tally.count).sum(); + let mut by_reason: Vec<(usize, Tally)> = self + .reclaim_by + .iter() + .copied() + .enumerate() + .filter(|(_, tally)| tally.count > 0) + .collect(); + by_reason.sort_by_key(|(_, tally)| Reverse(tally.bytes)); + let by_reason: Vec = by_reason + .into_iter() + .map(|(index, tally)| { + let reason = RECLAIMS[index]; + json!({ + "reason": reclaim_slug(reason), + "bytes": tally.bytes, + "size": human_bytes(tally.bytes), + "percent": percent(tally.bytes, total), + "places": tally.count, + "description": reclaim_description(reason), + }) + }) + .collect(); + let largest: Vec = + std::mem::replace(&mut self.reclaim_top, Top::new(0)) + .into_sorted() + .iter() + .map(|hit| hit.json(total, now)) + .collect(); + let available_after = volume.map(|info| { + let after = info.available.saturating_add(bytes); + json!({ + "bytes": after, + "size": human_bytes(after), + "percent_of_capacity": percent(after, info.total), + }) + }); + json!({ + "bytes": bytes, + "size": human_bytes(bytes), + "percent": percent(bytes, total), + "places": places, + "available_after": available_after, + "note": "Space disktree recognises as regenerable or disposable, \ + counted once at the outermost folder. A hint to weigh, \ + not a promise: check before removing anything.", + "by_reason": by_reason, + "largest": largest, + }) + } + + fn matched_json(&self) -> Value { + let mut matched = Map::new(); + if self.query.sections.dirs { + matched.insert("directories".into(), self.matched_dirs.into()); + } + if self.query.sections.files { + let files = self.matched_files; + matched.insert("files".into(), files.count.into()); + matched.insert("file_bytes".into(), files.bytes.into()); + matched.insert("file_size".into(), human_bytes(files.bytes).into()); + matched.insert( + "file_percent".into(), + percent(files.bytes, self.total).into(), + ); + } + Value::Object(matched) + } +} + +fn category_index(category: Category) -> usize { + CATEGORIES + .iter() + .position(|known| *known == category) + .unwrap_or(CATEGORIES.len() - 1) +} + +fn reclaim_index(reclaim: Reclaim) -> usize { + RECLAIMS + .iter() + .position(|known| *known == reclaim) + .unwrap_or_default() +} + +/// A shared scan, as the MCP server caches it. +pub type SharedScan = Arc; diff --git a/crates/disktree-agent/tests/guide.rs b/crates/disktree-agent/tests/guide.rs new file mode 100644 index 0000000..646e2a9 --- /dev/null +++ b/crates/disktree-agent/tests/guide.rs @@ -0,0 +1,53 @@ +//! The prompt's commands, run through a real shell. Kept out of `src`, +//! which may not start processes at all (see `read_only.rs`). + +#![cfg(not(windows))] + +use std::path::Path; + +use disktree_agent::guide::{agent_prompt, claude_code_command}; + +/// What a shell makes of `word`: the arguments `printf` is handed. +fn as_shell_reads(word: &str) -> String { + let output = std::process::Command::new("sh") + .arg("-c") + .arg(format!("printf '%s' {word}")) + .output() + .expect("sh runs"); + String::from_utf8_lossy(&output.stdout).into_owned() +} + +/// A folder name a shell would run comes back from the shell as the same +/// name, and nothing it names is done. +#[test] +fn a_hostile_name_stays_a_name() { + let temp = tempfile::tempdir().expect("tempdir"); + let sentinel = temp.path().join("SENTINEL"); + let hostile = temp.path().join(format!( + "a $(touch {0}) `touch {0}` it's \"quoted\" $HOME\nline", + sentinel.display() + )); + let exe = Path::new("/opt/disk tree/it's/disktree"); + + let prompt = agent_prompt(exe, &hostile); + // Step 1's command: the program, `scan`, then the root. The name holds + // a newline, so the command runs over two lines of the prompt. + let command = prompt + .split_once("1. Run: ") + .and_then(|(_, rest)| rest.split_once(" --json --only overview")) + .map(|(command, _)| command) + .expect("step 1"); + let root = command.split_once(" scan ").expect("a root argument").1; + let program = command.split_once(" scan ").expect("a program").0; + + assert_eq!(as_shell_reads(program), exe.to_string_lossy()); + assert_eq!(as_shell_reads(root), hostile.to_string_lossy()); + assert!(!sentinel.exists(), "the shell ran part of the name"); + + let add = claude_code_command(exe); + let word = add + .strip_prefix("claude mcp add disktree -- ") + .and_then(|rest| rest.strip_suffix(" mcp")) + .expect("the program"); + assert_eq!(as_shell_reads(word), exe.to_string_lossy()); +} diff --git a/crates/disktree-agent/tests/mcp.rs b/crates/disktree-agent/tests/mcp.rs new file mode 100644 index 0000000..a37c21c --- /dev/null +++ b/crates/disktree-agent/tests/mcp.rs @@ -0,0 +1,536 @@ +//! The MCP server, driven a message at a time as a client would. + +use std::fs; + +use disktree_agent::mcp::Server; +use serde_json::{Value, json}; + +/// Send `message`; return what the server wrote back. +fn send(server: &mut Server, message: &Value) -> Vec { + let mut replies = Vec::new(); + server.handle_line(&message.to_string(), &mut |reply| { + replies.push(reply.clone()); + }); + replies +} + +fn request( + server: &mut Server, + id: u64, + method: &str, + params: &Value, +) -> Value { + let mut replies = send( + server, + &json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }), + ); + let reply = replies.pop().expect("a reply"); + assert_eq!(reply["id"], id); + assert_eq!(reply["jsonrpc"], "2.0"); + reply +} + +fn call(server: &mut Server, id: u64, tool: &str, arguments: &Value) -> Value { + let reply = request( + server, + id, + "tools/call", + &json!({ "name": tool, "arguments": arguments }), + ); + reply["result"].clone() +} + +fn scratch() -> (tempfile::TempDir, String) { + let temp = tempfile::tempdir().expect("tempdir"); + for (path, bytes) in [ + ("big/disk.iso", 40_000), + ("big/small.txt", 10), + ("cache/blob", 5_000), + ("loose.bin", 20_000), + ] { + let path = temp.path().join(path); + fs::create_dir_all(path.parent().expect("parent")).expect("mkdir"); + fs::write(path, vec![0_u8; bytes]).expect("write"); + } + let root = dunce::canonicalize(temp.path()) + .expect("canonical") + .to_str() + .expect("utf-8") + .to_owned(); + (temp, root) +} + +#[test] +fn the_handshake_and_tool_list() { + let mut server = Server::new(None); + let reply = request( + &mut server, + 1, + "initialize", + &json!({ + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": { "name": "test", "version": "0" }, + }), + ); + let result = &reply["result"]; + assert_eq!(result["protocolVersion"], "2025-06-18"); + assert_eq!(result["serverInfo"]["name"], "disktree"); + assert!(result["capabilities"]["tools"].is_object()); + assert!( + result["instructions"] + .as_str() + .expect("instructions") + .contains("read-only") + ); + + // An unknown revision gets the newest this server speaks. + let reply = request( + &mut server, + 2, + "initialize", + &json!({ "protocolVersion": "1999-01-01" }), + ); + assert_eq!(reply["result"]["protocolVersion"], "2025-11-25"); + + // Notifications are never answered. + assert!( + send( + &mut server, + &json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }) + ) + .is_empty() + ); + + let reply = request(&mut server, 3, "tools/list", &json!({})); + let tools = reply["result"]["tools"].as_array().expect("tools"); + let names: Vec<&str> = tools + .iter() + .map(|tool| tool["name"].as_str().expect("name")) + .collect(); + assert_eq!( + names, + [ + "scan_path", + "largest_files", + "largest_dirs", + "list_files", + "reclaimable_space", + "disk_summary" + ] + ); + for tool in tools { + assert_eq!( + tool["annotations"]["readOnlyHint"], true, + "{}", + tool["name"] + ); + assert_eq!(tool["annotations"]["destructiveHint"], false); + assert_eq!(tool["inputSchema"]["type"], "object"); + assert!( + tool["description"] + .as_str() + .expect("description") + .contains("read-only") + ); + } + let list_files = &tools[3]["inputSchema"]["properties"]; + for filter in [ + "min_size", + "extensions", + "older_than", + "name", + "category", + "reclaim", + ] { + assert!( + list_files.get(filter).is_some(), + "list_files lacks {filter}" + ); + } + assert!( + tools[2]["inputSchema"]["properties"] + .get("extensions") + .is_none() + ); + + let reply = request(&mut server, 4, "ping", &json!({})); + assert_eq!(reply["result"], json!({})); +} + +#[test] +fn scan_path_returns_the_cli_report() { + let (temp, root) = scratch(); + let mut server = Server::new(None); + let result = call( + &mut server, + 7, + "scan_path", + &json!({ "path": root, "apparent_size": true, "top": 2 }), + ); + assert_eq!(result["isError"], false); + let report = &result["structuredContent"]; + let text: Value = serde_json::from_str( + result["content"][0]["text"].as_str().expect("text"), + ) + .expect("the text is the same JSON"); + assert_eq!(&text, report); + assert_eq!(report["schema_version"], 1); + assert_eq!(report["total"]["bytes"], 65_010); + assert_eq!(report["largest_files"][0]["name"], "disk.iso"); + assert_eq!(report["largest_dirs"][0]["name"], "big"); + assert_eq!(report["largest_files"].as_array().expect("files").len(), 2); + + // The command line prints the same lists for the same question. + let args: Vec = + [root.as_str(), "--json", "-a", "-n", "2"] + .iter() + .map(Into::into) + .collect(); + let mut out = Vec::new(); + assert_eq!( + disktree_agent::cli::run("scan", &args, &mut out, &mut Vec::new()), + 0 + ); + let printed: Value = serde_json::from_slice(&out).expect("json"); + for key in [ + "root", + "total", + "largest_dirs", + "largest_files", + "options", + "filters", + ] { + assert_eq!(printed[key], report[key], "{key}"); + } + drop(temp); +} + +#[test] +fn each_tool_returns_its_sections_and_filters_apply() { + let (_temp, root) = scratch(); + let mut server = Server::new(None); + let files = call( + &mut server, + 1, + "largest_files", + &json!({ "path": root, "apparent_size": true }), + ); + let report = &files["structuredContent"]; + assert!(report.get("largest_dirs").is_none()); + assert_eq!(report["largest_files"][0]["name"], "disk.iso"); + + let dirs = call( + &mut server, + 2, + "largest_dirs", + &json!({ "path": root, "depth": 1 }), + ); + let report = &dirs["structuredContent"]; + assert!(report.get("largest_files").is_none()); + assert_eq!(report["largest_dirs"][0]["name"], "big"); + + let listed = call( + &mut server, + 3, + "list_files", + &json!({ "path": root, "apparent_size": true, "min_size": "10K", "extensions": "iso,bin" }), + ); + let report = &listed["structuredContent"]; + let names: Vec<&str> = report["largest_files"] + .as_array() + .expect("files") + .iter() + .map(|file| file["name"].as_str().expect("name")) + .collect(); + assert_eq!(names, ["disk.iso", "loose.bin"]); + assert_eq!(report["options"]["top"], 100); + assert_eq!(report["matched"]["file_bytes"], 60_000); + + let summary = + call(&mut server, 4, "disk_summary", &json!({ "path": root })); + assert!( + summary["structuredContent"]["capacity"]["bytes"] + .as_u64() + .expect("capacity") + > 0 + ); +} + +#[test] +fn a_repeated_question_is_answered_from_the_cache() { + let (temp, root) = scratch(); + let mut server = Server::new(None); + let first = call(&mut server, 1, "scan_path", &json!({ "path": root })); + fs::write(temp.path().join("late.bin"), vec![0_u8; 100_000]) + .expect("write"); + let cached = + call(&mut server, 2, "largest_files", &json!({ "path": root })); + assert_eq!( + first["structuredContent"]["scanned_at"], + cached["structuredContent"]["scanned_at"] + ); + assert_ne!( + cached["structuredContent"]["largest_files"][0]["name"], + "late.bin" + ); + let fresh = call( + &mut server, + 3, + "largest_files", + &json!({ "path": root, "refresh": true }), + ); + assert_eq!( + fresh["structuredContent"]["largest_files"][0]["name"], + "late.bin" + ); +} + +#[test] +fn mistakes_are_reported_not_fatal() { + let (temp, root) = scratch(); + // Absolute on every platform, unlike `/no/such`, which Windows reads as + // relative to the current drive. + let missing = temp + .path() + .join("missing") + .to_str() + .expect("utf-8") + .to_owned(); + let mut server = Server::new(None); + for (tool, arguments, needle) in [ + ("scan_path", json!({}), "path is required"), + ("scan_path", json!({ "path": "relative/dir" }), "absolute"), + ("scan_path", json!({ "path": missing }), "cannot read"), + ("scan_path", json!({ "path": root, "top": 0 }), "top"), + ( + "scan_path", + json!({ "path": root, "min_size": "huge" }), + "size", + ), + ( + "list_files", + json!({ "path": root, "older_than": "soon" }), + "age", + ), + ( + "largest_dirs", + json!({ "path": root, "extensions": ["iso"] }), + "takes no argument", + ), + ( + "largest_dirs", + json!({ "path": root, "category": "junk" }), + "category", + ), + ( + "largest_files", + json!({ "path": root, "delete": true }), + "takes no argument", + ), + ] { + let result = call(&mut server, 9, tool, &arguments); + assert_eq!(result["isError"], true, "{tool} {arguments}"); + let text = result["content"][0]["text"].as_str().expect("text"); + assert!(text.contains(needle), "{tool} {arguments}: {text}"); + } + + let reply = request( + &mut server, + 10, + "tools/call", + &json!({ "name": "delete_files", "arguments": {} }), + ); + assert_eq!(reply["error"]["code"], -32602); + let reply = request(&mut server, 11, "resources/list", &json!({})); + assert_eq!(reply["error"]["code"], -32601); + + let mut replies = Vec::new(); + server.handle_line("{not json", &mut |reply| replies.push(reply.clone())); + assert_eq!(replies[0]["error"]["code"], -32700); + assert_eq!(replies[0]["id"], Value::Null); +} + +#[test] +fn no_tool_can_change_files() { + let mut server = Server::new(None); + let reply = request(&mut server, 1, "tools/list", &json!({})); + for tool in reply["result"]["tools"].as_array().expect("tools") { + let name = tool["name"].as_str().expect("name"); + for verb in [ + "delete", "remove", "trash", "move", "rename", "write", "clean", + ] { + assert!(!name.contains(verb), "{name}"); + } + } +} + +#[test] +fn an_expired_scan_is_let_go_while_idle() { + use std::sync::mpsc; + use std::time::Duration; + + let (_temp, root) = scratch(); + let (sender, inbox) = mpsc::channel(); + let (replies, received) = mpsc::channel(); + let server = std::thread::spawn(move || { + let mut server = + Server::new(Some(inbox)).with_cache_for(Duration::from_millis(200)); + server.run(&mut |reply| { + let _ = replies.send(reply.clone()); + }); + server + }); + let call = json!({ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": { "name": "scan_path", "arguments": { "path": root } }, + }); + sender.send(call.to_string()).expect("send"); + let reply = received + .recv_timeout(Duration::from_secs(30)) + .expect("reply"); + assert_eq!(reply["result"]["isError"], false); + // Nothing more is asked; the scan expires on its own. + std::thread::sleep(Duration::from_millis(600)); + drop(sender); + let server = server.join().expect("server"); + assert!(!server.has_cached_scan()); +} + +#[test] +fn reclaimable_space_returns_only_the_reclaimable_summary() { + let (_temp, root) = scratch(); + let mut server = Server::new(None); + let result = call( + &mut server, + 1, + "reclaimable_space", + &json!({ "path": root, "apparent_size": true }), + ); + assert_eq!(result["isError"], false); + let report = &result["structuredContent"]; + let reclaimable = &report["reclaimable"]; + assert_eq!(reclaimable["bytes"], 5_000); + assert_eq!(reclaimable["by_reason"][0]["reason"], "regenerable"); + assert_eq!(reclaimable["largest"][0]["name"], "cache"); + assert!(report["volume"]["available"]["bytes"].as_u64().is_some()); + for absent in ["breakdown", "largest_dirs", "largest_files"] { + assert!(report.get(absent).is_none(), "{absent}"); + } + // It describes the whole tree, so listing filters are refused. + let result = call( + &mut server, + 2, + "reclaimable_space", + &json!({ "path": root, "min_size": "1G" }), + ); + assert_eq!(result["isError"], true); +} + +/// A cancellation for a request still waiting behind a running scan is +/// kept, and that request is skipped with no reply. +#[test] +fn a_queued_request_can_be_cancelled() { + use std::sync::mpsc; + + let (_temp, root) = scratch(); + let (sender, inbox) = mpsc::channel(); + let scan = |id: u64| { + json!({ + "jsonrpc": "2.0", "id": id, "method": "tools/call", + "params": { "name": "scan_path", "arguments": { "path": root } }, + }) + .to_string() + }; + // All of it is waiting before the first scan starts: the scan reads + // the rest while it runs. + sender.send(scan(1)).expect("send"); + sender.send(scan(2)).expect("send"); + sender + .send( + json!({ + "jsonrpc": "2.0", "method": "notifications/cancelled", + "params": { "requestId": 2 }, + }) + .to_string(), + ) + .expect("send"); + sender + .send( + json!({ "jsonrpc": "2.0", "id": 3, "method": "ping" }).to_string(), + ) + .expect("send"); + drop(sender); + let mut replies = Vec::new(); + Server::new(Some(inbox)).run(&mut |reply| replies.push(reply.clone())); + let ids: Vec<&Value> = replies.iter().map(|reply| &reply["id"]).collect(); + assert_eq!(ids, [&json!(1), &json!(3)]); +} + +/// A cancellation that arrives before its request means the request is +/// never answered. +#[test] +fn a_request_cancelled_in_advance_is_not_answered() { + let mut server = Server::new(None); + assert!( + send( + &mut server, + &json!({ + "jsonrpc": "2.0", "method": "notifications/cancelled", + "params": { "requestId": "a" }, + }) + ) + .is_empty() + ); + assert!( + send( + &mut server, + &json!({ "jsonrpc": "2.0", "id": "a", "method": "ping" }) + ) + .is_empty() + ); + // Only once: the next request with that id is answered. + assert_eq!( + send( + &mut server, + &json!({ "jsonrpc": "2.0", "id": "a", "method": "ping" }) + ) + .len(), + 1 + ); +} + +#[test] +fn malformed_messages_are_invalid_requests() { + let mut server = Server::new(None); + for (message, id) in [ + (json!({}), Value::Null), + ( + json!({ "jsonrpc": "2.0", "id": true, "method": "ping" }), + Value::Null, + ), + ( + json!({ "jsonrpc": "2.0", "id": [1], "method": "ping" }), + Value::Null, + ), + ( + json!({ "jsonrpc": "1.0", "id": 4, "method": "ping" }), + json!(4), + ), + (json!({ "id": 5, "method": "ping" }), json!(5)), + (json!({ "jsonrpc": "2.0", "id": 6 }), json!(6)), + ] { + let replies = send(&mut server, &message); + assert_eq!(replies.len(), 1, "{message}"); + assert_eq!(replies[0]["error"]["code"], -32600, "{message}"); + assert_eq!(replies[0]["id"], id, "{message}"); + } + // A response to something the server never asked is ignored. + assert!( + send( + &mut server, + &json!({ "jsonrpc": "2.0", "id": 7, "result": {} }) + ) + .is_empty() + ); +} diff --git a/crates/disktree-agent/tests/read_only.rs b/crates/disktree-agent/tests/read_only.rs new file mode 100644 index 0000000..f37a346 --- /dev/null +++ b/crates/disktree-agent/tests/read_only.rs @@ -0,0 +1,70 @@ +//! The headless front ends must never change a file. This reads the crate's +//! own source and fails if anything that could delete, move, trash, rename +//! or write a file appears in it, or if it reaches for the window's removal +//! and export code. + +use std::path::Path; + +/// What the source must never contain. The test lives outside `src`, so +/// spelling them out here does not trip it. +const FORBIDDEN: [&str; 20] = [ + "removal", + "export::", + "trash::", + "remove_file", + "remove_dir", + "rename(", + "fs::write", + "fs::copy", + "File::create", + "OpenOptions", + "set_permissions", + "set_len", + "hard_link", + "symlink(", + "create_dir", + "Command::new", + "process::Command", + "unlink", + "rmdir", + "truncate", +]; + +#[test] +fn the_crate_source_cannot_touch_files() { + let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut checked = 0; + for entry in std::fs::read_dir(&src).expect("src") { + let path = entry.expect("entry").path(); + if path.extension().is_none_or(|extension| extension != "rs") { + continue; + } + let text = std::fs::read_to_string(&path).expect("source"); + for (number, line) in text.lines().enumerate() { + // Doc comments may name what the crate refuses to do. + let code = line.trim_start(); + if code.starts_with("//") { + continue; + } + for word in FORBIDDEN { + assert!( + !code.contains(word), + "{}:{}: `{word}` has no place in a read-only crate: {line}", + path.display(), + number + 1, + ); + } + } + checked += 1; + } + assert!(checked >= 4, "found only {checked} source files"); +} + +#[test] +fn the_crate_depends_on_nothing_that_writes() { + let manifest = std::fs::read_to_string( + Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml"), + ) + .expect("manifest"); + assert!(!manifest.contains("trash"), "no trash crate"); +} diff --git a/crates/disktree-agent/tests/report.rs b/crates/disktree-agent/tests/report.rs new file mode 100644 index 0000000..a8722fa --- /dev/null +++ b/crates/disktree-agent/tests/report.rs @@ -0,0 +1,493 @@ +//! Reports made from real scans of a scratch tree. + +use std::fs; +use std::path::Path; +use std::time::{Duration, SystemTime}; + +use disktree_agent::query::{ + OlderThan, Query, Sections, normalize_extension, parse_category, + parse_reclaim, +}; +use disktree_agent::report::{self, Scanned}; +use serde_json::Value; + +fn write(root: &Path, relative: &str, bytes: usize) { + let path = root.join(relative); + fs::create_dir_all(path.parent().expect("parent")).expect("mkdir"); + fs::write(&path, vec![b'x'; bytes]).expect("write"); +} + +fn age(root: &Path, relative: &str, days: u64) { + let file = fs::File::options() + .write(true) + .open(root.join(relative)) + .expect("open"); + let then = SystemTime::now() - Duration::from_secs(days * 86_400); + file.set_modified(then).expect("set mtime"); +} + +/// A home-like tree with a cache, a build dir, media and documents. +fn tree() -> (tempfile::TempDir, Scanned) { + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path(); + write(root, "Movies/film.mkv", 50_000); + write(root, "Movies/trailer.mp4", 8_000); + write(root, "Downloads/ubuntu.iso", 30_000); + write(root, "Downloads/notes.txt", 100); + write(root, ".cache/pip/wheels/a.whl", 12_000); + write(root, ".cache/pip/http/b.bin", 3_000); + write(root, "src/app/target/debug/app", 20_000); + write(root, "src/app/Cargo.toml", 200); + write(root, "src/app/node_modules/x/index.js", 900); + age(root, "Downloads/ubuntu.iso", 400); + age(root, "Movies/film.mkv", 10); + let canonical = dunce::canonicalize(root).expect("canonical"); + let mut query = Query::new(canonical.clone()); + query.settings.apparent_size = true; + let scanned = + report::scan_quietly(&canonical, &query.settings).expect("scan"); + (temp, scanned) +} + +fn base(scanned: &Scanned) -> Query { + let mut query = Query::new(scanned.root.clone()); + query.settings = scanned.settings.clone(); + query +} + +fn make(scanned: &Scanned, query: &Query) -> Value { + report::report(scanned, query, chrono::Utc::now().timestamp()) + .expect("report") +} + +fn names(report: &Value, key: &str) -> Vec { + report[key] + .as_array() + .expect(key) + .iter() + .map(|entry| entry["name"].as_str().expect("name").to_owned()) + .collect() +} + +#[test] +fn the_report_has_the_documented_shape() { + let (_temp, scanned) = tree(); + let report = make(&scanned, &base(&scanned)); + assert_eq!(report["schema_version"], 1); + assert_eq!(report["generator"]["name"], "disktree"); + assert_eq!(report["root"], scanned.root.to_string_lossy().as_ref()); + assert!(report["scanned_at"].as_str().expect("time").ends_with('Z')); + assert_eq!(report["options"]["size_mode"], "apparent"); + assert_eq!(report["options"]["one_filesystem"], true); + assert_eq!(report["total"]["bytes"], 124_200); + assert_eq!(report["total"]["size"], "121 KiB"); + assert_eq!(report["total"]["files"], 9); + assert_eq!(report["errors"]["unreadable_paths"], 0); + + let files = report["largest_files"].as_array().expect("files"); + assert_eq!(files.len(), 9); + let first = &files[0]; + assert_eq!(first["name"], "film.mkv"); + assert_eq!(first["type"], "file"); + assert_eq!(first["bytes"], 50_000); + assert_eq!(first["size"], "49 KiB"); + assert_eq!(first["depth"], 2); + // Compared as paths, so the separator is the platform's own. + assert_eq!( + Path::new(first["path"].as_str().expect("path")), + scanned.root.join("Movies").join("film.mkv") + ); + assert!(first["modified"].as_str().expect("modified").ends_with('Z')); + assert_eq!(first["age_days"], 10); + assert_eq!(first["percent"], 40.3); + assert!(first.get("files").is_none(), "files only on directories"); + + let dirs = report["largest_dirs"].as_array().expect("dirs"); + assert_eq!(dirs[0]["name"], "Movies"); + assert_eq!(dirs[0]["type"], "directory"); + assert_eq!(dirs[0]["files"], 2); + assert_eq!(dirs[0]["unreadable"], false); + // The root itself is never listed. + assert!(dirs.iter().all(|dir| dir["depth"].as_u64() >= Some(1))); + // Biggest first. + let sizes: Vec = dirs + .iter() + .map(|dir| dir["bytes"].as_u64().expect("bytes")) + .collect(); + assert!(sizes.windows(2).all(|pair| pair[0] >= pair[1])); +} + +#[test] +fn top_depth_and_sections_shape_the_lists() { + let (_temp, scanned) = tree(); + let mut query = base(&scanned); + query.top = 2; + query.depth = Some(1); + let report = make(&scanned, &query); + assert_eq!(names(&report, "largest_dirs"), ["Movies", "Downloads"]); + assert!( + report["largest_files"] + .as_array() + .expect("files") + .is_empty() + ); + assert_eq!(report["matched"]["directories"], 4); + + query.sections = Sections::DIRS; + let report = make(&scanned, &query); + assert!(report.get("largest_files").is_none()); + assert!(report["matched"].get("files").is_none()); + + query.sections = Sections::FILES; + query.depth = Some(2); + let report = make(&scanned, &query); + assert!(report.get("largest_dirs").is_none()); + assert_eq!(names(&report, "largest_files"), ["film.mkv", "ubuntu.iso"]); +} + +#[test] +fn filters_narrow_files() { + let (_temp, scanned) = tree(); + let mut query = base(&scanned); + query.sections = Sections::FILES; + query.filters.min_size = Some(10_000); + let report = make(&scanned, &query); + assert_eq!( + names(&report, "largest_files"), + ["film.mkv", "ubuntu.iso", "app", "a.whl"] + ); + assert_eq!(report["matched"]["files"], 4); + assert_eq!(report["matched"]["file_bytes"], 112_000); + assert_eq!(report["filters"]["min_size_bytes"], 10_000); + + let mut query = base(&scanned); + query.sections = Sections::FILES; + query.filters.extensions = ["ISO", ".mp4"] + .into_iter() + .filter_map(normalize_extension) + .collect(); + assert_eq!( + names(&make(&scanned, &query), "largest_files"), + ["ubuntu.iso", "trailer.mp4"] + ); + + let mut query = base(&scanned); + query.sections = Sections::FILES; + query.filters.older_than = Some(OlderThan::Age(365 * 86_400)); + let report = make(&scanned, &query); + assert_eq!(names(&report, "largest_files"), ["ubuntu.iso"]); + assert!(report["filters"]["older_than"].as_str().is_some()); + + let mut query = base(&scanned); + query.sections = Sections::FILES; + query.filters.names = vec!["*.t?t".into()]; + assert_eq!( + names(&make(&scanned, &query), "largest_files"), + ["notes.txt"] + ); +} + +#[test] +fn category_and_reclaim_list_the_outermost_folder() { + let (_temp, scanned) = tree(); + let mut query = base(&scanned); + query.sections = Sections::DIRS; + query.filters.category = Some(parse_category("cache").expect("cache")); + let report = make(&scanned, &query); + // `.cache` and `node_modules`, not `.cache/pip` and everything inside. + assert_eq!(names(&report, "largest_dirs"), [".cache", "node_modules"]); + assert_eq!(report["largest_dirs"][0]["category"], "cache"); + + query.filters.category = None; + query.filters.reclaim = Some(parse_reclaim("any").expect("any")); + let report = make(&scanned, &query); + let listed = names(&report, "largest_dirs"); + assert!(listed.contains(&"target".to_owned()), "{listed:?}"); + assert!(!listed.contains(&"debug".to_owned()), "{listed:?}"); + assert!( + report["largest_dirs"] + .as_array() + .expect("dirs") + .iter() + .all(|dir| dir["reclaim"].is_string()) + ); +} + +#[test] +fn excluded_paths_are_not_scanned() { + let (temp, _) = tree(); + let root = dunce::canonicalize(temp.path()).expect("canonical"); + let mut query = Query::new(root.clone()); + query.settings.apparent_size = true; + query.settings.exclude = vec!["Movies".into(), "*.iso".into()]; + let scanned = report::scan_quietly(&root, &query.settings).expect("scan"); + let report = make(&scanned, &query); + assert_eq!(report["total"]["bytes"], 124_200 - 58_000 - 30_000); + assert_eq!(report["options"]["exclude"][0], "Movies"); + assert!( + !names(&report, "largest_files").contains(&"ubuntu.iso".to_owned()) + ); +} + +#[test] +fn text_output_lists_the_same_entries() { + let (_temp, scanned) = tree(); + let report = make(&scanned, &base(&scanned)); + let text = report::text(&report); + assert!(text.contains("Largest directories")); + assert!(text.contains("film.mkv")); + assert!(text.contains("121 KiB apparent")); +} + +#[test] +fn the_summary_describes_the_volume() { + let temp = tempfile::tempdir().expect("tempdir"); + let summary = report::summary(temp.path()).expect("summary"); + assert_eq!(summary["schema_version"], 1); + let capacity = summary["capacity"]["bytes"].as_u64().expect("capacity"); + assert!(capacity > 0); + assert!( + summary["available"]["bytes"].as_u64().expect("available") <= capacity + ); + assert!(summary["used_percent"].as_f64().is_some()); +} + +#[test] +fn the_cli_prints_the_same_json() { + let (temp, scanned) = tree(); + let path = temp.path().to_str().expect("utf-8").to_owned(); + let args: Vec = + [path.as_str(), "--json", "-a", "--top", "3"] + .iter() + .map(Into::into) + .collect(); + let mut out = Vec::new(); + let mut err = Vec::new(); + let status = disktree_agent::cli::run("scan", &args, &mut out, &mut err); + assert_eq!(status, 0, "{}", String::from_utf8_lossy(&err)); + let printed: Value = serde_json::from_slice(&out).expect("json"); + let mut query = base(&scanned); + query.top = 3; + let expected = make(&scanned, &query); + for key in [ + "schema_version", + "root", + "total", + "largest_dirs", + "largest_files", + "matched", + ] { + assert_eq!(printed[key], expected[key], "{key}"); + } +} + +fn sum(list: &Value) -> u64 { + list.as_array() + .expect("list") + .iter() + .map(|entry| entry["bytes"].as_u64().expect("bytes")) + .sum() +} + +#[test] +fn every_breakdown_adds_up_to_the_total() { + let (_temp, scanned) = tree(); + let mut query = base(&scanned); + query.top = 2; + let report = make(&scanned, &query); + let total = report["total"]["bytes"].as_u64().expect("total"); + let breakdown = &report["breakdown"]; + assert_eq!( + sum(&breakdown["children"]) + + breakdown["children_rest"]["bytes"].as_u64().expect("rest"), + total + ); + assert_eq!(breakdown["children"].as_array().expect("children").len(), 2); + assert_eq!(breakdown["children_rest"]["count"], 2); + assert_eq!(sum(&breakdown["by_category"]), total); + assert_eq!(sum(&breakdown["by_age"]), total); + let files: u64 = breakdown["by_category"] + .as_array() + .expect("categories") + .iter() + .map(|row| row["files"].as_u64().expect("files")) + .sum(); + assert_eq!(files, 9); + + // Ages: film.mkv is 10 days old, ubuntu.iso 400; the rest are new. + let ages = breakdown["by_age"].as_array().expect("ages"); + assert_eq!(ages[0]["age"], "under_30_days"); + assert_eq!(ages[0]["bytes"], 124_200 - 30_000); + assert_eq!(ages[3]["age"], "over_1_year"); + assert_eq!(ages[3]["bytes"], 30_000); + assert_eq!(ages[3]["percent"], 24.2); + + let reclaimable = &report["reclaimable"]; + assert_eq!( + sum(&reclaimable["by_reason"]), + reclaimable["bytes"].as_u64().expect("reclaimable") + ); + // .cache (15 000) and target (20 000), outermost only, so .cache/pip + // is not counted a second time. node_modules has no package.json + // beside it here, so it is not called reinstallable. + assert_eq!(reclaimable["bytes"], 35_000); + assert_eq!(reclaimable["places"], 2); + assert!( + reclaimable["by_reason"][0]["description"] + .as_str() + .is_some_and(|text| !text.is_empty()) + ); + assert_eq!(reclaimable["largest"][0]["name"], "target"); + // Apparent sizes are not what freeing gives back: no projection. + assert!(reclaimable["available_after"].is_null()); + assert!(report["volume"]["root_percent_of_used"].as_f64().is_some()); +} + +/// On-disk sizes on one volume are what freeing gives back, so the +/// projection is the volume's available space plus the reclaimable bytes. +#[test] +fn the_free_space_projection_uses_on_disk_bytes_only() { + let (temp, _) = tree(); + let root = dunce::canonicalize(temp.path()).expect("canonical"); + let query = Query::new(root.clone()); + let scanned = + report::scan_quietly(&root, &query.settings).expect("on-disk scan"); + let report = make(&scanned, &query); + let reclaimable = &report["reclaimable"]; + let bytes = reclaimable["bytes"].as_u64().expect("reclaimable"); + let available = report["volume"]["available"]["bytes"] + .as_u64() + .expect("available"); + let after = reclaimable["available_after"]["bytes"] + .as_u64() + .expect("a projection"); + // The volume may change between the two reads by the scan's own + // neighbours; allow a little drift. + assert!(after.abs_diff(available + bytes) < 64 * 1024 * 1024); + + for (apparent, follow) in [(true, false), (false, true)] { + let mut query = Query::new(root.clone()); + query.settings.apparent_size = apparent; + query.settings.follow_links = follow; + let scanned = + report::scan_quietly(&root, &query.settings).expect("scan"); + assert!( + make(&scanned, &query)["reclaimable"]["available_after"].is_null(), + "apparent {apparent}, follow {follow}" + ); + } +} + +/// The top-level children are chosen by bytes even when the tree was +/// ranked by file count, as the window's is in its Files mode. +#[test] +fn top_children_are_the_largest_by_bytes_whatever_the_ranking() { + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path(); + write(root, "big/one.bin", 50_000); + for index in 0..20 { + write(root, &format!("many/{index}.txt"), 10); + } + let canonical = dunce::canonicalize(root).expect("canonical"); + let mut settings = Query::new(canonical.clone()).settings; + settings.apparent_size = true; + let options = disktree_core::scan::ScanOptions { + metric: disktree_core::tree::Metric::Files, + ..settings.options().expect("options") + }; + let tree = disktree_core::scan::scan(&canonical, options).expect("scan"); + assert_eq!(&*tree.children[0].name, "many", "ranked by files"); + let scanned = Scanned { + root: canonical, + tree: std::sync::Arc::new(tree), + settings, + scanned_at: chrono::Utc::now().timestamp(), + elapsed: std::time::Duration::ZERO, + errors: 0, + error_examples: Vec::new(), + }; + let mut query = base(&scanned); + query.top = 1; + let report = make(&scanned, &query); + assert_eq!(report["breakdown"]["children"][0]["name"], "big"); + assert_eq!(report["breakdown"]["children_rest"]["count"], 1); +} + +#[test] +fn listed_directories_say_which_listed_folder_holds_them() { + let (_temp, scanned) = tree(); + let mut query = base(&scanned); + query.sections = Sections::DIRS; + query.top = 50; + let report = make(&scanned, &query); + let dirs = report["largest_dirs"].as_array().expect("dirs"); + let find = |name: &str| { + dirs.iter() + .find(|dir| dir["name"] == name) + .unwrap_or_else(|| panic!("{name} listed")) + }; + assert_eq!(find("Movies")["nested_in"], Value::Null); + let src = find("src")["path"].as_str().expect("path").to_owned(); + assert_eq!(find("app")["nested_in"], src.as_str()); + let app = find("app")["path"].as_str().expect("path").to_owned(); + assert_eq!(find("target")["nested_in"], app.as_str()); +} + +#[test] +fn a_folder_is_not_called_a_cache_for_holding_one() { + // `~/Library` holds `Caches`; the window colours it as a cache, but + // counting all of it as one would be wrong. + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path(); + write(root, "Library/Caches/x/blob", 1_000); + write(root, "Library/Application Support/app/data", 9_000); + let canonical = dunce::canonicalize(root).expect("canonical"); + let mut query = Query::new(canonical.clone()); + query.settings.apparent_size = true; + let scanned = + report::scan_quietly(&canonical, &query.settings).expect("scan"); + let report = make(&scanned, &query); + let category_bytes = |name: &str| { + report["breakdown"]["by_category"] + .as_array() + .expect("categories") + .iter() + .find(|row| row["category"] == name) + .map_or(0, |row| row["bytes"].as_u64().expect("bytes")) + }; + assert_eq!(category_bytes("cache"), 1_000); + assert_eq!(category_bytes("other"), 9_000); + assert_eq!(report["breakdown"]["children"][0]["category"], "other"); + + query.sections = Sections::DIRS; + query.filters.category = Some(parse_category("cache").expect("cache")); + assert_eq!(names(&make(&scanned, &query), "largest_dirs"), ["Caches"]); +} + +#[test] +fn sections_are_chosen() { + let (_temp, scanned) = tree(); + let mut query = base(&scanned); + query.sections = Sections::RECLAIM; + let report = make(&scanned, &query); + assert!(report.get("reclaimable").is_some()); + assert!(report.get("volume").is_some()); + for absent in ["breakdown", "largest_dirs", "largest_files"] { + assert!(report.get(absent).is_none(), "{absent}"); + } + query.sections = Sections::FILES; + let report = make(&scanned, &query); + for absent in ["breakdown", "reclaimable", "volume", "largest_dirs"] { + assert!(report.get(absent).is_none(), "{absent}"); + } + assert_eq!( + Sections::parse("overview, files").expect("parses"), + Sections { + overview: true, + files: true, + ..Sections::NONE + } + ); + assert!(Sections::parse("").is_err()); + assert!(Sections::parse("dirs,bogus").is_err()); +} diff --git a/crates/disktree-app/Cargo.toml b/crates/disktree-app/Cargo.toml index 4bc758a..2578480 100644 --- a/crates/disktree-app/Cargo.toml +++ b/crates/disktree-app/Cargo.toml @@ -16,6 +16,7 @@ workspace = true [dependencies] gpui-kit = { version = "=0.6.6", default-features = false, features = ["assets"] } gpui-omarchy = "0.1.3" +disktree-agent = { workspace = true } disktree-core = { workspace = true } anyhow = { workspace = true } dunce = "1.0.5" diff --git a/crates/disktree-app/src/app_menu.rs b/crates/disktree-app/src/app_menu.rs index faee476..243f188 100644 --- a/crates/disktree-app/src/app_menu.rs +++ b/crates/disktree-app/src/app_menu.rs @@ -39,6 +39,12 @@ mod menu_actions { GoBack, /// Forward again, after going back. GoForward, + /// Save the scan as a JSON report. + ExportReport, + /// Explain how to drive disktree from an AI agent. + AgentGuide, + /// Show the version and where disktree comes from. + About, ] ); } @@ -60,8 +66,9 @@ pub fn install(cx: &mut App) { let _ = window.update(cx, |_, window, _| window.remove_window()); } }); - // `Rescan`, `OpenFolder`, `ShowInFinder` and the history are handled by - // the window, which owns the scan and the selection; see `views::root`. + // `Rescan`, `OpenFolder`, `ShowInFinder`, `ExportReport`, `AgentGuide`, + // `About` and the history are handled by the window, which owns the + // scan and the selection; see `views::root`. if !cfg!(target_os = "macos") { cx.bind_keys([ KeyBinding::new("ctrl-q", Quit, None), @@ -70,6 +77,7 @@ pub fn install(cx: &mut App) { KeyBinding::new("ctrl-r", Rescan, None), KeyBinding::new("f5", Rescan, None), KeyBinding::new("ctrl-shift-r", ShowInFinder, None), + KeyBinding::new("ctrl-e", ExportReport, None), ]); return; } @@ -86,12 +94,15 @@ pub fn install(cx: &mut App) { // ⌘R is taken by Rescan, as in a browser; Xcode uses ⌘⇧R-like // chords for its reveals, and `o` does the same without a modifier. KeyBinding::new("cmd-shift-r", ShowInFinder, None), + KeyBinding::new("cmd-e", ExportReport, None), // Finder's and Safari's back and forward; alt-arrows work too. KeyBinding::new("cmd-[", GoBack, None), KeyBinding::new("cmd-]", GoForward, None), ]); cx.set_menus([ Menu::new("disktree").items([ + MenuItem::action("About disktree", About), + MenuItem::separator(), MenuItem::os_submenu("Services", SystemMenuType::Services), MenuItem::separator(), MenuItem::action("Hide disktree", Hide), @@ -106,6 +117,9 @@ pub fn install(cx: &mut App) { MenuItem::separator(), MenuItem::action("Rescan", Rescan), MenuItem::separator(), + MenuItem::action("Export Report as JSON\u{2026}", ExportReport), + MenuItem::action("Use with an AI Agent\u{2026}", AgentGuide), + MenuItem::separator(), MenuItem::action("Close Window", CloseWindow), ]), Menu::new("Go").items([ diff --git a/crates/disktree-app/src/main.rs b/crates/disktree-app/src/main.rs index 92fa2f7..d3c96e4 100644 --- a/crates/disktree-app/src/main.rs +++ b/crates/disktree-app/src/main.rs @@ -51,6 +51,9 @@ const USAGE: &str = "\ disktree — a treemap of what is using your disk usage: disktree [OPTIONS] [PATH] + disktree scan [OPTIONS] [PATH] headless, read-only report (--json) + disktree summary [PATH] free space on PATH's volume + disktree mcp MCP server on stdio for AI agents arguments: PATH directory to scan (default: the home directory) @@ -78,19 +81,30 @@ options: --thread-system-cpu-percent N best-effort host CPU budget; 0 disables it (80) --metric files rank by file count instead of bytes + -V, --version print the version -h, --help show this help "; fn main() -> Result<()> { #[cfg(windows)] console::attach(); - let outcome = run(); + // `scan`, `summary` and `mcp` run headless and read-only, and are + // handed over before anything else: no settings are loaded or saved, + // and the cmux re-spawn in `run`, which sends stdout to /dev/null, is + // never reached. A directory named `scan` opens as `./scan`. + let args: Vec = std::env::args_os().skip(1).collect(); + if let Some(status) = disktree_agent::cli::dispatch(&args) { + #[cfg(windows)] + console::detach(); + std::process::exit(status); + } + let outcome = run(args); #[cfg(windows)] console::detach(); outcome } -fn run() -> Result<()> { +fn run(args: Vec) -> Result<()> { let saved = power::settings_path() .map_or_else( || Ok(power::PowerEfficiency::default()), @@ -100,7 +114,7 @@ fn run() -> Result<()> { eprintln!("Cannot load Power Efficiency: {error}; using Balanced"); power::PowerEfficiency::default() }); - let args = parse_args_with_power(std::env::args_os().skip(1), saved)?; + let args = parse_args_with_power(args.into_iter(), saved)?; // When the app executable is reached through the command-line symlink, // cmux sends SIGTERM to its foreground process group as AppKit takes @@ -236,6 +250,10 @@ fn parse_args_with_power( println!("{USAGE}"); std::process::exit(0); } + "-V" | "--version" => { + println!("disktree {}", env!("CARGO_PKG_VERSION")); + std::process::exit(0); + } "-a" | "--apparent-size" => options.apparent_size = true, "-l" | "--follow-links" => options.follow_links = true, "-H" | "--no-hidden" => options.include_hidden = false, diff --git a/crates/disktree-app/src/state.rs b/crates/disktree-app/src/state.rs index ee351f3..4963fea 100644 --- a/crates/disktree-app/src/state.rs +++ b/crates/disktree-app/src/state.rs @@ -8,7 +8,7 @@ use std::cell::Cell; use std::path::{Path, PathBuf}; use std::rc::Rc; use std::sync::Arc; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, SystemTime}; use disktree_core::access::file_table_readable; use disktree_core::filter::{Keep, Matches, filter}; @@ -99,6 +99,33 @@ pub fn panel_width(pointer_x: f32, viewport: f32, rem: f32) -> f32 { width.clamp(PANEL_MIN_REMS, max) } +/// Entries per list in an exported report: more than a terminal wants, +/// since a file is read at leisure or handed to an agent. +const EXPORTED_TOP: usize = 100; + +/// Unreadable paths quoted in an exported report, as the command line does. +const EXPORTED_ERRORS: usize = 10; + +/// What the AI agent panel can copy. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AgentCopy { + /// A prompt asking an agent to plan a cleanup with disktree. + Prompt, + /// The `mcpServers` block for Claude Desktop, Cursor and the like. + McpConfig, + /// The command that adds disktree to Claude Code. + ClaudeCode, +} + +/// Seconds since the Unix epoch; `0`, which reports read as unknown, for +/// a clock set before it. +fn unix_seconds(time: SystemTime) -> i64 { + time.duration_since(SystemTime::UNIX_EPOCH) + .ok() + .and_then(|since| i64::try_from(since.as_secs()).ok()) + .unwrap_or_default() +} + /// How many "worth a look" findings the panel lists. const INSIGHT_LIMIT: usize = 6; @@ -409,6 +436,10 @@ pub struct Disktree { /// Enter was pressed before the search finished: apply on arrival. apply_pending: bool, pub show_help: bool, + /// The panel that explains how to drive disktree from an AI agent. + pub show_agent: bool, + /// The About panel: the version and where disktree comes from. + pub show_about: bool, pub show_selection: bool, pub focus: FocusHandle, /// A trail crumb's sibling menu, when open. @@ -528,6 +559,8 @@ impl Disktree { finding: false, apply_pending: false, show_help: false, + show_agent: false, + show_about: false, show_selection: true, focus: cx.focus_handle(), crumb_menu: None, @@ -2497,6 +2530,110 @@ impl Disktree { cx.notify(); } + /// Save this scan as the JSON report `disktree scan --json` prints, + /// wherever the user chooses. Nothing is scanned again: the report is + /// made off the UI thread from the tree on screen. + pub fn export_report(&mut self, cx: &mut Context<'_, Self>) { + let Some(tree) = self.tree.clone().filter(|_| self.scan.is_none()) + else { + self.notice = Some(( + "the report can be saved once the scan finishes".into(), + Status::Warning, + )); + cx.notify(); + return; + }; + let now = SystemTime::now(); + let scanned = disktree_agent::report::Scanned { + root: self.root_path.clone(), + tree, + settings: disktree_agent::query::ScanSettings::from_options( + &self.options, + ), + scanned_at: self.scanned_at, + elapsed: self.scan_elapsed.unwrap_or_default(), + errors: self.progress.errors, + error_examples: self + .progress + .messages + .iter() + .take(EXPORTED_ERRORS) + .cloned() + .collect(), + }; + let mut query = + disktree_agent::query::Query::new(self.root_path.clone()); + query.settings = scanned.settings.clone(); + query.top = EXPORTED_TOP; + let directory = + self.home.clone().unwrap_or_else(|| self.root_path.clone()); + let chosen = + cx.prompt_for_new_path(&directory, Some("disktree-report.json")); + cx.spawn(async move |this, cx| { + let Ok(Ok(Some(path))) = chosen.await else { + return; + }; + let target = path.clone(); + let written = cx + .background_executor() + .spawn(async move { + let report = disktree_agent::report::report( + &scanned, + &query, + unix_seconds(now), + )?; + let text = disktree_agent::report::json_text(&report); + std::fs::write(&target, text) + .map_err(|error| error.to_string()) + }) + .await; + let notice = match written { + Ok(()) => ( + format!("saved the report to {}", path.display()), + Status::Success, + ), + Err(error) => ( + format!("could not save {}: {error}", path.display()), + Status::Error, + ), + }; + let _ = this.update(cx, |this, cx| { + this.notice = Some(notice); + cx.notify(); + }); + }) + .detach(); + } + + /// Copy something from the AI agent panel: a prompt to paste into an + /// agent, or the settings that make disktree one of its tools. Every + /// command in it names this copy of disktree by its full path. + pub fn copy_for_agent( + &mut self, + what: AgentCopy, + cx: &mut Context<'_, Self>, + ) { + let exe = std::env::current_exe() + .unwrap_or_else(|_| PathBuf::from("disktree")); + let (text, notice) = match what { + AgentCopy::Prompt => ( + disktree_agent::guide::agent_prompt(&exe, &self.root_path), + "copied: paste it into Claude, Codex, Cursor or your agent", + ), + AgentCopy::McpConfig => ( + disktree_agent::guide::mcp_config(&exe), + "copied the MCP settings for Claude Desktop or Cursor", + ), + AgentCopy::ClaudeCode => ( + disktree_agent::guide::claude_code_command(&exe), + "copied: run it in a terminal to add disktree to Claude Code", + ), + }; + cx.write_to_clipboard(gpui_kit::ClipboardItem::new_string(text)); + self.notice = Some((notice.into(), Status::Success)); + cx.notify(); + } + /// Show the tile a key acts on in Finder (or the file manager), selected. pub fn reveal_target(&mut self, cx: &mut Context<'_, Self>) { let crumbs = @@ -2577,6 +2714,28 @@ impl Disktree { return; } + if self.show_about { + if matches!(key, "escape" | "enter" | "q") { + self.show_about = false; + cx.notify(); + } + return; + } + + if self.show_agent { + match key { + "escape" | "a" | "q" => { + self.show_agent = false; + cx.notify(); + } + "enter" | "c" => self.copy_for_agent(AgentCopy::Prompt, cx), + "m" => self.copy_for_agent(AgentCopy::McpConfig, cx), + "e" => self.export_report(cx), + _ => {} + } + return; + } + // The search field takes the keyboard while it is open. It is a field // of three keys on purpose: no editor state to keep in sync with the // tree, and Escape always means "give the keyboard back". @@ -2743,6 +2902,11 @@ impl Disktree { cx.notify(); } "o" if !control => self.reveal_target(cx), + "e" if !control => self.export_report(cx), + "a" if !control => { + self.show_agent = true; + cx.notify(); + } "?" => { self.show_help = true; cx.notify(); diff --git a/crates/disktree-app/src/tests.rs b/crates/disktree-app/src/tests.rs index 3d7f151..b4a2bca 100644 --- a/crates/disktree-app/src/tests.rs +++ b/crates/disktree-app/src/tests.rs @@ -517,6 +517,133 @@ fn the_help_overlay_opens_and_closes(cx: &mut TestAppContext) { assert!(!read(&view, cx, |app| app.show_help)); } +/// The AI agent panel opens from the explore screen, draws, hands over a +/// prompt naming this disktree and the scanned root, and closes. +#[gpui_kit::test] +fn the_agent_panel_copies_a_prompt_and_closes(cx: &mut TestAppContext) { + cx.update(gpui_omarchy::init); + let temp = fixture(); + let (view, cx) = view_over(temp.path(), cx); + draw(cx); + assert!( + cx.debug_bounds("agent-actions").is_some(), + "the top bar buttons" + ); + assert!(cx.debug_bounds("agent-panel").is_none()); + + press(cx, "a"); + assert!(read(&view, cx, |app| app.show_agent)); + assert!( + cx.debug_bounds("agent-panel").is_some(), + "the panel is drawn" + ); + + press(cx, "enter"); + let copied = cx + .read_from_clipboard() + .and_then(|item| item.text()) + .expect("a prompt on the clipboard"); + let exe = std::env::current_exe().expect("test binary"); + assert!( + copied.contains(&format!("'{}' scan", exe.display())), + "{copied}" + ); + let root = read(&view, cx, |app| app.root_path.clone()); + assert!(copied.contains(&root.display().to_string()), "{copied}"); + assert!(copied.contains("until I approve each item"), "{copied}"); + // The panel holds the keyboard: its keys do not reach the treemap. + press(cx, "m"); + let config = cx + .read_from_clipboard() + .and_then(|item| item.text()) + .expect("settings on the clipboard"); + assert!(config.contains("\"mcpServers\""), "{config}"); + assert_eq!(read(&view, cx, |app| app.screen), Screen::Explore); + + press(cx, "escape"); + assert!(!read(&view, cx, |app| app.show_agent)); +} + +/// At every width from the smallest window up, the agent buttons sit +/// between the trail and the settings without pushing the settings off the +/// window or overlapping them; where there is no room they step aside. +#[gpui_kit::test] +fn the_top_bar_buttons_never_crowd_the_settings(cx: &mut TestAppContext) { + cx.update(gpui_omarchy::init); + let temp = fixture(); + let (_view, cx) = view_over(temp.path(), cx); + let mut shown = Vec::new(); + for width in [ + 900.0, 1000.0, 1100.0, 1150.0, 1250.0, 1300.0, 1440.0, 1800.0, + ] { + cx.simulate_resize(gpui_kit::size(px(width), px(900.))); + draw(cx); + let settings = cx + .debug_bounds("view-settings") + .expect("the settings are drawn"); + let Some(actions) = cx + .debug_bounds("agent-actions") + .filter(|actions| actions.size.width > px(0.)) + else { + continue; + }; + // Only where the buttons are shown: at the very narrowest the + // settings alone already run a few pixels past the edge. + assert!( + settings.right() <= px(width + 0.5), + "buttons pushed the settings off a {width} px window: \ + {settings:?}" + ); + assert!( + actions.right() <= settings.left(), + "buttons overlap the settings at {width} px: {actions:?} \ + {settings:?}" + ); + shown.push(width); + } + // The default window, 1440 px wide, shows them. + assert!(shown.contains(&1440.0), "shown at {shown:?}"); +} + +/// About shows this build's version, and Escape closes it. +#[gpui_kit::test] +fn the_about_panel_shows_the_version(cx: &mut TestAppContext) { + cx.update(gpui_omarchy::init); + let temp = fixture(); + let (view, cx) = view_over(temp.path(), cx); + draw(cx); + cx.dispatch_action(crate::app_menu::About); + draw(cx); + assert!(read(&view, cx, |app| app.show_about)); + assert!( + cx.debug_bounds("about-panel").is_some(), + "the panel is drawn" + ); + assert_eq!(crate::views::VERSION, env!("CARGO_PKG_VERSION")); + press(cx, "escape"); + assert!(!read(&view, cx, |app| app.show_about)); + assert!(cx.debug_bounds("about-panel").is_none()); +} + +/// Without a finished scan there is nothing to export, and the window +/// says so instead of opening a save panel. +#[gpui_kit::test] +fn exporting_waits_for_the_scan(cx: &mut TestAppContext) { + cx.update(gpui_omarchy::init); + let temp = fixture(); + let (view, cx) = view_over(temp.path(), cx); + draw(cx); + update(&view, cx, |app, cx| { + app.tree = None; + app.export_report(cx); + }); + let notice = read(&view, cx, |app| app.notice.clone()); + assert!( + notice.is_some_and(|(message, _)| message.contains("scan finishes")), + "the export waits" + ); +} + #[gpui_kit::test] fn the_volume_picker_opens_moves_and_closes(cx: &mut TestAppContext) { cx.update(gpui_omarchy::init); diff --git a/crates/disktree-app/src/ui.rs b/crates/disktree-app/src/ui.rs index ea715d2..fa85745 100644 --- a/crates/disktree-app/src/ui.rs +++ b/crates/disktree-app/src/ui.rs @@ -98,6 +98,10 @@ pub mod size { pub const TOOLTIP: Rems = Rems(16.75); /// The keyboard overlay. pub const HELP: Rems = Rems(32.5); + /// The AI agent panel: wide enough for the prompt's commands. + pub const AGENT: Rems = Rems(40.0); + /// The prompt preview inside it, before it scrolls. + pub const AGENT_PROMPT: Rems = Rems(15.0); /// The key column of the keyboard overlay. pub const KEY_LANE: Rems = Rems(7.0); } diff --git a/crates/disktree-app/src/views.rs b/crates/disktree-app/src/views.rs index c23e91b..e8a00d7 100644 --- a/crates/disktree-app/src/views.rs +++ b/crates/disktree-app/src/views.rs @@ -27,12 +27,15 @@ use gpui_kit::prelude::FluentBuilder as _; use crate::palette; use crate::state::{ - ColorMode, Crumb, Disktree, PANEL_REMS, Screen, panel_width, + AgentCopy, ColorMode, Crumb, Disktree, PANEL_REMS, Screen, panel_width, }; use crate::treemap_view::{self, Mosaic}; use crate::ui::{icon, size, space, text}; use crate::widgets; +/// This build's version, as About and the keyboard overlay show it. +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + /// How many marks the review screen lists. Everything above the cap is still /// removed; the list only stops being exhaustive, which it says out loud. const LIST_LIMIT: usize = 1200; @@ -75,6 +78,24 @@ pub fn root( this.reveal_target(cx); }, )) + .on_action(cx.listener( + |this, _: &crate::app_menu::ExportReport, _, cx| { + this.export_report(cx); + }, + )) + .on_action(cx.listener(|this, _: &crate::app_menu::About, _, cx| { + this.show_help = false; + this.show_agent = false; + this.show_about = true; + cx.notify(); + })) + .on_action(cx.listener( + |this, _: &crate::app_menu::AgentGuide, _, cx| { + this.show_help = false; + this.show_agent = true; + cx.notify(); + }, + )) // Only where the history buttons are: the review screen has none. .on_action(cx.listener(|this, _: &crate::app_menu::GoBack, _, cx| { if this.screen == Screen::Explore { @@ -111,6 +132,12 @@ pub fn root( if app.show_help { root = root.child(help_overlay(app, cx)); } + if app.show_agent { + root = root.child(agent_overlay(app, cx)); + } + if app.show_about { + root = root.child(about_overlay(cx)); + } if app.volumes_open { root = root.child(volumes_dialog(app, cx)); } @@ -460,6 +487,11 @@ fn top_bar( .child(logo(theme)) // Where you are is navigation, and it belongs to the whole window. .child(trail(app, theme, cx)) + .child(agent_actions( + app, + window.viewport_size().width.as_f32() / window.rem_size().as_f32(), + cx, + )) .child(div().flex_1()) .child(view_settings(app, window, cx)) } @@ -971,6 +1003,7 @@ fn view_settings( ); div() + .debug_selector(|| "view-settings".into()) .flex() .flex_row() .items_center() @@ -2210,11 +2243,12 @@ fn review_button( /// to every other key and the scan's own numbers hold the trailing edge. fn key_bar(app: &Disktree, theme: &Theme, cx: &App) -> Div { // Most useful first, so a narrow window clips the least useful. - let hints: [(&str, &str); 11] = [ + let hints: [(&str, &str); 12] = [ ("space", "mark"), ("enter", "open"), ("\u{232b}", "up"), ("c", "review"), + ("a", "AI agent"), ("hjkl", "move"), ("/", "filter"), ("[ ]", "depth"), @@ -3542,6 +3576,8 @@ fn help_overlay(app: &Disktree, cx: &gpui_kit::App) -> Div { "Show it in the file manager" }, ), + ("e", "Save this scan as a JSON report"), + ("a", "Use disktree from your AI agent: a prompt to paste"), ("q", "Quit"), ("", ""), ( @@ -3624,10 +3660,343 @@ fn help_overlay(app: &Disktree, cx: &gpui_kit::App) -> Div { .text_size(text::CAPTION) .text_color(theme.secondary) .child(format!( - "? or esc closes \u{00b7} {} \u{00b7} {}", + "? or esc closes \u{00b7} {} \u{00b7} {} \ + \u{00b7} disktree {}", app.root_path.display(), - app.options.metric.label() + app.options.metric.label(), + VERSION, )), ), ) } + +/// The two ways out to an AI agent, in the top bar where they are seen: +/// most people who download the app will not know it can be driven from +/// one. Bordered, so they read as buttons beside the trail's text. +fn agent_actions(app: &Disktree, room: f32, cx: &Context<'_, Disktree>) -> Div { + let theme = cx.omarchy(); + let ready = app.tree().is_some() && app.scan.is_none(); + // The view settings never give way, so the buttons do: both where + // there is room, the agent alone where it is tight, and at the + // narrowest neither (`a`, `e` and the File menu still reach them). + // Measured in rems, so interface zoom moves the thresholds with it. + let (agent, export) = (room >= AGENT_ROOM_REMS, room >= EXPORT_ROOM_REMS); + let agent_button = filled( + button( + "agent-guide", + "AI agent\u{2026}", + ButtonVariant::Outline, + cx, + ), + theme.accent, + cx, + ) + .on_click(cx.listener(|this, _, _, cx| { + this.show_agent = true; + cx.notify(); + })); + let export_button = filled( + button( + "export-json", + "Export JSON\u{2026}", + ButtonVariant::Outline, + cx, + ), + theme.bright, + cx, + ) + .disabled(!ready) + .on_click(cx.listener(|this, _, _, cx| { + this.export_report(cx); + })); + div() + .debug_selector(|| "agent-actions".into()) + .flex() + .flex_row() + .items_center() + .gap(space::SM) + .flex_shrink_0() + // Apart from the trail: these act on the scan, not on where you are. + // Only with a button to set apart: empty, it must take no room. + .when(agent || export, |this| this.ml(space::XXL)) + .when(agent, |this| { + this.child(with_tooltip( + div().id("agent-guide-tip").child(agent_button), + "Have Claude, Codex or Cursor read this scan and plan a \ + cleanup \u{00b7} a", + )) + }) + .when(export, |this| { + this.child(with_tooltip( + div().id("export-json-tip").child(export_button), + // A greyed-out button says nothing about why: say it. + if ready { + "Save this scan as a JSON report \u{00b7} e" + } else { + "Ready when the scan finishes" + }, + )) + }) +} + +/// A top-bar button that reads as one against the bar: a raised fill, a +/// border, and `text` for its label. The library's buttons are bare text +/// until hovered, which suits a row of them and not a lone pair beside a +/// trail. +fn filled( + button: gpui_omarchy::Button, + text: gpui_kit::Hsla, + cx: &Context<'_, Disktree>, +) -> gpui_omarchy::Button { + let theme = cx.omarchy(); + button + .bg(theme.surface) + .border_color(theme.control_border()) + .text_color(text) +} + +/// Window widths, in rems, from which the top bar has room for the agent +/// button and then the export button beside the trail and the settings. +/// Checked by `the_top_bar_buttons_never_crowd_the_settings`. +pub const AGENT_ROOM_REMS: f32 = 72.0; +pub const EXPORT_ROOM_REMS: f32 = 80.0; + +/// How to hand this to an AI agent: a prompt to paste, written for this +/// machine, and the settings that make disktree one of the agent's tools. +fn agent_overlay(app: &Disktree, cx: &Context<'_, Disktree>) -> Div { + let theme = cx.omarchy(); + let exe = std::env::current_exe() + .unwrap_or_else(|_| std::path::PathBuf::from("disktree")); + let prompt = disktree_agent::guide::agent_prompt(&exe, &app.root_path); + let ready = app.tree().is_some() && app.scan.is_none(); + + let heading = |label: &str| { + div() + .text_size(text::BODY) + .font_weight(FontWeight::BOLD) + .text_color(theme.bright) + .child(label.to_string()) + }; + let note = |label: &str| { + div() + .text_size(text::CAPTION) + .text_color(theme.secondary) + .child(label.to_string()) + }; + // One element per line: the prompt's line breaks are part of it. + let mut preview = div() + .id("agent-prompt") + .flex() + .flex_col() + .max_h(size::AGENT_PROMPT) + .overflow_y_scroll() + .p(space::MD) + .border_1() + .border_color(theme.border) + .bg(theme.background) + .text_size(text::CAPTION) + .text_color(theme.foreground); + for line in prompt.lines() { + preview = preview.child(if line.is_empty() { + div().h(space::SM) + } else { + div().child(line.to_string()) + }); + } + let row = || div().flex().flex_row().flex_wrap().gap(space::SM); + + div() + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .bg(theme.background.opacity(0.86)) + .child( + div() + .debug_selector(|| "agent-panel".into()) + .flex() + .flex_col() + .gap(space::MD) + .w(size::AGENT) + .p(space::XL) + .border_1() + .border_color(theme.border) + .bg(theme.surface) + .child( + div() + .text_size(text::TITLE) + .font_weight(FontWeight::BOLD) + .text_color(theme.bright) + .child("Use disktree from your AI agent"), + ) + .child(note( + "Claude, Codex, Cursor and other agents that can run \ + commands can use disktree to measure this disk, then \ + suggest what to clean up. disktree itself never \ + deletes or changes anything.", + )) + .child(heading("1. Paste this prompt into your agent")) + .child(preview) + .child( + row() + .child( + button( + "agent-copy-prompt", + "Copy prompt", + ButtonVariant::Primary, + cx, + ) + .on_click(cx.listener( + |this, _, _, cx| { + this.copy_for_agent(AgentCopy::Prompt, cx); + }, + )), + ) + .child( + button( + "agent-export", + "Export JSON\u{2026}", + ButtonVariant::Secondary, + cx, + ) + .disabled(!ready) + .on_click(cx.listener( + |this, _, _, cx| { + this.export_report(cx); + }, + )), + ), + ) + .child(note( + "Or export the report and attach the file to a chat \ + instead.", + )) + .child(heading("2. Or make it one of your agent's tools")) + .child(note( + "As an MCP server, disktree answers your agent directly \ + whenever you ask about disk space. Claude Desktop and \ + Cursor: add the MCP settings to their config. Claude \ + Code: run the command in a terminal.", + )) + .child( + row() + .child( + button( + "agent-copy-mcp", + "Copy MCP settings", + ButtonVariant::Secondary, + cx, + ) + .on_click(cx.listener( + |this, _, _, cx| { + this.copy_for_agent( + AgentCopy::McpConfig, + cx, + ); + }, + )), + ) + .child( + button( + "agent-copy-claude-code", + "Copy Claude Code command", + ButtonVariant::Secondary, + cx, + ) + .on_click(cx.listener( + |this, _, _, cx| { + this.copy_for_agent( + AgentCopy::ClaudeCode, + cx, + ); + }, + )), + ), + ) + .children(app.notice.as_ref().map(|(message, _)| { + div() + .text_size(text::CAPTION) + .text_color(theme.accent) + .child(message.clone()) + })) + .child( + div() + .flex() + .flex_row() + .items_center() + .justify_between() + .child(note( + "enter copies the prompt \u{00b7} e exports \ + \u{00b7} esc closes", + )) + .child( + button( + "agent-close", + "Close", + ButtonVariant::Secondary, + cx, + ) + .on_click(cx.listener( + |this, _, window, cx| { + this.show_agent = false; + cx.notify(); + window.focus(&this.focus, cx); + }, + )), + ), + ), + ) +} + +/// The version, what disktree is, and where it comes from: what the app +/// menu's About item shows. +fn about_overlay(cx: &Context<'_, Disktree>) -> Div { + let theme = cx.omarchy(); + let line = |label: String| { + div() + .text_size(text::CAPTION) + .text_color(theme.secondary) + .child(label) + }; + div() + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .bg(theme.background.opacity(0.86)) + .child( + div() + .debug_selector(|| "about-panel".into()) + .flex() + .flex_col() + .items_center() + .gap(space::SM) + .p(space::XL) + .border_1() + .border_color(theme.border) + .bg(theme.surface) + .child(logo(theme)) + .child( + div() + .text_size(text::BODY) + .text_color(theme.foreground) + .child(format!("Version {VERSION}")), + ) + .child(line("A treemap of what is using your disk".to_owned())) + .child(line( + "github.com/tobi/disktree \u{00b7} MIT licence".to_owned(), + )) + .child( + div().pt(space::SM).child( + button("about-close", "OK", ButtonVariant::Primary, cx) + .on_click(cx.listener(|this, _, window, cx| { + this.show_about = false; + cx.notify(); + window.focus(&this.focus, cx); + })), + ), + ), + ) +} diff --git a/crates/disktree-core/src/exclude.rs b/crates/disktree-core/src/exclude.rs new file mode 100644 index 0000000..771c750 --- /dev/null +++ b/crates/disktree-core/src/exclude.rs @@ -0,0 +1,272 @@ +//! Names and paths a scan leaves out, by pattern. +//! +//! A pattern without a `/` is matched against an entry's name, so +//! `node_modules` or `*.iso` leaves those out wherever they are. A pattern +//! with one is matched against the entry's whole path, so +//! `/Users/me/Library/**/Caches` names one place. `*` matches any run of +//! characters within one path component, `**` any run across them, and `?` +//! one character. Matching is case-sensitive. An excluded directory is not +//! entered, so its contents cost nothing and count for nothing. + +use std::fmt; +use std::path::Path; +use std::sync::Arc; + +/// A compiled set of exclude patterns. Cheap to clone: scans copy their +/// options. +#[derive(Clone, Default)] +pub struct Exclude { + patterns: Arc<[Pattern]>, + /// Whether any pattern needs the full path, which is otherwise never + /// built: a scan meets millions of entries, nearly all of them files. + any_path: bool, +} + +#[derive(Debug)] +struct Pattern { + text: Box, + tokens: Box<[Token]>, + /// Matched against the full path rather than the name. + path: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Token { + Literal(char), + /// `?`: one character, not a separator. + One, + /// `*`: any run within a component. + Star, + /// `**`: any run, separators included. + Globstar, +} + +impl Exclude { + /// Compile `patterns`; blank ones are ignored. Fails only on a pattern + /// too long to match, over 127 characters. + pub fn new(patterns: I) -> Result + where + I: IntoIterator, + S: AsRef, + { + let mut compiled = Vec::new(); + for text in patterns { + compiled.extend(compile(text.as_ref())?); + } + let patterns: Arc<[Pattern]> = compiled.into(); + let any_path = patterns.iter().any(|pattern| pattern.path); + Ok(Self { patterns, any_path }) + } + + pub fn is_empty(&self) -> bool { + self.patterns.is_empty() + } + + /// The patterns as given, for reporting what a scan left out. + pub fn patterns(&self) -> impl Iterator { + self.patterns.iter().map(|pattern| &*pattern.text) + } + + /// Whether the entry called `name` is left out. `path` builds its full + /// path and is only called when some pattern needs it. + pub fn matches( + &self, + name: &str, + path: impl FnOnce() -> std::path::PathBuf, + ) -> bool { + if self.patterns.is_empty() { + return false; + } + if self + .patterns + .iter() + .any(|pattern| !pattern.path && glob(&pattern.tokens, name)) + { + return true; + } + if !self.any_path { + return false; + } + let path = path(); + let text = normalized(&path); + self.patterns + .iter() + .any(|pattern| pattern.path && glob(&pattern.tokens, &text)) + } + + /// [`Self::matches`] for a path already in hand. + pub fn matches_path(&self, path: &Path) -> bool { + let name = path + .file_name() + .map(|name| name.to_string_lossy()) + .unwrap_or_default(); + self.matches(&name, || path.to_path_buf()) + } +} + +impl fmt::Debug for Exclude { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_list().entries(self.patterns()).finish() + } +} + +/// The path as text with `/` separators, which is what patterns are +/// written in on every platform. +fn normalized(path: &Path) -> String { + let text = path.to_string_lossy(); + if cfg!(windows) { + text.replace('\\', "/") + } else { + text.into_owned() + } +} + +/// Positions a pattern can have, one bit each, plus the end. +const MAX_TOKENS: usize = 127; + +fn compile(text: &str) -> Result, String> { + let text = text.trim(); + if text.is_empty() { + return Ok(None); + } + let source = if cfg!(windows) { + text.replace('\\', "/") + } else { + text.to_owned() + }; + // A trailing separator would never match: paths are compared without + // one. + let source = source.trim_end_matches('/'); + let source = if source.is_empty() { "/" } else { source }; + let mut tokens = Vec::with_capacity(source.len()); + let mut chars = source.chars().peekable(); + while let Some(c) = chars.next() { + let token = match c { + '*' if chars.peek() == Some(&'*') => { + while chars.peek() == Some(&'*') { + chars.next(); + } + Token::Globstar + } + '*' => Token::Star, + '?' => Token::One, + other => Token::Literal(other), + }; + // Adjacent stars are one star, and a star beside a globstar is the + // globstar: the same pattern in fewer positions. + match (tokens.last_mut(), token) { + (Some(last @ Token::Star), Token::Globstar) => { + *last = Token::Globstar; + } + (Some(Token::Star | Token::Globstar), Token::Star) + | (Some(Token::Globstar), Token::Globstar) => {} + _ => tokens.push(token), + } + } + if tokens.len() > MAX_TOKENS { + return Err(format!( + "exclude pattern {text:?} is longer than {MAX_TOKENS} characters" + )); + } + Ok(Some(Pattern { + text: text.into(), + path: source.contains('/'), + tokens: tokens.into(), + })) +} + +/// Whether `tokens` match all of `text`: the pattern run as an automaton +/// over a bit set of positions, so stars never backtrack and a hostile +/// pattern costs no more than a plain one. +fn glob(tokens: &[Token], text: &str) -> bool { + let end = tokens.len(); + let mut states = closure(tokens, 1); + for c in text.chars() { + let mut next = 0_u128; + for (index, token) in tokens.iter().enumerate() { + if states & (1 << index) == 0 { + continue; + } + match *token { + Token::Literal(want) if want == c => next |= 1 << (index + 1), + Token::One if c != '/' => next |= 1 << (index + 1), + Token::Star if c != '/' => next |= 1 << index, + Token::Globstar => next |= 1 << index, + _ => {} + } + } + if next == 0 { + return false; + } + states = closure(tokens, next); + } + states & (1 << end) != 0 +} + +/// `states` plus every position reachable by letting a star match nothing. +fn closure(tokens: &[Token], mut states: u128) -> u128 { + for (index, token) in tokens.iter().enumerate() { + if states & (1 << index) != 0 + && matches!(token, Token::Star | Token::Globstar) + { + states |= 1 << (index + 1); + } + } + states +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + + fn excluded(patterns: &[&str], path: &str) -> bool { + Exclude::new(patterns) + .expect("compiles") + .matches_path(Path::new(path)) + } + + #[test] + fn a_bare_pattern_matches_the_name_anywhere() { + assert!(excluded(&["node_modules"], "/a/b/node_modules")); + assert!(!excluded(&["node_modules"], "/a/node_modules_old")); + assert!(excluded(&["*.iso"], "/x/Ubuntu.iso")); + assert!(!excluded(&["*.iso"], "/x/iso")); + assert!(excluded(&["cache?"], "/x/cache1")); + assert!(!excluded(&["cache?"], "/x/cache")); + assert!(excluded(&[".*"], "/x/.git")); + } + + #[test] + fn a_pattern_with_a_separator_matches_the_whole_path() { + assert!(excluded(&["/Users/*/Library"], "/Users/me/Library")); + // `*` stays within one component. + assert!(!excluded(&["/Users/*/Library"], "/Users/me/x/Library")); + assert!(excluded(&["/Users/**/Caches"], "/Users/me/Library/Caches")); + assert!(excluded(&["**/target/debug"], "/src/app/target/debug")); + assert!(!excluded(&["**/target/debug"], "/src/app/target/release")); + // A trailing separator is the same pattern. + assert!(excluded(&["/data/"], "/data")); + } + + #[test] + fn blank_patterns_are_ignored_and_the_path_is_built_only_on_demand() { + let exclude = Exclude::new(["", " ", "*.tmp"]).expect("compiles"); + assert_eq!(exclude.patterns().collect::>(), ["*.tmp"]); + assert!(exclude.matches("a.tmp", || unreachable!("name is enough"))); + assert!(!exclude.matches("a.txt", || unreachable!("no path pattern"))); + assert!(Exclude::new::<_, &str>([]).expect("empty").is_empty()); + assert!(Exclude::new(["x".repeat(200)]).is_err()); + assert!(!Exclude::default().matches("x", PathBuf::new)); + } + + #[test] + fn many_stars_do_not_blow_up() { + let exclude = Exclude::new(["*a*a*a*a*a*a*a*b"]).expect("compiles"); + assert!(!exclude.matches(&"a".repeat(10_000), PathBuf::new)); + assert!( + exclude.matches(&format!("{}b", "a".repeat(10_000)), PathBuf::new) + ); + } +} diff --git a/crates/disktree-core/src/lib.rs b/crates/disktree-core/src/lib.rs index a620d80..4a46398 100644 --- a/crates/disktree-core/src/lib.rs +++ b/crates/disktree-core/src/lib.rs @@ -7,6 +7,7 @@ pub mod access; pub mod classify; +pub mod exclude; pub mod export; pub mod filter; pub mod insights; diff --git a/crates/disktree-core/src/mft.rs b/crates/disktree-core/src/mft.rs index 0a20044..c2201e8 100644 --- a/crates/disktree-core/src/mft.rs +++ b/crates/disktree-core/src/mft.rs @@ -172,6 +172,11 @@ pub fn scan( if options.follow_links { return None; } + // Exclude patterns are applied as the walk lists each entry, and the + // table is read whole; the walk honours them. + if !options.exclude.is_empty() { + return None; + } let path = format!(r"\\.\{}:", drive_letter(canonical)?); let volume = open_volume(&path).ok()?; let geometry = geometry(&volume)?; diff --git a/crates/disktree-core/src/scan.rs b/crates/disktree-core/src/scan.rs index 693253a..2b7028a 100644 --- a/crates/disktree-core/src/scan.rs +++ b/crates/disktree-core/src/scan.rs @@ -29,6 +29,7 @@ use std::thread; use rayon::Scope; use rustc_hash::FxHashSet; +use crate::exclude::Exclude; use crate::scan_threads::{Admission, ControllerGuard, ScanThreads}; use crate::tree::{Metric, Node, NodeKind, Seen, aggregate, aggregate_deduped}; @@ -61,6 +62,9 @@ pub struct ScanOptions { pub dedup_hardlinks: bool, /// Whether children are ranked by bytes or by file count. pub metric: Metric, + /// Names and paths left out, never entered and never counted. See + /// [`crate::exclude`]. + pub exclude: Exclude, /// Directory admission for this scan; other scans have their own budget. pub threads: ScanThreads, } @@ -75,6 +79,7 @@ impl Default for ScanOptions { max_depth: None, dedup_hardlinks: true, metric: Metric::Bytes, + exclude: Exclude::default(), threads: ScanThreads::default(), } } @@ -341,6 +346,13 @@ impl WalkContext { { return Classified::Skipped; } + if self + .options + .exclude + .matches(entry.name(), || entry.entry_path(dir)) + { + return Classified::Skipped; + } let kind = match listing { Listing::Symlink => { @@ -859,9 +871,13 @@ fn scan_on_pool( let resolved = root.canonicalize().ok(); let canonical = resolved.as_deref().unwrap_or(root); let mut never = crate::space::never_scanned(root, canonical); - // Hidden-entry and depth filters can leave an alias as the only route - // to files. Keep every mount view when either filter is active. - if context.options.include_hidden && context.options.max_depth.is_none() { + // Hidden-entry, depth and exclude filters can leave an alias as the + // only route to files: `--exclude source` must not also lose the + // bind mount of it elsewhere. Keep every mount view when one is active. + if context.options.include_hidden + && context.options.max_depth.is_none() + && context.options.exclude.is_empty() + { never.extend(crate::space::repeated_mounts_for( root, canonical, @@ -1558,6 +1574,41 @@ mod tests { assert_eq!(without.bytes, 100); } + #[test] + fn excluded_entries_are_neither_entered_nor_counted() { + let temp = TempDir::new().expect("tempdir"); + let root = temp.path(); + write(root, "app/node_modules/dep/index.js", 700); + write(root, "app/main.rs", 100); + write(root, "disk.iso", 50); + write(root, "keep/notes.txt", 10); + + let tree = scan_dir( + root, + &ScanOptions { + exclude: Exclude::new(["node_modules", "*.iso"]) + .expect("patterns"), + ..options() + }, + ); + assert_eq!(tree.bytes, 110); + assert!(child(&tree, "app").child_named("node_modules").is_none()); + assert!(tree.child_named("disk.iso").is_none()); + + // A path pattern is matched against the path the walk built, under + // whichever spelling of the root it was given. + let anywhere = Exclude::new(["**/keep"]).expect("patterns"); + let tree = scan_dir( + root, + &ScanOptions { + exclude: anywhere, + ..options() + }, + ); + assert_eq!(tree.bytes, 850); + assert!(tree.child_named("keep").is_none()); + } + /// Explorer's hidden attribute counts as hidden, as `AppData` has it. #[cfg(windows)] #[test] diff --git a/docs/agent-json.md b/docs/agent-json.md new file mode 100644 index 0000000..f9968ac --- /dev/null +++ b/docs/agent-json.md @@ -0,0 +1,243 @@ +# disktree JSON reports + +`disktree scan PATH --json`, `disktree summary PATH --json` and every tool of +`disktree mcp` print the objects described here. The MCP tools return the same +object twice: as `structuredContent`, and pretty-printed as the text content. + +The report is built for a reader that should not have to do arithmetic. +Every size comes as exact `bytes` and human `size`, with its `percent` of the +scanned total; every time also comes as `age_days`; and every breakdown is +already summed. Lists that overlap say so (`nested_in`), and lists that are +meant to be added up are built not to overlap. + +The format is versioned by `schema_version`. It is raised only when a field is +removed or changes meaning; new fields can appear without a bump, so ignore +fields you do not know. + +Conventions: + +- `bytes` is exact; `size` is binary units: `1.5 GiB`, `523 MiB`, `12 B`; +- `percent` is of `total.bytes` unless the field name says otherwise, rounded + to one decimal, so a column of them can sum to 99.9 or 100.1; +- times are RFC 3339 in UTC (`2026-10-11T09:30:00Z`); `age_days` is whole + days before the report was made. Both are `null` when unknown; +- for a directory, `modified` and `age_days` describe the newest write + anywhere beneath it: a directory 200 days old has had nothing changed + inside it for 200 days; +- paths are absolute, with `/` on macOS and Linux. A name that is not valid + Unicode is shown lossily (`�`), so treat such a path as a label, not + something to open. + +## Scan report + +Keys appear in this order: the short answers first, the long lists last. + +| key | present | what it holds | +| --- | --- | --- | +| `schema_version`, `generator` | always | format version; `{name, version}` of disktree | +| `root` | always | the scanned directory, canonical | +| `scanned_at`, `scan_seconds` | always | when the walk finished, and how long it took. The MCP server reuses a scan for five minutes, so `scanned_at` can be older than the call | +| `options` | always | `size_mode` (`on_disk` like `du`, or `apparent` like `ls -l`), `one_filesystem`, `include_hidden`, `follow_links`, `exclude`, `top`, `depth` | +| `filters` | always | the filters the lists were made with; `older_than` is the resolved cutoff time | +| `total` | always | `bytes`, `size`, `files`, `directories` beneath the root | +| `errors` | always | `unreadable_paths` (count), up to 10 `examples`, and a `note` when the count is not zero. Those paths are counted nowhere | +| `volume` | overview, reclaim | the disk the root is on | +| `breakdown` | overview | three non-overlapping views of `total` | +| `reclaimable` | overview, reclaim | space that is likely safe to free, counted once | +| `largest_dirs` | dirs | biggest directories, filtered | +| `largest_files` | files | biggest files, filtered | +| `matched` | always | how many entries passed the filters, listed or not | + +Which parts are present is chosen with `--only overview,reclaim,dirs,files` +(default: all). The MCP tools choose for you: `scan_path` is everything, +`reclaimable_space` is `volume` + `reclaimable`, `largest_dirs` is the +directory list, and `largest_files` and `list_files` are the file list. + +### `volume` + +```json +"volume": { + "capacity": { "bytes": 994610155520, "size": "926 GiB" }, + "used": { "bytes": 608242950144, "size": "566 GiB" }, + "available": { "bytes": 386367205376, "size": "360 GiB" }, + "used_percent": 61.2, + "available_percent": 38.8, + "root_percent_of_used": 22.6 +} +``` + +`root_percent_of_used` is how much of the volume's used space this tree is. +`null` if the volume cannot be measured. + +### `breakdown` + +Three ways of dividing `total.bytes`. In each, the parts do not overlap and +their `bytes` add up to `total.bytes` exactly. They describe the whole tree: +`depth` and the filters do not apply. + +```json +"breakdown": { + "note": "Each list divides total.bytes into parts that do not overlap; …", + "children": [ { …entry… }, … ], + "children_rest": { "count": 61, "bytes": 2040109056, "size": "1.9 GiB", "percent": 0.5, "files": 40112 }, + "by_category": [ + { "category": "other", "bytes": 214748364800, "size": "200 GiB", "percent": 51.6, "files": 2210345 }, + … + ], + "by_age": [ + { "age": "under_30_days", … }, + { "age": "30_days_to_6_months", … }, + { "age": "6_months_to_1_year", … }, + { "age": "over_1_year", … }, + { "age": "unknown", … } + ] +} +``` + +- `children`: the root's direct children, the largest `top` of them, as + entries (below). `children_rest` totals the ones not listed, so + `children` + `children_rest` is the whole root. +- `by_category`: bytes and files by kind of data, largest first, kinds with + nothing left out. A folder's kind is what its own name says (`node_modules` + and `Caches` are `cache`, `.git` is `git`, `Movies` is `media`), or else + the kind of the folder holding it; files take their folder's kind. An + unrecognised top-level folder is `other`. (The window goes further and + colours, say, `~/Library` by its largest recognisable child; that makes a + good colour and a wrong total, so reports do not.) +- `by_age`: bytes and files by last write, always these five rows in this + order. + +### `reclaimable` + +Space disktree recognises as regenerable or disposable: caches, build +output, dependencies beside their manifest, package stores, trash, temporary +files and logs. Each place is counted once, at its outermost folder, so +nothing is counted twice and `by_reason` adds up to `bytes`. It describes the +whole tree: `depth` and the filters do not apply. + +```json +"reclaimable": { + "bytes": 90301702144, + "size": "84 GiB", + "percent": 65.8, + "places": 785, + "available_after": { "bytes": 476668907520, "size": "444 GiB", "percent_of_capacity": 47.9 }, + "note": "… A hint to weigh, not a promise: check before removing anything.", + "by_reason": [ + { + "reason": "reinstallable", + "bytes": 78734090240, + "size": "73 GiB", + "percent": 57.4, + "places": 103, + "description": "Installed dependencies beside their manifest; reinstall with the project's package manager." + } + ], + "largest": [ { …entry… }, … ] +} +``` + +- `places`: how many outermost folders. +- `available_after`: the volume's available space if all of it were freed. + `null` unless the bytes are what freeing would give back to this one + volume: it needs on-disk sizes (not `apparent`, which overstates sparse + files), one filesystem, and links not followed (a followed link can lead + to another volume). +- `by_reason`: largest first, with one plain-language `description` per + reason. Reasons: `regenerable`, `sync-history`, `package-store`, + `build-output`, `reinstallable`, `sandbox-layers`, `snapshots`, `trash`, + `temporary`. +- `largest`: the biggest `top` places, as entries. + +### Entries + +`largest_dirs`, `largest_files`, `breakdown.children` and +`reclaimable.largest` all hold entries: + +```json +{ + "path": "/Users/me/Library/Developer/Xcode/DerivedData", + "name": "DerivedData", + "type": "directory", + "bytes": 55834574848, + "size": "52 GiB", + "percent": 13.4, + "modified": "2026-10-10T18:02:11Z", + "age_days": 1, + "depth": 4, + "category": "toolchain", + "reclaim": "build-output", + "files": 210554, + "unreadable": false, + "nested_in": null +} +``` + +| field | meaning | +| --- | --- | +| `path`, `name` | where it is | +| `type` | `directory` or `file` | +| `bytes`, `size`, `percent` | for a directory, everything beneath it. A hardlinked file is counted once, so a second name of it has `0` | +| `modified`, `age_days` | last write; for a directory, the newest write anywhere beneath it | +| `depth` | levels below `root`; its children are `1` | +| `category` | kind of data, as in `breakdown.by_category` | +| `reclaim` | the reason it is reclaimable, or `null`. Inherited: everything inside a cache is reclaimable too | +| `files` | directories only: files beneath it | +| `unreadable` | directories only: it could not be listed, so its size is a lower bound | +| `nested_in` | `largest_dirs` only: the nearest directory **also in this list** that contains it, or `null`. Entries with a `nested_in` are already counted in that directory: never add them to it | + +### `largest_dirs` and `largest_files` + +The biggest `top` matching entries, largest first. The root itself is never +listed; `largest_files` holds regular files only. `depth` limits both lists to +entries at most that many levels below the root, files included, so a small +depth can hide large files deeper down. The filters apply to both: + +| CLI | MCP argument | passes when | +| --- | --- | --- | +| `--min-size 1G` | `min_size: "1G"` or bytes | at least that size. `K`, `M`, `G`, `T` with or without `B` or `iB` are all binary: `1GB` is 1024³ bytes | +| `--ext iso,dmg` | `extensions: ["iso", "dmg"]` | files only: the name ends in `.iso` or `.dmg` (any case; `tar.gz` works) | +| `--older-than 6mo` | `older_than: "6mo"` | last written before then. `s`, `min`, `h`, `d`, `w`, `mo` (30 days), `y` (365 days), or a date `2025-01-31`. An unknown time never passes | +| `--name '*backup*'` | `name: ["*backup*"]` | the name matches, or the path if the pattern has a `/` | +| `--category cache` | `category: "cache"` | of that kind. Directories: only the outermost match is listed | +| `--reclaim any` | `reclaim: "any"` | reclaimable for that reason, or any. Directories: only the outermost match is listed | + +Patterns, for `--name` and `--exclude`: `*` matches within one path component, +`**` across them, `?` one character. Case-sensitive. + +### `matched` + +```json +"matched": { "directories": 5541, "files": 17, "file_bytes": 70228426752, "file_size": "65 GiB", "file_percent": 16.9 } +``` + +Every entry that passed the filters and `depth`, listed or not. +`directories` is present when directories were listed, the `file_*` fields +when files were. `file_bytes` totals the matching files, which never +overlap; directory bytes are not summed, since nested directories would be +counted more than once. + +## Summary + +`disktree summary PATH --json` and the MCP tool `disk_summary`. Nothing is +scanned; this asks the volume. + +```json +{ + "schema_version": 1, + "generator": { "name": "disktree", "version": "0.11.0" }, + "path": "/Users/me", + "volume_root": "/", + "device": "/dev/disk3s5", + "capacity": { "bytes": 994610155520, "size": "926 GiB" }, + "used": { "bytes": 588366356480, "size": "548 GiB" }, + "free": { "bytes": 406243799040, "size": "378 GiB" }, + "available": { "bytes": 406243799040, "size": "378 GiB" }, + "used_percent": 59.2, + "available_percent": 40.8 +} +``` + +`available` is what an unprivileged user can still write; `free` can be larger +where the filesystem reserves blocks for root. `device` is the mount source +where the platform reports one.