rust: CLI, app wiring and benchmark harnesses
This commit is contained in:
Executable
+49
@@ -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)
|
||||||
@@ -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<Item> = (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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+216
@@ -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<String>,
|
||||||
|
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<Scanner>, Arc<RwLock<Tree>>), 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<Cancel> = 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<Tree>) -> 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(())
|
||||||
|
}
|
||||||
@@ -39,3 +39,11 @@ impl Cancel {
|
|||||||
&self.0.rx
|
&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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<String>) -> 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::<bool>) {
|
||||||
|
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<i64, String> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user