Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions crates/disktree-scan/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
184 changes: 178 additions & 6 deletions crates/disktree-scan/src/main.rs
Original file line number Diff line number Diff line change
@@ -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<()> {
Expand Down Expand Up @@ -44,20 +45,118 @@ 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 {
Err(io::Error::other("unknown option; try --help"))
}
}

/// `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<PathBuf> = 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")]
Expand Down Expand Up @@ -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<serde_json::Value> {
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<u8> = 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<u8> = 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<u8> = Vec::new();
run_scan(dir.path(), false, true, 0, &mut blocked).unwrap();
let mut apparent: Vec<u8> = 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());
}
}