From f6c3fe82cc4df6dcd487fc4129f15e7a6897772d Mon Sep 17 00:00:00 2001 From: snowsword20 Date: Thu, 29 Oct 2026 02:38:11 +0800 Subject: [PATCH] scan: add a local JSON Lines mode to the headless helper disktree-scan scan PATH [--apparent-size] [--no-hidden] [--min-size N] writes the finished tree as JSON Lines, one directory per line, parent before children, children largest first. Same read-only promise as the SSH helper: no stdin, no deletion. Scripts and coding agents cannot open a window, and du is an order of magnitude slower than the scanner on the same tree. The mode reuses disktree_core::scan unchanged; directories below --min-size are left out of the output while the totals on surviving lines stay complete. --- crates/disktree-scan/Cargo.toml | 4 +- crates/disktree-scan/src/main.rs | 184 ++++++++++++++++++++++++++++++- 2 files changed, 180 insertions(+), 8 deletions(-) diff --git a/crates/disktree-scan/Cargo.toml b/crates/disktree-scan/Cargo.toml index 2f6e000b..787592cf 100644 --- a/crates/disktree-scan/Cargo.toml +++ b/crates/disktree-scan/Cargo.toml @@ -4,17 +4,17 @@ version.workspace = true edition.workspace = true rust-version.workspace = true license.workspace = true -description = "Read-only headless scanner for disktree SSH sessions" +description = "Read-only headless scanner, for SSH sessions and the command line" [lints] workspace = true [dependencies] disktree-core.workspace = true +serde_json = "1" [target.'cfg(target_os = "linux")'.dependencies] rustix = { version = "1.1", features = ["process"] } [dev-dependencies] -serde_json = "1" tempfile = "3" diff --git a/crates/disktree-scan/src/main.rs b/crates/disktree-scan/src/main.rs index 2b9ad573..b4741516 100644 --- a/crates/disktree-scan/src/main.rs +++ b/crates/disktree-scan/src/main.rs @@ -1,5 +1,6 @@ //! Headless helper: no GPUI dependency and no destructive command surface. -use std::io::{self, BufReader, BufWriter, Read as _}; +use std::io::{self, BufReader, BufWriter, Read as _, Write}; +use std::path::{Path, PathBuf}; use std::time::Duration; fn main() -> io::Result<()> { @@ -44,13 +45,20 @@ fn main() -> io::Result<()> { session, &mut BufWriter::new(io::stdout().lock()), ) + } else if args.first().map(String::as_str) == Some("scan") { + scan_command(&args[1..]) } else if args.is_empty() || args == ["--help"] || args == ["-h"] { println!( - "disktree-scan — read-only SSH scan helper\n\ - usage: disktree-scan --stdio-v1\n\ - Used by: disktree ssh HOST [PATH]\n\ - Reads a versioned JSON request from stdin and streams a \ - snapshot to stdout. No deletion commands." + "disktree-scan — read-only scan helper\n\ + usage:\n\ + \x20 disktree-scan --stdio-v1 SSH helper, used by disktree ssh\n\ + \x20 disktree-scan scan PATH [options] local scan as JSON Lines\n\ + scan options:\n\ + \x20 --apparent-size report apparent length, not allocated blocks\n\ + \x20 --no-hidden skip dotfiles and dot-directories\n\ + \x20 --min-size N print only directories of at least N bytes\n\ + Reads nothing from stdin, writes nothing but the report to \ + stdout. No deletion commands." ); Ok(()) } else { @@ -58,6 +66,97 @@ fn main() -> io::Result<()> { } } +/// `scan PATH [--apparent-size] [--no-hidden] [--min-size N]`: the same +/// scan the treemap draws, written as JSON Lines for scripts and coding +/// agents that cannot open a window. One directory per line, parent +/// before children, children largest first; the root line is the summary. +fn scan_command(args: &[String]) -> io::Result<()> { + let mut path: Option = None; + let mut apparent_size = false; + let mut include_hidden = true; + let mut min_size = 0; + let mut values = args.iter(); + while let Some(arg) = values.next() { + match arg.as_str() { + "--apparent-size" => apparent_size = true, + "--no-hidden" => include_hidden = false, + "--min-size" => { + min_size = values + .next() + .and_then(|count| count.parse().ok()) + .ok_or_else(|| { + io::Error::other("--min-size needs a byte count") + })?; + } + other if other.starts_with('-') => { + return Err(io::Error::other(format!( + "unknown option {other}; try --help" + ))); + } + other => { + if path.replace(PathBuf::from(other)).is_some() { + return Err(io::Error::other("one path per scan")); + } + } + } + } + let root = path.ok_or_else(|| io::Error::other("scan needs a path"))?; + let mut out = BufWriter::new(io::stdout().lock()); + run_scan(&root, apparent_size, include_hidden, min_size, &mut out)?; + out.flush() +} + +/// Scan and write; split from [`scan_command`] so tests can read the bytes. +fn run_scan( + root: &Path, + apparent_size: bool, + include_hidden: bool, + min_size: u64, + out: &mut impl Write, +) -> io::Result<()> { + let options = disktree_core::scan::ScanOptions { + apparent_size, + include_hidden, + ..disktree_core::scan::ScanOptions::default() + }; + let tree = disktree_core::scan::scan(root, options)?; + write_directory(root, &tree, min_size, out) +} + +/// Parent first, then surviving children largest first. A directory below +/// `min_size` is left out together with everything beneath it: the totals +/// on its parent's line still count it, they are only not printed. +fn write_directory( + dir: &Path, + node: &disktree_core::tree::Node, + min_size: u64, + out: &mut impl Write, +) -> io::Result<()> { + serde_json::to_writer( + &mut *out, + &serde_json::json!({ + "path": dir.display().to_string(), + "bytes": node.bytes, + "files": node.files, + "dirs": node.dirs, + "modified": node.modified, + "read_error": node.read_error, + }), + )?; + out.write_all(b"\n")?; + for child in &node.children { + if child.is_dir() && child.bytes >= min_size { + write_directory( + &dir.join(child.name.as_ref()), + child, + min_size, + out, + )?; + } + } + Ok(()) +} + /// Kernel-enforced, not a sampled RSS check that can miss an allocation /// burst. Keep this in the helper: never constrain the desktop process. #[cfg(target_os = "linux")] @@ -91,3 +190,76 @@ fn limit_resources() -> io::Result<()> { const fn limit_resources() -> io::Result<()> { Ok(()) } + +#[cfg(test)] +mod scan_mode_tests { + use super::*; + + fn grow(root: &Path) { + std::fs::create_dir_all(root.join("big/deep")).unwrap(); + std::fs::write(root.join("big/deep/blob"), vec![0; 1000]).unwrap(); + std::fs::create_dir(root.join("tiny")).unwrap(); + std::fs::write(root.join("tiny/note"), b"x").unwrap(); + } + + fn lines(out: &[u8]) -> Vec { + std::str::from_utf8(out) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect() + } + + #[test] + fn scan_prints_the_root_summary_first() { + let dir = tempfile::tempdir().unwrap(); + grow(dir.path()); + let mut out: Vec = Vec::new(); + run_scan(dir.path(), false, true, 0, &mut out).unwrap(); + let report = lines(&out); + assert_eq!( + report[0]["path"], + dir.path().display().to_string().as_str() + ); + assert_eq!(report[0]["files"], 2); + assert_eq!(report[0]["dirs"], 4); + // Allocated blocks: a 1000-byte blob and a 1-byte note each round up + // to a cluster, so the total is past the apparent 1001. + assert!(report[0]["bytes"].as_u64().unwrap() >= 1001); + } + + #[test] + fn a_directory_below_min_size_is_pruned_with_its_children() { + let dir = tempfile::tempdir().unwrap(); + grow(dir.path()); + let mut out: Vec = Vec::new(); + run_scan(dir.path(), false, true, 500, &mut out).unwrap(); + let report = lines(&out); + // The root survives as the summary, big carries the blob, tiny is + // gone even though its parent's totals still count it. + assert_eq!(report.len(), 3, "{report:?}"); + assert!(report[1]["path"].as_str().unwrap().ends_with("big")); + assert!(!std::str::from_utf8(&out).unwrap().contains("tiny")); + } + + #[test] + fn apparent_size_reports_lengths_not_blocks() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("one"), b"12345").unwrap(); + let mut blocked: Vec = Vec::new(); + run_scan(dir.path(), false, true, 0, &mut blocked).unwrap(); + let mut apparent: Vec = Vec::new(); + run_scan(dir.path(), true, true, 0, &mut apparent).unwrap(); + assert_eq!(lines(&apparent)[0]["bytes"], 5); + assert!(lines(&blocked)[0]["bytes"].as_u64().unwrap() >= 5); + } + + #[test] + fn a_missing_or_repeated_path_and_an_unknown_option_are_refused() { + assert!(scan_command(&[]).is_err()); + assert!(scan_command(&["--min-size".into(), "5".into()]).is_err()); + assert!(scan_command(&["--wat".into(), "x".into()]).is_err()); + let twice = ["a".into(), "b".into()]; + assert!(scan_command(&twice).is_err()); + } +}