diff --git a/rust/bench/compare.py b/rust/bench/compare.py new file mode 100755 index 0000000..967d749 --- /dev/null +++ b/rust/bench/compare.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Times the Go and Rust mapsize binaries on the same command lines. + + rust/bench/compare.py GO_BIN RUST_BIN PATH [RUNS] + +Runs are interleaved (go, rust, go, rust, …) after one untimed warm-up each, +so both see the same page-cache state. Reports median and best wall time and +median peak RSS per scenario as a Markdown table. Linux only (/usr/bin/time). +""" +import os, statistics, subprocess, sys, tempfile + +go, rust, path = sys.argv[1:4] +runs = int(sys.argv[4]) if len(sys.argv) > 4 else 7 +tmp = tempfile.mkdtemp(prefix="mapsize-bench-") +snap = {"go": os.path.join(tmp, "go.msz"), "rust": os.path.join(tmp, "rust.msz")} +bins = {"go": go, "rust": rust} + +def timed(argv): + r = subprocess.run(["/usr/bin/time", "-f", "%e %M"] + argv, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True) + if r.returncode != 0: + sys.exit(f"failed: {' '.join(argv)}\n{r.stderr}") + secs, kb = r.stderr.strip().splitlines()[-1].split() + return float(secs), int(kb) / 1024 + +scenarios = [(f"scan, {w} workers", lambda b, w=w: [bins[b], "--no-ui", "--workers", str(w), path]) for w in (1, 8, 32)] +scenarios += [ + ("scan + save snapshot", lambda b: [bins[b], "--no-ui", "--save", snap[b], path]), + ("load own snapshot", lambda b: [bins[b], "--no-ui", snap[b]]), + ("load Go snapshot", lambda b: [bins[b], "--no-ui", snap["go"]]), + ("scan + JSON export", lambda b: [bins[b], "--json", path]), + ("scan + top 50 largest files", lambda b: [bins[b], "--largest-files", "50", path]), +] + +print(f"Path: `{path}`, {runs} interleaved runs per binary, warm cache\n") +print("| scenario | Go median | Rust median | Go best | Rust best | Go RSS | Rust RSS | speed-up |") +print("|---|---:|---:|---:|---:|---:|---:|---:|") +for name, mk in scenarios: + argv = {k: mk(k) for k in bins} + res = {k: [] for k in bins} + for k in bins: + timed(argv[k]) # warm-up (also creates the snapshot files) + for _ in range(runs): + for k in bins: + res[k].append(timed(argv[k])) + med = {k: statistics.median(t for t, _ in v) for k, v in res.items()} + best = {k: min(t for t, _ in v) for k, v in res.items()} + rss = {k: statistics.median(m for _, m in v) for k, v in res.items()} + print(f"| {name} | {med['go']:.2f} s | {med['rust']:.2f} s | {best['go']:.2f} s | {best['rust']:.2f} s " + f"| {rss['go']:.0f} MB | {rss['rust']:.0f} MB | {med['go'] / med['rust']:.2f}× |", flush=True) diff --git a/rust/examples/bench.rs b/rust/examples/bench.rs new file mode 100644 index 0000000..12d1282 --- /dev/null +++ b/rust/examples/bench.rs @@ -0,0 +1,58 @@ +//! In-process benchmarks mirroring the Go `go test -bench` suite +//! (internal/tui/bench_test.go, internal/treemap/treemap_test.go): +//! +//! cargo run --release --example bench +//! +//! Each case runs for ~2 s after a warm-up and reports the mean time per op. + +use mapsize::treemap::{self, Direction, Item, Rect}; +use mapsize::tui::bench::{big_tree, BenchModel}; +use std::hint::black_box; +use std::sync::{Arc, RwLock}; +use std::time::{Duration, Instant}; + +fn bench(name: &str, mut f: impl FnMut(u64)) { + f(0); + let budget = Duration::from_secs(2); + let start = Instant::now(); + let mut n = 0u64; + while start.elapsed() < budget { + f(n); + n += 1; + } + let per = start.elapsed() / n as u32; + println!("{name:<40} {n:>8} ops {per:>12.2?}/op"); +} + +fn main() { + let items: Vec = (0..300).map(|i| Item { id: i, size: (1 + i * i) as f64 }).collect(); + let bounds = Rect { x: 0, y: 0, w: 200, h: 60 }; + bench("treemap/Squarify(300)", |_| { + black_box(treemap::squarify(black_box(&items), bounds)); + }); + let blocks = treemap::squarify(&items, bounds); + bench("treemap/Neighbour(300)", |_| { + black_box(treemap::neighbour(black_box(&blocks), 150, Direction::Right)); + }); + + let t0 = Instant::now(); + let tree = Arc::new(RwLock::new(big_tree())); + println!("big tree: {} nodes built in {:.2?}", tree.read().unwrap().len(), t0.elapsed()); + + let mut m = BenchModel::new(tree.clone(), 200, 60); + bench("tui/BigColdFrame", |_| m.cold_frame()); + let mut m = BenchModel::new(tree.clone(), 200, 60); + m.cold_frame(); + bench("tui/BigResize", |i| m.resize(150 + (i % 100) as u16, 40 + (i % 30) as u16)); + let mut m = BenchModel::new(tree.clone(), 200, 60); + m.zoom_to_child_named(b"flat"); + bench("tui/BigFlatDirCold", |_| m.cold_frame()); + let mut m = BenchModel::new(tree.clone(), 200, 60); + m.cold_frame(); + let keys = ["right", "down", "left", "up"]; + bench("tui/BigNavigate", |i| m.key(keys[i as usize % 4])); + for q in ["size > 1MB", "*.dat AND size > 500k", "path contains sub3"] { + let mut m = BenchModel::new(tree.clone(), 200, 60); + bench(&format!("tui/BigFilter/{q}"), |_| m.apply_filter(q)); + } +} diff --git a/rust/src/app.rs b/rust/src/app.rs new file mode 100644 index 0000000..54cc0da --- /dev/null +++ b/rust/src/app.rs @@ -0,0 +1,216 @@ +//! Wires the command-line modes together: interactive TUI, non-interactive +//! reports, snapshots and comparisons. TUI and CLI share the same scanner +//! and inventory. + +use crate::cancel::Cancel; +use crate::config::Settings; +use crate::inventory::{Kind, SizeMode, Tree, FLAG_HARDLINK_DUP}; +use crate::scan::{self, Scanner}; +use crate::{duplicate, export, snapshot, textutil, tui, Error}; +use std::io::{IsTerminal, Write}; +use std::path::Path; +use std::sync::{Arc, RwLock}; +use std::time::{Duration, Instant}; + +/// The fully-resolved command line. +pub struct Config { + pub paths: Vec, + pub scan_opts: scan::Options, + pub no_ui: bool, + pub json: bool, + pub csv: bool, + pub depth: i64, + pub top: usize, + pub largest_files: usize, + pub save: String, + pub load: String, + pub compare: Option<(String, String)>, + pub duplicates: bool, + pub read_only: bool, + pub theme: String, + pub size_mode: SizeMode, + pub mouse: bool, + pub color: String, + pub settings: Settings, +} + +impl Config { + pub fn new(settings: Settings) -> Config { + Config { + paths: Vec::new(), scan_opts: Default::default(), no_ui: false, json: false, csv: false, depth: -1, + top: 0, largest_files: 0, save: String::new(), load: String::new(), compare: None, duplicates: false, + read_only: false, theme: settings.theme.clone(), size_mode: SizeMode::Allocated, mouse: settings.mouse, + color: "auto".into(), settings, + } + } + + /// Whether any non-interactive output was requested. + pub fn report(&self) -> bool { + self.no_ui || self.json || self.csv || self.top > 0 || self.largest_files > 0 || self.duplicates + } +} + +/// Executes the configured mode. +pub fn run(cfg: Config) -> Result<(), Error> { + if cfg.compare.is_some() { + return run_compare(cfg); + } + if !cfg.report() { + return run_tui(cfg); + } + run_report(cfg) +} + +/// Begins a scan (or loads a snapshot). The scanner is None when the tree +/// came from a snapshot. +fn start_source(cancel: Cancel, load: &str, paths: &[String], opts: &scan::Options) -> Result<(Option, Arc>), Error> { + if !load.is_empty() { + return Ok((None, Arc::new(RwLock::new(snapshot::load_file(Path::new(load))?)))); + } + let root = paths.first().map_or(".", |s| s.as_str()); + let s = scan::start(root.as_bytes(), opts.clone(), cancel)?; + let t = s.tree.clone(); + Ok((Some(s), t)) +} + +/// Cancels on SIGINT for report mode, like Go's signal.NotifyContext. +fn interrupt_cancel() -> Cancel { + static CANCEL: std::sync::OnceLock = std::sync::OnceLock::new(); + extern "C" fn on_sigint(_: libc::c_int) { + if let Some(c) = CANCEL.get() { + // Only an atomic store happens here; the channel drop is deferred. + c.cancel_flag_only(); + } + } + let c = CANCEL.get_or_init(Cancel::new).clone(); + unsafe { libc::signal(libc::SIGINT, on_sigint as libc::sighandler_t) }; + c +} + +fn run_report(cfg: Config) -> Result<(), Error> { + let cancel = interrupt_cancel(); + let (s, tree) = start_source(cancel.clone(), &cfg.load, &cfg.paths, &cfg.scan_opts)?; + if let Some(s) = &s { + wait_with_progress(s, &cancel); + } + let mut out = std::io::stdout().lock(); + if cfg.duplicates && !cfg.json && !cfg.csv { + // find takes its own read lock; never nest read locks. + run_duplicates(&mut out, &cancel, &tree)?; + } + let t = tree.read().unwrap(); + if s.is_some() && !cfg.save.is_empty() { + snapshot::save_file(Path::new(&cfg.save), &t)?; + eprintln!("Saved snapshot {}", cfg.save); + } + if cfg.json { + return Ok(export::json(&mut out, &t, t.root(), cfg.depth)?); + } + if cfg.csv { + return Ok(export::csv(&mut out, &t, t.root(), cfg.depth)?); + } + if cfg.top > 0 { + let mut ids = t.sorted_children(t.root(), cfg.size_mode); + ids.truncate(cfg.top); + export::table(&mut out, &t, &ids, cfg.size_mode)?; + writeln!(out)?; + } + if cfg.largest_files > 0 { + let m = cfg.size_mode; + let ids = t.top(t.root(), cfg.largest_files, |n| n.kind == Kind::File && n.flags & FLAG_HARDLINK_DUP == 0, |n| n.own(m)); + export::table(&mut out, &t, &ids, m)?; + writeln!(out)?; + } + export::summary(&mut out, &t)?; + Ok(()) +} + +/// Blocks until the scan finishes, printing a progress line to stderr if it +/// is a terminal. +fn wait_with_progress(s: &Scanner, cancel: &Cancel) { + let tty = std::io::stderr().is_terminal(); + let start = Instant::now(); + let mut forwarded = false; + loop { + match s.done().recv_timeout(Duration::from_millis(250)) { + Err(crossbeam_channel::RecvTimeoutError::Timeout) => {} + _ => { + if tty { + eprint!("\r\x1b[K"); + } + return; + } + } + if cancel.is_cancelled() && !forwarded { + cancel.cancel(); // complete the signal-handler cancellation + forwarded = true; + } + if tty { + let p = s.progress(); + let el = start.elapsed(); + eprint!( + "\r\x1b[Kscanning… {} entries {}/s queue {} {}", + textutil::count(p.entries), + textutil::count((p.entries as f64 / el.as_secs_f64()) as i64), + p.pending, + textutil::duration(el) + ); + } + } +} + +fn run_tui(cfg: Config) -> Result<(), Error> { + let (load, paths, opts) = (cfg.load.clone(), cfg.paths.clone(), cfg.scan_opts.clone()); + tui::run(tui::Options { + read_only: cfg.read_only, theme: cfg.theme, size_mode: cfg.size_mode, mouse: cfg.mouse, color: cfg.color, + save: cfg.save, settings: cfg.settings, diff: None, + start: Arc::new(move |c| start_source(c, &load, &paths, &opts)), + }) +} + +fn run_compare(cfg: Config) -> Result<(), Error> { + let (a, b) = cfg.compare.clone().unwrap(); + let old = snapshot::load_file(Path::new(&a)).map_err(|e| format!("{a}: {e}"))?; + let nw = snapshot::load_file(Path::new(&b)).map_err(|e| format!("{b}: {e}"))?; + let d = snapshot::compare(old, &nw); + if !cfg.report() { + let tree = Arc::new(RwLock::new(nw)); + return tui::run(tui::Options { + read_only: true, theme: cfg.theme, size_mode: cfg.size_mode, mouse: cfg.mouse, color: cfg.color, + save: String::new(), settings: cfg.settings, diff: Some(d), + start: Arc::new(move |_| Ok((None, tree.clone()))), + }); + } + let k = if cfg.top > 0 { cfg.top } else { 30 }; + let m = cfg.size_mode; + let mut out = std::io::stdout().lock(); + let (ro, rn) = (d.old.node(0), nw.node(0)); + writeln!(out, "{} → {}", textutil::sanitize(&d.old.stats.from_snapshot), textutil::sanitize(&nw.stats.from_snapshot))?; + writeln!(out, "Total {} → {} ({})\n", textutil::size(ro.total(m)), textutil::size(rn.total(m)), textutil::signed_size(rn.total(m) - ro.total(m)))?; + for c in d.changes(&nw, k, m) { + writeln!(out, "{:<10} {:>14} {}", c.status.name(), textutil::signed_size(c.delta), textutil::sanitize(&c.path))?; + } + Ok(()) +} + +fn run_duplicates(out: &mut dyn Write, cancel: &Cancel, tree: &RwLock) -> Result<(), Error> { + let f = duplicate::Finder::default(); + let groups = f.find(cancel, tree, duplicate::Options { min_size: 1, workers: 0 }).ok_or("cancelled")?; + let t = tree.read().unwrap(); + let mut wasted = 0; + for g in &groups { + wasted += g.wasted(); + let h: String = g.hash[..6].iter().map(|b| format!("{b:02x}")).collect(); + writeln!(out, "{} × {} (sha256 {h}…)", textutil::size(g.size), g.files.len())?; + for &id in &g.files { + writeln!(out, " {}", textutil::sanitize_bytes(&t.path_bytes(id)))?; + } + } + let p = f.progress(); + writeln!( + out, + "\n{} verified duplicate groups, {} reclaimable ({} candidates by size, {} unreadable/changed)\n", + groups.len(), textutil::size(wasted), p.candidates, p.skipped + )?; + Ok(()) +} diff --git a/rust/src/cancel.rs b/rust/src/cancel.rs index fdf8542..e78a996 100644 --- a/rust/src/cancel.rs +++ b/rust/src/cancel.rs @@ -39,3 +39,11 @@ impl Cancel { &self.0.rx } } + +impl Cancel { + /// Async-signal-safe half of cancel(): sets the flag only. Pollers must + /// follow up with cancel() to wake select-based waiters. + pub fn cancel_flag_only(&self) { + self.0.flag.store(true, Ordering::Relaxed); + } +} diff --git a/rust/src/main.rs b/rust/src/main.rs new file mode 100644 index 0000000..c32a9ac --- /dev/null +++ b/rust/src/main.rs @@ -0,0 +1,189 @@ +//! mapsize: interactive terminal disk usage analyser. + +use mapsize::app::{self, Config}; +use mapsize::inventory::SizeMode; +use mapsize::{brand, config, scan, snapshot, textutil}; +use std::path::Path; + +/// Flags taking a value (Go's flag package: `-f v`, `-f=v`, `--f v`, `--f=v`). +const VALUE_FLAGS: &[&str] = &[ + "workers", "follow-symlinks", "depth", "top", "largest-files", "save", "snapshot", "load", "scan", + "theme", "color", "log", "log-level", "exclude", +]; +/// Boolean flags (`-f`, `-f=false`). +const BOOL_FLAGS: &[&str] = &["one-file-system", "x", "read-only", "no-ui", "json", "csv", "duplicates", "compare", "si", "apparent", "no-mouse", "version"]; + +fn usage() { + let n = brand::NAME; + let e = brand::SNAPSHOT_EXT; + eprint!( + "{n} — interactive terminal disk usage analyser + +Usage: + {n} [flags] [PATH] scan PATH (default .) interactively + {n} [flags] SNAPSHOT{e} open a saved snapshot + {n} --compare OLD NEW compare two snapshots + {n} PATH --top 50 | --json | --csv | --no-ui --save FILE + +Flags: + --workers MODE concurrency: conservative, balanced, aggressive or a number + --follow-symlinks MODE none, same-filesystem or all + -x, --one-file-system do not cross filesystem boundaries + --exclude PATTERN exclude PATTERN (glob on name, or path prefix if it contains a separator); repeatable + --read-only disable all modifying actions + --no-ui scan without the interactive interface and print a summary + --json, --csv print the inventory as JSON / CSV + --depth N maximum depth for --json/--csv (-1 = unlimited) + --top N print the N largest entries of the root + --largest-files N print the N largest files + --duplicates find duplicate files (non-interactive) + --save FILE save a snapshot to FILE after scanning (alias --snapshot) + --load FILE open a saved snapshot FILE instead of scanning + --scan PATH path to scan (alternative to a positional argument) + --compare compare two snapshots: --compare OLD NEW + --si use SI units (kB, MB) instead of IEC (KiB, MiB) + --apparent size by logical (apparent) size instead of disk usage + --theme NAME colour theme: default, dark, high-contrast, mono + --color MODE colour support: auto, truecolor, 256, 16, none + --no-mouse disable mouse support + --log FILE write a debug log to FILE + --log-level LEVEL log level: error, warn, info, debug, trace + --version print version and exit +" + ); +} + +fn main() { + std::process::exit(run(std::env::args_os().skip(1).map(|a| a.to_string_lossy().into_owned()).collect())); +} + +fn fail(msg: impl std::fmt::Display) -> i32 { + eprintln!("{}: {msg}", brand::NAME); + 2 +} + +fn run(args: Vec) -> i32 { + let (settings, cfg_err) = config::load(); + let mut vals: std::collections::HashMap<&str, String> = std::collections::HashMap::new(); + let mut bools: std::collections::HashMap<&str, bool> = std::collections::HashMap::new(); + let mut excludes = settings.excludes.clone(); + let mut positional = Vec::new(); + let mut it = args.into_iter(); + while let Some(a) = it.next() { + if a == "--" { + positional.extend(it.by_ref()); + break; + } + let Some(flag) = a.strip_prefix("--").or_else(|| a.strip_prefix('-')).filter(|f| !f.is_empty() && a != "-") else { + positional.push(a); + continue; + }; + let (name, inline) = match flag.split_once('=') { + Some((n, v)) => (n, Some(v.to_string())), + None => (flag, None), + }; + if name == "h" || name == "help" { + usage(); + return 0; + } + if let Some(&n) = BOOL_FLAGS.iter().find(|&&f| f == name) { + match inline.as_deref().map(str::parse::) { + None => bools.insert(n, true), + Some(Ok(v)) => bools.insert(n, v), + Some(Err(_)) => return fail(format!("invalid boolean value {:?} for -{n}", inline.unwrap())), + }; + } else if let Some(&n) = VALUE_FLAGS.iter().find(|&&f| f == name) { + let Some(v) = inline.or_else(|| it.next()) else { return fail(format!("flag needs an argument: -{n}")) }; + if n == "exclude" { + excludes.push(v); + } else { + vals.insert(n, v); + } + } else { + usage(); + return fail(format!("flag provided but not defined: -{name}")); + } + } + let b = |n: &str| bools.get(n).copied(); + let v = |n: &str| vals.get(n).cloned(); + let int = |n: &str, d: i64| -> Result { + v(n).map_or(Ok(d), |s| s.parse().map_err(|_| format!("invalid value {s:?} for flag -{n}: parse error"))) + }; + + if b("version") == Some(true) { + println!("{} {} ({}/{})", brand::NAME, brand::VERSION, std::env::consts::OS, std::env::consts::ARCH); + return 0; + } + if let Some(e) = cfg_err { + eprintln!("{}: warning: config: {e} (using defaults)", brand::NAME); + } + textutil::set_si(b("si").unwrap_or(settings.units == "si")); + if let Some(l) = v("log-level") { + if !["error", "warn", "info", "debug", "trace"].contains(&l.as_str()) { + return fail(format!("invalid --log-level {l:?}")); + } + } + // ponytail: --log is accepted for CLI compatibility; the Rust port has no debug logging yet. + + let (depth, top, largest) = match (int("depth", -1), int("top", 0), int("largest-files", 0)) { + (Ok(d), Ok(t), Ok(l)) => (d, t.max(0) as usize, l.max(0) as usize), + (Err(e), _, _) | (_, Err(e), _) | (_, _, Err(e)) => return fail(e), + }; + let mut cfg = Config { + no_ui: b("no-ui").unwrap_or(false), + json: b("json").unwrap_or(false), + csv: b("csv").unwrap_or(false), + depth, + top, + largest_files: largest, + duplicates: b("duplicates").unwrap_or(false), + read_only: b("read-only").unwrap_or(false), + theme: v("theme").unwrap_or_else(|| settings.theme.clone()), + mouse: !b("no-mouse").unwrap_or(!settings.mouse), + color: v("color").unwrap_or_else(|| "auto".into()), + load: v("load").unwrap_or_default(), + save: v("save").or_else(|| v("snapshot")).unwrap_or_default(), + size_mode: if b("apparent").unwrap_or(settings.size_mode == "logical") { SizeMode::Logical } else { SizeMode::Allocated }, + ..Config::new(settings.clone()) + }; + if let Some(p) = v("scan") { + positional.insert(0, p); + } + if b("compare") == Some(true) { + if positional.len() != 2 { + return fail("--compare needs exactly two snapshot files"); + } + cfg.compare = Some((positional[0].clone(), positional[1].clone())); + } else { + if positional.len() > 1 { + return fail("only one path may be scanned at a time"); + } + if positional.len() == 1 && cfg.load.is_empty() && snapshot::is_snapshot(Path::new(&positional[0])) { + cfg.load = positional.remove(0); + } else { + cfg.paths = positional; + } + } + let workers = match scan::workers(&v("workers").unwrap_or_else(|| settings.workers.clone())) { + Ok(w) => w, + Err(e) => return fail(e), + }; + let follow = match scan::parse_follow(&v("follow-symlinks").unwrap_or_else(|| settings.follow_symlinks.clone())) { + Ok(f) => f, + Err(e) => return fail(e), + }; + cfg.scan_opts = scan::Options { + workers, + follow, + one_file_system: b("one-file-system").unwrap_or(settings.one_file_system) || b("x").unwrap_or(false), + excludes, + ..Default::default() + }; + match app::run(cfg) { + Ok(()) => 0, + Err(e) => { + eprintln!("{}: {}", brand::NAME, textutil::sanitize(&e.to_string())); + 1 + } + } +}