From a16c00671b2344d04781c92f8676a43eb8afead1 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Thu, 8 Oct 2026 20:05:25 +0100 Subject: [PATCH] Apply rustfmt to the crate --- examples/bench.rs | 30 +++- src/app.rs | 109 ++++++++++-- src/cancel.rs | 6 +- src/config.rs | 10 +- src/duplicate.rs | 66 +++++-- src/export.rs | 144 ++++++++++++--- src/filter/apply.rs | 14 +- src/filter/eval.rs | 21 ++- src/filter/lexer.rs | 16 +- src/filter/mod.rs | 63 +++++-- src/filter/parser.rs | 87 +++++++-- src/inventory/category.rs | 40 ++++- src/inventory/mod.rs | 57 ++++-- src/inventory/query.rs | 19 +- src/inventory/stats.rs | 52 ++++-- src/main.rs | 84 +++++++-- src/platform/mod.rs | 144 ++++++++++++--- src/scan.rs | 251 +++++++++++++++++++++----- src/snapshot/compare.rs | 31 +++- src/snapshot/mod.rs | 359 +++++++++++++++++++++++++++++++------- src/textutil.rs | 39 ++++- src/treemap/mod.rs | 108 ++++++++++-- src/treemap/nav.rs | 71 ++++++-- src/tui/actions.rs | 187 ++++++++++++++++---- src/tui/canvas.rs | 94 ++++++++-- src/tui/color256.rs | 6 +- src/tui/data.rs | 13 +- src/tui/infoview.rs | 187 +++++++++++++++++--- src/tui/keys.rs | 30 +++- src/tui/mapview.rs | 170 +++++++++++++++--- src/tui/mod.rs | 31 +++- src/tui/modal.rs | 99 ++++++++--- src/tui/modals.rs | 232 ++++++++++++++++++++---- src/tui/model.rs | 117 ++++++++++--- src/tui/mouse.rs | 3 +- src/tui/render.rs | 164 ++++++++++++++--- src/tui/run.rs | 116 ++++++++++-- src/tui/search.rs | 62 +++++-- src/tui/side.rs | 62 ++++++- src/tui/table.rs | 81 +++++++-- src/tui/tests.rs | 250 +++++++++++++++++++++----- src/tui/theme.rs | 6 +- src/tui/views.rs | 221 +++++++++++++++++++---- 43 files changed, 3300 insertions(+), 652 deletions(-) diff --git a/examples/bench.rs b/examples/bench.rs index 4153a75..603dbd5 100644 --- a/examples/bench.rs +++ b/examples/bench.rs @@ -25,25 +25,45 @@ fn bench(name: &str, mut f: impl FnMut(u64) -> R) { } 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 }; + 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)); + 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()); + 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)); + 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()); diff --git a/src/app.rs b/src/app.rs index 54cc0da..2cd420b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -37,16 +37,35 @@ pub struct Config { 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, + 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 + self.no_ui + || self.json + || self.csv + || self.top > 0 + || self.largest_files > 0 + || self.duplicates } } @@ -63,9 +82,17 @@ pub fn run(cfg: Config) -> Result<(), Error> { /// 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> { +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))?)))); + 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)?; @@ -117,7 +144,12 @@ fn run_report(cfg: Config) -> Result<(), Error> { } 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)); + 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)?; } @@ -162,8 +194,14 @@ fn wait_with_progress(s: &Scanner, cancel: &Cancel) { 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, + 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)), }) } @@ -176,8 +214,14 @@ fn run_compare(cfg: Config) -> Result<(), Error> { 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), + 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()))), }); } @@ -185,23 +229,54 @@ fn run_compare(cfg: Config) -> Result<(), Error> { 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)))?; + 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))?; + 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 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())?; + 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)))?; } diff --git a/src/cancel.rs b/src/cancel.rs index e78a996..c529f9b 100644 --- a/src/cancel.rs +++ b/src/cancel.rs @@ -22,7 +22,11 @@ impl Default for Cancel { impl Cancel { pub fn new() -> Cancel { let (tx, rx) = crossbeam_channel::bounded(0); - Cancel(Arc::new(Inner { flag: AtomicBool::new(false), tx: Mutex::new(Some(tx)), rx })) + Cancel(Arc::new(Inner { + flag: AtomicBool::new(false), + tx: Mutex::new(Some(tx)), + rx, + })) } pub fn cancel(&self) { diff --git a/src/config.rs b/src/config.rs index 422838d..3dcfe7c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -46,7 +46,11 @@ impl Default for Settings { /// The config file location. pub fn path() -> Option { - let env = |k: &str| std::env::var_os(k).filter(|v| !v.is_empty()).map(PathBuf::from); + let env = |k: &str| { + std::env::var_os(k) + .filter(|v| !v.is_empty()) + .map(PathBuf::from) + }; let dir = if cfg!(target_os = "macos") { env("HOME")?.join("Library/Application Support") } else { @@ -57,7 +61,9 @@ pub fn path() -> Option { /// Reads the config file if present. A missing file is not an error. pub fn load() -> (Settings, Option) { - let Some(p) = path() else { return (Settings::default(), None) }; + let Some(p) = path() else { + return (Settings::default(), None); + }; let b = match std::fs::read(&p) { Ok(b) => b, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return (Settings::default(), None), diff --git a/src/duplicate.rs b/src/duplicate.rs index 745c644..1fd640d 100644 --- a/src/duplicate.rs +++ b/src/duplicate.rs @@ -104,7 +104,11 @@ impl Finder { let mut by_size: HashMap> = HashMap::new(); t.walk(t.root(), |id, n| { if n.kind == Kind::File && !n.has(FLAG_HARDLINK_DUP) && n.size >= o.min_size { - by_size.entry(n.size).or_default().push(Cand { id, path: PathBuf::new(), size: n.size }); + by_size.entry(n.size).or_default().push(Cand { + id, + path: PathBuf::new(), + size: n.size, + }); } true }); @@ -133,11 +137,18 @@ impl Finder { sampled.push(g); // a sample would be the whole file continue; } - sampled.extend(self.split(cancel, &g, o.workers, sample_hash).into_values().filter(|v| v.len() > 1)); + sampled.extend( + self.split(cancel, &g, o.workers, sample_hash) + .into_values() + .filter(|v| v.len() > 1), + ); } // Stage 3: full hashes. - self.to_hash.store(sampled.iter().map(|g| g[0].size * g.len() as i64).sum(), Ordering::Relaxed); + self.to_hash.store( + sampled.iter().map(|g| g[0].size * g.len() as i64).sum(), + Ordering::Relaxed, + ); self.stage("hashing", |_| {}); let mut out = Vec::new(); for g in &sampled { @@ -148,21 +159,35 @@ impl Finder { if v.len() > 1 { let mut files: Vec = v.iter().map(|c| c.id).collect(); files.sort_unstable(); - out.push(Group { size: v[0].size, hash, files }); + out.push(Group { + size: v[0].size, + hash, + files, + }); } } } if cancel.is_cancelled() { return None; } - out.sort_by(|a, b| b.wasted().cmp(&a.wasted()).then(a.files[0].cmp(&b.files[0]))); + out.sort_by(|a, b| { + b.wasted() + .cmp(&a.wasted()) + .then(a.files[0].cmp(&b.files[0])) + }); self.stage("done", |p| p.verified_groups = out.len() as i64); Some(out) } /// Hashes every candidate with a bounded pool of reader threads and /// buckets the successes by hash. Stops starting new reads once cancelled. - fn split(&self, cancel: &Cancel, g: &[Cand], workers: usize, f: HashFn) -> HashMap<[u8; 32], Vec> { + fn split( + &self, + cancel: &Cancel, + g: &[Cand], + workers: usize, + f: HashFn, + ) -> HashMap<[u8; 32], Vec> { let next = AtomicUsize::new(0); let results = Mutex::new(Vec::with_capacity(g.len())); std::thread::scope(|s| { @@ -244,7 +269,15 @@ mod tests { use std::time::Duration; fn scan(root: &Path, workers: usize) -> Arc> { - let s = scan::start(root.as_os_str().as_bytes(), scan::Options { workers, ..Default::default() }, Cancel::new()).unwrap(); + let s = scan::start( + root.as_os_str().as_bytes(), + scan::Options { + workers, + ..Default::default() + }, + Cancel::new(), + ) + .unwrap(); s.wait(); s.tree.clone() } @@ -270,7 +303,11 @@ mod tests { assert_eq!(groups.len(), 2); let t = tree.read().unwrap(); let names = |g: &Group| { - let mut v: Vec = g.files.iter().map(|&id| t.node(id).name_str().into_owned()).collect(); + let mut v: Vec = g + .files + .iter() + .map(|&id| t.node(id).name_str().into_owned()) + .collect(); v.sort(); v }; @@ -278,7 +315,10 @@ mod tests { assert_eq!(groups[0].size, 200_000); assert_eq!(names(&groups[1]), ["small1", "small2"], "small group"); let p = f.progress(); - assert!(p.candidates == 6 && p.verified_groups == 2, "progress {p:?}"); + assert!( + p.candidates == 6 && p.verified_groups == 2, + "progress {p:?}" + ); } /// A candidate replaced by a FIFO (or a symlink to one) after the scan @@ -308,8 +348,12 @@ mod tests { let f = Arc::new(Finder::default()); let (tx, rx) = crossbeam_channel::bounded(1); let (f2, t2) = (f.clone(), tree.clone()); - std::thread::spawn(move || tx.send(f2.find(&Cancel::new(), &t2, Options::default()).is_some())); - assert!(rx.recv_timeout(Duration::from_secs(5)).expect("duplicate search blocked on a FIFO")); + std::thread::spawn(move || { + tx.send(f2.find(&Cancel::new(), &t2, Options::default()).is_some()) + }); + assert!(rx + .recv_timeout(Duration::from_secs(5)) + .expect("duplicate search blocked on a FIFO")); assert_eq!(f.progress().skipped, 2); } } diff --git a/src/export.rs b/src/export.rs index 60490bd..0673681 100644 --- a/src/export.rs +++ b/src/export.rs @@ -8,17 +8,27 @@ use std::io::{self, Write}; use std::time::UNIX_EPOCH; const FLAG_NAMES: [(Flags, &str); 12] = [ - (inv::FLAG_HARDLINK_DUP, "hardlink-duplicate"), (inv::FLAG_MOUNT_POINT, "mount-point"), - (inv::FLAG_VIRTUAL_FS, "virtual-fs-skipped"), (inv::FLAG_SKIPPED_FS, "other-fs-skipped"), - (inv::FLAG_SPARSE, "sparse"), (inv::FLAG_ERROR, "error"), (inv::FLAG_INCOMPLETE, "incomplete"), - (inv::FLAG_LOOP, "loop-skipped"), (inv::FLAG_FOLLOWED, "followed-symlink"), - (inv::FLAG_ALLOC_UNKNOWN, "allocated-unknown"), (inv::FLAG_BROKEN_LINK, "broken-symlink"), + (inv::FLAG_HARDLINK_DUP, "hardlink-duplicate"), + (inv::FLAG_MOUNT_POINT, "mount-point"), + (inv::FLAG_VIRTUAL_FS, "virtual-fs-skipped"), + (inv::FLAG_SKIPPED_FS, "other-fs-skipped"), + (inv::FLAG_SPARSE, "sparse"), + (inv::FLAG_ERROR, "error"), + (inv::FLAG_INCOMPLETE, "incomplete"), + (inv::FLAG_LOOP, "loop-skipped"), + (inv::FLAG_FOLLOWED, "followed-symlink"), + (inv::FLAG_ALLOC_UNKNOWN, "allocated-unknown"), + (inv::FLAG_BROKEN_LINK, "broken-symlink"), (inv::FLAG_HARDLINKED, "hardlinked"), ]; /// Human-readable names for a node's flags. pub fn flag_list(f: Flags) -> Vec<&'static str> { - FLAG_NAMES.iter().filter(|(b, _)| f & b != 0).map(|(_, n)| *n).collect() + FLAG_NAMES + .iter() + .filter(|(b, _)| f & b != 0) + .map(|(_, n)| *n) + .collect() } fn kind_name(k: Kind) -> &'static str { @@ -41,7 +51,10 @@ pub fn json(w: &mut dyn Write, t: &Tree, root: NodeId, max_depth: i64) -> io::Re .filter(|(_, &c)| c > 0) .map(|(k, &c)| (inv::ErrKind::from_u8(k as u8).name().to_string(), c.into())) .collect(); - let started = st.start.duration_since(UNIX_EPOCH).map_or(0, |d| d.as_nanos() as i64); + let started = st + .start + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_nanos() as i64); let head = serde_json::json!({ "generator": format!("{} {}", brand::NAME, brand::VERSION), "root": t.path_string(root), @@ -66,7 +79,14 @@ pub fn json(w: &mut dyn Write, t: &Tree, root: NodeId, max_depth: i64) -> io::Re /// proportional to depth, not tree size. fn write_node(bw: &mut impl Write, t: &Tree, id: NodeId, depth: i64) -> io::Result<()> { let n = t.node(id); - write!(bw, "{{\"name\":{},\"type\":\"{}\",\"size\":{},\"allocated\":{}", json_str(&n.name_str()), kind_name(n.kind), n.tot_size, n.tot_alloc)?; + write!( + bw, + "{{\"name\":{},\"type\":\"{}\",\"size\":{},\"allocated\":{}", + json_str(&n.name_str()), + kind_name(n.kind), + n.tot_size, + n.tot_alloc + )?; for (k, v) in [("files", n.files), ("dirs", n.dirs), ("errors", n.errors)] { if v != 0 { write!(bw, ",\"{k}\":{v}")?; @@ -83,7 +103,11 @@ fn write_node(bw: &mut impl Write, t: &Tree, id: NodeId, depth: i64) -> io::Resu return bw.write_all(b"}"); } bw.write_all(b",\"children\":[")?; - for (i, c) in t.sorted_children(id, SizeMode::Allocated).into_iter().enumerate() { + for (i, c) in t + .sorted_children(id, SizeMode::Allocated) + .into_iter() + .enumerate() + { if i > 0 { bw.write_all(b",")?; } @@ -107,20 +131,35 @@ pub fn csv(w: &mut dyn Write, t: &Tree, root: NodeId, max_depth: i64) -> io::Res bw.write_all(b"path,type,size,allocated,files,dirs,errors,modified,flags\n")?; fn rec(bw: &mut impl Write, t: &Tree, id: NodeId, path: &[u8], depth: i64) -> io::Result<()> { let n = t.node(id); - let modified = if n.mtime != 0 { textutil::rfc3339_utc(n.mtime) } else { String::new() }; + let modified = if n.mtime != 0 { + textutil::rfc3339_utc(n.mtime) + } else { + String::new() + }; writeln!( bw, "{},{},{},{},{},{},{},{},{}", csv_field(&textutil::sanitize_bytes(path)), kind_name(n.kind), - n.tot_size, n.tot_alloc, n.files, n.dirs, n.errors, modified, + n.tot_size, + n.tot_alloc, + n.files, + n.dirs, + n.errors, + modified, flag_list(n.flags).join("|") )?; if depth == 0 { return Ok(()); } for c in t.sorted_children(id, SizeMode::Allocated) { - rec(bw, t, c, &crate::platform::join_path(path, &t.node(c).name), depth - 1)?; + rec( + bw, + t, + c, + &crate::platform::join_path(path, &t.node(c).name), + depth - 1, + )?; } Ok(()) } @@ -134,7 +173,11 @@ pub fn table(w: &mut dyn Write, t: &Tree, ids: &[NodeId], m: SizeMode) -> io::Re writeln!(w, "{:>12} {:>6} {:>12} PATH", "SIZE", "%", "FILES")?; for &id in ids { let n = t.node(id); - let files = if n.is_dir() { textutil::count(n.files as i64) } else { String::new() }; + let files = if n.is_dir() { + textutil::count(n.files as i64) + } else { + String::new() + }; writeln!( w, "{:>12} {:>6} {:>12} {}", @@ -152,30 +195,65 @@ pub fn summary(w: &mut dyn Write, t: &Tree) -> io::Result<()> { let st = &t.stats; let r = t.node(t.root()); writeln!(w, "Root {}", textutil::sanitize_bytes(&st.root))?; - writeln!(w, "Files {}", textutil::count(st.files + st.symlinks + st.others))?; + writeln!( + w, + "Files {}", + textutil::count(st.files + st.symlinks + st.others) + )?; writeln!(w, "Directories {}", textutil::count(st.dirs))?; - writeln!(w, "Logical size {} ({} bytes)", textutil::size(r.tot_size), r.tot_size)?; - writeln!(w, "Allocated size {} ({} bytes)", textutil::size(r.tot_alloc), r.tot_alloc)?; + writeln!( + w, + "Logical size {} ({} bytes)", + textutil::size(r.tot_size), + r.tot_size + )?; + writeln!( + w, + "Allocated size {} ({} bytes)", + textutil::size(r.tot_alloc), + r.tot_alloc + )?; writeln!(w, "Elapsed {}", textutil::go_duration(st.elapsed()))?; for (k, &c) in st.err_counts.iter().enumerate() { if c > 0 { - writeln!(w, "{:<15} {}", inv::ErrKind::from_u8(k as u8).name(), textutil::count(c))?; + writeln!( + w, + "{:<15} {}", + inv::ErrKind::from_u8(k as u8).name(), + textutil::count(c) + )?; } } if st.excluded > 0 { - writeln!(w, "Excluded {} entries ({})", textutil::count(st.excluded), textutil::sanitize(&st.excludes.join(", ")))?; + writeln!( + w, + "Excluded {} entries ({})", + textutil::count(st.excluded), + textutil::sanitize(&st.excludes.join(", ")) + )?; } if st.skipped_mounts > 0 { writeln!(w, "Other FS skipped {}", textutil::count(st.skipped_mounts))?; } if st.virtual_skipped > 0 { - writeln!(w, "Virtual FS skipped {}", textutil::count(st.virtual_skipped))?; + writeln!( + w, + "Virtual FS skipped {}", + textutil::count(st.virtual_skipped) + )?; } if st.hardlink_dups > 0 { - writeln!(w, "Hard-link dups {} (counted once)", textutil::count(st.hardlink_dups))?; + writeln!( + w, + "Hard-link dups {} (counted once)", + textutil::count(st.hardlink_dups) + )?; } if st.incomplete() { - writeln!(w, "WARNING: totals are incomplete — some data could not be inspected.")?; + writeln!( + w, + "WARNING: totals are incomplete — some data could not be inspected." + )?; } Ok(()) } @@ -199,11 +277,27 @@ mod tests { fn outputs_are_terminal_safe() { let t = hostile_tree(); for out in [ - { let mut b = Vec::new(); json(&mut b, &t, 0, -1).unwrap(); b }, - { let mut b = Vec::new(); csv(&mut b, &t, 0, -1).unwrap(); b }, - { let mut b = Vec::new(); table(&mut b, &t, &[1, 2], SizeMode::Allocated).unwrap(); b }, + { + let mut b = Vec::new(); + json(&mut b, &t, 0, -1).unwrap(); + b + }, + { + let mut b = Vec::new(); + csv(&mut b, &t, 0, -1).unwrap(); + b + }, + { + let mut b = Vec::new(); + table(&mut b, &t, &[1, 2], SizeMode::Allocated).unwrap(); + b + }, ] { - assert!(!out.iter().any(|&c| c == 0x1b || c == 0x07), "{}", String::from_utf8_lossy(&out)); + assert!( + !out.iter().any(|&c| c == 0x1b || c == 0x07), + "{}", + String::from_utf8_lossy(&out) + ); } let mut b = Vec::new(); json(&mut b, &t, 0, -1).unwrap(); diff --git a/src/filter/apply.rs b/src/filter/apply.rs index 6fa4200..464b5ca 100644 --- a/src/filter/apply.rs +++ b/src/filter/apply.rs @@ -41,7 +41,9 @@ pub fn apply(cancel: &Cancel, t: &Tree, q: Arc, m: SizeMode) -> Option, m: SizeMode) -> Option Ctx<'a> { pub fn new(tree: &'a Tree, id: NodeId, now: i64) -> Ctx<'a> { - Ctx { tree, id, node: tree.node(id), now, path: String::new(), has_path: false, name: String::new(), dir_paths: None } + Ctx { + tree, + id, + node: tree.node(id), + now, + path: String::new(), + has_path: false, + name: String::new(), + dir_paths: None, + } } /// Moves the context to another node. @@ -82,7 +91,12 @@ impl<'a> Ctx<'a> { pub(super) fn fill_path(&mut self) { self.has_path = true; self.path.clear(); - if let Some(pp) = self.dir_paths.as_ref().and_then(|dp| dp.get(self.node.parent as usize)).filter(|p| !p.is_empty()) { + if let Some(pp) = self + .dir_paths + .as_ref() + .and_then(|dp| dp.get(self.node.parent as usize)) + .filter(|p| !p.is_empty()) + { self.path.push_str(pp); if !pp.ends_with('/') { self.path.push('/'); @@ -90,7 +104,8 @@ impl<'a> Ctx<'a> { push_lower(&mut self.path, &self.node.name); return; } - self.path.push_str(&self.tree.path_string(self.id).to_lowercase()); + self.path + .push_str(&self.tree.path_string(self.id).to_lowercase()); } } diff --git a/src/filter/lexer.rs b/src/filter/lexer.rs index f39f5f7..f733893 100644 --- a/src/filter/lexer.rs +++ b/src/filter/lexer.rs @@ -17,7 +17,11 @@ pub(super) struct Token { } fn tok(kind: TokKind, val: &str, pos: usize) -> Token { - Token { kind, val: val.to_string(), pos } + Token { + kind, + val: val.to_string(), + pos, + } } /// ASCII white space as Go's unicode.IsSpace sees it. @@ -64,12 +68,18 @@ pub(super) fn lex(s: &str) -> Result, String> { } i += 1; // Only ASCII bytes were dropped, so v is still valid UTF-8. - out.push(Token { kind: Str, val: String::from_utf8_lossy(&v).into_owned(), pos: start }); + out.push(Token { + kind: Str, + val: String::from_utf8_lossy(&v).into_owned(), + pos: start, + }); } b'=' | b'!' | b'<' | b'>' | b'~' | b'&' | b'|' => { let start = i; let n = b.get(i + 1).copied(); - let two = n == Some(b'=') || (c == b'&' && n == Some(b'&')) || (c == b'|' && n == Some(b'|')); + let two = n == Some(b'=') + || (c == b'&' && n == Some(b'&')) + || (c == b'|' && n == Some(b'|')); let op = &s[i..i + 1 + two as usize]; i += op.len(); match op { diff --git a/src/filter/mod.rs b/src/filter/mod.rs index 77dcfb2..094c015 100644 --- a/src/filter/mod.rs +++ b/src/filter/mod.rs @@ -43,7 +43,10 @@ mod tests { let n = t.node_mut(id); n.size = size; n.alloc = size; - n.mtime = (SystemTime::now() - age).duration_since(UNIX_EPOCH).unwrap().as_nanos() as i64; + n.mtime = (SystemTime::now() - age) + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() as i64; n.cat = category_for(&ext(name.as_bytes()), false, false); }; add(vms, "ubuntu.qcow2", 80 << 30, 400 * DAY); @@ -58,7 +61,10 @@ mod tests { fn names(tr: &Tree, q: &str) -> HashSet { let pq = parse(q, SystemTime::now()).unwrap_or_else(|e| panic!("parse({q:?}): {e}")); let r = apply(&Cancel::new(), tr, Arc::new(pq), SizeMode::Logical).unwrap(); - (0..tr.len() as NodeId).filter(|&i| r.is_match(i)).map(|i| tr.node(i).name_str().into_owned()).collect() + (0..tr.len() as NodeId) + .filter(|&i| r.is_match(i)) + .map(|i| tr.node(i).name_str().into_owned()) + .collect() } #[test] @@ -70,7 +76,10 @@ mod tests { ("size > 1GB", &["vms", "ubuntu.qcow2", "win.vmdk"]), ("size > 1GB AND type = file", &["ubuntu.qcow2", "win.vmdk"]), ("ext = qcow2", &["ubuntu.qcow2"]), - ("size > 500MB AND ext IN (iso,qcow2,vmdk)", &["ubuntu.qcow2", "win.vmdk", "blob.iso"]), + ( + "size > 500MB AND ext IN (iso,qcow2,vmdk)", + &["ubuntu.qcow2", "win.vmdk", "blob.iso"], + ), ("path contains cache", &["cache", "blob.iso", "x.tmp"]), ("age > 365d", &["ubuntu.qcow2"]), ("NOT type = dir AND size < 1k", &["notes.txt", "x.tmp"]), @@ -91,16 +100,32 @@ mod tests { let tr = tree(); let q = parse("name = vms OR ext = qcow2", SystemTime::now()).unwrap(); let r = apply(&Cancel::new(), &tr, Arc::new(q), SizeMode::Logical).unwrap(); - assert_eq!(r.total, tr.node(1).tot_size, "matching dir must not double count matching child"); + assert_eq!( + r.total, + tr.node(1).tot_size, + "matching dir must not double count matching child" + ); } #[test] fn parse_errors() { for q in [ - "", "size >", "size > banana", "(ext = iso", "ext = iso)", "size contains 5", - "name matches '['", "type = blob", "'unterminated", "a & b", "category = nope", + "", + "size >", + "size > banana", + "(ext = iso", + "ext = iso)", + "size contains 5", + "name matches '['", + "type = blob", + "'unterminated", + "a & b", + "category = nope", ] { - assert!(parse(q, SystemTime::now()).is_err(), "parse({q:?}) should fail"); + assert!( + parse(q, SystemTime::now()).is_err(), + "parse({q:?}) should fail" + ); } } @@ -109,10 +134,20 @@ mod tests { let tr = tree(); assert_eq!(names(&tr, "modified < 2000-01-01"), HashSet::new()); assert_eq!(names(&tr, "modified > '2000-01-01 9:30'").len(), 5); - assert_eq!(names(&tr, "modified > 2000-01-01T00:00:00.5+01:00").len(), 5); + assert_eq!( + names(&tr, "modified > 2000-01-01T00:00:00.5+01:00").len(), + 5 + ); assert_eq!(names(&tr, "modified > 30d").len(), 4); - for bad in ["modified > 2024-02-30", "modified > 2024-01-31T10:00", "ext = 'a['"] { - assert!(parse(bad, SystemTime::now()).is_ok() == bad.ends_with("10:00"), "{bad}"); + for bad in [ + "modified > 2024-02-30", + "modified > 2024-01-31T10:00", + "ext = 'a['", + ] { + assert!( + parse(bad, SystemTime::now()).is_ok() == bad.ends_with("10:00"), + "{bad}" + ); } assert!(names(&tr, "[").is_empty()); // malformed bare glob matches nothing assert!(names(&tr, "voilà\u{a0}x").is_empty()); // Go's lexer hangs on byte 0xA0 @@ -120,7 +155,13 @@ mod tests { #[test] fn parse_size_units() { - for (input, want) in [("1GB", 1_000_000_000), ("1GiB", 1 << 30), ("1.5k", 1536), ("500MB", 500_000_000), ("10", 10)] { + for (input, want) in [ + ("1GB", 1_000_000_000), + ("1GiB", 1 << 30), + ("1.5k", 1536), + ("500MB", 500_000_000), + ("10", 10), + ] { assert_eq!(parse_size(input), Ok(want), "{input}"); } } diff --git a/src/filter/parser.rs b/src/filter/parser.rs index ef0bf3d..7633c07 100644 --- a/src/filter/parser.rs +++ b/src/filter/parser.rs @@ -43,7 +43,8 @@ fn field_by_name(s: &str) -> Option { } /// Field names for help text. -pub const FIELDS: &str = "name path ext type category size allocated age modified owner group files flag"; +pub const FIELDS: &str = + "name path ext type category size allocated age modified owner group files flag"; /// A compiled filter. pub struct Query { @@ -68,7 +69,12 @@ struct Parser { /// Compiles a query. now anchors relative ages (pass SystemTime::now()). pub fn parse(s: &str, now: SystemTime) -> Result { let toks = lex(s)?; - let mut p = Parser { toks, i: 0, uses_path: false, now }; + let mut p = Parser { + toks, + i: 0, + uses_path: false, + now, + }; if p.peek().kind == TokKind::Eof { return Err("empty query".into()); } @@ -77,7 +83,11 @@ pub fn parse(s: &str, now: SystemTime) -> Result { if t.kind != TokKind::Eof { return Err(format!("unexpected {:?} at {}", t.val, t.pos + 1)); } - Ok(Query { src: s.to_string(), root, uses_path: p.uses_path }) + Ok(Query { + src: s.to_string(), + root, + uses_path: p.uses_path, + }) } fn is_kw(t: &Token, kw: &str) -> bool { @@ -193,7 +203,10 @@ impl Parser { if f == Field::Path { self.uses_path = true; } - if matches!(f, Field::Size | Field::Allocated | Field::Age | Field::Modified | Field::Files) { + if matches!( + f, + Field::Size | Field::Allocated | Field::Age | Field::Modified | Field::Files + ) { let nop = match op.as_str() { "=" | "in" => NumOp::Eq, "!=" => NumOp::Ne, @@ -203,7 +216,10 @@ impl Parser { "<=" => NumOp::Le, _ => return Err(format!("{op} cannot be used with numeric field")), }; - let nums = vals.iter().map(|v| self.number(f, &v.val)).collect::, _>>()?; + let nums = vals + .iter() + .map(|v| self.number(f, &v.val)) + .collect::, _>>()?; return Ok(Expr::Num(f, nop, nums)); } // String-like fields. @@ -244,7 +260,11 @@ impl Parser { } else { Expr::Eq(f, set) }; - Ok(if op == "!=" { Expr::Not(Box::new(n)) } else { n }) + Ok(if op == "!=" { + Expr::Not(Box::new(n)) + } else { + n + }) } _ => Err(format!("operator {op} not valid for {}", op_tok.val)), } @@ -256,7 +276,9 @@ impl Parser { fn number(&self, f: Field, s: &str) -> Result { match f { Field::Size | Field::Allocated => parse_size(s), - Field::Files => s.parse::().map_err(|e| format!("bad number {s:?}: {e}")), + Field::Files => s + .parse::() + .map_err(|e| format!("bad number {s:?}: {e}")), Field::Age => parse_age(s).map(nanos), Field::Modified => { if let Some(t) = parse_date(s) { @@ -266,7 +288,9 @@ impl Parser { let now = self.now.duration_since(UNIX_EPOCH).map(nanos).unwrap_or(0); return Ok(now.saturating_sub(nanos(d))); } - Err(format!("bad date {s:?} (use YYYY-MM-DD or an age like 30d)")) + Err(format!( + "bad date {s:?} (use YYYY-MM-DD or an age like 30d)" + )) } _ => Err("not numeric".into()), } @@ -285,7 +309,11 @@ pub(super) fn name_match(v: &str) -> Expr { let lv = v.to_lowercase(); if has_glob(v) { // Go's filepath.Match never matches a malformed pattern. - return if glob_ok(lv.as_bytes()) { Expr::Glob(Field::Name, lv) } else { Expr::False }; + return if glob_ok(lv.as_bytes()) { + Expr::Glob(Field::Name, lv) + } else { + Expr::False + }; } Expr::Contains(Field::Name, lv) } @@ -365,7 +393,10 @@ fn flag_bit(s: &str) -> Option { /// Splits a leading run of digits and dots from s. fn split_num(s: &str) -> (&str, &str) { - let i = s.bytes().position(|c| !(c.is_ascii_digit() || c == b'.')).unwrap_or(s.len()); + let i = s + .bytes() + .position(|c| !(c.is_ascii_digit() || c == b'.')) + .unwrap_or(s.len()); s.split_at(i) } @@ -374,8 +405,14 @@ fn split_num(s: &str) -> (&str, &str) { /// (textutil::set_si). pub fn parse_size(s: &str) -> Result { let (num, unit_raw) = split_num(s); - let Ok(v) = num.parse::() else { return Err(format!("bad size {s:?}")) }; - let bare: f64 = if crate::textutil::si() { 1000.0 } else { 1024.0 }; + let Ok(v) = num.parse::() else { + return Err(format!("bad size {s:?}")); + }; + let bare: f64 = if crate::textutil::si() { + 1000.0 + } else { + 1024.0 + }; let m = match unit_raw.trim().to_lowercase().as_str() { "" | "b" => 1.0, "k" => bare, @@ -405,7 +442,9 @@ pub fn parse_size(s: &str) -> Result { /// Parses durations like 90s, 30min, 12h, 7d, 2w, 6mo, 1y. pub fn parse_age(s: &str) -> Result { let (num, unit) = split_num(s); - let Ok(v) = num.parse::() else { return Err(format!("bad age {s:?}")) }; + let Ok(v) = num.parse::() else { + return Err(format!("bad age {s:?}")); + }; const DAY: f64 = 86400.0; let secs = match unit.to_lowercase().as_str() { "s" | "sec" => 1.0, @@ -415,7 +454,11 @@ pub fn parse_age(s: &str) -> Result { "w" => 7.0 * DAY, "m" | "mo" => 30.0 * DAY, "y" => 365.0 * DAY, - _ => return Err(format!("unknown age unit {unit:?} (s, min, h, d, w, mo, y)")), + _ => { + return Err(format!( + "unknown age unit {unit:?} (s, min, h, d, w, mo, y)" + )) + } }; // Saturates where Go's time.Duration would overflow. Ok(Duration::from_nanos((v * secs * 1e9) as u64)) @@ -428,10 +471,17 @@ fn parse_date(s: &str) -> Option { let b = s.as_bytes(); let fixed = |i: usize, n: usize| -> Option { let d = b.get(i..i + n)?; - d.iter().all(u8::is_ascii_digit).then(|| d.iter().fold(0, |a, &c| a * 10 + (c - b'0') as i64)) + d.iter() + .all(u8::is_ascii_digit) + .then(|| d.iter().fold(0, |a, &c| a * 10 + (c - b'0') as i64)) }; let (y, mo, d) = (fixed(0, 4)?, fixed(5, 2)?, fixed(8, 2)?); - if b.get(4) != Some(&b'-') || b.get(7) != Some(&b'-') || !(1..=12).contains(&mo) || d < 1 || d > days_in(y, mo) { + if b.get(4) != Some(&b'-') + || b.get(7) != Some(&b'-') + || !(1..=12).contains(&mo) + || d < 1 + || d > days_in(y, mo) + { return None; } if b.len() == 10 { @@ -473,7 +523,10 @@ fn parse_date(s: &str) -> Option { return None; } for k in 0..9 { - ns = ns * 10 + b.get(i + 1 + k).filter(|_| k < n).map_or(0, |c| (c - b'0') as i64); + ns = ns * 10 + + b.get(i + 1 + k) + .filter(|_| k < n) + .map_or(0, |c| (c - b'0') as i64); } i += 1 + n; } diff --git a/src/inventory/category.rs b/src/inventory/category.rs index 9b1a2a8..ab424d9 100644 --- a/src/inventory/category.rs +++ b/src/inventory/category.rs @@ -23,14 +23,33 @@ pub enum Category { pub const NUM_CATEGORIES: usize = 12; const CATEGORY_NAMES: [&str; NUM_CATEGORIES] = [ - "Other", "Image", "Video", "Audio", "Archive", "Disk image", "Database", - "Source code", "Document", "Executable", "Log", "Cache", + "Other", + "Image", + "Video", + "Audio", + "Archive", + "Disk image", + "Database", + "Source code", + "Document", + "Executable", + "Log", + "Cache", ]; const ALL: [Category; NUM_CATEGORIES] = [ - Category::Other, Category::Image, Category::Video, Category::Audio, Category::Archive, - Category::DiskImage, Category::Database, Category::Code, Category::Document, - Category::Executable, Category::Log, Category::Cache, + Category::Other, + Category::Image, + Category::Video, + Category::Audio, + Category::Archive, + Category::DiskImage, + Category::Database, + Category::Code, + Category::Document, + Category::Executable, + Category::Log, + Category::Cache, ]; impl Category { @@ -98,8 +117,12 @@ fn ext_table() -> &'static HashMap<&'static str, Category> { pub fn category_for(ext: &str, in_cache: bool, in_log: bool) -> Category { let c = ext_table().get(ext).copied(); match c { - _ if in_cache && matches!(c, None | Some(Category::Other) | Some(Category::Code)) => Category::Cache, - _ if in_log && matches!(c, None | Some(Category::Archive) | Some(Category::Other)) => Category::Log, + _ if in_cache && matches!(c, None | Some(Category::Other) | Some(Category::Code)) => { + Category::Cache + } + _ if in_log && matches!(c, None | Some(Category::Archive) | Some(Category::Other)) => { + Category::Log + } Some(c) => c, None => Category::Other, } @@ -114,7 +137,8 @@ pub fn path_hints(path: &[u8]) -> (bool, bool) { continue; } match part.to_ascii_lowercase().as_slice() { - b"cache" | b".cache" | b"caches" | b"__pycache__" | b".npm" | b".gradle" | b"inetcache" | b"temp" | b"tmp" => in_cache = true, + b"cache" | b".cache" | b"caches" | b"__pycache__" | b".npm" | b".gradle" + | b"inetcache" | b"temp" | b"tmp" => in_cache = true, b"log" | b"logs" | b".logs" | b"journal" => in_log = true, _ => {} } diff --git a/src/inventory/mod.rs b/src/inventory/mod.rs index 4905241..315a6c6 100644 --- a/src/inventory/mod.rs +++ b/src/inventory/mod.rs @@ -72,8 +72,8 @@ pub struct Node { /// Single path component (raw bytes); the root holds the full root path. pub name: Box<[u8]>, - pub size: i64, // own logical bytes - pub alloc: i64, // own allocated bytes + pub size: i64, // own logical bytes + pub alloc: i64, // own allocated bytes pub tot_size: i64, // aggregates (hard-link duplicates contribute 0) pub tot_alloc: i64, pub mtime: i64, // unix nanoseconds @@ -81,7 +81,7 @@ pub struct Node { pub files: u32, // recursive counts beneath (dirs excludes self) pub dirs: u32, pub errors: u32, // recursive error count beneath and including self - pub mode: u32, // Go fs.FileMode bit layout (see platform::mode) + pub mode: u32, // Go fs.FileMode bit layout (see platform::mode) pub uid: u32, pub gid: u32, pub nlink: u32, @@ -129,7 +129,10 @@ struct Chunked { impl Chunked { fn new() -> Self { - Chunked { c: Vec::new(), n: 0 } + Chunked { + c: Vec::new(), + n: 0, + } } fn add(&mut self, v: T) -> u32 { if self.n & (CHUNK_SIZE - 1) == 0 && self.n >> CHUNK_BITS == self.c.len() { @@ -202,9 +205,22 @@ impl Tree { next_sibling: NO_NODE, dir: 0, name: name.into(), - size: 0, alloc: 0, tot_size: 0, tot_alloc: 0, mtime: 0, - files: 0, dirs: 0, errors: 0, mode: 0, uid: 0, gid: 0, nlink: 0, - ext: 0, flags: 0, kind, cat: Category::Other, + size: 0, + alloc: 0, + tot_size: 0, + tot_alloc: 0, + mtime: 0, + files: 0, + dirs: 0, + errors: 0, + mode: 0, + uid: 0, + gid: 0, + nlink: 0, + ext: 0, + flags: 0, + kind, + cat: Category::Other, }; if kind == Kind::Dir { n.dir = self.dirs.add([0; NUM_CATEGORIES]); @@ -274,7 +290,10 @@ impl Tree { /// Iterates the children of id (in reverse insertion order). pub fn children(&self, id: NodeId) -> Children<'_> { - Children { t: self, cur: self.node(id).first_child } + Children { + t: self, + cur: self.node(id).first_child, + } } pub fn child_count(&self, id: NodeId) -> usize { @@ -408,9 +427,17 @@ struct Shallow { impl Node { fn clone_shallow(&self) -> Shallow { Shallow { - parent: self.parent, dir: self.dir, tot_size: self.tot_size, tot_alloc: self.tot_alloc, - alloc: self.alloc, files: self.files, dirs: self.dirs, errors: self.errors, - flags: self.flags, kind: self.kind, cat: self.cat, + parent: self.parent, + dir: self.dir, + tot_size: self.tot_size, + tot_alloc: self.tot_alloc, + alloc: self.alloc, + files: self.files, + dirs: self.dirs, + errors: self.errors, + flags: self.flags, + kind: self.kind, + cat: self.cat, } } } @@ -471,7 +498,13 @@ mod tests { #[test] fn ext_and_category() { - for (input, want) in [("a.QCOW2", "qcow2"), (".bashrc", ""), ("noext", ""), ("x.tar.gz", "gz"), ("trail.", "")] { + for (input, want) in [ + ("a.QCOW2", "qcow2"), + (".bashrc", ""), + ("noext", ""), + ("x.tar.gz", "gz"), + ("trail.", ""), + ] { assert_eq!(ext(input.as_bytes()), want, "{input}"); } assert_eq!(category_for("qcow2", false, false), Category::DiskImage); diff --git a/src/inventory/query.rs b/src/inventory/query.rs index cb02600..9182507 100644 --- a/src/inventory/query.rs +++ b/src/inventory/query.rs @@ -47,7 +47,10 @@ pub struct TopK { impl TopK { pub fn new(k: usize) -> TopK { - TopK { k, h: BinaryHeap::with_capacity(k.min(1 << 16)) } + TopK { + k, + h: BinaryHeap::with_capacity(k.min(1 << 16)), + } } pub fn offer(&mut self, id: NodeId, key: i64) { @@ -67,7 +70,11 @@ impl TopK { /// The collected IDs, largest key first (ties by ID). pub fn sorted(self) -> Vec { // into_sorted_vec is ascending in Reverse order = descending key. - self.h.into_sorted_vec().into_iter().map(|Reverse((_, Reverse(id)))| id).collect() + self.h + .into_sorted_vec() + .into_iter() + .map(|Reverse((_, Reverse(id)))| id) + .collect() } } @@ -87,7 +94,13 @@ impl Tree { /// The k nodes beneath (and including) root with the largest key, /// considering only nodes accepted by keep. - pub fn top(&self, root: NodeId, k: usize, keep: impl Fn(&Node) -> bool, key: impl Fn(&Node) -> i64) -> Vec { + pub fn top( + &self, + root: NodeId, + k: usize, + keep: impl Fn(&Node) -> bool, + key: impl Fn(&Node) -> i64, + ) -> Vec { let mut tk = TopK::new(k); self.walk(root, |id, n| { if keep(n) { diff --git a/src/inventory/stats.rs b/src/inventory/stats.rs index a5d0d2d..2c9369c 100644 --- a/src/inventory/stats.rs +++ b/src/inventory/stats.rs @@ -18,13 +18,25 @@ pub const NUM_ERR_KINDS: usize = 5; impl ErrKind { pub fn from_u8(v: u8) -> ErrKind { - [ErrKind::Permission, ErrKind::Vanished, ErrKind::Io, ErrKind::Loop, ErrKind::Other] - .get(v as usize) - .copied() - .unwrap_or(ErrKind::Other) + [ + ErrKind::Permission, + ErrKind::Vanished, + ErrKind::Io, + ErrKind::Loop, + ErrKind::Other, + ] + .get(v as usize) + .copied() + .unwrap_or(ErrKind::Other) } pub fn name(self) -> &'static str { - ["Permission denied", "Vanished during scan", "I/O error", "Symlink loop", "Other error"][self as usize] + [ + "Permission denied", + "Vanished during scan", + "I/O error", + "Symlink loop", + "Other error", + ][self as usize] } } @@ -46,7 +58,7 @@ pub fn classify(err: &io::Error) -> ErrKind { /// One recorded scan error. #[derive(Clone, Debug, Default)] pub struct ErrorRecord { - pub node: NodeId, // node the error is attached to (the directory for listing failures) + pub node: NodeId, // node the error is attached to (the directory for listing failures) pub name: Vec, // entry name when no node was created (e.g. vanished) pub kind: ErrKind, pub msg: String, @@ -89,11 +101,29 @@ pub struct Stats { impl Default for Stats { fn default() -> Self { Stats { - root: Vec::new(), start: SystemTime::now(), end: None, complete: false, cancelled: false, - files: 0, dirs: 0, symlinks: 0, others: 0, err_counts: [0; NUM_ERR_KINDS], errors: Vec::new(), - excluded: 0, skipped_mounts: 0, virtual_skipped: 0, loops_skipped: 0, broken_links: 0, - hardlink_dups: 0, unscanned: 0, excludes: Vec::new(), one_file_system: false, - follow: String::new(), workers: 0, from_snapshot: String::new(), + root: Vec::new(), + start: SystemTime::now(), + end: None, + complete: false, + cancelled: false, + files: 0, + dirs: 0, + symlinks: 0, + others: 0, + err_counts: [0; NUM_ERR_KINDS], + errors: Vec::new(), + excluded: 0, + skipped_mounts: 0, + virtual_skipped: 0, + loops_skipped: 0, + broken_links: 0, + hardlink_dups: 0, + unscanned: 0, + excludes: Vec::new(), + one_file_system: false, + follow: String::new(), + workers: 0, + from_snapshot: String::new(), } } } diff --git a/src/main.rs b/src/main.rs index c32a9ac..0642318 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,11 +7,36 @@ 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", + "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"]; +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; @@ -54,7 +79,10 @@ Flags: } fn main() { - std::process::exit(run(std::env::args_os().skip(1).map(|a| a.to_string_lossy().into_owned()).collect())); + 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 { @@ -74,7 +102,11 @@ fn run(args: Vec) -> i32 { positional.extend(it.by_ref()); break; } - let Some(flag) = a.strip_prefix("--").or_else(|| a.strip_prefix('-')).filter(|f| !f.is_empty() && a != "-") else { + let Some(flag) = a + .strip_prefix("--") + .or_else(|| a.strip_prefix('-')) + .filter(|f| !f.is_empty() && a != "-") + else { positional.push(a); continue; }; @@ -90,10 +122,17 @@ fn run(args: Vec) -> i32 { 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())), + 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}")) }; + let Some(v) = inline.or_else(|| it.next()) else { + return fail(format!("flag needs an argument: -{n}")); + }; if n == "exclude" { excludes.push(v); } else { @@ -107,11 +146,20 @@ fn run(args: Vec) -> i32 { 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"))) + 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); + println!( + "{} {} ({}/{})", + brand::NAME, + brand::VERSION, + std::env::consts::OS, + std::env::consts::ARCH + ); return 0; } if let Some(e) = cfg_err { @@ -143,7 +191,11 @@ fn run(args: Vec) -> i32 { 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 }, + 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") { @@ -158,7 +210,10 @@ fn run(args: Vec) -> i32 { 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])) { + if positional.len() == 1 + && cfg.load.is_empty() + && snapshot::is_snapshot(Path::new(&positional[0])) + { cfg.load = positional.remove(0); } else { cfg.paths = positional; @@ -168,14 +223,17 @@ fn run(args: Vec) -> i32 { Ok(w) => w, Err(e) => return fail(e), }; - let follow = match scan::parse_follow(&v("follow-symlinks").unwrap_or_else(|| settings.follow_symlinks.clone())) { + 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), + one_file_system: b("one-file-system").unwrap_or(settings.one_file_system) + || b("x").unwrap_or(false), excludes, ..Default::default() }; diff --git a/src/platform/mod.rs b/src/platform/mod.rs index d52c8de..bb1a180 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -117,7 +117,12 @@ fn cvt(r: libc::c_int) -> io::Result { fn open_dir(path: &[u8]) -> io::Result { let c = cstr(path)?; - let fd = cvt(unsafe { libc::open(c.as_ptr(), libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC) })?; + let fd = cvt(unsafe { + libc::open( + c.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC, + ) + })?; Ok(unsafe { OwnedFd::from_raw_fd(fd) }) } @@ -129,7 +134,15 @@ fn stat_at(dirfd: RawFd, name: &CStr, flags: libc::c_int) -> io::Result { if !NO_STATX.load(Ordering::Relaxed) { let mut st: libc::statx = unsafe { std::mem::zeroed() }; let mask = libc::STATX_BASIC_STATS | libc::STATX_BTIME; - let r = unsafe { libc::statx(dirfd, name.as_ptr(), flags | libc::AT_STATX_DONT_SYNC, mask, &mut st) }; + let r = unsafe { + libc::statx( + dirfd, + name.as_ptr(), + flags | libc::AT_STATX_DONT_SYNC, + mask, + &mut st, + ) + }; if r == 0 { let mut m = Meta { mode: mode_from_unix(st.stx_mode as u32), @@ -202,7 +215,11 @@ impl Drop for DirStream { /// Streams the entries of a directory in chunks of up to `chunk`, stat-ing /// each entry relative to the directory fd (no repeated path resolution). /// Returning an error from f stops the read and returns it. -pub fn read_dir>(path: &[u8], chunk: usize, mut f: impl FnMut(Vec) -> Result<(), E>) -> Result<(), E> { +pub fn read_dir>( + path: &[u8], + chunk: usize, + mut f: impl FnMut(Vec) -> Result<(), E>, +) -> Result<(), E> { let fd = open_dir(path)?; let raw = fd.as_raw_fd(); let dir = unsafe { libc::fdopendir(raw) }; @@ -227,7 +244,10 @@ pub fn read_dir>(path: &[u8], chunk: usize, mut f: impl FnMut if nb == b"." || nb == b".." { continue; } - let mut m = stat_at(raw, name, libc::AT_SYMLINK_NOFOLLOW).unwrap_or_else(|e| Meta { err: Some(e), ..Default::default() }); + let mut m = stat_at(raw, name, libc::AT_SYMLINK_NOFOLLOW).unwrap_or_else(|e| Meta { + err: Some(e), + ..Default::default() + }); m.name = nb.to_vec(); out.push(m); if out.len() >= chunk { @@ -387,14 +407,22 @@ fn check_abs(path: &Path) -> io::Result<()> { /// final symlink, and O_NONBLOCK keeps a FIFO swapped in since the scan from /// blocking open(2) forever; callers still check the file type. pub fn open_content(path: &Path) -> io::Result { - std::fs::OpenOptions::new().read(true).custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK).open(path) + std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(path) } /// Runs a helper program with an argument vector (never a shell), detached /// from the terminal so it cannot disturb the TUI. fn start_detached(name: &str, arg: &Path) -> io::Result<()> { use std::process::{Command, Stdio}; - let mut child = Command::new(name).arg(arg).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn()?; + let mut child = Command::new(name) + .arg(arg) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; std::thread::spawn(move || child.wait()); Ok(()) } @@ -406,8 +434,16 @@ pub const TRASH_NAME: &str = "trash"; pub fn reveal(path: &Path) -> io::Result<()> { check_abs(path)?; let md = std::fs::symlink_metadata(path)?; - let target = if md.is_dir() { path } else { path.parent().unwrap_or(path) }; - let opener = if cfg!(target_os = "macos") { "open" } else { "xdg-open" }; + let target = if md.is_dir() { + path + } else { + path.parent().unwrap_or(path) + }; + let opener = if cfg!(target_os = "macos") { + "open" + } else { + "xdg-open" + }; start_detached(opener, target) } @@ -417,7 +453,8 @@ pub fn headless() -> bool { if cfg!(target_os = "macos") { return false; } - std::env::var_os("DISPLAY").is_none_or(|v| v.is_empty()) && std::env::var_os("WAYLAND_DISPLAY").is_none_or(|v| v.is_empty()) + std::env::var_os("DISPLAY").is_none_or(|v| v.is_empty()) + && std::env::var_os("WAYLAND_DISPLAY").is_none_or(|v| v.is_empty()) } fn split_parent(path: &Path) -> io::Result<(&Path, &OsStr)> { @@ -430,7 +467,9 @@ fn split_parent(path: &Path) -> io::Result<(&Path, &OsStr)> { fn check_expect(dirfd: RawFd, base: &CStr, want: Expect) -> io::Result { let mut st: libc::stat = unsafe { std::mem::zeroed() }; cvt(unsafe { libc::fstatat(dirfd, base.as_ptr(), &mut st, libc::AT_SYMLINK_NOFOLLOW) })?; - if st.st_uid != want.uid || mode_from_unix(st.st_mode as u32) & mode::TYPE != want.mode & mode::TYPE { + if st.st_uid != want.uid + || mode_from_unix(st.st_mode as u32) & mode::TYPE != want.mode & mode::TYPE + { return Err(other(CHANGED)); } Ok(st) @@ -446,7 +485,11 @@ pub fn delete(path: &Path, want: Expect) -> io::Result<()> { let dfd = open_dir(dir.as_os_str().as_bytes())?; let cbase = cstr(base.as_bytes())?; let st = check_expect(dfd.as_raw_fd(), &cbase, want)?; - remove_at(dfd.as_raw_fd(), &cbase, st.st_mode & libc::S_IFMT == libc::S_IFDIR) + remove_at( + dfd.as_raw_fd(), + &cbase, + st.st_mode & libc::S_IFMT == libc::S_IFDIR, + ) } fn remove_at(dirfd: RawFd, name: &CStr, is_dir: bool) -> io::Result<()> { @@ -454,7 +497,11 @@ fn remove_at(dirfd: RawFd, name: &CStr, is_dir: bool) -> io::Result<()> { return cvt(unsafe { libc::unlinkat(dirfd, name.as_ptr(), 0) }).map(|_| ()); } let fd = cvt(unsafe { - libc::openat(dirfd, name.as_ptr(), libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC) + libc::openat( + dirfd, + name.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC, + ) })?; let sub = unsafe { OwnedFd::from_raw_fd(fd) }; // Collect names first: removing while iterating a DIR stream is unspecified. @@ -475,7 +522,8 @@ fn remove_at(dirfd: RawFd, name: &CStr, is_dir: bool) -> io::Result<()> { if n.to_bytes() != b"." && n.to_bytes() != b".." { let t = unsafe { (*ent).d_type }; let is_dir = match t { - libc::DT_UNKNOWN => fstat_at(sub.as_raw_fd(), n, libc::AT_SYMLINK_NOFOLLOW).is_ok_and(|m| mode::is_dir(m.mode)), + libc::DT_UNKNOWN => fstat_at(sub.as_raw_fd(), n, libc::AT_SYMLINK_NOFOLLOW) + .is_ok_and(|m| mode::is_dir(m.mode)), t => t == libc::DT_DIR, }; names.push((n.to_owned(), is_dir)); @@ -498,7 +546,15 @@ fn rename_checked(path: &Path, dst: &Path, want: Expect) -> io::Result<()> { let cbase = cstr(base.as_bytes())?; check_expect(dfd.as_raw_fd(), &cbase, want)?; let cdst = cstr(dst.as_os_str().as_bytes())?; - cvt(unsafe { libc::renameat(dfd.as_raw_fd(), cbase.as_ptr(), libc::AT_FDCWD, cdst.as_ptr()) }).map(|_| ()) + cvt(unsafe { + libc::renameat( + dfd.as_raw_fd(), + cbase.as_ptr(), + libc::AT_FDCWD, + cdst.as_ptr(), + ) + }) + .map(|_| ()) } /// Creates dir (mode 0700) if missing; an existing entry must be a real @@ -529,7 +585,9 @@ fn ensure_private_dir(dir: &Path, strict: bool) -> io::Result<()> { fn mount_top(path: &Path, dev: u64) -> PathBuf { let mut cur = path.parent().unwrap_or(path).to_path_buf(); loop { - let Some(parent) = cur.parent() else { return cur }; + let Some(parent) = cur.parent() else { + return cur; + }; match std::fs::metadata(parent) { Ok(m) if m.dev() == dev => cur = parent.to_path_buf(), _ => return cur, @@ -553,7 +611,15 @@ fn local_timestamp() -> String { let mut t: libc::time_t = unsafe { libc::time(std::ptr::null_mut()) }; let mut tm: libc::tm = unsafe { std::mem::zeroed() }; unsafe { libc::localtime_r(&mut t, &mut tm) }; - format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}", tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday, tm.tm_hour, tm.tm_min, tm.tm_sec) + format!( + "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}", + tm.tm_year + 1900, + tm.tm_mon + 1, + tm.tm_mday, + tm.tm_hour, + tm.tm_min, + tm.tm_sec + ) } /// Moves path to the freedesktop.org trash: the home trash when on the same @@ -565,7 +631,9 @@ pub fn trash(path: &Path, want: Expect) -> io::Result<()> { let data_home = match std::env::var_os("XDG_DATA_HOME").filter(|v| !v.is_empty()) { Some(d) => PathBuf::from(d), None => { - let home = std::env::var_os("HOME").filter(|v| !v.is_empty()).ok_or_else(|| other("$HOME is not defined"))?; + let home = std::env::var_os("HOME") + .filter(|v| !v.is_empty()) + .ok_or_else(|| other("$HOME is not defined"))?; PathBuf::from(home).join(".local/share") } }; @@ -575,21 +643,31 @@ pub fn trash(path: &Path, want: Expect) -> io::Result<()> { _ => { let top = mount_top(path, dev); let rel = path.strip_prefix(&top).map_err(|e| other(e.to_string()))?; - (top.join(format!(".Trash-{}", unsafe { libc::getuid() })), rel.as_os_str().as_bytes().to_vec()) + ( + top.join(format!(".Trash-{}", unsafe { libc::getuid() })), + rel.as_os_str().as_bytes().to_vec(), + ) } }; let shared = trash != home; if !shared { use std::os::unix::fs::DirBuilderExt; - std::fs::DirBuilder::new().recursive(true).mode(0o700).create(&trash)?; + std::fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(&trash)?; } else if let Err(e) = ensure_private_dir(&trash, true) { // On a shared filesystem another user could plant a symlink or a // world-writable directory here; refuse rather than follow it. - return Err(other(format!("trash directory {} is unsafe: {e}", trash.display()))); + return Err(other(format!( + "trash directory {} is unsafe: {e}", + trash.display() + ))); } let (files, info) = (trash.join("files"), trash.join("info")); for d in [&files, &info] { - ensure_private_dir(d, shared).map_err(|e| other(format!("trash directory {} is unsafe: {e}", d.display())))?; + ensure_private_dir(d, shared) + .map_err(|e| other(format!("trash directory {} is unsafe: {e}", d.display())))?; } let base = path.file_name().ok_or_else(|| other("no file name"))?; for i in 1..10000 { @@ -604,12 +682,23 @@ pub fn trash(path: &Path, want: Expect) -> io::Result<()> { let mut info_name = name.clone(); info_name.push(".trashinfo"); let ip = info.join(info_name); - let mut f = match std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(&ip) { + let mut f = match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&ip) + { Ok(f) => f, Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue, Err(e) => return Err(e), }; - let w = write!(f, "[Trash Info]\nPath={}\nDeletionDate={}\n", percent_encode_path(&info_path), local_timestamp()).and_then(|_| f.sync_all()); + let w = write!( + f, + "[Trash Info]\nPath={}\nDeletionDate={}\n", + percent_encode_path(&info_path), + local_timestamp() + ) + .and_then(|_| f.sync_all()); drop(f); if let Err(e) = w.and_then(|_| rename_checked(path, &dst, want)) { let _ = std::fs::remove_file(&ip); @@ -638,7 +727,14 @@ mod tests { std::fs::write(d.join("sub/inner/f"), b"x").unwrap(); let uid = unsafe { libc::getuid() }; assert!(delete(&d.join("sub"), Expect { uid, mode: 0 }).is_err()); - delete(&d.join("sub"), Expect { uid, mode: mode::DIR }).unwrap(); + delete( + &d.join("sub"), + Expect { + uid, + mode: mode::DIR, + }, + ) + .unwrap(); assert!(!d.join("sub").exists()); std::fs::remove_dir_all(&d).unwrap(); } diff --git a/src/scan.rs b/src/scan.rs index f117a7d..ef6852f 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -38,7 +38,9 @@ pub fn parse_follow(s: &str) -> Result { "none" | "" => Ok(FollowMode::None), "same-filesystem" | "same-fs" => Ok(FollowMode::SameFs), "all" => Ok(FollowMode::All), - _ => Err(format!("invalid --follow-symlinks {s:?} (want none, same-filesystem or all)")), + _ => Err(format!( + "invalid --follow-symlinks {s:?} (want none, same-filesystem or all)" + )), } } @@ -51,7 +53,9 @@ pub fn workers(mode: &str) -> Result { "aggressive" => Ok((8 * cpu).clamp(4, 64)), _ => match mode.trim().parse::() { Ok(n) if (1..=1024).contains(&n) => Ok(n), - _ => Err(format!("invalid workers {mode:?} (conservative, balanced, aggressive or 1-1024)")), + _ => Err(format!( + "invalid workers {mode:?} (conservative, balanced, aggressive or 1-1024)" + )), }, } } @@ -123,7 +127,10 @@ impl Scanner { } pub fn is_done(&self) -> bool { - matches!(self.done.try_recv(), Err(crossbeam_channel::TryRecvError::Disconnected)) + matches!( + self.done.try_recv(), + Err(crossbeam_channel::TryRecvError::Disconnected) + ) } /// Blocks until the scan is done. @@ -235,7 +242,12 @@ pub fn start(root: &[u8], mut opts: Options, cancel: Cancel) -> io::Result io::Result io::Result>, results: Sender if cancel.is_cancelled() { return Err(ReadErr::Cancelled); } - results.send(ScanResult { job: j.clone(), entries: ms, is_final: false, err: None }).map_err(|_| ReadErr::Cancelled) + results + .send(ScanResult { + job: j.clone(), + entries: ms, + is_final: false, + err: None, + }) + .map_err(|_| ReadErr::Cancelled) }) .err() }; - let _ = results.send(ScanResult { job: j, entries: Vec::new(), is_final: true, err }); + let _ = results.send(ScanResult { + job: j, + entries: Vec::new(), + is_final: true, + err, + }); } } @@ -304,7 +339,12 @@ impl Controller { let (res_tx, res_rx) = crossbeam_channel::bounded::(self.opts.result_buf); let mut handles = Vec::with_capacity(self.opts.workers); for i in 0..self.opts.workers { - let (jobs, results, cancel, chunk) = (jobs_rx.clone(), res_tx.clone(), cancel.clone(), self.opts.chunk_size); + let (jobs, results, cancel, chunk) = ( + jobs_rx.clone(), + res_tx.clone(), + cancel.clone(), + self.opts.chunk_size, + ); handles.push( std::thread::Builder::new() .name(format!("scan-worker-{i}")) @@ -332,7 +372,8 @@ impl Controller { let op = sel.select(); let i = op.index(); if Some(i) == send_i { - op.send(&jobs_tx, next.take().unwrap()).expect("workers alive"); + op.send(&jobs_tx, next.take().unwrap()) + .expect("workers alive"); self.head += 1; if self.head > 4096 && self.head * 2 > self.pending.len() { self.pending.drain(..self.head); @@ -366,7 +407,9 @@ impl Controller { next = None; } self.counters.inflight.store(inflight, Ordering::Relaxed); - self.counters.pending.store((self.pending.len() - self.head) as i64, Ordering::Relaxed); + self.counters + .pending + .store((self.pending.len() - self.head) as i64, Ordering::Relaxed); } drop(jobs_tx); for h in handles { @@ -391,7 +434,13 @@ impl Controller { fn make_job(&self, (id, dev): (NodeId, u64)) -> Arc { let path = self.tree.read().unwrap().path_bytes(id); let (in_cache, in_log) = inv::path_hints(&path); - Arc::new(Job { id, path, dev, in_cache, in_log }) + Arc::new(Job { + id, + path, + dev, + in_cache, + in_log, + }) } /// Merges one result into the tree. Reports whether the result completed @@ -399,7 +448,9 @@ impl Controller { fn apply(&mut self, t: &mut Tree, r: ScanResult) -> bool { let j = &*r.job; let mut d = Delta::default(); - self.counters.entries.fetch_add(r.entries.len() as i64, Ordering::Relaxed); + self.counters + .entries + .fetch_add(r.entries.len() as i64, Ordering::Relaxed); for m in r.entries { self.add_entry(t, j, m, &mut d); } @@ -413,7 +464,12 @@ impl Controller { } Some(ReadErr::Io(e)) => { dn.flags |= inv::FLAG_ERROR | inv::FLAG_INCOMPLETE; - t.stats.add_error(ErrorRecord { node: j.id, kind: inv::classify(&e), msg: format!("open: {}", err_msg(&e)), ..Default::default() }); + t.stats.add_error(ErrorRecord { + node: j.id, + kind: inv::classify(&e), + msg: format!("open: {}", err_msg(&e)), + ..Default::default() + }); d.errors += 1; } } @@ -427,7 +483,9 @@ impl Controller { fn excluded(&self, path: &[u8], name: &[u8]) -> bool { self.excludes.iter().any(|(is_path, p)| { if *is_path { - path == p.as_slice() || path.starts_with(p) && path.get(p.len()) == Some(&b'/') || platform::glob_match(p, path) + path == p.as_slice() + || path.starts_with(p) && path.get(p.len()) == Some(&b'/') + || platform::glob_match(p, path) } else { platform::glob_match(p, name) } @@ -438,7 +496,12 @@ impl Controller { if let Some(e) = &m.err { let k = inv::classify(e); if k == inv::ErrKind::Vanished { - t.stats.add_error(ErrorRecord { node: j.id, name: m.name, kind: k, msg: "vanished between listing and stat".into() }); + t.stats.add_error(ErrorRecord { + node: j.id, + name: m.name, + kind: k, + msg: "vanished between listing and stat".into(), + }); d.errors += 1; return; } @@ -446,7 +509,12 @@ impl Controller { let n = t.node_mut(id); n.flags |= inv::FLAG_ERROR; n.errors = 1; - t.stats.add_error(ErrorRecord { node: id, kind: k, msg: format!("stat: {}", err_msg(e)), ..Default::default() }); + t.stats.add_error(ErrorRecord { + node: id, + kind: k, + msg: format!("stat: {}", err_msg(e)), + ..Default::default() + }); d.errors += 1; d.files += 1; t.stats.others += 1; @@ -472,7 +540,12 @@ impl Controller { t.stats.broken_links += 1; broken = true; } - Ok(tm) if platform::mode::is_dir(tm.mode) && tm.has_ino && (self.opts.follow == FollowMode::All || self.opts.follow == FollowMode::SameFs && tm.dev == j.dev) => { + Ok(tm) + if platform::mode::is_dir(tm.mode) + && tm.has_ino + && (self.opts.follow == FollowMode::All + || self.opts.follow == FollowMode::SameFs && tm.dev == j.dev) => + { target = Some(tm); kind = Kind::Dir; } @@ -596,7 +669,11 @@ pub(crate) mod tests { impl TempDir { pub fn new(tag: &str) -> TempDir { static N: AtomicI64 = AtomicI64::new(0); - let p = std::env::temp_dir().join(format!("mapsize-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); + let p = std::env::temp_dir().join(format!( + "mapsize-{tag}-{}-{}", + std::process::id(), + N.fetch_add(1, Ordering::Relaxed) + )); std::fs::create_dir_all(&p).unwrap(); TempDir(p) } @@ -609,7 +686,14 @@ pub(crate) mod tests { /// Creates a fan-out tree: (files, dirs, bytes). pub fn mk_tree(dir: &Path, fanout: usize, depth: usize, files: usize) -> (i64, i64, i64) { - fn rec(dir: &Path, d: usize, fanout: usize, depth: usize, files: usize, acc: &mut (i64, i64, i64)) { + fn rec( + dir: &Path, + d: usize, + fanout: usize, + depth: usize, + files: usize, + acc: &mut (i64, i64, i64), + ) { for i in 0..files { let sz = (i + 1) * 100; std::fs::write(dir.join(format!("f{i}.txt")), vec![0u8; sz]).unwrap(); @@ -633,23 +717,38 @@ pub(crate) mod tests { pub fn run_scan(root: &Path, o: Options) -> Tree { let s = start(root.as_os_str().as_bytes(), o, Cancel::new()).unwrap(); - s.done().recv_timeout(std::time::Duration::from_secs(60)).expect_err("scan did not finish (deadlock?)"); + s.done() + .recv_timeout(std::time::Duration::from_secs(60)) + .expect_err("scan did not finish (deadlock?)"); let t = s.tree.read().unwrap().clone(); t } fn totals(t: &Tree) -> Vec<[i64; 4]> { - (0..t.len() as NodeId).map(|i| t.node(i)).map(|n| [n.tot_size, n.tot_alloc, n.files as i64, n.dirs as i64]).collect() + (0..t.len() as NodeId) + .map(|i| t.node(i)) + .map(|n| [n.tot_size, n.tot_alloc, n.files as i64, n.dirs as i64]) + .collect() } #[test] fn counts_and_aggregates() { let root = TempDir::new("scan"); let (nf, nd, bytes) = mk_tree(&root.0, 3, 3, 4); - let mut t = run_scan(&root.0, Options { workers: 4, ..Default::default() }); + let mut t = run_scan( + &root.0, + Options { + workers: 4, + ..Default::default() + }, + ); let r = t.node(0); assert_eq!((r.files as i64, r.dirs as i64), (nf, nd)); - let dir_bytes: i64 = (0..t.len() as NodeId).map(|i| t.node(i)).filter(|n| n.is_dir()).map(|n| n.size).sum(); + let dir_bytes: i64 = (0..t.len() as NodeId) + .map(|i| t.node(i)) + .filter(|n| n.is_dir()) + .map(|n| n.size) + .sum(); assert_eq!(r.tot_size, bytes + dir_bytes); assert!(t.stats.complete && !t.stats.incomplete()); for i in 0..t.len() as NodeId { @@ -661,7 +760,11 @@ pub(crate) mod tests { } let before = totals(&t); t.recompute(); - assert_eq!(before, totals(&t), "incremental aggregates differ from recompute"); + assert_eq!( + before, + totals(&t), + "incremental aggregates differ from recompute" + ); } #[test] @@ -669,8 +772,22 @@ pub(crate) mod tests { let root = TempDir::new("bp"); let (nf, nd, _) = mk_tree(&root.0, 6, 4, 3); for w in [1, 2, 16] { - let t = run_scan(&root.0, Options { workers: w, job_buffer: 1, result_buf: 1, chunk_size: 1, apply_batch: 1, ..Default::default() }); - assert_eq!((t.node(0).files as i64, t.node(0).dirs as i64), (nf, nd), "workers={w}"); + let t = run_scan( + &root.0, + Options { + workers: w, + job_buffer: 1, + result_buf: 1, + chunk_size: 1, + apply_batch: 1, + ..Default::default() + }, + ); + assert_eq!( + (t.node(0).files as i64, t.node(0).dirs as i64), + (nf, nd), + "workers={w}" + ); } } @@ -679,9 +796,22 @@ pub(crate) mod tests { let root = TempDir::new("cancel"); mk_tree(&root.0, 8, 3, 2); let c = Cancel::new(); - let s = start(root.0.as_os_str().as_bytes(), Options { workers: 2, job_buffer: 1, result_buf: 1, chunk_size: 1, ..Default::default() }, c.clone()).unwrap(); + let s = start( + root.0.as_os_str().as_bytes(), + Options { + workers: 2, + job_buffer: 1, + result_buf: 1, + chunk_size: 1, + ..Default::default() + }, + c.clone(), + ) + .unwrap(); c.cancel(); - s.done().recv_timeout(std::time::Duration::from_secs(10)).expect_err("cancel did not stop scan"); + s.done() + .recv_timeout(std::time::Duration::from_secs(10)) + .expect_err("cancel did not stop scan"); let t = s.tree.read().unwrap(); assert!(t.stats.cancelled && t.stats.incomplete()); } @@ -696,12 +826,21 @@ pub(crate) mod tests { std::os::unix::fs::symlink("../a", a.join("self")).unwrap(); std::os::unix::fs::symlink("nowhere", root.0.join("broken")).unwrap(); for f in [FollowMode::None, FollowMode::SameFs, FollowMode::All] { - let t = run_scan(&root.0, Options { workers: 4, follow: f, ..Default::default() }); + let t = run_scan( + &root.0, + Options { + workers: 4, + follow: f, + ..Default::default() + }, + ); let r = t.node(0); assert!(r.files > 0 && r.tot_size > 5000, "{f:?}"); assert_eq!(t.stats.broken_links, 1); assert!(f == FollowMode::None || t.stats.loops_skipped > 0); - let count = (0..t.len() as NodeId).filter(|&i| &*t.node(i).name == b"x").count(); + let count = (0..t.len() as NodeId) + .filter(|&i| &*t.node(i).name == b"x") + .count(); assert_eq!(count, 1, "{f:?}"); } } @@ -713,7 +852,13 @@ pub(crate) mod tests { std::fs::write(&p, vec![0u8; 100000]).unwrap(); std::fs::create_dir(root.0.join("d")).unwrap(); std::fs::hard_link(&p, root.0.join("d/link")).unwrap(); - let t = run_scan(&root.0, Options { workers: 2, ..Default::default() }); + let t = run_scan( + &root.0, + Options { + workers: 2, + ..Default::default() + }, + ); assert_eq!(t.stats.hardlink_dups, 1); assert!(t.node(0).tot_size < 200000); } @@ -729,7 +874,13 @@ pub(crate) mod tests { std::fs::create_dir(&locked).unwrap(); std::fs::write(locked.join("f"), b"x").unwrap(); std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0)).unwrap(); - let t = run_scan(&root.0, Options { workers: 2, ..Default::default() }); + let t = run_scan( + &root.0, + Options { + workers: 2, + ..Default::default() + }, + ); std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap(); assert_eq!(t.stats.err_counts[inv::ErrKind::Permission as usize], 1); assert!(t.stats.incomplete()); @@ -745,18 +896,38 @@ pub(crate) mod tests { std::fs::write(root.0.join("keep/b.txt"), b"x").unwrap(); std::fs::write(root.0.join("skip/c.txt"), b"x").unwrap(); let skip = root.0.join("skip").to_string_lossy().into_owned(); - let t = run_scan(&root.0, Options { workers: 2, excludes: vec!["*.tmp".into(), skip], ..Default::default() }); + let t = run_scan( + &root.0, + Options { + workers: 2, + excludes: vec!["*.tmp".into(), skip], + ..Default::default() + }, + ); assert_eq!((t.stats.excluded, t.node(0).files), (2, 1)); } #[test] fn hostile_names_preserved() { let root = TempDir::new("names"); - let names: [&[u8]; 6] = ["日本語.txt".as_bytes(), "emoji-🎉.png".as_bytes(), b"esc\x1b[31mred", b"nl\nname", b"sp ace", b"bad\xffutf8"]; + let names: [&[u8]; 6] = [ + "日本語.txt".as_bytes(), + "emoji-🎉.png".as_bytes(), + b"esc\x1b[31mred", + b"nl\nname", + b"sp ace", + b"bad\xffutf8", + ]; for n in names { std::fs::write(root.0.join(std::ffi::OsStr::from_bytes(n)), b"x").unwrap(); } - let t = run_scan(&root.0, Options { workers: 2, ..Default::default() }); + let t = run_scan( + &root.0, + Options { + workers: 2, + ..Default::default() + }, + ); let got: HashSet<&[u8]> = t.children(0).map(|(_, n)| &*n.name).collect(); for n in names { assert!(got.contains(n), "missing {n:?}"); diff --git a/src/snapshot/compare.rs b/src/snapshot/compare.rs index 08989a7..16b88e2 100644 --- a/src/snapshot/compare.rs +++ b/src/snapshot/compare.rs @@ -28,7 +28,8 @@ pub fn compare(old: Tree, new: &Tree) -> Diff { if !old.node(o).is_dir() || !new.node(n).is_dir() { continue; } - let mut by_name: HashMap<&[u8], NodeId> = old.children(o).map(|(id, c)| (&c.name[..], id)).collect(); + let mut by_name: HashMap<&[u8], NodeId> = + old.children(o).map(|(id, c)| (&c.name[..], id)).collect(); for (id, c) in new.children(n) { if let Some(oid) = by_name.remove(&c.name[..]) { stack.push((oid, id)); @@ -43,7 +44,12 @@ pub fn compare(old: Tree, new: &Tree) -> Diff { } } removed.sort_unstable(); - Diff { old, old_of, removed, removed_under } + Diff { + old, + old_of, + removed, + removed_under, + } } /// Classifies a node. @@ -115,7 +121,10 @@ impl Diff { continue; } let n = new.node(id); - if self.old_of[id as usize] != NO_NODE && n.is_dir() && self.explained_by_child(new, id, delta, m) { + if self.old_of[id as usize] != NO_NODE + && n.is_dir() + && self.explained_by_child(new, id, delta, m) + { continue; } if n.parent != NO_NODE && self.old_of[n.parent as usize] == NO_NODE { @@ -132,9 +141,21 @@ impl Diff { } for &oid in &self.removed { let o = self.old.node(oid).total(m); - out.push(Change { path: self.old.path_string(oid), status: Status::Removed, delta: -o, old: o, new: 0, node: NO_NODE }); + out.push(Change { + path: self.old.path_string(oid), + status: Status::Removed, + delta: -o, + old: o, + new: 0, + node: NO_NODE, + }); } - out.sort_by(|a, b| b.delta.unsigned_abs().cmp(&a.delta.unsigned_abs()).then_with(|| a.path.cmp(&b.path))); + out.sort_by(|a, b| { + b.delta + .unsigned_abs() + .cmp(&a.delta.unsigned_abs()) + .then_with(|| a.path.cmp(&b.path)) + }); if k > 0 { out.truncate(k); } diff --git a/src/snapshot/mod.rs b/src/snapshot/mod.rs index 814ef58..b935b57 100644 --- a/src/snapshot/mod.rs +++ b/src/snapshot/mod.rs @@ -31,7 +31,9 @@ mod compare; pub use compare::*; use crate::brand; -use crate::inventory::{Category, ErrKind, ErrorRecord, Kind, NodeId, Tree, MAX_ERROR_RECORDS, NUM_CATEGORIES}; +use crate::inventory::{ + Category, ErrKind, ErrorRecord, Kind, NodeId, Tree, MAX_ERROR_RECORDS, NUM_CATEGORIES, +}; use crossbeam_channel::Receiver; use flate2::{bufread::GzDecoder, write::GzEncoder, Compression}; use serde::{Deserialize, Serialize}; @@ -91,7 +93,10 @@ struct Meta { /// Reports whether path starts with the snapshot magic. pub fn is_snapshot(path: &Path) -> bool { let mut b = [0u8; 8]; - File::open(path).and_then(|mut f| f.read_exact(&mut b)).is_ok() && &b == brand::SNAPSHOT_MAGIC + File::open(path) + .and_then(|mut f| f.read_exact(&mut b)) + .is_ok() + && &b == brand::SNAPSHOT_MAGIC } /// Accumulates the body; flushes it through the hash and gzip in chunks. @@ -140,14 +145,21 @@ pub fn save(out: &mut dyn Write, t: &Tree) -> io::Result<()> { bw.write_all(brand::SNAPSHOT_MAGIC)?; bw.write_all(&VERSION.to_le_bytes())?; bw.write_all(&FLAG_GZIPPED.to_le_bytes())?; - let mut w = Writer { gz: GzEncoder::new(bw, Compression::fast()), h: Sha256::new(), buf: Vec::with_capacity(256 << 10) }; + let mut w = Writer { + gz: GzEncoder::new(bw, Compression::fast()), + h: Sha256::new(), + buf: Vec::with_capacity(256 << 10), + }; let st = &t.stats; let m = Meta { root: String::from_utf8_lossy(&st.root).into_owned(), created: GoTime::from_system(SystemTime::now()), start: GoTime::from_system(st.start).local(), - end: st.end.map(|e| GoTime::from_system(e).local()).unwrap_or_default(), + end: st + .end + .map(|e| GoTime::from_system(e).local()) + .unwrap_or_default(), complete: st.complete, cancelled: st.cancelled, files: st.files, @@ -223,16 +235,31 @@ pub fn save_file(path: &Path, t: &Tree) -> io::Result<()> { /// Creates a new 0600 file named .mapsize-*.tmp in dir (like os.CreateTemp). fn create_temp(dir: &Path) -> io::Result<(PathBuf, File)> { - let seed = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().subsec_nanos() ^ std::process::id(); + let seed = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .subsec_nanos() + ^ std::process::id(); for i in 0..10000u32 { - let p = dir.join(format!(".mapsize-{}.tmp", seed.wrapping_add(i.wrapping_mul(7919)))); - match OpenOptions::new().write(true).create_new(true).mode(0o600).open(&p) { + let p = dir.join(format!( + ".mapsize-{}.tmp", + seed.wrapping_add(i.wrapping_mul(7919)) + )); + match OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&p) + { Ok(f) => return Ok((p, f)), Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue, Err(e) => return Err(e), } } - Err(io::Error::new(io::ErrorKind::AlreadyExists, "snapshot: cannot create temporary file")) + Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "snapshot: cannot create temporary file", + )) } fn hostname() -> String { @@ -257,7 +284,12 @@ const UNEXPECTED_EOF: &str = "unexpected EOF"; impl Body { fn new(rx: Receiver, String>>) -> Body { - Body { rx, buf: Vec::new(), pos: 0, err: None } + Body { + rx, + buf: Vec::new(), + pos: 0, + err: None, + } } /// Ensures unread bytes are buffered; false at end of stream. @@ -320,7 +352,8 @@ impl Body { fn bytes_n(&mut self, n: u64) -> Result, String> { let mut b = vec![0u8; n as usize]; - self.read_full(&mut b).map_err(|e| format!("snapshot: truncated: {e}"))?; + self.read_full(&mut b) + .map_err(|e| format!("snapshot: truncated: {e}"))?; Ok(b) } @@ -391,13 +424,16 @@ pub fn load_file(path: &Path) -> Result { fn load_inner(input: impl Read, max_body: i64, max_nodes: u64) -> Result { let mut br = BufReader::with_capacity(1 << 20, input); let mut hdr = [0u8; 12]; - br.read_exact(&mut hdr).map_err(|_| "snapshot: file too short".to_string())?; + br.read_exact(&mut hdr) + .map_err(|_| "snapshot: file too short".to_string())?; if &hdr[..8] != brand::SNAPSHOT_MAGIC { return Err(format!("snapshot: not a {} snapshot", brand::NAME)); } let v = u16::from_le_bytes([hdr[8], hdr[9]]); if v != VERSION { - return Err(format!("snapshot: unsupported version {v} (this build reads {VERSION})")); + return Err(format!( + "snapshot: unsupported version {v} (this build reads {VERSION})" + )); } if u16::from_le_bytes([hdr[10], hdr[11]]) & FLAG_GZIPPED == 0 { return Err("snapshot: uncompressed bodies are not supported".into()); @@ -436,13 +472,16 @@ fn load_inner(input: impl Read, max_body: i64, max_nodes: u64) -> Result Result Result<(Tree, Meta), String> { return Err(format!("snapshot: node {i}: parent is not a directory")); } if !valid_name(&name) { - return Err(format!("snapshot: node {i}: invalid name {:?}", String::from_utf8_lossy(&name))); + return Err(format!( + "snapshot: node {i}: invalid name {:?}", + String::from_utf8_lossy(&name) + )); } let d = depth[parent as usize] + 1; if d > MAX_DEPTH { - return Err(format!("snapshot: node {i}: deeper than {MAX_DEPTH} levels")); + return Err(format!( + "snapshot: node {i}: deeper than {MAX_DEPTH} levels" + )); } depth.push(d); t.add(parent, &name, kind) @@ -544,7 +607,12 @@ fn parse(r: &mut Body, max_nodes: u64) -> Result<(Tree, Meta), String> { let kind = ErrKind::from_u8(r.byte1()?); // out-of-range kinds become Other let name = r.str(MAX_NAME_LEN, "error name")?; let msg = String::from_utf8_lossy(&r.str(MAX_MSG_LEN, "error message")?).into_owned(); - t.stats.errors.push(ErrorRecord { node, kind, name, msg }); + t.stats.errors.push(ErrorRecord { + node, + kind, + name, + msg, + }); } // The body must end here. if r.byte() != Err(EOF.into()) { @@ -571,19 +639,35 @@ const GO_ZERO_SECS: i64 = -62_135_596_800; impl Default for GoTime { fn default() -> Self { - GoTime { secs: GO_ZERO_SECS, nanos: 0, off: 0 } + GoTime { + secs: GO_ZERO_SECS, + nanos: 0, + off: 0, + } } } impl GoTime { fn from_system(t: SystemTime) -> GoTime { match t.duration_since(UNIX_EPOCH) { - Ok(d) => GoTime { secs: d.as_secs() as i64, nanos: d.subsec_nanos(), off: 0 }, + Ok(d) => GoTime { + secs: d.as_secs() as i64, + nanos: d.subsec_nanos(), + off: 0, + }, Err(e) => { let d = e.duration(); let (s, n) = (d.as_secs() as i64, d.subsec_nanos()); - let (secs, nanos) = if n == 0 { (-s, 0) } else { (-s - 1, 1_000_000_000 - n) }; - GoTime { secs, nanos, off: 0 } + let (secs, nanos) = if n == 0 { + (-s, 0) + } else { + (-s - 1, 1_000_000_000 - n) + }; + GoTime { + secs, + nanos, + off: 0, + } } } } @@ -602,7 +686,8 @@ impl GoTime { if self.secs >= 0 { UNIX_EPOCH + Duration::new(self.secs as u64, self.nanos) } else { - UNIX_EPOCH - Duration::from_secs(self.secs.unsigned_abs()) + Duration::from_nanos(self.nanos as u64) + UNIX_EPOCH - Duration::from_secs(self.secs.unsigned_abs()) + + Duration::from_nanos(self.nanos as u64) } } @@ -611,7 +696,12 @@ impl GoTime { let secs = self.secs + self.off as i64; let (y, mo, d) = civil_from_days(secs.div_euclid(86400)); let rem = secs.rem_euclid(86400); - let mut s = format!("{y:04}-{mo:02}-{d:02}T{:02}:{:02}:{:02}", rem / 3600, rem / 60 % 60, rem % 60); + let mut s = format!( + "{y:04}-{mo:02}-{d:02}T{:02}:{:02}:{:02}", + rem / 3600, + rem / 60 % 60, + rem % 60 + ); if self.nanos != 0 { s += format!(".{:09}", self.nanos).trim_end_matches('0'); } @@ -619,7 +709,12 @@ impl GoTime { return s + "Z"; } let a = self.off.unsigned_abs() / 60; - format!("{s}{}{:02}:{:02}", if self.off < 0 { '-' } else { '+' }, a / 60, a % 60) + format!( + "{s}{}{:02}:{:02}", + if self.off < 0 { '-' } else { '+' }, + a / 60, + a % 60 + ) } /// Parses RFC 3339 ("2026-10-08T10:40:00.123456789+01:00"). @@ -627,12 +722,27 @@ impl GoTime { let b = s.as_bytes(); let num = |r: std::ops::Range| -> Option { let p = b.get(r)?; - p.iter().all(u8::is_ascii_digit).then(|| p.iter().fold(0i64, |a, &c| a * 10 + (c - b'0') as i64)) + p.iter() + .all(u8::is_ascii_digit) + .then(|| p.iter().fold(0i64, |a, &c| a * 10 + (c - b'0') as i64)) }; - if b.len() < 20 || b[4] != b'-' || b[7] != b'-' || b[10] != b'T' || b[13] != b':' || b[16] != b':' { + if b.len() < 20 + || b[4] != b'-' + || b[7] != b'-' + || b[10] != b'T' + || b[13] != b':' + || b[16] != b':' + { return None; } - let (y, mo, d, h, mi, se) = (num(0..4)?, num(5..7)?, num(8..10)?, num(11..13)?, num(14..16)?, num(17..19)?); + let (y, mo, d, h, mi, se) = ( + num(0..4)?, + num(5..7)?, + num(8..10)?, + num(11..13)?, + num(14..16)?, + num(17..19)?, + ); if !(1..=12).contains(&mo) || !(1..=31).contains(&d) || h > 23 || mi > 59 || se > 59 { return None; } @@ -666,13 +776,26 @@ impl GoTime { _ => return None, }; let secs = days_from_civil(y, mo, d) * 86400 + h * 3600 + mi * 60 + se - off; - Some(GoTime { secs, nanos, off: off as i32 }) + Some(GoTime { + secs, + nanos, + off: off as i32, + }) } /// "2006-01-02 15:04" in local time. fn local_minutes(self) -> String { - let Some(tm) = localtime(self.secs) else { return self.format() }; - format!("{:04}-{:02}-{:02} {:02}:{:02}", tm.tm_year as i64 + 1900, tm.tm_mon + 1, tm.tm_mday, tm.tm_hour, tm.tm_min) + let Some(tm) = localtime(self.secs) else { + return self.format(); + }; + format!( + "{:04}-{:02}-{:02} {:02}:{:02}", + tm.tm_year as i64 + 1900, + tm.tm_mon + 1, + tm.tm_mday, + tm.tm_hour, + tm.tm_min + ) } } @@ -693,7 +816,8 @@ impl<'de> Deserialize<'de> for GoTime { // null leaves the zero time, as in Go. match Option::::deserialize(d)? { None => Ok(GoTime::default()), - Some(s) => GoTime::parse(&s).ok_or_else(|| serde::de::Error::custom(format!("parsing time {s:?}"))), + Some(s) => GoTime::parse(&s) + .ok_or_else(|| serde::de::Error::custom(format!("parsing time {s:?}"))), } } } @@ -737,7 +861,12 @@ mod tests { t.node_mut(f2).flags = FLAG_SPARSE; t.recompute(); (t.stats.files, t.stats.dirs) = (2, 3); - t.stats.add_error(ErrorRecord { node: b, kind: ErrKind::Permission, msg: "denied".into(), ..Default::default() }); + t.stats.add_error(ErrorRecord { + node: b, + kind: ErrKind::Permission, + msg: "denied".into(), + ..Default::default() + }); t } @@ -755,8 +884,12 @@ mod tests { for i in 0..src.len() as NodeId { let (a, b) = (src.node(i), got.node(i)); assert!( - a.name == b.name && a.tot_size == b.tot_size && a.tot_alloc == b.tot_alloc && a.flags == b.flags - && src.path(i) == got.path(i) && src.ext_name(a) == got.ext_name(b), + a.name == b.name + && a.tot_size == b.tot_size + && a.tot_alloc == b.tot_alloc + && a.flags == b.flags + && src.path(i) == got.path(i) + && src.ext_name(a) == got.ext_name(b), "node {i} differs: {a:?} vs {b:?}" ); } @@ -802,8 +935,14 @@ mod tests { assert_eq!(paths.get("/srv/a/x.iso"), Some(&Status::Grew), "{ch:?}"); assert_eq!(paths.get("/srv/b"), Some(&Status::Removed), "{ch:?}"); assert_eq!(paths.get("/srv/c"), Some(&Status::Added), "{ch:?}"); - assert!(!paths.contains_key("/srv/a"), "/srv/a should be explained by its child"); - assert!(!paths.contains_key("/srv/c/z"), "contents of new dirs should not be listed separately"); + assert!( + !paths.contains_key("/srv/a"), + "/srv/a should be explained by its child" + ); + assert!( + !paths.contains_key("/srv/c/z"), + "contents of new dirs should not be listed separately" + ); } fn body(b: &[u8]) -> Body { @@ -813,7 +952,11 @@ mod tests { } fn put_uvarint(v: u64) -> Vec { - let mut w = Writer { gz: GzEncoder::new(Vec::new(), Compression::fast()), h: Sha256::new(), buf: Vec::new() }; + let mut w = Writer { + gz: GzEncoder::new(Vec::new(), Compression::fast()), + h: Sha256::new(), + buf: Vec::new(), + }; w.uvarint(v).unwrap(); w.buf } @@ -824,7 +967,10 @@ mod tests { assert_eq!(read_uvarint(&mut body(&put_uvarint(v))), Ok(v)); } assert_eq!(put_uvarint(300), [0xac, 0x02]); - assert!(read_uvarint(&mut body(&[0xff; 11])).is_err(), "overflow not detected"); + assert!( + read_uvarint(&mut body(&[0xff; 11])).is_err(), + "overflow not detected" + ); // Zig-zag as Go's PutVarint. for (v, enc) in [(0i64, 0u64), (-1, 1), (1, 2), (-2, 3), (i64::MIN, u64::MAX)] { assert_eq!(((v << 1) ^ (v >> 63)) as u64, enc); @@ -837,13 +983,38 @@ mod tests { let t = GoTime::parse("2026-10-08T10:40:00.123456789+01:00").unwrap(); assert_eq!((t.secs, t.nanos, t.off), (1_791_452_400, 123_456_789, 3600)); assert_eq!(t.format(), "2026-10-08T10:40:00.123456789+01:00"); - assert_eq!(GoTime { off: 0, ..t }.format(), "2026-10-08T09:40:00.123456789Z"); - assert_eq!(GoTime { off: -5 * 3600 - 1800, ..t }.format(), "2026-10-08T04:10:00.123456789-05:30"); - assert_eq!(GoTime::parse("2026-10-08T09:40:00.1Z").unwrap().format(), "2026-10-08T09:40:00.1Z"); - assert_eq!(GoTime::parse("0001-01-01T00:00:00Z"), Some(GoTime::default())); + assert_eq!( + GoTime { off: 0, ..t }.format(), + "2026-10-08T09:40:00.123456789Z" + ); + assert_eq!( + GoTime { + off: -5 * 3600 - 1800, + ..t + } + .format(), + "2026-10-08T04:10:00.123456789-05:30" + ); + assert_eq!( + GoTime::parse("2026-10-08T09:40:00.1Z").unwrap().format(), + "2026-10-08T09:40:00.1Z" + ); + assert_eq!( + GoTime::parse("0001-01-01T00:00:00Z"), + Some(GoTime::default()) + ); assert_eq!(GoTime::default().format(), "0001-01-01T00:00:00Z"); - assert_eq!(GoTime::from_system(GoTime::default().to_system()), GoTime::default()); - for bad in ["", "2026-10-08", "2026-13-08T00:00:00Z", "2026-10-08T00:00:00", "2026-10-08T00:00:00.Z"] { + assert_eq!( + GoTime::from_system(GoTime::default().to_system()), + GoTime::default() + ); + for bad in [ + "", + "2026-10-08", + "2026-13-08T00:00:00Z", + "2026-10-08T00:00:00", + "2026-10-08T00:00:00.Z", + ] { assert_eq!(GoTime::parse(bad), None, "{bad}"); } } @@ -852,21 +1023,34 @@ mod tests { /// (tree/{a/x.iso 10000 B, a/b/note.txt, c/y.log 3000 B, c/link -> ../a}). #[test] fn loads_go_snapshot() { - let t = load_file(Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/src/snapshot/testdata/go.msz"))).unwrap(); + let t = load_file(Path::new(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/snapshot/testdata/go.msz" + ))) + .unwrap(); let r = t.node(0); assert_eq!((r.tot_size, r.tot_alloc), (13310, 20480)); assert_eq!((t.stats.files, t.stats.dirs, t.stats.symlinks), (3, 4, 1)); assert_eq!(t.len(), 8); assert!(t.stats.root.ends_with(b"/tree")); assert!(t.stats.end.is_some() && t.stats.complete); - assert!(t.stats.from_snapshot.contains("mapsize"), "{}", t.stats.from_snapshot); - let iso = (0..t.len() as NodeId).find(|&i| &t.node(i).name[..] == b"x.iso").unwrap(); + assert!( + t.stats.from_snapshot.contains("mapsize"), + "{}", + t.stats.from_snapshot + ); + let iso = (0..t.len() as NodeId) + .find(|&i| &t.node(i).name[..] == b"x.iso") + .unwrap(); assert_eq!(t.node(iso).size, 10000); assert_eq!(t.ext_name(t.node(iso)), "iso"); assert!(t.path_string(iso).ends_with("/tree/a/x.iso")); // And it survives a Rust round trip unchanged. let again = load(&saved(&t)[..]).unwrap(); - assert_eq!((again.node(0).tot_size, again.len(), again.stats.start), (13310, 8, t.stats.start)); + assert_eq!( + (again.node(0).tot_size, again.len(), again.stats.start), + (13310, 8, t.stats.start) + ); } // Hostile input. @@ -877,7 +1061,11 @@ mod tests { out.extend_from_slice(brand::SNAPSHOT_MAGIC); out.extend_from_slice(&VERSION.to_le_bytes()); out.extend_from_slice(&FLAG_GZIPPED.to_le_bytes()); - let mut w = Writer { gz: GzEncoder::new(out, Compression::best()), h: Sha256::new(), buf: Vec::new() }; + let mut w = Writer { + gz: GzEncoder::new(out, Compression::best()), + h: Sha256::new(), + buf: Vec::new(), + }; w.uvarint(meta.len() as u64).unwrap(); w.bytes(meta.as_bytes()).unwrap(); w.uvarint(1).unwrap(); @@ -908,49 +1096,80 @@ mod tests { } fn chain(n: usize) -> Vec { - craft(n, r#"{"root":"/r"}"#, |i, w| if i == 0 { node(w, 0, Kind::Dir, "", 0) } else { node(w, 1, Kind::Dir, "a", 0) }) + craft(n, r#"{"root":"/r"}"#, |i, w| { + if i == 0 { + node(w, 0, Kind::Dir, "", 0) + } else { + node(w, 1, Kind::Dir, "a", 0) + } + }) } #[test] fn deep_chain_rejected() { load(&chain(MAX_DEPTH as usize + 1)[..]).expect("depth at the limit must load"); - let err = load(&chain(MAX_DEPTH as usize + 2)[..]).err().expect("over-deep chain accepted"); + let err = load(&chain(MAX_DEPTH as usize + 2)[..]) + .err() + .expect("over-deep chain accepted"); assert!(err.to_string().contains("deeper"), "{err}"); } #[test] fn decompression_bomb_limited() { let flat = craft(100_000, r#"{"root":"/r"}"#, |i, w| { - if i == 0 { node(w, 0, Kind::Dir, "", 0) } else { node(w, i as u64, Kind::File, "f", 0) } + if i == 0 { + node(w, 0, Kind::Dir, "", 0) + } else { + node(w, i as u64, Kind::File, "f", 0) + } }); - let err = load_limit(&flat[..], 64 << 10).err().expect("bomb not limited"); + let err = load_limit(&flat[..], 64 << 10) + .err() + .expect("bomb not limited"); assert!(err.to_string().contains("limit"), "{err}"); - assert!(body_limit(flat.len() as i64) >= 64 << 20, "body_limit floor"); + assert!( + body_limit(flat.len() as i64) >= 64 << 20, + "body_limit floor" + ); } #[test] fn huge_sizes_do_not_wrap() { let data = craft(3, r#"{"root":"/r"}"#, |i, w| { - if i == 0 { node(w, 0, Kind::Dir, "", 0) } else { node(w, i as u64, Kind::File, "f", MAX_SIZE) } + if i == 0 { + node(w, 0, Kind::Dir, "", 0) + } else { + node(w, i as u64, Kind::File, "f", MAX_SIZE) + } }); let tr = load(&data[..]).unwrap(); assert!(tr.node(0).tot_size >= 0, "aggregate wrapped negative"); let over = craft(2, r#"{"root":"/r"}"#, |i, w| { - if i == 0 { node(w, 0, Kind::Dir, "", 0) } else { node(w, 1, Kind::File, "f", MAX_SIZE + 1) } + if i == 0 { + node(w, 0, Kind::Dir, "", 0) + } else { + node(w, 1, Kind::File, "f", MAX_SIZE + 1) + } }); assert!(load(&over[..]).is_err(), "size above MAX_SIZE accepted"); } #[test] fn hostile_metadata_sanitised_in_summary() { - let data = craft(1, r#"{"root":"/r\u001b[2J","excludes":["\u001b]0;PWNED\u0007"],"excluded":1}"#, |_, w| { - node(w, 0, Kind::Dir, "", 0) - }); + let data = craft( + 1, + r#"{"root":"/r\u001b[2J","excludes":["\u001b]0;PWNED\u0007"],"excluded":1}"#, + |_, w| node(w, 0, Kind::Dir, "", 0), + ); let mut tr = load(&data[..]).unwrap(); tr.stats.excluded = 1; let mut b = Vec::new(); crate::export::summary(&mut b, &tr).unwrap(); - assert!(!b.iter().any(|&c| c == 0x1b || c == 0x07), "raw control characters in summary: {:?}", String::from_utf8_lossy(&b)); + assert!( + !b.iter().any(|&c| c == 0x1b || c == 0x07), + "raw control characters in summary: {:?}", + String::from_utf8_lossy(&b) + ); } /// Blocks of one directory plus K-1 files repeat byte for byte, so a @@ -971,7 +1190,9 @@ mod tests { fs::write(&p, &data).unwrap(); let res = load_file(&p); let _ = fs::remove_file(&p); - let err = res.err().unwrap_or_else(|| panic!("{N} nodes from a {}-byte file accepted", data.len())); + let err = res + .err() + .unwrap_or_else(|| panic!("{N} nodes from a {}-byte file accepted", data.len())); assert!(err.to_string().contains("node count"), "{err}"); } @@ -983,7 +1204,11 @@ mod tests { save_file(&p, &sample()).unwrap(); assert!(is_snapshot(&p)); assert_eq!(load_file(&p).unwrap().len(), 5); - assert_eq!(fs::read_dir(&dir).unwrap().count(), 1, "temp file left behind"); + assert_eq!( + fs::read_dir(&dir).unwrap().count(), + 1, + "temp file left behind" + ); fs::remove_dir_all(&dir).unwrap(); } } diff --git a/src/textutil.rs b/src/textutil.rs index a64cea0..0a2784f 100644 --- a/src/textutil.rs +++ b/src/textutil.rs @@ -41,7 +41,11 @@ pub fn sanitize(s: &str) -> String { } fn is_bidi_control(r: u32) -> bool { - (0x202a..=0x202e).contains(&r) || (0x2066..=0x2069).contains(&r) || r == 0x200e || r == 0x200f || r == 0x061c + (0x202a..=0x202e).contains(&r) + || (0x2066..=0x2069).contains(&r) + || r == 0x200e + || r == 0x200f + || r == 0x061c } /// Calls f for each grapheme cluster of s with its display width. Clusters @@ -53,7 +57,11 @@ pub fn clusters(s: &str, mut f: impl FnMut(&str, usize) -> bool) { for c in s.graphemes(true) { let mut chars = c.chars(); let first = chars.next().unwrap(); - let w = if chars.next().is_none() { first.width().unwrap_or(0) } else { c.width() }; + let w = if chars.next().is_none() { + first.width().unwrap_or(0) + } else { + c.width() + }; if w == 0 { continue; } @@ -171,7 +179,11 @@ const IEC_UNITS: [&str; 7] = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"]; const SI_UNITS: [&str; 7] = ["B", "kB", "MB", "GB", "TB", "PB", "EB"]; fn scaled(n: i64) -> (f64, &'static str) { - let (base, units) = if si() { (1000.0, &SI_UNITS) } else { (1024.0, &IEC_UNITS) }; + let (base, units) = if si() { + (1000.0, &SI_UNITS) + } else { + (1024.0, &IEC_UNITS) + }; let neg = n < 0; let mut v = (n as f64).abs(); let mut i = 0; @@ -295,7 +307,14 @@ mod tests { #[test] fn sizes() { - for (n, want) in [(0, "0 B"), (1023, "1023 B"), (1024, "1.00 KiB"), (1536, "1.50 KiB"), (181 << 30, "181 GiB"), (1 << 62, "4.00 EiB")] { + for (n, want) in [ + (0, "0 B"), + (1023, "1023 B"), + (1024, "1.00 KiB"), + (1536, "1.50 KiB"), + (181 << 30, "181 GiB"), + (1 << 62, "4.00 EiB"), + ] { assert_eq!(size(n), want); } assert_eq!(count(2184392), "2,184,392"); @@ -330,7 +349,12 @@ pub fn rfc3339_utc(unix_nanos: i64) -> String { let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; let y = yoe + era * 400 + (m <= 2) as i64; - format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, rem / 60 % 60, rem % 60) + format!( + "{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", + rem / 3600, + rem / 60 % 60, + rem % 60 + ) } /// Formats a duration like Go's time.Duration.String after rounding to @@ -365,7 +389,10 @@ mod time_tests { #[test] fn formats() { assert_eq!(rfc3339_utc(0), "1970-01-01T00:00:00Z"); - assert_eq!(rfc3339_utc(1_791_456_000 * 1_000_000_000), "2026-10-08T10:40:00Z"); + assert_eq!( + rfc3339_utc(1_791_456_000 * 1_000_000_000), + "2026-10-08T10:40:00Z" + ); assert_eq!(go_duration(Duration::from_micros(1_234_400)), "1.234s"); assert_eq!(go_duration(Duration::from_millis(250)), "250ms"); assert_eq!(go_duration(Duration::from_millis(123_500)), "2m3.5s"); diff --git a/src/treemap/mod.rs b/src/treemap/mod.rs index c412aea..eea578b 100644 --- a/src/treemap/mod.rs +++ b/src/treemap/mod.rs @@ -33,7 +33,12 @@ impl Rect { /// Shrinks by n cells on each side. pub fn inset(&self, n: i32) -> Rect { - Rect { x: self.x + n, y: self.y + n, w: (self.w - 2 * n).max(0), h: (self.h - 2 * n).max(0) } + Rect { + x: self.x + n, + y: self.y + n, + w: (self.w - 2 * n).max(0), + h: (self.h - 2 * n).max(0), + } } } @@ -61,7 +66,11 @@ pub fn squarify(items: &[Item], bounds: Rect) -> Vec { if bounds.empty() { return Vec::new(); } - let mut its: Vec = items.iter().copied().filter(|it| it.size > 0.0 && it.size.is_finite()).collect(); + let mut its: Vec = items + .iter() + .copied() + .filter(|it| it.size > 0.0 && it.size.is_finite()) + .collect(); let total: f64 = its.iter().map(|it| it.size).sum(); if its.is_empty() || total <= 0.0 { return Vec::new(); @@ -69,14 +78,28 @@ pub fn squarify(items: &[Item], bounds: Rect) -> Vec { its.sort_by(|a, b| b.size.total_cmp(&a.size).then(a.id.cmp(&b.id))); let scale = bounds.w as f64 * bounds.h as f64 / total; let areas: Vec = its.iter().map(|it| it.size * scale).collect(); - let frs = layout(&areas, bounds.x as f64, bounds.y as f64, bounds.w as f64, bounds.h as f64); + let frs = layout( + &areas, + bounds.x as f64, + bounds.y as f64, + bounds.w as f64, + bounds.h as f64, + ); let mut out = Vec::with_capacity(its.len()); for (it, f) in its.iter().zip(frs) { let (x0, y0) = (f[0].round() as i32, f[1].round() as i32); let x1 = ((f[0] + f[2]).round() as i32).min(bounds.x + bounds.w); let y1 = ((f[1] + f[3]).round() as i32).min(bounds.y + bounds.h); if x1 > x0 && y1 > y0 { - out.push(Block { id: it.id, rect: Rect { x: x0, y: y0, w: x1 - x0, h: y1 - y0 } }); + out.push(Block { + id: it.id, + rect: Rect { + x: x0, + y: y0, + w: x1 - x0, + h: y1 - y0, + }, + }); } } out @@ -117,7 +140,11 @@ fn layout(areas: &[f64], mut x: f64, mut y: f64, mut w: f64, mut h: f64) -> Vec< } let mut yy = y; for k in i..j { - let hh = if k == j - 1 { y + h - yy } else { areas[k] / sum * h }; + let hh = if k == j - 1 { + y + h - yy + } else { + areas[k] / sum * h + }; out[k] = [x, yy, thick, hh]; yy += hh; } @@ -130,7 +157,11 @@ fn layout(areas: &[f64], mut x: f64, mut y: f64, mut w: f64, mut h: f64) -> Vec< } let mut xx = x; for k in i..j { - let ww = if k == j - 1 { x + w - xx } else { areas[k] / sum * w }; + let ww = if k == j - 1 { + x + w - xx + } else { + areas[k] / sum * w + }; out[k] = [xx, y, ww, thick]; xx += ww; } @@ -203,7 +234,10 @@ pub(crate) mod tests { for bl in blocks { let br = bl.rect; assert!(br.w > 0 && br.h > 0, "non-positive block {bl:?}"); - assert!(br.x >= b.x && br.y >= b.y && br.x + br.w <= b.x + b.w && br.y + br.h <= b.y + b.h, "block {bl:?} outside bounds {b:?}"); + assert!( + br.x >= b.x && br.y >= b.y && br.x + br.w <= b.x + b.w && br.y + br.h <= b.y + b.h, + "block {bl:?} outside bounds {b:?}" + ); for y in br.y..br.y + br.h { for x in br.x..br.x + br.w { let i = ((y - b.y) * b.w + (x - b.x)) as usize; @@ -222,7 +256,15 @@ pub(crate) mod tests { #[test] fn squarify_basic() { let b = r(2, 3, 60, 20); - let items = [it(1, 6.0), it(2, 6.0), it(3, 4.0), it(4, 3.0), it(5, 2.0), it(6, 2.0), it(7, 1.0)]; + let items = [ + it(1, 6.0), + it(2, 6.0), + it(3, 4.0), + it(4, 3.0), + it(5, 2.0), + it(6, 2.0), + it(7, 1.0), + ]; let blocks = squarify(&items, b); assert_eq!(blocks.len(), items.len()); check_partition(&blocks, b, true); @@ -230,7 +272,11 @@ pub(crate) mod tests { for bl in &blocks { let want = b.area() as f64 * items[(bl.id - 1) as usize].size / 24.0; let got = bl.rect.area() as f64; - assert!(got >= want * 0.6 - 4.0 && got <= want * 1.4 + 4.0, "block {} area {got} want ~{want}", bl.id); + assert!( + got >= want * 0.6 - 4.0 && got <= want * 1.4 + 4.0, + "block {} area {got} want ~{want}", + bl.id + ); } // Deterministic regardless of input order. let mut rev = items.to_vec(); @@ -241,9 +287,18 @@ pub(crate) mod tests { #[test] fn squarify_edge_cases() { assert!(squarify(&[], r(0, 0, 10, 10)).is_empty()); - assert!(squarify(&[it(1, 0.0), it(2, -5.0)], r(0, 0, 10, 10)).is_empty(), "zero sizes should produce nothing"); + assert!( + squarify(&[it(1, 0.0), it(2, -5.0)], r(0, 0, 10, 10)).is_empty(), + "zero sizes should produce nothing" + ); assert!(squarify(&[it(1, f64::NAN), it(2, f64::INFINITY)], r(0, 0, 10, 10)).is_empty()); - for b in [r(0, 0, 0, 0), r(0, 0, 1, 1), r(0, 0, 1, 50), r(0, 0, 50, 1), r(0, 0, -3, 4)] { + for b in [ + r(0, 0, 0, 0), + r(0, 0, 1, 1), + r(0, 0, 1, 50), + r(0, 0, 50, 1), + r(0, 0, -3, 4), + ] { let bl = squarify(&[it(1, 5.0), it(2, 3.0), it(3, 1.0)], b); if !b.empty() { check_partition(&bl, b, true); @@ -258,7 +313,13 @@ pub(crate) mod tests { #[test] fn resize_recomputes() { let items = [it(1, 50.0), it(2, 30.0), it(3, 20.0)]; - for b in [r(0, 0, 80, 24), r(0, 0, 120, 40), r(0, 0, 300, 80), r(0, 0, 70, 20), r(0, 0, 180, 50)] { + for b in [ + r(0, 0, 80, 24), + r(0, 0, 120, 40), + r(0, 0, 300, 80), + r(0, 0, 70, 20), + r(0, 0, 180, 50), + ] { let bl = squarify(&items, b); check_partition(&bl, b, true); assert_eq!(bl.len(), 3, "{b:?}"); @@ -270,7 +331,11 @@ pub(crate) mod tests { fn squarify_random_partitions() { let mut rng = Rng::new(1); for _ in 0..300 { - let (w, h, n) = (rng.intn(400) as i32, rng.intn(150) as i32, rng.intn(3000) as usize); + let (w, h, n) = ( + rng.intn(400) as i32, + rng.intn(150) as i32, + rng.intn(3000) as usize, + ); let items: Vec = (0..n) .map(|i| { let bits = rng.intn(40) + 1; @@ -290,12 +355,25 @@ pub(crate) mod tests { let sizes = [100.0, 50.0, 10.0, 1.0, 1.0, 1.0]; assert_eq!(visible(&sizes, 163.0, 1630, 20, 100), 3); assert_eq!(visible(&sizes, 163.0, 1630, 20, 2), 2, "max_items"); - assert_eq!(visible(&[10.0, 1.0], 11.0, 1100, 200, 10), 2, "single leftover"); + assert_eq!( + visible(&[10.0, 1.0], 11.0, 1100, 200, 10), + 2, + "single leftover" + ); } #[test] fn at_finds_block() { - let bl = [Block { id: 1, rect: r(0, 0, 5, 5) }, Block { id: 2, rect: r(5, 0, 5, 5) }]; + let bl = [ + Block { + id: 1, + rect: r(0, 0, 5, 5), + }, + Block { + id: 2, + rect: r(5, 0, 5, 5), + }, + ]; assert_eq!(at(&bl, 6, 2), Some(2)); assert_eq!(at(&bl, 10, 2), None); } diff --git a/src/treemap/nav.rs b/src/treemap/nav.rs index 86187d7..e8fc446 100644 --- a/src/treemap/nav.rs +++ b/src/treemap/nav.rs @@ -33,10 +33,38 @@ pub fn neighbour(blocks: &[Block], sel: i64, d: Direction) -> Option { } let c = b.rect; let (ok, gap, lo, hi, clo, chi) = match d { - Direction::Right => (c.x >= s.x + s.w, c.x - (s.x + s.w), s.y, s.y + s.h, c.y, c.y + c.h), - Direction::Left => (c.x + c.w <= s.x, s.x - (c.x + c.w), s.y, s.y + s.h, c.y, c.y + c.h), - Direction::Down => (c.y >= s.y + s.h, c.y - (s.y + s.h), s.x, s.x + s.w, c.x, c.x + c.w), - Direction::Up => (c.y + c.h <= s.y, s.y - (c.y + c.h), s.x, s.x + s.w, c.x, c.x + c.w), + Direction::Right => ( + c.x >= s.x + s.w, + c.x - (s.x + s.w), + s.y, + s.y + s.h, + c.y, + c.y + c.h, + ), + Direction::Left => ( + c.x + c.w <= s.x, + s.x - (c.x + c.w), + s.y, + s.y + s.h, + c.y, + c.y + c.h, + ), + Direction::Down => ( + c.y >= s.y + s.h, + c.y - (s.y + s.h), + s.x, + s.x + s.w, + c.x, + c.x + c.w, + ), + Direction::Up => ( + c.y + c.h <= s.y, + s.y - (c.y + c.h), + s.x, + s.x + s.w, + c.x, + c.x + c.w, + ), }; if !ok { continue; @@ -68,7 +96,10 @@ mod tests { use super::Direction::*; fn blk(id: i64, x: i32, y: i32, w: i32, h: i32) -> Block { - Block { id, rect: r(x, y, w, h) } + Block { + id, + rect: r(x, y, w, h), + } } // Layout: @@ -86,9 +117,18 @@ mod tests { let d = blk(4, 4, 4, 12, 4); let bs = [d, c, b, a]; // order must not matter let cases = [ - (1, Right, Some(2)), (2, Left, Some(1)), (1, Down, Some(3)), (2, Down, Some(4)), - (3, Right, Some(4)), (4, Left, Some(3)), (3, Up, Some(1)), (4, Up, Some(2)), - (1, Left, None), (1, Up, None), (4, Right, None), (4, Down, None), + (1, Right, Some(2)), + (2, Left, Some(1)), + (1, Down, Some(3)), + (2, Down, Some(4)), + (3, Right, Some(4)), + (4, Left, Some(3)), + (3, Up, Some(1)), + (4, Up, Some(2)), + (1, Left, None), + (1, Up, None), + (4, Right, None), + (4, Down, None), ]; for (from, dir, want) in cases { assert_eq!(neighbour(&bs, from, dir), want, "from {from} dir {dir:?}"); @@ -109,7 +149,11 @@ mod tests { let r1 = blk(11, 10, 0, 10, 3); let r2 = blk(12, 10, 3, 10, 3); let r3 = blk(13, 10, 6, 10, 3); - assert_eq!(neighbour(&[r3, r1, r2, t], 10, Right), Some(12), "centre preference"); + assert_eq!( + neighbour(&[r3, r1, r2, t], 10, Right), + Some(12), + "centre preference" + ); assert_eq!(neighbour(&[r3, r1, r2, t], 13, Left), Some(10), "back left"); } @@ -122,7 +166,9 @@ mod tests { let bl = squarify(&items, r(0, 0, 120, 40)); for s in &bl { for d in [Left, Right, Up, Down] { - let Some(id) = neighbour(&bl, s.id, d) else { continue }; + let Some(id) = neighbour(&bl, s.id, d) else { + continue; + }; let c = bl.iter().find(|b| b.id == id).unwrap().rect; let s = s.rect; // In a partition, if any block touches the edge in direction d @@ -133,7 +179,10 @@ mod tests { Down => c.y == s.y + s.h, Up => c.y + c.h == s.y, }; - assert!(touch, "iter {iter}: from {s:?} dir {d:?} chose non-adjacent {c:?}"); + assert!( + touch, + "iter {iter}: from {s:?} dir {d:?} chose non-adjacent {c:?}" + ); } } } diff --git a/src/tui/actions.rs b/src/tui/actions.rs index 3ca119e..1db319c 100644 --- a/src/tui/actions.rs +++ b/src/tui/actions.rs @@ -36,11 +36,25 @@ impl Model { } let n = tr.node(id); let path = tr.path(id); - let want = Expect { uid: n.uid, mode: n.mode }; - let what = if n.is_dir() { format!("directory with {} files", textutil::count(n.files as i64)) } else { "file".into() }; - let mut body = vec![san(&tr.path_bytes(id)), String::new(), format!("{what} · {}", textutil::size(n.total(self.size_mode))), String::new()]; + let want = Expect { + uid: n.uid, + mode: n.mode, + }; + let what = if n.is_dir() { + format!("directory with {} files", textutil::count(n.files as i64)) + } else { + "file".into() + }; + let mut body = vec![ + san(&tr.path_bytes(id)), + String::new(), + format!("{what} · {}", textutil::size(n.total(self.size_mode))), + String::new(), + ]; if platform::headless() { - body.push("This is a headless system (no X or Wayland display), so there is no trash:".into()); + body.push( + "This is a headless system (no X or Wayland display), so there is no trash:".into(), + ); body.push("it will be permanently deleted and cannot be recovered.".into()); self.open_modal(Box::new(ConfirmModal { ttl: "Delete permanently? Are you sure?".into(), @@ -50,13 +64,21 @@ impl Model { let t = m.tree.clone(); Cmd::run(move || { let err = platform::delete(&path, want).err().map(|e| e.to_string()); - Msg::TrashDone { tree: t, id, err, done: "Deleted".into() } + Msg::TrashDone { + tree: t, + id, + err, + done: "Deleted".into(), + } }) })), })); return Cmd::None; } - body.push(format!("It will be moved to the {}, not permanently deleted.", platform::TRASH_NAME)); + body.push(format!( + "It will be moved to the {}, not permanently deleted.", + platform::TRASH_NAME + )); self.open_modal(Box::new(ConfirmModal { ttl: "Move to trash?".into(), body, @@ -65,14 +87,25 @@ impl Model { let t = m.tree.clone(); Cmd::run(move || { let err = platform::trash(&path, want).err().map(|e| e.to_string()); - Msg::TrashDone { tree: t, id, err, done: format!("Moved to {}", platform::TRASH_NAME) } + Msg::TrashDone { + tree: t, + id, + err, + done: format!("Moved to {}", platform::TRASH_NAME), + } }) })), })); Cmd::None } - pub fn trash_done(&mut self, tree: Arc>, id: NodeId, err: Option, done: String) -> Cmd { + pub fn trash_done( + &mut self, + tree: Arc>, + id: NodeId, + err: Option, + done: String, + ) -> Cmd { if let Some(e) = err { return self.warn(format!("Delete failed: {}", textutil::sanitize(&e))); } @@ -84,7 +117,13 @@ impl Model { let name = { let mut t = self.tree.write().unwrap(); let n = t.node(id).clone(); - let mut d = Delta { size: -n.tot_size, alloc: -n.tot_alloc, files: -(n.files as i64), dirs: -(n.dirs as i64), ..Default::default() }; + let mut d = Delta { + size: -n.tot_size, + alloc: -n.tot_alloc, + files: -(n.files as i64), + dirs: -(n.dirs as i64), + ..Default::default() + }; if n.is_dir() { d.dirs -= 1; if let Some(cs) = t.cat_sizes(id) { @@ -125,21 +164,38 @@ impl Model { "Files are grouped by size, then sampled, then fully hashed with SHA-256.".into(), "Only identical hashes are reported as duplicates.".into(), String::new(), - "This reads file contents (it may take a while and can update access times).".into(), + "This reads file contents (it may take a while and can update access times)." + .into(), ], yes: "start".into(), on_yes: Some(Box::new(|m: &mut Model| { let cancel = Cancel::new(); let f = Arc::new(Finder::default()); - m.dups = Some(DupState { running: true, finder: f.clone(), groups: Vec::new(), err: None, cancel: cancel.clone() }); + m.dups = Some(DupState { + running: true, + finder: f.clone(), + groups: Vec::new(), + err: None, + cancel: cancel.clone(), + }); m.ensure_dup_view(); let t = m.tree.clone(); let tick = m.start_ticking(); Cmd::batch(vec![ tick, Cmd::run(move || { - let g = f.find(&cancel, &t, duplicate::Options { min_size: 1, workers: 4 }); - Msg::DupDone { finder: f, groups: g } + let g = f.find( + &cancel, + &t, + duplicate::Options { + min_size: 1, + workers: 4, + }, + ); + Msg::DupDone { + finder: f, + groups: g, + } }), ]) })), @@ -148,22 +204,39 @@ impl Model { } pub fn ensure_dup_view(&mut self) { - if let Some(i) = self.views.iter().position(|v| matches!(v, super::views::View::Dups(_))) { + if let Some(i) = self + .views + .iter() + .position(|v| matches!(v, super::views::View::Dups(_))) + { self.set_view(i); return; } - self.views.push(super::views::View::Dups(DupView::default())); + self.views + .push(super::views::View::Dups(DupView::default())); self.set_view(self.views.len() - 1); } pub fn dup_done(&mut self, finder: Arc, groups: Option>) -> Cmd { - let Some(d) = self.dups.as_mut().filter(|d| Arc::ptr_eq(&d.finder, &finder)) else { return Cmd::None }; + let Some(d) = self + .dups + .as_mut() + .filter(|d| Arc::ptr_eq(&d.finder, &finder)) + else { + return Cmd::None; + }; d.running = false; d.err = groups.is_none().then(|| "cancelled".to_string()); d.groups = groups.unwrap_or_default(); - let (n, wasted) = (d.groups.len(), d.groups.iter().map(|g| g.wasted()).sum::()); + let (n, wasted) = ( + d.groups.len(), + d.groups.iter().map(|g| g.wasted()).sum::(), + ); self.dirty = true; - self.info(format!("Duplicates: {n} verified groups, {} reclaimable", textutil::size(wasted))) + self.info(format!( + "Duplicates: {n} verified groups, {} reclaimable", + textutil::size(wasted) + )) } } @@ -185,23 +258,53 @@ impl DupView { pub fn paint(&mut self, m: &mut Model, tr: &Tree, cv: &mut Canvas, r: Rect) { let t = m.theme.clone(); let Some(d) = &m.dups else { - cv.text_centered(r.x, r.y + r.h / 2, r.w, "Press D to search for duplicates", t.muted()); + cv.text_centered( + r.x, + r.y + r.h / 2, + r.w, + "Press D to search for duplicates", + t.muted(), + ); return; }; if d.running { let p = d.finder.progress(); cv.fill(r, " ", t.base()); let y = r.y + r.h / 2 - 2; - cv.text_centered(r.x, y, r.w, &format!("Finding duplicates — {}…", p.stage), Style::new(t.accent, t.bg).with(BOLD)); - cv.text_centered(r.x, y + 2, r.w, - &format!("{} candidate files in {} size groups", textutil::count(p.candidates), textutil::count(p.candidate_groups)), t.base()); + cv.text_centered( + r.x, + y, + r.w, + &format!("Finding duplicates — {}…", p.stage), + Style::new(t.accent, t.bg).with(BOLD), + ); + cv.text_centered( + r.x, + y + 2, + r.w, + &format!( + "{} candidate files in {} size groups", + textutil::count(p.candidates), + textutil::count(p.candidate_groups) + ), + t.base(), + ); if p.bytes_to_hash > 0 { let frac = p.bytes_hashed as f64 / p.bytes_to_hash as f64; let bw = 60.min(r.w - 10); m.paint_bar(cv, r.x + (r.w - bw) / 2, y + 4, bw, frac, t.accent, false); - cv.text_centered(r.x, y + 5, r.w, - &format!("{} of {} hashed · {} skipped", textutil::size(p.bytes_hashed), textutil::size(p.bytes_to_hash), textutil::count(p.skipped)), - t.muted()); + cv.text_centered( + r.x, + y + 5, + r.w, + &format!( + "{} of {} hashed · {} skipped", + textutil::size(p.bytes_hashed), + textutil::size(p.bytes_to_hash), + textutil::count(p.skipped) + ), + t.muted(), + ); } return; } @@ -211,22 +314,44 @@ impl DupView { self.rows.push((gi, NO_NODE)); let hash: String = g.hash[..8].iter().map(|b| format!("{b:02x}")).collect(); rows.push(TableRow { - cells: vec![textutil::size(g.wasted()), format!("{} × {}", g.files.len(), textutil::size(g.size)), - format!("verified identical · sha256 {hash}…")], - styles: vec![Some(Style::new(t.warn, t.bg).with(BOLD)), Some(Style::new(t.fg, t.bg).with(BOLD)), Some(Style::new(t.ok, t.bg))], + cells: vec![ + textutil::size(g.wasted()), + format!("{} × {}", g.files.len(), textutil::size(g.size)), + format!("verified identical · sha256 {hash}…"), + ], + styles: vec![ + Some(Style::new(t.warn, t.bg).with(BOLD)), + Some(Style::new(t.fg, t.bg).with(BOLD)), + Some(Style::new(t.ok, t.bg)), + ], bar: -1.0, ..Default::default() }); for &id in &g.files { self.rows.push((gi, id)); - rows.push(TableRow { cells: vec![String::new(), String::new(), format!(" {}", san(&tr.path_bytes(id)))], bar: -1.0, ..Default::default() }); + rows.push(TableRow { + cells: vec![ + String::new(), + String::new(), + format!(" {}", san(&tr.path_bytes(id))), + ], + bar: -1.0, + ..Default::default() + }); } } - let mut title = format!("Duplicates — {} groups (Enter details · Space go to)", d.groups.len()); + let mut title = format!( + "Duplicates — {} groups (Enter details · Space go to)", + d.groups.len() + ); if let Some(e) = &d.err { title += &format!(" — stopped: {}", textutil::sanitize(e)); } - let cols = [col("WASTED", 10, true, false), col("COPIES", 16, false, false), col("", 0, false, false)]; + let cols = [ + col("WASTED", 10, true, false), + col("COPIES", 16, false, false), + col("", 0, false, false), + ]; self.tb.paint(m, cv, r, &cols, &rows, &title); } diff --git a/src/tui/canvas.rs b/src/tui/canvas.rs index a8a04b0..c20e30e 100644 --- a/src/tui/canvas.rs +++ b/src/tui/canvas.rs @@ -40,7 +40,11 @@ impl Color { } pub fn comps(self) -> (f64, f64, f64) { - (self.r() as f64 / 255.0, self.g() as f64 / 255.0, self.b() as f64 / 255.0) + ( + self.r() as f64 / 255.0, + self.g() as f64 / 255.0, + self.b() as f64 / 255.0, + ) } /// Blends c towards o by t (0..1). Default colours are returned as-is. @@ -82,7 +86,10 @@ impl Style { Style { fg, bg, attr: 0 } } pub const fn with(self, a: Attr) -> Style { - Style { attr: self.attr | a, ..self } + Style { + attr: self.attr | a, + ..self + } } } @@ -127,11 +134,46 @@ pub(crate) struct BoxChars { pub br: &'static str, } -pub(crate) const BOX_LIGHT: BoxChars = BoxChars { h: "─", v: "│", tl: "┌", tr: "┐", bl: "└", br: "┘" }; -pub(crate) const BOX_ROUND: BoxChars = BoxChars { h: "─", v: "│", tl: "╭", tr: "╮", bl: "╰", br: "╯" }; -pub(crate) const BOX_DOUBLE: BoxChars = BoxChars { h: "═", v: "║", tl: "╔", tr: "╗", bl: "╚", br: "╝" }; -pub(crate) const BOX_ASCII: BoxChars = BoxChars { h: "-", v: "|", tl: "+", tr: "+", bl: "+", br: "+" }; -pub(crate) const BOX_ASCII_SEL: BoxChars = BoxChars { h: "=", v: "#", tl: "#", tr: "#", bl: "#", br: "#" }; +pub(crate) const BOX_LIGHT: BoxChars = BoxChars { + h: "─", + v: "│", + tl: "┌", + tr: "┐", + bl: "└", + br: "┘", +}; +pub(crate) const BOX_ROUND: BoxChars = BoxChars { + h: "─", + v: "│", + tl: "╭", + tr: "╮", + bl: "╰", + br: "╯", +}; +pub(crate) const BOX_DOUBLE: BoxChars = BoxChars { + h: "═", + v: "║", + tl: "╔", + tr: "╗", + bl: "╚", + br: "╝", +}; +pub(crate) const BOX_ASCII: BoxChars = BoxChars { + h: "-", + v: "|", + tl: "+", + tr: "+", + bl: "+", + br: "+", +}; +pub(crate) const BOX_ASCII_SEL: BoxChars = BoxChars { + h: "=", + v: "#", + tl: "#", + tr: "#", + bl: "#", + br: "#", +}; /// Selects how colours are serialised. #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -154,7 +196,12 @@ impl Canvas { /// Creates a canvas filled with spaces in style st. pub fn new(w: i32, h: i32, st: Style) -> Canvas { let (w, h) = (w.max(0), h.max(0)); - Canvas { w, h, c: vec![Cell::space(st); (w * h) as usize], big: Vec::new() } + Canvas { + w, + h, + c: vec![Cell::space(st); (w * h) as usize], + big: Vec::new(), + } } fn inside(&self, x: i32, y: i32) -> bool { @@ -180,7 +227,12 @@ impl Canvas { self.big.push(s.to_string()); HEAP }; - Cell { g, n, w: w as u8, st } + Cell { + g, + n, + w: w as u8, + st, + } } /// Writes one cluster of width w at (x, y), repairing any wide cluster @@ -204,7 +256,12 @@ impl Canvas { } self.c[row + xu] = self.make(s, w, st); for i in 1..wu { - self.c[row + xu + i] = Cell { g: [0; INLINE], n: 0, w: 0, st }; + self.c[row + xu + i] = Cell { + g: [0; INLINE], + n: 0, + w: 0, + st, + }; } } @@ -331,7 +388,13 @@ impl Canvas { fn sgr(b: &mut String, st: Style, depth: Depth) { b.push_str("\x1b[0"); - for (bit, code) in [(BOLD, ";1"), (FAINT, ";2"), (ITALIC, ";3"), (UNDERLINE, ";4"), (REVERSE, ";7")] { + for (bit, code) in [ + (BOLD, ";1"), + (FAINT, ";2"), + (ITALIC, ";3"), + (UNDERLINE, ";4"), + (REVERSE, ";7"), + ] { if st.attr & bit != 0 { b.push_str(code); } @@ -351,7 +414,14 @@ fn sgr(b: &mut String, st: Style, depth: Depth) { } write!(b, ";{code}") } - Depth::True => write!(b, ";{};2;{};{};{}", if bg { 48 } else { 38 }, c.r(), c.g(), c.b()), + Depth::True => write!( + b, + ";{};2;{};{};{}", + if bg { 48 } else { 38 }, + c.r(), + c.g(), + c.b() + ), }; }; col(b, st.fg, false); diff --git a/src/tui/color256.rs b/src/tui/color256.rs index 8e5b056..b8aa8b2 100644 --- a/src/tui/color256.rs +++ b/src/tui/color256.rs @@ -28,7 +28,11 @@ pub(crate) fn quant256(c: Color) -> u8 { let (r, g, b) = (c.r() as i32, c.g() as i32, c.b() as i32); let (mut best, mut best_d) = (16, i32::MAX); for i in 0..216 { - let (cr, cg, cb) = (CUBE_LEVELS[i / 36], CUBE_LEVELS[i / 6 % 6], CUBE_LEVELS[i % 6]); + let (cr, cg, cb) = ( + CUBE_LEVELS[i / 36], + CUBE_LEVELS[i / 6 % 6], + CUBE_LEVELS[i % 6], + ); let d = redmean(r, g, b, cr, cg, cb); if d < best_d { (best, best_d) = (16 + i, d); diff --git a/src/tui/data.rs b/src/tui/data.rs index 3b0f29b..188f7bb 100644 --- a/src/tui/data.rs +++ b/src/tui/data.rs @@ -39,8 +39,11 @@ impl Model { if let Some(k) = self.kids_cache.get(&id) { return k.clone(); } - let mut v: Vec<(i64, NodeId)> = - tr.children(id).map(|(c, _)| (self.size_of(tr, c), c)).filter(|&(s, _)| s > 0).collect(); + let mut v: Vec<(i64, NodeId)> = tr + .children(id) + .map(|(c, _)| (self.size_of(tr, c), c)) + .filter(|&(s, _)| s > 0) + .collect(); v.sort_unstable_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1))); let k = Rc::new(Kids { total: v.iter().map(|e| e.0).sum(), @@ -119,7 +122,11 @@ impl Model { let prev = self.zoom; self.zoom = dir; // Coming back out, keep the directory we left selected. - self.sel = if tr.is_ancestor(dir, prev) { prev as i64 } else { NO_SEL }; + self.sel = if tr.is_ancestor(dir, prev) { + prev as i64 + } else { + NO_SEL + }; self.user_sel = self.sel != NO_SEL; self.zoomed = true; self.dirty = true; diff --git a/src/tui/infoview.rs b/src/tui/infoview.rs index 6a5a858..4f4cb1a 100644 --- a/src/tui/infoview.rs +++ b/src/tui/infoview.rs @@ -2,8 +2,8 @@ use super::canvas::{Canvas, Style, BOLD}; use super::model::{Cmd, Model}; -use super::table::{col, Table, TableRow}; use super::san; +use super::table::{col, Table, TableRow}; use crate::inventory::{ErrKind, Tree, FLAG_ALLOC_UNKNOWN}; use crate::textutil; use crate::treemap::Rect; @@ -57,66 +57,193 @@ impl InfoView { } else { state }; - cv.text(x, y, "SCAN INFORMATION", lw, Style::new(t.accent, t.bg).with(BOLD)); + cv.text( + x, + y, + "SCAN INFORMATION", + lw, + Style::new(t.accent, t.bg).with(BOLD), + ); y += 1; line(cv, &mut y, "Root", &san(&st.root), value); line(cv, &mut y, "State", state, state_st); if !st.from_snapshot.is_empty() { - line(cv, &mut y, "Snapshot", &textutil::sanitize(&st.from_snapshot), value); + line( + cv, + &mut y, + "Snapshot", + &textutil::sanitize(&st.from_snapshot), + value, + ); } let files = st.files + st.symlinks + st.others; - line(cv, &mut y, "Items scanned", &textutil::count(files + st.dirs), value); + line( + cv, + &mut y, + "Items scanned", + &textutil::count(files + st.dirs), + value, + ); line(cv, &mut y, " Files", &textutil::count(st.files), value); - line(cv, &mut y, " Directories", &textutil::count(st.dirs), value); - line(cv, &mut y, " Symlinks", &textutil::count(st.symlinks), value); + line( + cv, + &mut y, + " Directories", + &textutil::count(st.dirs), + value, + ); + line( + cv, + &mut y, + " Symlinks", + &textutil::count(st.symlinks), + value, + ); line(cv, &mut y, " Special", &textutil::count(st.others), value); - line(cv, &mut y, "Logical size", &format!("{} ({} bytes)", textutil::size(root.tot_size), textutil::count(root.tot_size)), value); - line(cv, &mut y, "Allocated size", &format!("{} ({} bytes)", textutil::size(root.tot_alloc), textutil::count(root.tot_alloc)), value); + line( + cv, + &mut y, + "Logical size", + &format!( + "{} ({} bytes)", + textutil::size(root.tot_size), + textutil::count(root.tot_size) + ), + value, + ); + line( + cv, + &mut y, + "Allocated size", + &format!( + "{} ({} bytes)", + textutil::size(root.tot_alloc), + textutil::count(root.tot_alloc) + ), + value, + ); let el = st.elapsed(); if st.from_snapshot.is_empty() { line(cv, &mut y, "Elapsed", &textutil::duration(el), value); let rate = (files + st.dirs) as f64 / el.as_secs_f64().max(0.001); - line(cv, &mut y, "Rate", &(textutil::count(rate as i64) + " items/s"), value); + line( + cv, + &mut y, + "Rate", + &(textutil::count(rate as i64) + " items/s"), + value, + ); let cpus = std::thread::available_parallelism().map_or(1, |n| n.get()); - line(cv, &mut y, "Workers", &format!("{} ({} CPUs)", st.workers, cpus), value); + line( + cv, + &mut y, + "Workers", + &format!("{} ({} CPUs)", st.workers, cpus), + value, + ); } head(cv, &mut y, "ERRORS AND OMISSIONS"); let mut any_issue = false; for (k, &c) in st.err_counts.iter().enumerate() { if c > 0 { - line(cv, &mut y, ErrKind::from_u8(k as u8).name(), &textutil::count(c), Style::new(t.err, t.bg).with(BOLD)); + line( + cv, + &mut y, + ErrKind::from_u8(k as u8).name(), + &textutil::count(c), + Style::new(t.err, t.bg).with(BOLD), + ); any_issue = true; } } - for (k, v) in [("Unscanned dirs", st.unscanned), ("Excluded", st.excluded), ("Other FS skipped", st.skipped_mounts), - ("Virtual FS skipped", st.virtual_skipped), ("Loops skipped", st.loops_skipped), ("Broken symlinks", st.broken_links), - ("Hard-link dups", st.hardlink_dups)] - { + for (k, v) in [ + ("Unscanned dirs", st.unscanned), + ("Excluded", st.excluded), + ("Other FS skipped", st.skipped_mounts), + ("Virtual FS skipped", st.virtual_skipped), + ("Loops skipped", st.loops_skipped), + ("Broken symlinks", st.broken_links), + ("Hard-link dups", st.hardlink_dups), + ] { if v > 0 { - line(cv, &mut y, k, &textutil::count(v), Style::new(t.warn, t.bg).with(BOLD)); + line( + cv, + &mut y, + k, + &textutil::count(v), + Style::new(t.warn, t.bg).with(BOLD), + ); any_issue = true; } } if !any_issue { - line(cv, &mut y, "None", "every entry was read", Style::new(t.ok, t.bg)); + line( + cv, + &mut y, + "None", + "every entry was read", + Style::new(t.ok, t.bg), + ); } head(cv, &mut y, "OPTIONS"); - let excl = if st.excludes.is_empty() { "none".to_string() } else { textutil::sanitize(&st.excludes.join(", ")) }; + let excl = if st.excludes.is_empty() { + "none".to_string() + } else { + textutil::sanitize(&st.excludes.join(", ")) + }; line(cv, &mut y, "Exclusions", &excl, value); - line(cv, &mut y, "One filesystem", &st.one_file_system.to_string(), value); - line(cv, &mut y, "Follow symlinks", &textutil::sanitize(&st.follow), value); + line( + cv, + &mut y, + "One filesystem", + &st.one_file_system.to_string(), + value, + ); + line( + cv, + &mut y, + "Follow symlinks", + &textutil::sanitize(&st.follow), + value, + ); line(cv, &mut y, "Size mode", m.size_mode.name(), value); - line(cv, &mut y, "Units", if crate::textutil::si() { "SI (1000)" } else { "IEC (1024)" }, value); + line( + cv, + &mut y, + "Units", + if crate::textutil::si() { + "SI (1000)" + } else { + "IEC (1024)" + }, + value, + ); if root.has(FLAG_ALLOC_UNKNOWN) { - line(cv, &mut y, "Note", "allocated size unavailable on this platform", Style::new(t.warn, t.bg)); + line( + cv, + &mut y, + "Note", + "allocated size unavailable on this platform", + Style::new(t.warn, t.bg), + ); } // Error list on the right (or below in narrow terminals). let er = if r.w < 100 { - Rect { x: r.x, y: y + 1, w: r.w, h: r.y + r.h - y - 1 } + Rect { + x: r.x, + y: y + 1, + w: r.w, + h: r.y + r.h - y - 1, + } } else { - Rect { x: r.x + lw + 4, y: r.y, w: r.w - lw - 4, h: r.h } + Rect { + x: r.x + lw + 4, + y: r.y, + w: r.w - lw - 4, + h: r.h, + } }; if er.w < 30 || er.h < 4 { return; @@ -132,7 +259,11 @@ impl InfoView { } TableRow { cells: vec![e.kind.name().into(), san(&p), textutil::sanitize(&e.msg)], - styles: vec![Some(Style::new(t.err, t.bg)), None, Some(Style::new(t.muted, t.bg))], + styles: vec![ + Some(Style::new(t.err, t.bg)), + None, + Some(Style::new(t.muted, t.bg)), + ], bar: -1.0, ..Default::default() } @@ -142,7 +273,11 @@ impl InfoView { if (st.errors.len() as i64) < st.total_errors() { title += &format!(" — first {} shown", textutil::count(st.errors.len() as i64)); } - let cols = [col("KIND", 18, false, false), col("PATH", 0, false, true), col("DETAIL", 22, false, false)]; + let cols = [ + col("KIND", 18, false, false), + col("PATH", 0, false, true), + col("DETAIL", 22, false, false), + ]; self.tb.paint(m, cv, er, &cols, &rows, &title); } diff --git a/src/tui/keys.rs b/src/tui/keys.rs index 3dcfbb0..8adf699 100644 --- a/src/tui/keys.rs +++ b/src/tui/keys.rs @@ -1,7 +1,7 @@ //! Key dispatch and the actions keys trigger. -use super::model::{is_group, Cmd, Key, Model, Msg}; use super::modals::{new_group_modal, new_help_modal, new_info_modal, new_save_modal}; +use super::model::{is_group, Cmd, Key, Model, Msg}; use super::theme::{load_theme, theme_names}; use super::views::View; use super::{fmt_stamp_now, san}; @@ -53,13 +53,19 @@ impl Model { } "tab" => self.set_view((self.view + 1) % self.views.len()), "shift+tab" => self.set_view((self.view + self.views.len() - 1) % self.views.len()), - "1" | "2" | "3" | "4" | "5" | "6" | "7" => self.set_view((k.as_bytes()[0] - b'1') as usize), + "1" | "2" | "3" | "4" | "5" | "6" | "7" => { + self.set_view((k.as_bytes()[0] - b'1') as usize) + } "enter" => return self.inspect_selection(&tr), "space" | "right" => return self.zoom_into_selection(&tr), "backspace" | "left" => self.zoom_out(&tr), "home" => self.zoom_to(&tr, 0), "a" => { - self.size_mode = if self.size_mode == SizeMode::Allocated { SizeMode::Logical } else { SizeMode::Allocated }; + self.size_mode = if self.size_mode == SizeMode::Allocated { + SizeMode::Logical + } else { + SizeMode::Allocated + }; self.invalidate(); let mut cmds = Vec::new(); if self.search.query.is_some() { @@ -88,7 +94,10 @@ impl Model { "d" | "delete" => return self.request_trash(&tr), "T" => { let names = theme_names(); - let cur = names.iter().position(|n| *n == self.theme.name).unwrap_or(0); + let cur = names + .iter() + .position(|n| *n == self.theme.name) + .unwrap_or(0); let ascii = self.theme.ascii; let mut th = load_theme(names[(cur + 1) % names.len()], &self.opts.settings); th.ascii = ascii; @@ -200,7 +209,10 @@ impl Model { return self.info(msg); } if let Err(e) = platform::reveal(&tr.path(id)) { - return self.warn(format!("Open failed: {}", textutil::sanitize(&e.to_string()))); + return self.warn(format!( + "Open failed: {}", + textutil::sanitize(&e.to_string()) + )); } self.info("Opened in file manager") } @@ -210,7 +222,9 @@ impl Model { Cmd::run(move || { let err = { let g = t.read().unwrap(); - snapshot::save_file(Path::new(&path), &g).err().map(|e| e.to_string()) + snapshot::save_file(Path::new(&path), &g) + .err() + .map(|e| e.to_string()) }; Msg::SaveDone { path, err } }) @@ -224,5 +238,7 @@ pub(crate) fn default_snapshot_name(root: &[u8]) -> String { None => "root".into(), }; let wd = std::env::current_dir().unwrap_or_default(); - wd.join(format!("{base}-{}{}", fmt_stamp_now(), brand::SNAPSHOT_EXT)).to_string_lossy().into_owned() + wd.join(format!("{base}-{}{}", fmt_stamp_now(), brand::SNAPSHOT_EXT)) + .to_string_lossy() + .into_owned() } diff --git a/src/tui/mapview.rs b/src/tui/mapview.rs index d78aa01..6b86b1f 100644 --- a/src/tui/mapview.rs +++ b/src/tui/mapview.rs @@ -70,8 +70,14 @@ impl Model { } } let mut l = TmLayout { - zoom: self.zoom, rect: r, ver: self.data_ver, blocks: Vec::new(), nested: Vec::new(), - groups: HashMap::new(), visible: HashSet::new(), inner: HashSet::new(), + zoom: self.zoom, + rect: r, + ver: self.data_ver, + blocks: Vec::new(), + nested: Vec::new(), + groups: HashMap::new(), + visible: HashSet::new(), + inner: HashSet::new(), }; l.blocks = self.layout_level(tr, &mut l, self.zoom, r, TOP_MIN_AREA, TOP_MAX_ITEMS); for i in 0..l.blocks.len() { @@ -99,34 +105,87 @@ impl Model { /// Lays out the children of parent in r with level-of-detail grouping: /// children too small to be useful collapse into one group block. - fn layout_level(&mut self, tr: &Tree, l: &mut TmLayout, parent: NodeId, r: Rect, min_area: i32, max_items: usize) -> Vec { + fn layout_level( + &mut self, + tr: &Tree, + l: &mut TmLayout, + parent: NodeId, + r: Rect, + min_area: i32, + max_items: usize, + ) -> Vec { let k = self.kids_of(tr, parent); if k.ids.is_empty() || r.empty() { return Vec::new(); } let sizes: Vec = k.sizes.iter().map(|&s| s as f64).collect(); let vis = treemap::visible(&sizes, k.total as f64, r.area(), min_area, max_items); - let mut items: Vec = (0..vis).map(|i| Item { id: k.ids[i] as i64, size: sizes[i] }).collect(); + let mut items: Vec = (0..vis) + .map(|i| Item { + id: k.ids[i] as i64, + size: sizes[i], + }) + .collect(); if vis < k.ids.len() { let size = k.sizes[vis..].iter().sum(); let gid = group_id(parent); - l.groups.insert(gid, Group { parent, kids: k.clone(), from: vis, size }); - items.push(Item { id: gid, size: size as f64 }); + l.groups.insert( + gid, + Group { + parent, + kids: k.clone(), + from: vis, + size, + }, + ); + items.push(Item { + id: gid, + size: size as f64, + }); } treemap::squarify(&items, r) } fn layout_nested(&mut self, tr: &Tree, l: &mut TmLayout, parent: NodeId, r: Rect, depth: i32) { // Deeper levels need bigger blocks to stay legible rather than noisy. - let blocks = self.layout_level(tr, l, parent, r, NEST_MIN_AREA * (depth - 1), NEST_MAX_ITEMS); + let blocks = self.layout_level( + tr, + l, + parent, + r, + NEST_MIN_AREA * (depth - 1), + NEST_MAX_ITEMS, + ); for (i, b) in blocks.iter().enumerate() { let idx = l.nested.len(); - l.nested.push(Nested { id: b.id, r: b.rect, depth, alt: i % 2 == 1, inner: false }); + l.nested.push(Nested { + id: b.id, + r: b.rect, + depth, + alt: i % 2 == 1, + inner: false, + }); let br = b.rect; - if depth < MAX_NEST_DEPTH && b.id >= 0 && br.w >= NEST_RECURSE_W && br.h >= NEST_RECURSE_H && tr.node(b.id as NodeId).is_dir() { + if depth < MAX_NEST_DEPTH + && b.id >= 0 + && br.w >= NEST_RECURSE_W + && br.h >= NEST_RECURSE_H + && tr.node(b.id as NodeId).is_dir() + { l.nested[idx].inner = true; let end = self.chain_end(tr, b.id as NodeId); - self.layout_nested(tr, l, end, Rect { x: br.x, y: br.y + 1, w: br.w - 1, h: br.h - 2 }, depth + 1); + self.layout_nested( + tr, + l, + end, + Rect { + x: br.x, + y: br.y + 1, + w: br.w - 1, + h: br.h - 2, + }, + depth + 1, + ); } } } @@ -182,7 +241,9 @@ impl Model { /// The size of the folder a block sits in. fn parent_size(&self, tr: &Tree, id: i64) -> i64 { if is_group(id) { - return self.group_info(id).map_or(0, |g| self.size_of(tr, g.parent)); + return self + .group_info(id) + .map_or(0, |g| self.size_of(tr, g.parent)); } let p = tr.node(id as NodeId).parent; if p != NO_NODE { @@ -191,7 +252,15 @@ impl Model { 0 } - fn paint_top(&mut self, tr: &Tree, cv: &mut Canvas, b: Block, sel: bool, hov: bool, total: i64) { + fn paint_top( + &mut self, + tr: &Tree, + cv: &mut Canvas, + b: Block, + sel: bool, + hov: bool, + total: i64, + ) { let t = self.theme.clone(); let base = self.color_of(tr, b.id); let fill = base.mix(t.bg, 0.62); @@ -234,7 +303,13 @@ impl Model { title.attr = BOLD; } cv.draw_box(r, bx, frame); - cv.text(r.x + 1, r.y, &trunc(&format!(" {name} "), r.w - 2), r.w - 2, title); + cv.text( + r.x + 1, + r.y, + &trunc(&format!(" {name} "), r.w - 2), + r.w - 2, + title, + ); let mut label = textutil::size(size); let pct = textutil::percent(size, total); @@ -245,7 +320,17 @@ impl Model { } let label = format!(" {label} "); if r.h >= 3 && tw(&label) <= r.w - 2 { - cv.text_right(r.x + 1, r.y + r.h - 1, r.w - 2, &label, Style { fg: frame.fg, bg: fill, attr: frame.attr }); + cv.text_right( + r.x + 1, + r.y + r.h - 1, + r.w - 2, + &label, + Style { + fg: frame.fg, + bg: fill, + attr: frame.attr, + }, + ); } // Interior labels for blocks without nested previews. let inn = r.inset(1); @@ -266,7 +351,13 @@ impl Model { } let y0 = inn.y + (inn.h - lines.len() as i32) / 2; for (i, s) in lines.iter().enumerate() { - cv.text_centered(inn.x, y0 + i as i32, inn.w, s, Style::new(base.mix(t.fg, 0.4), fill)); + cv.text_centered( + inn.x, + y0 + i as i32, + inn.w, + s, + Style::new(base.mix(t.fg, 0.4), fill), + ); } } @@ -298,10 +389,28 @@ impl Model { } } else { if r.w >= 4 { - cv.fill(Rect { x: r.x + r.w - 1, y: r.y, w: 1, h: r.h }, " ", Style::new(Default::default(), shadow)); + cv.fill( + Rect { + x: r.x + r.w - 1, + y: r.y, + w: 1, + h: r.h, + }, + " ", + Style::new(Default::default(), shadow), + ); } if r.h >= 3 { - cv.fill(Rect { x: r.x, y: r.y + r.h - 1, w: r.w, h: 1 }, " ", Style::new(Default::default(), shadow)); + cv.fill( + Rect { + x: r.x, + y: r.y + r.h - 1, + w: r.w, + h: 1, + }, + " ", + Style::new(Default::default(), shadow), + ); } } let lw = if t.mono { r.w - 2 } else { r.w - 1 }; @@ -314,7 +423,13 @@ impl Model { if tw(&name) > lw && lw < 8 { return; // a stub like "te…" is noise, not information } - cv.text(lx, ly, &trunc(&name, lw), lw, Style::new(st.fg, bg).with(BOLD)); + cv.text( + lx, + ly, + &trunc(&name, lw), + lw, + Style::new(st.fg, bg).with(BOLD), + ); if !nb.inner && r.h >= 3 && lw >= 5 { let size = self.block_size(tr, nb.id); let mut label = textutil::size_compact(size); @@ -322,7 +437,13 @@ impl Model { if tw(&label) + tw(&pct) + 3 <= lw { label += &format!(" · {pct}"); } - cv.text(lx, ly + 1, &trunc(&label, lw), lw, Style::new(bg.mix(st.fg, 0.7), bg)); + cv.text( + lx, + ly + 1, + &trunc(&label, lw), + lw, + Style::new(bg.mix(st.fg, 0.7), bg), + ); } } } @@ -337,7 +458,11 @@ pub(crate) fn map_paint(m: &mut Model, tr: &Tree, cv: &mut Canvas, r: Rect) { } else if m.scanning { "Scanning…".to_string() } else if !z.is_dir() { - format!("{} — {}", san(&z.name), textutil::size(m.size_of(tr, m.zoom))) + format!( + "{} — {}", + san(&z.name), + textutil::size(m.size_of(tr, m.zoom)) + ) } else { "Empty directory".to_string() }; @@ -354,7 +479,10 @@ pub(crate) fn map_paint(m: &mut Model, tr: &Tree, cv: &mut Canvas, r: Rect) { m.paint_nested(tr, cv, nb); } for b in &l.blocks { - m.hits.push(Hit { r: b.rect, act: HitAct::Block(b.id) }); + m.hits.push(Hit { + r: b.rect, + act: HitAct::Block(b.id), + }); } } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index f923945..a4558f5 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -55,7 +55,6 @@ pub(crate) fn san(b: &[u8]) -> String { textutil::sanitize_bytes(b) } - fn local_tm(unix_nanos: i64) -> libc::tm { let secs = unix_nanos.div_euclid(1_000_000_000) as libc::time_t; let mut tm: libc::tm = unsafe { std::mem::zeroed() }; @@ -66,18 +65,40 @@ fn local_tm(unix_nanos: i64) -> libc::tm { /// Formats a timestamp as a local date ("2006-01-02"). pub(crate) fn fmt_date(unix_nanos: i64) -> String { let t = local_tm(unix_nanos); - format!("{:04}-{:02}-{:02}", t.tm_year + 1900, t.tm_mon + 1, t.tm_mday) + format!( + "{:04}-{:02}-{:02}", + t.tm_year + 1900, + t.tm_mon + 1, + t.tm_mday + ) } /// Formats a timestamp as local date and time ("2006-01-02 15:04:05"). pub(crate) fn fmt_time(unix_nanos: i64) -> String { let t = local_tm(unix_nanos); - format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02}", t.tm_year + 1900, t.tm_mon + 1, t.tm_mday, t.tm_hour, t.tm_min, t.tm_sec) + format!( + "{:04}-{:02}-{:02} {:02}:{:02}:{:02}", + t.tm_year + 1900, + t.tm_mon + 1, + t.tm_mday, + t.tm_hour, + t.tm_min, + t.tm_sec + ) } /// The current local time as "20060102-1504". pub(crate) fn fmt_stamp_now() -> String { - let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); let t = local_tm(now.as_nanos() as i64); - format!("{:04}{:02}{:02}-{:02}{:02}", t.tm_year + 1900, t.tm_mon + 1, t.tm_mday, t.tm_hour, t.tm_min) + format!( + "{:04}{:02}{:02}-{:02}{:02}", + t.tm_year + 1900, + t.tm_mon + 1, + t.tm_mday, + t.tm_hour, + t.tm_min + ) } diff --git a/src/tui/modal.rs b/src/tui/modal.rs index e10ec70..c9fc181 100644 --- a/src/tui/modal.rs +++ b/src/tui/modal.rs @@ -17,15 +17,30 @@ pub(crate) struct Span { pub(crate) type Line = Vec; pub(crate) fn txt(s: impl Into) -> Line { - vec![Span { s: s.into(), st: None }] + vec![Span { + s: s.into(), + st: None, + }] } pub(crate) fn styled(s: impl Into, st: Style) -> Line { - vec![Span { s: s.into(), st: Some(st) }] + vec![Span { + s: s.into(), + st: Some(st), + }] } pub(crate) fn kv(k: &str, v: impl Into, kst: Style, vst: Style) -> Line { - vec![Span { s: pad_right(k, 18), st: Some(kst) }, Span { s: v.into(), st: Some(vst) }] + vec![ + Span { + s: pad_right(k, 18), + st: Some(kst), + }, + Span { + s: v.into(), + st: Some(vst), + }, + ] } /// An overlay. Modals receive all keys while open; Esc always closes them @@ -61,7 +76,12 @@ impl ModalFrame { impl Model { pub fn open_modal(&mut self, md: Box) { - self.modals.push(ModalFrame { md, scroll: 0, view_h: 0, content_n: 0 }); + self.modals.push(ModalFrame { + md, + scroll: 0, + view_h: 0, + content_n: 0, + }); self.dirty = true; } @@ -119,16 +139,24 @@ impl Model { pub fn paint_modal(&mut self, tr: &Tree, cv: &mut Canvas) { let t = self.theme.clone(); // Dim everything underneath. - cv.restyle(Rect { x: 0, y: 0, w: cv.w, h: cv.h }, |mut s| { - if t.mono { - s.attr |= FAINT; - return s; - } - s.fg = s.fg.mix(t.bg, 0.6); - s.bg = s.bg.mix(rgb(0, 0, 0), 0.45); - s.attr &= !BOLD; - s - }); + cv.restyle( + Rect { + x: 0, + y: 0, + w: cv.w, + h: cv.h, + }, + |mut s| { + if t.mono { + s.attr |= FAINT; + return s; + } + s.fg = s.fg.mix(t.bg, 0.6); + s.bg = s.bg.mix(rgb(0, 0, 0), 0.45); + s.attr &= !BOLD; + s + }, + ); let mut f = self.modals.pop().unwrap(); let max_w = cv.w - 2; let w = (f.md.width() + 4).min(max_w).max(30.min(max_w)); @@ -139,10 +167,16 @@ impl Model { if !foot.is_empty() { chrome += 2; } - let h = (lines.len() as i32 + chrome).min(cv.h - 2).max((chrome + 1).min(cv.h)); + let h = (lines.len() as i32 + chrome) + .min(cv.h - 2) + .max((chrome + 1).min(cv.h)); let (x0, y0) = ((cv.w - w) / 2, (cv.h - h) / 2); let r = Rect { x: x0, y: y0, w, h }; - let bg = if t.panel_bg.is_default() { t.bg } else { t.panel_bg }; + let bg = if t.panel_bg.is_default() { + t.bg + } else { + t.panel_bg + }; let base = Style::new(t.fg, bg); // Drop shadow. if !t.mono { @@ -150,13 +184,35 @@ impl Model { s.bg = rgb(0, 0, 0); s }; - cv.restyle(Rect { x: x0 + 1, y: y0 + h, w, h: 1 }, black); - cv.restyle(Rect { x: x0 + w, y: y0 + 1, w: 1, h }, black); + cv.restyle( + Rect { + x: x0 + 1, + y: y0 + h, + w, + h: 1, + }, + black, + ); + cv.restyle( + Rect { + x: x0 + w, + y: y0 + 1, + w: 1, + h, + }, + black, + ); } cv.fill(r, " ", base); let border = Style::new(t.accent, bg).with(BOLD); cv.draw_box(r, if t.ascii { BOX_ASCII } else { BOX_ROUND }, border); - cv.text(x0 + 2, y0 + 1, &f.md.title().to_uppercase(), inner, Style::new(t.accent, bg).with(BOLD)); + cv.text( + x0 + 2, + y0 + 1, + &f.md.title().to_uppercase(), + inner, + Style::new(t.accent, bg).with(BOLD), + ); let rule = if t.ascii { "-" } else { "─" }; cv.hline(x0 + 1, y0 + 2, w - 2, rule, Style::new(t.faint, bg)); f.view_h = h - chrome; @@ -201,7 +257,10 @@ impl Model { } self.modals.push(f); self.hits.clear(); - self.hits.push(Hit { r, act: HitAct::None }); + self.hits.push(Hit { + r, + act: HitAct::None, + }); } } diff --git a/src/tui/modals.rs b/src/tui/modals.rs index 48de42b..de013ce 100644 --- a/src/tui/modals.rs +++ b/src/tui/modals.rs @@ -10,7 +10,10 @@ use super::search::LineEditor; use super::{fmt_time, pad_left, pad_right, san, trunc, tw}; use crate::export; use crate::filter; -use crate::inventory::{NodeId, Tree, FLAG_ALLOC_UNKNOWN, FLAG_HARDLINK_DUP, FLAG_INCOMPLETE, FLAG_LOOP, FLAG_SCANNED, FLAG_SKIPPED_FS, FLAG_SPARSE, FLAG_VIRTUAL_FS, NO_NODE}; +use crate::inventory::{ + NodeId, Tree, FLAG_ALLOC_UNKNOWN, FLAG_HARDLINK_DUP, FLAG_INCOMPLETE, FLAG_LOOP, FLAG_SCANNED, + FLAG_SKIPPED_FS, FLAG_SPARSE, FLAG_VIRTUAL_FS, NO_NODE, +}; use crate::platform; use crate::textutil; @@ -42,7 +45,10 @@ impl Modal for InfoModal { } fn footer(&self, m: &Model) -> String { - let mut s = format!("Esc close · c copy path · o {} · Space go to", reveal_verb()); + let mut s = format!( + "Esc close · c copy path · o {} · Space go to", + reveal_verb() + ); if !m.opts.read_only && !m.snapshot { s += &format!(" · d {}", delete_verb()); } @@ -71,25 +77,67 @@ impl Modal for InfoModal { out.push(styled(l, vs)); } if san(&n.name).as_bytes() != &*n.name { - out.push(styled("Name contains control or invalid characters (shown escaped)", warn)); + out.push(styled( + "Name contains control or invalid characters (shown escaped)", + warn, + )); } out.push(txt("")); let mode = m.size_mode; if n.is_dir() { - add_s(&mut out, "Total logical", format!("{} ({} bytes)", textutil::size(n.tot_size), textutil::count(n.tot_size)), bold); - add_s(&mut out, "Total allocated", format!("{} ({} bytes)", textutil::size(n.tot_alloc), textutil::count(n.tot_alloc)), bold); + add_s( + &mut out, + "Total logical", + format!( + "{} ({} bytes)", + textutil::size(n.tot_size), + textutil::count(n.tot_size) + ), + bold, + ); + add_s( + &mut out, + "Total allocated", + format!( + "{} ({} bytes)", + textutil::size(n.tot_alloc), + textutil::count(n.tot_alloc) + ), + bold, + ); add(&mut out, "Files", textutil::count(n.files as i64)); add(&mut out, "Directories", textutil::count(n.dirs as i64)); if n.parent != NO_NODE { - add(&mut out, "Of parent", textutil::percent(n.total(mode), tr.node(n.parent).total(mode))); + add( + &mut out, + "Of parent", + textutil::percent(n.total(mode), tr.node(n.parent).total(mode)), + ); } - add(&mut out, "Of scan", textutil::percent(n.total(mode), tr.node(0).total(mode))); + add( + &mut out, + "Of scan", + textutil::percent(n.total(mode), tr.node(0).total(mode)), + ); let k = m.kids_of(tr, self.id); if let Some(&first) = k.ids.first() { - add(&mut out, "Largest child", format!("{} ({})", san(&tr.node(first).name), textutil::size(k.sizes[0]))); + add( + &mut out, + "Largest child", + format!( + "{} ({})", + san(&tr.node(first).name), + textutil::size(k.sizes[0]) + ), + ); } if n.errors > 0 { - add_s(&mut out, "Errors beneath", textutil::count(n.errors as i64), Style::new(t.err, Default::default()).with(BOLD)); + add_s( + &mut out, + "Errors beneath", + textutil::count(n.errors as i64), + Style::new(t.err, Default::default()).with(BOLD), + ); } let state = if n.has(FLAG_INCOMPLETE) { "incomplete (not fully read)" @@ -102,15 +150,32 @@ impl Modal for InfoModal { }; add(&mut out, "Scan state", state.into()); } else { - add_s(&mut out, "Logical size", format!("{} ({} bytes)", textutil::size(n.size), textutil::count(n.size)), bold); + add_s( + &mut out, + "Logical size", + format!( + "{} ({} bytes)", + textutil::size(n.size), + textutil::count(n.size) + ), + bold, + ); let alloc = if n.has(FLAG_ALLOC_UNKNOWN) { "unknown on this platform".to_string() } else { - format!("{} ({} bytes)", textutil::size(n.alloc), textutil::count(n.alloc)) + format!( + "{} ({} bytes)", + textutil::size(n.alloc), + textutil::count(n.alloc) + ) }; add_s(&mut out, "Allocated size", alloc, bold); if n.parent != NO_NODE { - add(&mut out, "Of parent", textutil::percent(n.own(mode), tr.node(n.parent).total(mode))); + add( + &mut out, + "Of parent", + textutil::percent(n.own(mode), tr.node(n.parent).total(mode)), + ); } } if let Some(r) = &m.search.result { @@ -120,9 +185,19 @@ impl Modal for InfoModal { match d.old_size(self.id, mode) { Some(old) => { add(&mut out, "In old snapshot", textutil::size(old)); - add_s(&mut out, "Change", textutil::signed_size(n.total(mode) - old), bold); + add_s( + &mut out, + "Change", + textutil::signed_size(n.total(mode) - old), + bold, + ); } - None => add_s(&mut out, "Change", "new since old snapshot".into(), Style::new(t.diff_new, Default::default()).with(BOLD)), + None => add_s( + &mut out, + "Change", + "new since old snapshot".into(), + Style::new(t.diff_new, Default::default()).with(BOLD), + ), } } out.push(txt("")); @@ -143,12 +218,23 @@ impl Modal for InfoModal { } out.push(txt("")); add(&mut out, "Mode", platform::mode::string(n.mode)); - add(&mut out, "Owner", textutil::sanitize(&platform::user_name(n.uid))); - add(&mut out, "Group", textutil::sanitize(&platform::group_name(n.gid))); + add( + &mut out, + "Owner", + textutil::sanitize(&platform::user_name(n.uid)), + ); + add( + &mut out, + "Group", + textutil::sanitize(&platform::group_name(n.gid)), + ); if !n.is_dir() { add(&mut out, "Hard links", n.nlink.to_string()); let sparse = if n.has(FLAG_SPARSE) { - format!("Yes (or compressed) — {} not allocated", textutil::size(n.size - n.alloc)) + format!( + "Yes (or compressed) — {} not allocated", + textutil::size(n.size - n.alloc) + ) } else { "No".into() }; @@ -159,11 +245,17 @@ impl Modal for InfoModal { add_s(&mut out, "Flags", fl.join(", "), warn); } if n.has(FLAG_HARDLINK_DUP) { - out.push(styled("Another path to this inode was counted; this one adds nothing to totals.", ks)); + out.push(styled( + "Another path to this inode was counted; this one adds nothing to totals.", + ks, + )); } if m.snapshot { out.push(txt("")); - out.push(styled("From snapshot — metadata as recorded, not live.", ks)); + out.push(styled( + "From snapshot — metadata as recorded, not live.", + ks, + )); } out } @@ -216,13 +308,22 @@ impl Modal for GroupModal { let t = m.theme.clone(); let ids = self.g.ids(); let mut out = vec![ - txt(format!("{} items too small to draw individually at this size,", textutil::count(ids.len() as i64))), - txt(format!("totalling {}. Zoom in or enlarge the terminal to see them.", textutil::size(self.g.size))), + txt(format!( + "{} items too small to draw individually at this size,", + textutil::count(ids.len() as i64) + )), + txt(format!( + "totalling {}. Zoom in or enlarge the terminal to see them.", + textutil::size(self.g.size) + )), txt(""), ]; for (i, &id) in ids.iter().enumerate() { if i == 200 { - out.push(styled(format!("… and {} more", textutil::count(ids.len() as i64 - 200)), Style::new(t.muted, Default::default()))); + out.push(styled( + format!("… and {} more", textutil::count(ids.len() as i64 - 200)), + Style::new(t.muted, Default::default()), + )); break; } let n = tr.node(id); @@ -231,8 +332,17 @@ impl Modal for GroupModal { name.push('/'); } out.push(vec![ - Span { s: pad_left(&textutil::size(m.size_of(tr, id)), 11) + " ", st: Some(Style::new(t.muted, Default::default())) }, - Span { s: trunc(&name, w - 13), st: Some(Style::new(m.color_of(tr, id as i64).mix(t.fg, 0.4), Default::default())) }, + Span { + s: pad_left(&textutil::size(m.size_of(tr, id)), 11) + " ", + st: Some(Style::new(t.muted, Default::default())), + }, + Span { + s: trunc(&name, w - 13), + st: Some(Style::new( + m.color_of(tr, id as i64).mix(t.fg, 0.4), + Default::default(), + )), + }, ]); } out @@ -281,10 +391,23 @@ impl Modal for HelpModal { out.push(styled(s, h)); }; let row = |out: &mut Vec, keys: &str, desc: &str| { - out.push(vec![Span { s: format!(" {}", pad_right(keys, 19)), st: Some(k) }, Span { s: desc.into(), st: Some(d) }]); + out.push(vec![ + Span { + s: format!(" {}", pad_right(keys, 19)), + st: Some(k), + }, + Span { + s: desc.into(), + st: Some(d), + }, + ]); }; sec(&mut out, "NAVIGATION"); - row(&mut out, "↑ ↓ ← → hjkl", "move spatially through the treemap"); + row( + &mut out, + "↑ ↓ ← → hjkl", + "move spatially through the treemap", + ); row(&mut out, "Enter", "inspect the selected item"); row(&mut out, "Space →", "zoom into the selected directory"); row(&mut out, "Backspace ←", "zoom out one level"); @@ -292,20 +415,43 @@ impl Modal for HelpModal { row(&mut out, "click / dbl-click", "select / zoom (mouse)"); row(&mut out, "wheel", "zoom in/out (map), scroll (lists)"); sec(&mut out, "SEARCH & FILTER"); - row(&mut out, "/", "search or filter; the map shows only matches"); + row( + &mut out, + "/", + "search or filter; the map shows only matches", + ); row(&mut out, "Esc", "clear the filter"); - out.push(styled(" ubuntu *.iso size > 5GB ext IN (iso,qcow2) age > 365d", muted)); - out.push(styled(" path contains cache AND NOT type = dir owner = andy flag = sparse", muted)); + out.push(styled( + " ubuntu *.iso size > 5GB ext IN (iso,qcow2) age > 365d", + muted, + )); + out.push(styled( + " path contains cache AND NOT type = dir owner = andy flag = sparse", + muted, + )); let fields: Vec<&str> = filter::FIELDS.split_whitespace().collect(); let split = 7.min(fields.len()); - out.push(styled(format!(" fields: {}", fields[..split].join(" ")), muted)); - out.push(styled(format!(" {}", fields[split..].join(" ")), muted)); - out.push(styled(" sizes: KiB/MiB/GiB (1024), kB/MB/GB (1000); ages: s min h d w mo y", muted)); + out.push(styled( + format!(" fields: {}", fields[..split].join(" ")), + muted, + )); + out.push(styled( + format!(" {}", fields[split..].join(" ")), + muted, + )); + out.push(styled( + " sizes: KiB/MiB/GiB (1024), kB/MB/GB (1000); ages: s min h d w mo y", + muted, + )); sec(&mut out, "VIEWS"); row(&mut out, "Tab Shift+Tab", "next / previous view"); row(&mut out, "1 – 7", "jump to a view"); row(&mut out, "x", "file types (extension statistics)"); - row(&mut out, "g", "top lists: largest, oldest, sparse, hard links …"); + row( + &mut out, + "g", + "top lists: largest, oldest, sparse, hard links …", + ); row(&mut out, "e", "scan information and errors"); row(&mut out, "D", "find duplicate files (reads file contents)"); sec(&mut out, "APPLICATION"); @@ -321,8 +467,15 @@ impl Modal for HelpModal { row(&mut out, "Ctrl+C", "cancel scan; press again to quit"); row(&mut out, "q", "quit"); out.push(txt("")); - let units = if crate::textutil::si() { "SI (kB = 1000 bytes)" } else { "IEC (KiB = 1024 bytes)" }; - out.push(styled(format!("Sizes: {} · units: {units}", m.size_mode.name()), muted)); + let units = if crate::textutil::si() { + "SI (kB = 1000 bytes)" + } else { + "IEC (KiB = 1024 bytes)" + }; + out.push(styled( + format!("Sizes: {} · units: {units}", m.size_mode.name()), + muted, + )); out } @@ -371,7 +524,9 @@ struct SaveModal { } pub(crate) fn new_save_modal(tr: &Tree) -> Box { - let mut md = SaveModal { ed: LineEditor::default() }; + let mut md = SaveModal { + ed: LineEditor::default(), + }; md.ed.set(&default_snapshot_name(&tr.stats.root)); Box::new(md) } @@ -399,7 +554,10 @@ impl Modal for SaveModal { } if m.scanning { out.push(txt("")); - out.push(styled("The scan is still running: the snapshot will be marked incomplete.", Style::new(t.warn, Default::default()))); + out.push(styled( + "The scan is still running: the snapshot will be marked incomplete.", + Style::new(t.warn, Default::default()), + )); } out } diff --git a/src/tui/model.rs b/src/tui/model.rs index b5bbd08..b1f1873 100644 --- a/src/tui/model.rs +++ b/src/tui/model.rs @@ -32,7 +32,8 @@ use std::time::{Duration, Instant}; /// Begins a scan (or returns a loaded tree with no scanner). Called again /// on rescan. -pub type StartFn = Arc Result<(Option, Arc>), crate::Error> + Send + Sync>; +pub type StartFn = + Arc Result<(Option, Arc>), crate::Error> + Send + Sync>; /// Configures the interactive session. pub struct Options { @@ -75,7 +76,11 @@ impl Key { _ if s.chars().count() == 1 => s.to_string(), _ => String::new(), }; - Key { name: s.to_string(), text, ctrl_alt: s.starts_with("ctrl+") || s.starts_with("alt+") } + Key { + name: s.to_string(), + text, + ctrl_alt: s.starts_with("ctrl+") || s.starts_with("alt+"), + } } } @@ -91,15 +96,40 @@ pub(crate) enum Msg { Size(i32, i32), Tick, ScanDone(Arc>), // identifies the scan by its tree - Filter { gen: u64, res: Option }, + Filter { + gen: u64, + res: Option, + }, FilterDebounce(u64), - DupDone { finder: Arc, groups: Option> }, - TrashDone { tree: Arc>, id: NodeId, err: Option, done: String }, - SaveDone { path: String, err: Option }, + DupDone { + finder: Arc, + groups: Option>, + }, + TrashDone { + tree: Arc>, + id: NodeId, + err: Option, + done: String, + }, + SaveDone { + path: String, + err: Option, + }, Key(Key), - Click { x: i32, y: i32, button: Button }, - Wheel { x: i32, y: i32, up: bool }, - Motion { x: i32, y: i32 }, + Click { + x: i32, + y: i32, + button: Button, + }, + Wheel { + x: i32, + y: i32, + up: bool, + }, + Motion { + x: i32, + y: i32, + }, Quit, // SIGTERM } @@ -239,8 +269,13 @@ impl Model { { theme.ascii = true; } - let mut views = vec![View::Map, View::List(Default::default()), View::Ext(Default::default()), - View::Top(Default::default()), View::Info(Default::default())]; + let mut views = vec![ + View::Map, + View::List(Default::default()), + View::Ext(Default::default()), + View::Top(Default::default()), + View::Info(Default::default()), + ]; if opts.diff.is_some() { views.push(View::Changes(Default::default())); } @@ -298,7 +333,9 @@ impl Model { } fn wait_scan(&self) -> Cmd { - let Some(s) = &self.scanner else { return Cmd::None }; + let Some(s) = &self.scanner else { + return Cmd::None; + }; let (rx, tree) = (s.done().clone(), s.tree.clone()); Cmd::run(move || { let _ = rx.recv(); @@ -307,7 +344,11 @@ impl Model { } pub fn refresh_interval(&self) -> Duration { - let hz = if self.opts.settings.refresh_hz <= 0 { 10 } else { self.opts.settings.refresh_hz }; + let hz = if self.opts.settings.refresh_hz <= 0 { + 10 + } else { + self.opts.settings.refresh_hz + }; Duration::from_secs(1) / hz as u32 } @@ -321,7 +362,9 @@ impl Model { } fn needs_ticks(&self) -> bool { - self.scanning || Instant::now() < self.toast_until || self.dups.as_ref().is_some_and(|d| d.running) + self.scanning + || Instant::now() < self.toast_until + || self.dups.as_ref().is_some_and(|d| d.running) } /// Shows a transient status message. @@ -366,7 +409,10 @@ impl Model { self.ticking = false; if self.scanning { self.dirty = true; - if self.last_data.is_none_or(|t| t.elapsed() >= Duration::from_millis(400)) { + if self + .last_data + .is_none_or(|t| t.elapsed() >= Duration::from_millis(400)) + { self.invalidate(); self.last_data = Some(Instant::now()); } @@ -379,7 +425,10 @@ impl Model { let mut cmds = Vec::new(); if self.scanning && self.search.query.is_some() - && self.search.applied_at.is_none_or(|t| t.elapsed() > Duration::from_secs(1)) + && self + .search + .applied_at + .is_none_or(|t| t.elapsed() > Duration::from_secs(1)) { cmds.push(self.apply_filter()); } @@ -405,7 +454,12 @@ impl Model { Cmd::None } Msg::DupDone { finder, groups } => self.dup_done(finder, groups), - Msg::TrashDone { tree, id, err, done } => self.trash_done(tree, id, err, done), + Msg::TrashDone { + tree, + id, + err, + done, + } => self.trash_done(tree, id, err, done), Msg::SaveDone { path, err } => match err { Some(e) => self.warn(format!("Snapshot save failed: {}", textutil::sanitize(&e))), None => self.info(format!("Saved snapshot {}", textutil::sanitize(&path))), @@ -454,13 +508,24 @@ impl Model { let (total, files, dirs, errs, el, cancelled) = { let t = self.tree.read().unwrap(); let st = &t.stats; - (t.node(0).total(self.size_mode), st.files + st.symlinks + st.others, st.dirs, st.total_errors(), st.elapsed(), st.cancelled) + ( + t.node(0).total(self.size_mode), + st.files + st.symlinks + st.others, + st.dirs, + st.total_errors(), + st.elapsed(), + st.cancelled, + ) }; let mut cmds = Vec::new(); let rate = (files + dirs) as f64 / el.as_secs_f64().max(0.001); let mut msg = format!( "✓ Scan complete {} · {} files · {} dirs · {} · {}/s", - textutil::duration(el), textutil::count(files), textutil::count(dirs), textutil::size(total), textutil::count(rate as i64) + textutil::duration(el), + textutil::count(files), + textutil::count(dirs), + textutil::size(total), + textutil::count(rate as i64) ); if cancelled { msg = "✗ Scan cancelled — totals are incomplete".into(); @@ -507,7 +572,10 @@ impl Model { Ok(v) => v, Err(e) => { cancel.cancel(); - return self.warn(format!("Rescan failed: {}", textutil::sanitize(&e.to_string()))); + return self.warn(format!( + "Rescan failed: {}", + textutil::sanitize(&e.to_string()) + )); } }; self.scanning = s.is_some(); @@ -536,13 +604,18 @@ impl Model { /// Re-zooms into the remembered directory once the new scan has /// discovered it. Takes the read lock itself. pub fn resolve_pending_zoom(&mut self) { - let Some(pz) = self.pending_zoom.clone() else { return }; + let Some(pz) = self.pending_zoom.clone() else { + return; + }; let arc = self.tree.clone(); let t = arc.read().unwrap(); let root = t.path_bytes(0); let rel: &[u8] = if pz == root { b"" - } else if let Some(r) = pz.strip_prefix(root.as_slice()).filter(|r| root.ends_with(b"/") || r.starts_with(b"/")) { + } else if let Some(r) = pz + .strip_prefix(root.as_slice()) + .filter(|r| root.ends_with(b"/") || r.starts_with(b"/")) + { r } else { self.pending_zoom = None; diff --git a/src/tui/mouse.rs b/src/tui/mouse.rs index 9e32352..4011752 100644 --- a/src/tui/mouse.rs +++ b/src/tui/mouse.rs @@ -32,7 +32,8 @@ impl Model { for i in (0..self.hits.len()).rev() { if self.hits[i].r.contains(x, y) { let key = (y as i64) << 32 | i as i64; - let double = self.last_click.is_some_and(|t| t.elapsed() < DOUBLE_CLICK) && self.last_click_i == key; + let double = self.last_click.is_some_and(|t| t.elapsed() < DOUBLE_CLICK) + && self.last_click_i == key; (self.last_click, self.last_click_i) = (Some(Instant::now()), key); if double { self.last_click_i = NO_SEL; diff --git a/src/tui/render.rs b/src/tui/render.rs index d6bc3d5..73a7e13 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -1,9 +1,9 @@ //! Frame composition: panel geometry, tabs, breadcrumbs, info and status //! lines. -use super::canvas::{Canvas, Color, Depth, Style, Attr, BOLD, REVERSE, UNDERLINE}; +use super::canvas::{Attr, Canvas, Color, Depth, Style, BOLD, REVERSE, UNDERLINE}; use super::model::{is_group, Hit, HitAct, Model, Profile}; -use super::{fmt_date, san, tw, trunc_left}; +use super::{fmt_date, san, trunc_left, tw}; use crate::brand; use crate::inventory::{Kind, Node, Tree, FLAG_SCANNED, NO_NODE}; use crate::textutil; @@ -46,21 +46,46 @@ pub(crate) fn compute_geometry(w: i32, h: i32) -> (SizeClass, Geometry) { }; let mut y = 0; if class >= SizeClass::Medium { - g.tabs = Rect { x: 0, y: 0, w, h: 1 }; + g.tabs = Rect { + x: 0, + y: 0, + w, + h: 1, + }; y = 1; } g.crumbs = Rect { x: 0, y, w, h: 1 }; y += 1; let mut bottom = h - 1; - g.stat = Rect { x: 0, y: h - 1, w, h: 1 }; + g.stat = Rect { + x: 0, + y: h - 1, + w, + h: 1, + }; if class >= SizeClass::Medium { - g.info = Rect { x: 0, y: h - 2, w, h: 1 }; + g.info = Rect { + x: 0, + y: h - 2, + w, + h: 1, + }; bottom = h - 2; } - g.main = Rect { x: 0, y, w, h: bottom - y }; + g.main = Rect { + x: 0, + y, + w, + h: bottom - y, + }; if class == SizeClass::Large { let sw = 40.min(w / 5); - g.side = Rect { x: 0, y, w: sw, h: g.main.h }; + g.side = Rect { + x: 0, + y, + w: sw, + h: g.main.h, + }; (g.main.x, g.main.w) = (sw, w - sw); } (class, g) @@ -114,7 +139,11 @@ impl Model { fn render_too_small(&self, cv: &mut Canvas) { let st = self.theme.muted(); - let lines = ["Terminal too small".to_string(), format!("{}×{} — need {}×{}", self.w, self.h, MIN_W, MIN_H), "q quits".to_string()]; + let lines = [ + "Terminal too small".to_string(), + format!("{}×{} — need {}×{}", self.w, self.h, MIN_W, MIN_H), + "q quits".to_string(), + ]; let y0 = 0.max((self.h - lines.len() as i32) / 2); for (i, l) in lines.iter().enumerate() { cv.text_centered(0, y0 + i as i32, self.w, l, st); @@ -133,8 +162,18 @@ impl Model { fn paint_tabs(&mut self, tr: &Tree, cv: &mut Canvas, r: Rect) { let t = self.theme.clone(); cv.fill(r, " ", t.header()); - let badge = if t.ascii { format!(" {} ", brand::NAME) } else { format!(" ◧ {} ", brand::NAME) }; - let mut x = cv.text(r.x, r.y, &badge, r.w, Style::new(t.text_on(t.accent), t.accent).with(BOLD | self.mono_rev())); + let badge = if t.ascii { + format!(" {} ", brand::NAME) + } else { + format!(" ◧ {} ", brand::NAME) + }; + let mut x = cv.text( + r.x, + r.y, + &badge, + r.w, + Style::new(t.text_on(t.accent), t.accent).with(BOLD | self.mono_rev()), + ); x += 1; let names: Vec<&'static str> = self.views.iter().map(|v| v.name()).collect(); for (i, name) in names.iter().enumerate() { @@ -148,7 +187,10 @@ impl Model { break; } let w = cv.text(x, r.y, &label, r.w - x, st); - self.hits.push(Hit { r: Rect { x, y: r.y, w, h: 1 }, act: HitAct::SetView(i) }); + self.hits.push(Hit { + r: Rect { x, y: r.y, w, h: 1 }, + act: HitAct::SetView(i), + }); x += w; } // Right-aligned chips. @@ -177,7 +219,13 @@ impl Model { break; } rx -= w; - cv.text(rx, r.y, &s, w, Style::new(t.text_on(*bg), *bg).with(BOLD | self.mono_rev())); + cv.text( + rx, + r.y, + &s, + w, + Style::new(t.text_on(*bg), *bg).with(BOLD | self.mono_rev()), + ); rx -= 1; } } @@ -206,10 +254,20 @@ impl Model { right = format!(" {} ", textutil::size_compact(size)); rw = tw(&right); } - cv.text_right(r.x, r.y, r.w, &right, Style::new(t.header_fg, t.header_bg).with(BOLD)); + cv.text_right( + r.x, + r.y, + r.w, + &right, + Style::new(t.header_fg, t.header_bg).with(BOLD), + ); let sep = if t.ascii { " > " } else { " › " }; - let segs: Vec<(u32, String)> = tr.ancestors(self.zoom).into_iter().map(|id| (id, san(&tr.node(id).name))).collect(); + let segs: Vec<(u32, String)> = tr + .ancestors(self.zoom) + .into_iter() + .map(|id| (id, san(&tr.node(id).name))) + .collect(); let avail = r.w - rw - 2; // Drop leading segments until the rest fits; keep at least the last. let total = |from: usize| { @@ -231,17 +289,40 @@ impl Model { } let mut x = r.x + 1; if from > 0 { - x += cv.text(x, r.y, &format!("…{sep}"), avail, Style::new(t.muted, t.header_bg)); + x += cv.text( + x, + r.y, + &format!("…{sep}"), + avail, + Style::new(t.muted, t.header_bg), + ); } for i in from..segs.len() { if i > from { - x += cv.text(x, r.y, sep, r.x + 1 + avail - x, Style::new(t.faint, t.header_bg)); + x += cv.text( + x, + r.y, + sep, + r.x + 1 + avail - x, + Style::new(t.faint, t.header_bg), + ); } let last = i == segs.len() - 1; - let st = if last { Style::new(t.fg, t.header_bg).with(BOLD) } else { Style::new(t.muted, t.header_bg) }; - let s = if last { trunc_left(&segs[i].1, 1.max(r.x + 1 + avail - x)) } else { segs[i].1.clone() }; + let st = if last { + Style::new(t.fg, t.header_bg).with(BOLD) + } else { + Style::new(t.muted, t.header_bg) + }; + let s = if last { + trunc_left(&segs[i].1, 1.max(r.x + 1 + avail - x)) + } else { + segs[i].1.clone() + }; let w = cv.text(x, r.y, &s, r.x + 1 + avail - x, st); - self.hits.push(Hit { r: Rect { x, y: r.y, w, h: 1 }, act: HitAct::ZoomTo(segs[i].0) }); + self.hits.push(Hit { + r: Rect { x, y: r.y, w, h: 1 }, + act: HitAct::ZoomTo(segs[i].0), + }); x += w; } } @@ -279,9 +360,14 @@ impl Model { return "Nothing selected".into(); } let n = tr.node(id); - let mut parts = vec![format!("▸ {}", san(&n.name)), textutil::size(self.size_of(tr, id))]; + let mut parts = vec![ + format!("▸ {}", san(&n.name)), + textutil::size(self.size_of(tr, id)), + ]; if n.parent != NO_NODE { - parts.push(textutil::percent(self.size_of(tr, id), self.size_of(tr, n.parent)) + " of parent"); + parts.push( + textutil::percent(self.size_of(tr, id), self.size_of(tr, n.parent)) + " of parent", + ); } if n.is_dir() { parts.push(textutil::count(n.files as i64) + " files"); @@ -308,8 +394,18 @@ impl Model { fn paint_status(&mut self, tr: &Tree, cv: &mut Canvas, r: Rect, class: SizeClass) { let t = self.theme.clone(); cv.fill(r, " ", t.status()); - let help = if class == SizeClass::Small { " ? " } else { " ? help " }; - let hw = cv.text(r.x + r.w - tw(help), r.y, help, r.w, Style::new(t.muted, t.status_bg)); + let help = if class == SizeClass::Small { + " ? " + } else { + " ? help " + }; + let hw = cv.text( + r.x + r.w - tw(help), + r.y, + help, + r.w, + Style::new(t.muted, t.status_bg), + ); let avail = r.w - hw - 1; if !self.toast.is_empty() { cv.text(r.x + 1, r.y, &self.toast, avail - 1, self.toast_style); @@ -320,8 +416,15 @@ impl Model { let files = st.files + st.symlinks + st.others; let mut parts: Vec<(String, Color)> = Vec::new(); if self.scanning { - let ms = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis(); - let frame = if t.ascii { "*" } else { SPINNER[(ms / 100) as usize % SPINNER.len()] }; + let ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let frame = if t.ascii { + "*" + } else { + SPINNER[(ms / 100) as usize % SPINNER.len()] + }; parts.push((format!("{frame} scanning"), t.accent)); } else if st.cancelled { parts.push(("✗ cancelled".into(), t.warn)); @@ -334,8 +437,15 @@ impl Model { parts.push((textutil::size(root.total(self.size_mode)), t.status_fg)); if self.scanning { let el = st.elapsed(); - let p = self.scanner.as_ref().map(|s| s.progress()).unwrap_or_default(); - parts.push((textutil::count((p.entries as f64 / el.as_secs_f64().max(0.001)) as i64) + "/s", t.status_fg)); + let p = self + .scanner + .as_ref() + .map(|s| s.progress()) + .unwrap_or_default(); + parts.push(( + textutil::count((p.entries as f64 / el.as_secs_f64().max(0.001)) as i64) + "/s", + t.status_fg, + )); if class != SizeClass::Small { parts.push((format!("{} workers", p.workers), t.muted)); parts.push((format!("queue {}", textutil::count(p.pending)), t.muted)); diff --git a/src/tui/run.rs b/src/tui/run.rs index 373c6c4..2f683d6 100644 --- a/src/tui/run.rs +++ b/src/tui/run.rs @@ -11,7 +11,9 @@ use crate::brand; use crate::cancel::Cancel; use crate::textutil; use crossbeam_channel::{after, select, Receiver, Sender}; -use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind}; +use crossterm::event::{ + self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind, +}; use crossterm::{cursor, execute, terminal}; use std::fmt::Write as _; use std::io::{self, Write}; @@ -26,7 +28,8 @@ static TERMINATED: AtomicBool = AtomicBool::new(false); /// Mouse off (1000/1002/1003/1015/1006), bracketed paste off, cursor on, /// main screen. -const RESTORE: &str = "\x1b[?1006l\x1b[?1015l\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?2004l\x1b[?25h\x1b[?1049l"; +const RESTORE: &str = + "\x1b[?1006l\x1b[?1015l\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?2004l\x1b[?25h\x1b[?1049l"; fn write_raw(s: &str) { let mut b = s.as_bytes(); @@ -84,7 +87,18 @@ fn detect_profile() -> Profile { if ct == "truecolor" || ct == "24bit" || term.ends_with("-direct") { return Profile::TrueColor; } - if ["kitty", "alacritty", "wezterm", "ghostty", "foot", "contour", "rio"].iter().any(|t| term.contains(t)) { + if [ + "kitty", + "alacritty", + "wezterm", + "ghostty", + "foot", + "contour", + "rio", + ] + .iter() + .any(|t| term.contains(t)) + { return Profile::TrueColor; } if term.contains("256color") || !ct.is_empty() { @@ -100,7 +114,11 @@ pub fn run(opts: Options) -> Result<(), crate::Error> { let forced = forced_profile(&opts.color); if forced.is_none() && !opts.color.is_empty() && opts.color != "auto" { cancel.cancel(); - return Err(format!("invalid --color {:?} (auto, truecolor, 256, 16, none)", opts.color).into()); + return Err(format!( + "invalid --color {:?} (auto, truecolor, 256, 16, none)", + opts.color + ) + .into()); } let mouse = opts.mouse; let mut m = Model::new(opts, s, t, cancel.clone()); @@ -111,7 +129,11 @@ pub fn run(opts: Options) -> Result<(), crate::Error> { std::thread::spawn(|| { std::thread::sleep(Duration::from_millis(500)); // Mirrors the scanner's guard: a worker panic aborts after the hook. - if std::panic::catch_unwind(|| panic!("MAPSIZE_DEBUG_PANIC=scanner: deliberate panic to test terminal restoration")).is_err() { + if std::panic::catch_unwind(|| { + panic!("MAPSIZE_DEBUG_PANIC=scanner: deliberate panic to test terminal restoration") + }) + .is_err() + { std::process::abort(); } }); @@ -127,7 +149,13 @@ fn enter_terminal(mouse: bool) -> io::Result<()> { terminal::enable_raw_mode()?; ACTIVE.store(true, Ordering::SeqCst); let mut out = io::stdout(); - execute!(out, terminal::EnterAlternateScreen, cursor::Hide, event::EnableBracketedPaste, terminal::SetTitle(brand::NAME))?; + execute!( + out, + terminal::EnterAlternateScreen, + cursor::Hide, + event::EnableBracketedPaste, + terminal::SetTitle(brand::NAME) + )?; if mouse { execute!(out, event::EnableMouseCapture)?; } @@ -140,7 +168,11 @@ fn convert_key(ev: KeyEvent) -> Option { return None; } let m = ev.modifiers; - let (ctrl, alt, shift) = (m.contains(KeyModifiers::CONTROL), m.contains(KeyModifiers::ALT), m.contains(KeyModifiers::SHIFT)); + let (ctrl, alt, shift) = ( + m.contains(KeyModifiers::CONTROL), + m.contains(KeyModifiers::ALT), + m.contains(KeyModifiers::SHIFT), + ); let mut prefix = String::new(); if ctrl { prefix += "ctrl+"; @@ -148,14 +180,37 @@ fn convert_key(ev: KeyEvent) -> Option { if alt { prefix += "alt+"; } - let named = |n: &str| Some(Key { name: format!("{prefix}{}{n}", if shift { "shift+" } else { "" }), text: String::new(), ctrl_alt: ctrl || alt }); + let named = |n: &str| { + Some(Key { + name: format!("{prefix}{}{n}", if shift { "shift+" } else { "" }), + text: String::new(), + ctrl_alt: ctrl || alt, + }) + }; match ev.code { KeyCode::Char(c) => { - let base = if c == ' ' { "space".to_string() } else if ctrl || alt { c.to_lowercase().collect() } else { c.to_string() }; - let text = if ctrl || alt { String::new() } else { c.to_string() }; - Some(Key { name: prefix + &base, text, ctrl_alt: ctrl || alt }) + let base = if c == ' ' { + "space".to_string() + } else if ctrl || alt { + c.to_lowercase().collect() + } else { + c.to_string() + }; + let text = if ctrl || alt { + String::new() + } else { + c.to_string() + }; + Some(Key { + name: prefix + &base, + text, + ctrl_alt: ctrl || alt, + }) } - KeyCode::BackTab => Some(Key { name: format!("{prefix}shift+tab"), ..Default::default() }), + KeyCode::BackTab => Some(Key { + name: format!("{prefix}shift+tab"), + ..Default::default() + }), KeyCode::Enter => named("enter"), KeyCode::Esc => named("esc"), KeyCode::Tab => named("tab"), @@ -256,7 +311,9 @@ fn base64(b: &[u8]) -> String { const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let mut s = String::with_capacity(b.len().div_ceil(3) * 4); for c in b.chunks(3) { - let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32; + let n = (c[0] as u32) << 16 + | (*c.get(1).unwrap_or(&0) as u32) << 8 + | *c.get(2).unwrap_or(&0) as u32; for i in 0..4 { if i <= c.len() { s.push(A[(n >> (18 - 6 * i) & 63) as usize] as char); @@ -332,7 +389,14 @@ fn line_diff(old: &str, new: &str) -> Vec<(usize, std::ops::Range)> { } impl Screen { - fn draw(&mut self, out: &mut impl Write, frame: &str, w: i32, h: i32, extra: &str) -> io::Result<()> { + fn draw( + &mut self, + out: &mut impl Write, + frame: &str, + w: i32, + h: i32, + extra: &str, + ) -> io::Result<()> { self.buf.clear(); self.buf.push_str("\x1b[?2026h"); // synchronized output, where supported if (w, h) != self.size { @@ -386,7 +450,11 @@ fn event_loop(m: &mut Model) -> io::Result<()> { std::thread::spawn(move || input_thread(tx, stop)); } let (w, h) = terminal::size()?; - let mut rt = Runtime { tx, timers: Vec::new(), out: String::new() }; + let mut rt = Runtime { + tx, + timers: Vec::new(), + out: String::new(), + }; rt.exec(m.update(Msg::Size(w as i32, h as i32))); let init = m.init(); rt.exec(init); @@ -414,7 +482,9 @@ fn event_loop(m: &mut Model) -> io::Result<()> { // Fire due timers, then drain whatever else is queued before the next // frame, so bursts (resize storms) cost one render. let now = Instant::now(); - let (due, later): (Vec<_>, Vec<_>) = std::mem::take(&mut rt.timers).into_iter().partition(|t| t.0 <= now); + let (due, later): (Vec<_>, Vec<_>) = std::mem::take(&mut rt.timers) + .into_iter() + .partition(|t| t.0 <= now); rt.timers = later; for (_, msg) in due { let c = m.update(msg); @@ -438,7 +508,14 @@ mod tests { #[test] fn base64_matches_rfc4648() { - for (i, o) in [("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foob", "Zm9vYg=="), ("foobar", "Zm9vYmFy")] { + for (i, o) in [ + ("", ""), + ("f", "Zg=="), + ("fo", "Zm8="), + ("foo", "Zm9v"), + ("foob", "Zm9vYg=="), + ("foobar", "Zm9vYmFy"), + ] { assert_eq!(base64(i.as_bytes()), o); } } @@ -450,7 +527,10 @@ mod tests { s.draw(&mut out, "a\nb\nc", 1, 3, "").unwrap(); out.clear(); s.draw(&mut out, "a\nX\nc", 1, 3, "").unwrap(); - assert_eq!(String::from_utf8(out).unwrap(), "\x1b[?2026h\x1b[2;1HX\x1b[?2026l"); + assert_eq!( + String::from_utf8(out).unwrap(), + "\x1b[?2026h\x1b[2;1HX\x1b[?2026l" + ); // Within a line only the differing style runs are rewritten. let a = "\x1b[0;1mab\x1b[0;7m日x\x1b[0mcd\x1b[0m"; let b = "\x1b[0;1mab\x1b[0;7m日y\x1b[0mcd\x1b[0m"; diff --git a/src/tui/search.rs b/src/tui/search.rs index 4c18cdb..4d23378 100644 --- a/src/tui/search.rs +++ b/src/tui/search.rs @@ -2,7 +2,7 @@ use super::canvas::{Canvas, Style, BOLD}; use super::model::{Cmd, Key, Model, Msg}; -use super::{tw, trunc}; +use super::{trunc, tw}; use crate::cancel::Cancel; use crate::filter::{self, FilterResult, Query}; use crate::inventory::Tree; @@ -74,7 +74,11 @@ impl LineEditor { if k.text.is_empty() || k.ctrl_alt { return (false, false); } - let rs: Vec = k.text.chars().filter(|&r| r >= ' ' && r != '\x7f').collect(); + let rs: Vec = k + .text + .chars() + .filter(|&r| r >= ' ' && r != '\x7f') + .collect(); if rs.is_empty() || self.buf.len() + rs.len() > 1024 { return (false, true); } @@ -174,7 +178,9 @@ impl Model { /// Evaluates the current query in the background. The tree is /// read-locked by the worker; stale results are discarded by generation. pub fn apply_filter(&mut self) -> Cmd { - let Some(q) = self.search.query.clone() else { return Cmd::None }; + let Some(q) = self.search.query.clone() else { + return Cmd::None; + }; if let Some(c) = self.search.cancel.take() { c.cancel(); } @@ -235,7 +241,11 @@ impl Model { } else if s.busy { "filtering…".into() } else if let Some(res) = &s.result { - format!("{} matches · {}", textutil::count(res.count), textutil::size(res.total)) + format!( + "{} matches · {}", + textutil::count(res.count), + textutil::size(res.total) + ) } else if s.ed.buf.is_empty() { "name, *.iso, size > 1GB, ext IN (iso,vmdk), age > 365d … Enter keep · Esc clear".into() } else { @@ -245,14 +255,28 @@ impl Model { let avail = r.w - (x - r.x) - sw - 3; // Scroll the text so the cursor stays visible. let text = &s.ed.buf; - let start = if s.ed.cur as i32 > avail - 1 { (s.ed.cur as i32 - avail + 1) as usize } else { 0 }; + let start = if s.ed.cur as i32 > avail - 1 { + (s.ed.cur as i32 - avail + 1) as usize + } else { + 0 + }; let end = text.len().min(start + avail.max(0) as usize).max(start); let vis: String = text[start..end].iter().collect(); cv.text(x, r.y, &textutil::sanitize(&vis), avail, st); let before: String = text[start..s.ed.cur].iter().collect(); let cx = x + tw(&textutil::sanitize(&before)); - let under = if s.ed.cur < text.len() { textutil::sanitize(&text[s.ed.cur].to_string()) } else { " ".into() }; - cv.text(cx, r.y, &under, 2, Style::new(t.panel_bg, t.fg).with(self.mono_rev())); + let under = if s.ed.cur < text.len() { + textutil::sanitize(&text[s.ed.cur].to_string()) + } else { + " ".into() + }; + cv.text( + cx, + r.y, + &under, + 2, + Style::new(t.panel_bg, t.fg).with(self.mono_rev()), + ); cv.text_right(r.x, r.y, r.w - 1, &trunc(&status, sw), status_st); } @@ -260,9 +284,21 @@ impl Model { let t = &self.theme; let s = &self.search; let mut x = r.x + 1; - x += cv.text(x, r.y, " FILTER ", 8, Style::new(t.text_on(t.warn), t.warn).with(BOLD | self.mono_rev())); + x += cv.text( + x, + r.y, + " FILTER ", + 8, + Style::new(t.text_on(t.warn), t.warn).with(BOLD | self.mono_rev()), + ); let q = s.query.as_ref().map(|q| q.to_string()).unwrap_or_default(); - x += cv.text(x + 1, r.y, &textutil::sanitize(&q), r.w / 2, Style::new(t.fg, t.panel_bg).with(BOLD)) + 1; + x += cv.text( + x + 1, + r.y, + &textutil::sanitize(&q), + r.w / 2, + Style::new(t.fg, t.panel_bg).with(BOLD), + ) + 1; let mut info = "filtering…".to_string(); if let (Some(res), false) = (&s.result, s.busy) { info = format!( @@ -272,6 +308,12 @@ impl Model { textutil::size(tr.node(0).total(self.size_mode)) ); } - cv.text(x + 2, r.y, &info, r.x + r.w - x - 3, Style::new(t.muted, t.panel_bg)); + cv.text( + x + 2, + r.y, + &info, + r.x + r.w - x - 3, + Style::new(t.muted, t.panel_bg), + ); } } diff --git a/src/tui/side.rs b/src/tui/side.rs index 634e01b..3027dfc 100644 --- a/src/tui/side.rs +++ b/src/tui/side.rs @@ -15,7 +15,16 @@ impl Model { let t = self.theme.clone(); let ps = t.panel(); cv.fill(r, " ", ps); - cv.fill(Rect { x: r.x + r.w - 1, y: r.y, w: 1, h: r.h }, "│", Style::new(t.faint, t.panel_bg)); + cv.fill( + Rect { + x: r.x + r.w - 1, + y: r.y, + w: 1, + h: r.h, + }, + "│", + Style::new(t.faint, t.panel_bg), + ); let w = r.w - 3; let x = r.x + 1; let mut y = r.y + 1; @@ -45,21 +54,43 @@ impl Model { } } y += 1; - kvl(cv, &mut y, "Size", &textutil::size(self.size_of(tr, self.zoom)), val); + kvl( + cv, + &mut y, + "Size", + &textutil::size(self.size_of(tr, self.zoom)), + val, + ); kvl(cv, &mut y, "Logical", &textutil::size(z.tot_size), plain); kvl(cv, &mut y, "Allocated", &textutil::size(z.tot_alloc), plain); kvl(cv, &mut y, "Files", &textutil::count(z.files as i64), plain); - kvl(cv, &mut y, "Directories", &textutil::count(z.dirs as i64), plain); + kvl( + cv, + &mut y, + "Directories", + &textutil::count(z.dirs as i64), + plain, + ); if z.errors > 0 { - kvl(cv, &mut y, "Errors", &textutil::count(z.errors as i64), Style::new(t.err, t.panel_bg).with(BOLD)); + kvl( + cv, + &mut y, + "Errors", + &textutil::count(z.errors as i64), + Style::new(t.err, t.panel_bg).with(BOLD), + ); } y += 1; // Category breakdown with proportional bars. if let Some(cs) = tr.cat_sizes(self.zoom) { head(cv, &mut y, "CATEGORIES"); - let mut cats: Vec<(Category, i64)> = - cs.iter().enumerate().filter(|(_, &v)| v > 0).map(|(i, &v)| (Category::from_u8(i as u8), v)).collect(); + let mut cats: Vec<(Category, i64)> = cs + .iter() + .enumerate() + .filter(|(_, &v)| v > 0) + .map(|(i, &v)| (Category::from_u8(i as u8), v)) + .collect(); let total: i64 = cats.iter().map(|c| c.1).sum(); cats.sort_by(|a, b| b.1.cmp(&a.1)); for &(c, v) in &cats { @@ -70,7 +101,13 @@ impl Model { let sw = if t.mono || t.ascii { " " } else { "██ " }; cv.text(x, y, sw, 3, Style::new(col, t.panel_bg)); cv.text(x + 3, y, c.name(), w - 3, plain); - cv.text_right(x, y, w, &textutil::percent(v, total), Style::new(t.muted, t.panel_bg)); + cv.text_right( + x, + y, + w, + &textutil::percent(v, total), + Style::new(t.muted, t.panel_bg), + ); y += 1; self.paint_bar(cv, x + 3, y, w - 3, v as f64 / cats[0].1 as f64, col, false); y += 1; @@ -84,7 +121,10 @@ impl Model { let mut desc = Vec::new(); if is_group(self.sel) { if let Some(g) = self.group_info(self.sel) { - desc.push(format!("{} smaller items", textutil::count(g.ids().len() as i64))); + desc.push(format!( + "{} smaller items", + textutil::count(g.ids().len() as i64) + )); desc.push(textutil::size(g.size)); } } else { @@ -92,7 +132,11 @@ impl Model { if id != NO_NODE { let n = tr.node(id); desc.extend(wrap(&san(&n.name), w)); - desc.push(textutil::size(self.size_of(tr, id)) + " · " + &textutil::percent(self.size_of(tr, id), self.size_of(tr, self.zoom))); + desc.push( + textutil::size(self.size_of(tr, id)) + + " · " + + &textutil::percent(self.size_of(tr, id), self.size_of(tr, self.zoom)), + ); if n.is_dir() { desc.push(textutil::count(n.files as i64) + " files"); } else { diff --git a/src/tui/table.rs b/src/tui/table.rs index f3a5525..5fdb4f6 100644 --- a/src/tui/table.rs +++ b/src/tui/table.rs @@ -16,7 +16,12 @@ pub(crate) struct Column { } pub(crate) const fn col(title: &'static str, width: i32, right: bool, clip_left: bool) -> Column { - Column { title, width, right, clip_left } + Column { + title, + width, + right, + clip_left, + } } /// One rendered row: cell strings plus optional per-cell styles. @@ -69,7 +74,15 @@ impl Table { self.cursor.max(0) as usize } - pub fn paint(&mut self, m: &mut Model, cv: &mut Canvas, r: Rect, cols: &[Column], rows: &[TableRow], title: &str) { + pub fn paint( + &mut self, + m: &mut Model, + cv: &mut Canvas, + r: Rect, + cols: &[Column], + rows: &[TableRow], + title: &str, + ) { let t = m.theme.clone(); cv.fill(r, " ", t.base()); if r.h < 3 { @@ -77,7 +90,13 @@ impl Table { } let mut y = r.y; if !title.is_empty() { - cv.text(r.x + 1, y, title, r.w - 2, Style::new(t.accent, t.bg).with(BOLD)); + cv.text( + r.x + 1, + y, + title, + r.w - 2, + Style::new(t.accent, t.bg).with(BOLD), + ); y += 1; } // Resolve flexible width. @@ -88,7 +107,11 @@ impl Table { let hdr = Style::new(t.muted, t.bg).with(BOLD); for c in cols { let w = width(c); - let s = if c.right { pad_left(c.title, w) } else { c.title.to_string() }; + let s = if c.right { + pad_left(c.title, w) + } else { + c.title.to_string() + }; cv.text(x, y, &s, w, hdr); x += w + 1; } @@ -114,7 +137,16 @@ impl Table { let mut base = t.base(); if sel { base = Style::new(t.text_on(t.sel), t.sel).with(BOLD | m.mono_rev()); - cv.fill(Rect { x: r.x, y, w: r.w, h: 1 }, " ", base); + cv.fill( + Rect { + x: r.x, + y, + w: r.w, + h: 1, + }, + " ", + base, + ); } let mut x = r.x + 1; for (ci, c) in cols.iter().enumerate() { @@ -135,7 +167,15 @@ impl Table { } x += w + 1; } - m.hits.push(Hit { r: Rect { x: r.x, y, w: r.w, h: 1 }, act: HitAct::Row(i as usize) }); + m.hits.push(Hit { + r: Rect { + x: r.x, + y, + w: r.w, + h: 1, + }, + act: HitAct::Row(i as usize), + }); y += 1; i += 1; } @@ -143,7 +183,11 @@ impl Table { cv.text_centered(r.x, r.y + r.h / 2, r.w, "Nothing to show", t.muted()); } if n > self.height && self.height > 0 { - let pos = format!("{}/{}", textutil::count(self.cursor as i64 + 1), textutil::count(n as i64)); + let pos = format!( + "{}/{}", + textutil::count(self.cursor as i64 + 1), + textutil::count(n as i64) + ); cv.text_right(r.x, r.y, r.w - 1, &pos, t.muted()); } } @@ -152,10 +196,23 @@ impl Table { impl Model { /// Draws a proportional bar with eighth-block precision. #[allow(clippy::too_many_arguments)] - pub fn paint_bar(&self, cv: &mut Canvas, x: i32, y: i32, w: i32, frac: f64, col: Color, sel: bool) { + pub fn paint_bar( + &self, + cv: &mut Canvas, + x: i32, + y: i32, + w: i32, + frac: f64, + col: Color, + sel: bool, + ) { let t = &self.theme; let col = if col.is_default() { t.accent } else { col }; - let bg = if sel { t.sel.mix(rgb(0, 0, 0), 0.25) } else { t.bg.mix(t.fg, 0.08) }; + let bg = if sel { + t.sel.mix(rgb(0, 0, 0), 0.25) + } else { + t.bg.mix(t.fg, 0.08) + }; let mut eighths = (frac * (w * 8) as f64 + 0.5) as i64; const PARTS: [&str; 8] = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"]; for i in 0..w { @@ -170,7 +227,11 @@ impl Model { ch = if eighths >= 4 { "#" } else { " " }; } eighths -= 8; - let st = if t.mono { Style::default() } else { Style::new(col, bg) }; + let st = if t.mono { + Style::default() + } else { + Style::new(col, bg) + }; cv.text(x + i, y, ch, 1, st); } } diff --git a/src/tui/tests.rs b/src/tui/tests.rs index 10793b4..e6ecb1a 100644 --- a/src/tui/tests.rs +++ b/src/tui/tests.rs @@ -1,11 +1,11 @@ //! Headless model tests (ports of tui_test.go, nest_test.go, live_test.go). use super::bench::opts_for; -use super::color256::{quant16, quant256}; use super::canvas::rgb; +use super::color256::{quant16, quant256}; use super::mapview::TOP_MIN_AREA; -use super::model::*; use super::modals::new_info_modal; +use super::model::*; use crate::cancel::Cancel; use crate::inventory::{category_for, ext, Kind, NodeId, Tree, FLAG_SCANNED}; use crate::scan; @@ -39,7 +39,12 @@ fn test_tree() -> Tree { }; let backups = add_dir(&mut t, 0, b"backups"); for i in 0..40i64 { - file(&mut t, backups, format!("dump-{i:02}.tar.gz").as_bytes(), (1 + i) << 30); + file( + &mut t, + backups, + format!("dump-{i:02}.tar.gz").as_bytes(), + (1 + i) << 30, + ); } let vms = add_dir(&mut t, 0, b"vms"); file(&mut t, vms, b"ubuntu.qcow2", 86 << 30); @@ -47,7 +52,12 @@ fn test_tree() -> Tree { let media = add_dir(&mut t, 0, b"media"); let photos = add_dir(&mut t, media, "photos 日本語".as_bytes()); for i in 0..300i64 { - file(&mut t, photos, format!("img{i:03}.jpg").as_bytes(), (i + 1) << 20); + file( + &mut t, + photos, + format!("img{i:03}.jpg").as_bytes(), + (i + 1) << 20, + ); } file(&mut t, media, HOSTILE, 9 << 30); file(&mut t, media, "👨‍👩‍👧 family.mp4".as_bytes(), 12 << 30); @@ -114,7 +124,12 @@ fn check_frame(m: &mut Model, w: i32, h: i32) -> String { "terminal control sequence from a filename leaked into the frame" ); let lines: Vec<&str> = content.split('\n').collect(); - assert_eq!(lines.len() as i32, h, "{w}x{h}: frame has {} lines", lines.len()); + assert_eq!( + lines.len() as i32, + h, + "{w}x{h}: frame has {} lines", + lines.len() + ); for (i, l) in lines.iter().enumerate() { let got = textutil::width(&strip(l)) as i32; assert_eq!(got, w, "{w}x{h}: line {i} has width {got}: {:?}", strip(l)); @@ -135,7 +150,10 @@ fn find(m: &Model, name: &[u8]) -> NodeId { } fn find_block(bs: &[Block], id: i64) -> Block { - bs.iter().copied().find(|b| b.id == id).unwrap_or(Block { id: 0, rect: Default::default() }) + bs.iter().copied().find(|b| b.id == id).unwrap_or(Block { + id: 0, + rect: Default::default(), + }) } #[test] @@ -151,19 +169,39 @@ fn resize_transitions_preserve_state() { check_frame(&mut m, 120, 40); send(&mut m, vec![key("right")]); let sel = m.sel; - for (w, h) in [(80, 24), (120, 40), (300, 80), (70, 20), (180, 50), (40, 10), (39, 9), (200, 60)] { + for (w, h) in [ + (80, 24), + (120, 40), + (300, 80), + (70, 20), + (180, 50), + (40, 10), + (39, 9), + (200, 60), + ] { send(&mut m, vec![size(w, h)]); check_frame(&mut m, w, h); assert_eq!(m.zoom, media, "{w}x{h}: zoom lost"); - assert!(m.sel == sel || is_group(m.sel), "{w}x{h}: selection changed from {sel} to {}", m.sel); + assert!( + m.sel == sel || is_group(m.sel), + "{w}x{h}: selection changed from {sel} to {}", + m.sel + ); } // The layout must be recomputed for the new geometry, not stretched. let l = m.tm.clone().unwrap(); - assert!(l.rect.w <= 200 && l.rect.h <= 60, "layout not recomputed for final size: {:?}", l.rect); + assert!( + l.rect.w <= 200 && l.rect.h <= 60, + "layout not recomputed for final size: {:?}", + l.rect + ); let r = l.rect; for b in &l.blocks { let br = b.rect; - assert!(br.x >= r.x && br.y >= r.y && br.x + br.w <= r.x + r.w && br.y + br.h <= r.y + r.h, "block {b:?} outside treemap {r:?}"); + assert!( + br.x >= r.x && br.y >= r.y && br.x + br.w <= r.x + r.w && br.y + br.h <= r.y + r.h, + "block {b:?} outside treemap {r:?}" + ); } } @@ -172,7 +210,9 @@ fn resize_storm() { let mut m = new_test_model(); let mut rnd = 1u64; let mut intn = |n: u64| { - rnd = rnd.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + rnd = rnd + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); (rnd >> 33) % n }; send(&mut m, vec![size(120, 40)]); @@ -199,9 +239,16 @@ fn modal_survives_resize() { for (w, h) in [(120, 40), (60, 14), (41, 10), (300, 80), (45, 12)] { send(&mut m, vec![size(w, h)]); let out = check_frame(&mut m, w, h); - assert!(out.contains("ITEM INFORMATION"), "{w}x{h}: modal title missing:\n{out}"); + assert!( + out.contains("ITEM INFORMATION"), + "{w}x{h}: modal title missing:\n{out}" + ); let f = &m.modals[0]; - assert!(f.view_h >= 1 && f.view_h <= h, "{w}x{h}: modal viewport {}", f.view_h); + assert!( + f.view_h >= 1 && f.view_h <= h, + "{w}x{h}: modal viewport {}", + f.view_h + ); } // Scrolling in a tiny terminal must stay in range. send(&mut m, vec![size(45, 12)]); @@ -210,7 +257,12 @@ fn modal_survives_resize() { } check_frame(&mut m, 45, 12); let f = &m.modals[0]; - assert!(f.scroll >= 0 && f.scroll <= f.content_n, "scroll out of range: {} of {}", f.scroll, f.content_n); + assert!( + f.scroll >= 0 && f.scroll <= f.content_n, + "scroll out of range: {} of {}", + f.scroll, + f.content_n + ); send(&mut m, vec![key("esc")]); assert!(m.modals.is_empty(), "Esc should close the modal"); } @@ -228,14 +280,20 @@ fn spatial_navigation_uses_geometry() { if m.sel == before { continue; // edge of the map } - let (a, b) = (find_block(&blocks, before).rect, find_block(&blocks, m.sel).rect); + let (a, b) = ( + find_block(&blocks, before).rect, + find_block(&blocks, m.sel).rect, + ); let ok = match dir { "right" => b.x >= a.x + a.w, "left" => b.x + b.w <= a.x, "down" => b.y >= a.y + a.h, _ => b.y + b.h <= a.y, }; - assert!(ok, "{dir} moved to a block that is not in that direction: {a:?} -> {b:?}"); + assert!( + ok, + "{dir} moved to a block that is not in that direction: {a:?} -> {b:?}" + ); } } @@ -251,13 +309,21 @@ fn zoom_breadcrumbs_and_home() { send(&mut m, vec![key("space")]); assert_eq!(m.zoom, photos, "did not zoom into photos"); let out = check_frame(&mut m, 100, 30); - assert!(out.contains("media › photos 日本語"), "breadcrumbs missing:\n{out}"); + assert!( + out.contains("media › photos 日本語"), + "breadcrumbs missing:\n{out}" + ); // Narrow terminal: breadcrumbs truncate from the left, keeping the tail. send(&mut m, vec![size(42, 12)]); let out = check_frame(&mut m, 42, 12); assert!(out.contains("photos"), "breadcrumb tail missing:\n{out}"); send(&mut m, vec![key("backspace")]); - assert!(m.zoom == media && m.sel == photos as i64, "backspace: zoom {} sel {}", m.zoom, m.sel); + assert!( + m.zoom == media && m.sel == photos as i64, + "backspace: zoom {} sel {}", + m.zoom, + m.sel + ); send(&mut m, vec![key("home")]); assert_eq!(m.zoom, 0, "home did not return to root"); } @@ -267,20 +333,40 @@ fn filter_transforms_map() { let mut m = new_test_model(); send(&mut m, vec![size(120, 40), key("/")]); for c in "ext = gz".chars() { - send(&mut m, vec![Msg::Key(Key { name: c.to_string(), text: c.to_string(), ctrl_alt: false })]); + send( + &mut m, + vec![Msg::Key(Key { + name: c.to_string(), + text: c.to_string(), + ctrl_alt: false, + })], + ); } - assert!(m.search.err.is_empty() && m.search.query.is_some(), "query not parsed: {:?}", m.search.err); + assert!( + m.search.err.is_empty() && m.search.query.is_some(), + "query not parsed: {:?}", + m.search.err + ); let msgs = m.apply_filter().run_sync(); send(&mut m, msgs); send(&mut m, vec![key("enter")]); assert!(m.search.result.is_some(), "no filter result"); let out = check_frame(&mut m, 120, 40); - assert!(!out.contains("vms/") && out.contains("backups/"), "filter not applied to the map:\n{out}"); + assert!( + !out.contains("vms/") && out.contains("backups/"), + "filter not applied to the map:\n{out}" + ); assert!(out.contains("FILTER"), "filter indicator missing"); send(&mut m, vec![key("esc")]); - assert!(m.search.query.is_none() && m.search.result.is_none(), "Esc should clear the filter"); + assert!( + m.search.query.is_none() && m.search.result.is_none(), + "Esc should clear the filter" + ); let out = check_frame(&mut m, 120, 40); - assert!(out.contains("vms/"), "map not restored after clearing filter:\n{out}"); + assert!( + out.contains("vms/"), + "map not restored after clearing filter:\n{out}" + ); } #[test] @@ -292,7 +378,10 @@ fn hostile_name_sanitised_in_modal() { let md = new_info_modal(&m, &t.read().unwrap(), id); m.open_modal(md); let out = check_frame(&mut m, 120, 40); - assert!(out.contains(r"\x1b]0;pwned\x07"), "escaped name not shown:\n{out}"); + assert!( + out.contains(r"\x1b]0;pwned\x07"), + "escaped name not shown:\n{out}" + ); } #[test] @@ -321,15 +410,26 @@ fn level_of_detail_groups_tiny_files() { m.zoom_to(&t.read().unwrap(), tiny); m.view(); let l = m.tm.clone().unwrap(); - assert!(l.blocks.len() as i32 <= 100 * 30 / TOP_MIN_AREA + 1, "{} blocks for 2000 equal files: LOD not applied", l.blocks.len()); - assert!(l.groups.contains_key(&group_id(tiny)), "expected a group block for small items"); + assert!( + l.blocks.len() as i32 <= 100 * 30 / TOP_MIN_AREA + 1, + "{} blocks for 2000 equal files: LOD not applied", + l.blocks.len() + ); + assert!( + l.groups.contains_key(&group_id(tiny)), + "expected a group block for small items" + ); } #[test] fn ctrl_c_quits_when_idle() { let mut m = new_test_model(); send(&mut m, vec![size(80, 24)]); - m.update(Msg::Key(Key { name: "ctrl+c".into(), text: String::new(), ctrl_alt: true })); + m.update(Msg::Key(Key { + name: "ctrl+c".into(), + text: String::new(), + ctrl_alt: true, + })); assert!(m.quit, "Ctrl+C should quit when no scan runs"); } @@ -341,23 +441,45 @@ fn mouse() { // Click the centre of a block other than the selected one. let target = { let t = m.tree.read().unwrap(); - m.tm.as_ref().unwrap().blocks.iter().copied() + m.tm.as_ref() + .unwrap() + .blocks + .iter() + .copied() .find(|b| b.id != m.sel && b.id >= 0 && t.node(b.id as NodeId).is_dir() && b.rect.w > 4) .unwrap() }; - let click = || Msg::Click { x: target.rect.x + target.rect.w / 2, y: target.rect.y + target.rect.h / 2, button: Button::Left }; + let click = || Msg::Click { + x: target.rect.x + target.rect.w / 2, + y: target.rect.y + target.rect.h / 2, + button: Button::Left, + }; send(&mut m, vec![click()]); assert_eq!(m.sel, target.id, "click selected the wrong block"); m.view(); send(&mut m, vec![click()]); // second click within the double-click window assert_eq!(m.zoom as i64, target.id, "double click should zoom"); m.view(); - send(&mut m, vec![Msg::Wheel { x: 50, y: 20, up: false }]); + send( + &mut m, + vec![Msg::Wheel { + x: 50, + y: 20, + up: false, + }], + ); assert_eq!(m.zoom, 0, "wheel down should zoom out"); // A modal swallows clicks; clicking outside closes it. send(&mut m, vec![key("?")]); m.view(); - send(&mut m, vec![Msg::Click { x: 0, y: 39, button: Button::Left }]); + send( + &mut m, + vec![Msg::Click { + x: 0, + y: 39, + button: Button::Left, + }], + ); assert!(m.modals.is_empty(), "click outside modal should close it"); } @@ -370,12 +492,20 @@ fn quantizers() { assert!(n >= 16, "256 quantizer used palette-dependent index {n}"); // A dark, slightly tinted shade must land on the grey ramp or a dark // cube entry, not a saturated one like olive (58) or navy (17). - assert!(![58, 17, 22].contains(&n), "dark tint mapped to saturated {n}"); + assert!( + ![58, 17, 22].contains(&n), + "dark tint mapped to saturated {n}" + ); } #[test] fn colour_depths_render_same_geometry() { - for p in [Profile::TrueColor, Profile::Ansi256, Profile::Ansi, Profile::Ascii] { + for p in [ + Profile::TrueColor, + Profile::Ansi256, + Profile::Ansi, + Profile::Ascii, + ] { let mut m = new_test_model(); m.profile = p; send(&mut m, vec![size(100, 30)]); @@ -388,11 +518,23 @@ fn colour_depths_render_same_geometry() { fn copy_refuses_control_characters() { let mut m = new_test_model(); m.copy_path(b"/srv/x\ncurl evil | sh\n"); - assert!(m.toast.starts_with("Not copied"), "hostile path copied: {:?}", m.toast); + assert!( + m.toast.starts_with("Not copied"), + "hostile path copied: {:?}", + m.toast + ); m.copy_path(&[b"/srv/media/".as_slice(), HOSTILE].concat()); - assert!(m.toast.starts_with("Not copied"), "escape sequence copied: {:?}", m.toast); + assert!( + m.toast.starts_with("Not copied"), + "escape sequence copied: {:?}", + m.toast + ); m.copy_path("/srv/photos 日本語/img001.jpg".as_bytes()); - assert!(m.toast.starts_with("Copied"), "plain path refused: {:?}", m.toast); + assert!( + m.toast.starts_with("Copied"), + "plain path refused: {:?}", + m.toast + ); } // ---- nest_test.go ----------------------------------------------------------- @@ -431,9 +573,18 @@ fn nesting_reaches_deep_media() { let mut m = model_for(root_tree()); send(&mut m, vec![size(160, 45)]); let out = strip(m.view()); - assert!(out.contains("mnt/raid/media/") && out.contains("S01/"), "chain or seasons missing:\n{out}"); + assert!( + out.contains("mnt/raid/media/") && out.contains("S01/"), + "chain or seasons missing:\n{out}" + ); let t = m.tree.read().unwrap(); - let files = m.tm.as_ref().unwrap().nested.iter().filter(|nb| nb.id >= 0 && !t.node(nb.id as NodeId).is_dir()).count(); + let files = + m.tm.as_ref() + .unwrap() + .nested + .iter() + .filter(|nb| nb.id >= 0 && !t.node(nb.id as NodeId).is_dir()) + .count(); assert!(files >= 50, "only {files} episode blocks drawn"); } @@ -453,7 +604,12 @@ fn reveal_headless_zooms_map() { m.snapshot = false; // reveal is refused for snapshots (m.sel, m.user_sel) = (mnt as i64, true); send(&mut m, vec![key("o")]); - assert_eq!(m.zoom, mnt, "zoom = {}, want mnt", m.tree.read().unwrap().path_string(m.zoom)); + assert_eq!( + m.zoom, + mnt, + "zoom = {}, want mnt", + m.tree.read().unwrap().path_string(m.zoom) + ); } // ---- live_test.go ----------------------------------------------------------- @@ -467,12 +623,24 @@ fn live_scan_rendering() { let d = root.0.join(format!("d{i:02}")).join("sub"); std::fs::create_dir_all(&d).unwrap(); for j in 0..40 { - std::fs::write(d.join(format!("f{j}.log")), vec![0u8; (i + 1) * (j + 1) * 100]).unwrap(); + std::fs::write( + d.join(format!("f{j}.log")), + vec![0u8; (i + 1) * (j + 1) * 100], + ) + .unwrap(); } } let path = root.0.as_os_str().as_bytes().to_vec(); let start: StartFn = Arc::new(move |c: Cancel| { - let s = scan::start(&path, scan::Options { workers: 4, chunk_size: 3, ..Default::default() }, c)?; + let s = scan::start( + &path, + scan::Options { + workers: 4, + chunk_size: 3, + ..Default::default() + }, + c, + )?; let t = s.tree.clone(); Ok((Some(s), t)) }); diff --git a/src/tui/theme.rs b/src/tui/theme.rs index f99f200..9360c47 100644 --- a/src/tui/theme.rs +++ b/src/tui/theme.rs @@ -119,7 +119,11 @@ fn high_contrast_theme() -> Theme { } fn mono_theme() -> Theme { - Theme { name: "mono", mono: true, ..Theme::default() } + Theme { + name: "mono", + mono: true, + ..Theme::default() + } } /// Returns a built-in theme with user overrides applied. diff --git a/src/tui/views.rs b/src/tui/views.rs index 6dd44bb..34a9e14 100644 --- a/src/tui/views.rs +++ b/src/tui/views.rs @@ -4,11 +4,14 @@ use super::actions::DupView; use super::canvas::{Canvas, Color, Style, BOLD}; use super::infoview::InfoView; use super::mapview::{map_key, map_paint, map_wheel}; -use super::model::{Cmd, Model}; use super::modals::new_info_modal; +use super::model::{Cmd, Model}; use super::table::{col, Table, TableRow}; use super::{fmt_date, san}; -use crate::inventory::{ExtStat, Kind, NodeId, SizeMode, TopK, Tree, FLAG_DELETED, FLAG_HARDLINKED, FLAG_HARDLINK_DUP, FLAG_SPARSE, NO_NODE}; +use crate::inventory::{ + ExtStat, Kind, NodeId, SizeMode, TopK, Tree, FLAG_DELETED, FLAG_HARDLINKED, FLAG_HARDLINK_DUP, + FLAG_SPARSE, NO_NODE, +}; use crate::snapshot::{Change, Status}; use crate::textutil; use crate::treemap::Rect; @@ -159,16 +162,31 @@ impl ListView { name.push('/'); files = textutil::count(n.files as i64); } - let modified = if n.mtime > 0 { fmt_date(n.mtime) } else { String::new() }; + let modified = if n.mtime > 0 { + fmt_date(n.mtime) + } else { + String::new() + }; let c = m.color_of(tr, id as i64); let mut name_st = Style::new(c.mix(t.fg, 0.45), t.bg); if n.is_dir() { name_st.attr = BOLD; } - let frac = if k.sizes[0] > 0 { k.sizes[i] as f64 / k.sizes[0] as f64 } else { 0.0 }; + let frac = if k.sizes[0] > 0 { + k.sizes[i] as f64 / k.sizes[0] as f64 + } else { + 0.0 + }; let muted = Some(Style::new(t.muted, t.bg)); let mut row = TableRow { - cells: vec![textutil::size(k.sizes[i]), textutil::percent(k.sizes[i], total), String::new(), files, modified, name], + cells: vec![ + textutil::size(k.sizes[i]), + textutil::percent(k.sizes[i], total), + String::new(), + files, + modified, + name, + ], styles: vec![None, muted, None, muted, muted, Some(name_st)], bar: frac, bar_col: c, @@ -178,15 +196,29 @@ impl ListView { } rows.push(row); } - let mut cols = vec![col("SIZE", 10, true, false), col("%", 5, true, false), col("", 12, false, false), - col("FILES", 10, true, false), col("MODIFIED", 10, false, false), col("NAME", 0, false, false)]; + let mut cols = vec![ + col("SIZE", 10, true, false), + col("%", 5, true, false), + col("", 12, false, false), + col("FILES", 10, true, false), + col("MODIFIED", 10, false, false), + col("NAME", 0, false, false), + ]; if m.opts.diff.is_some() { cols[4] = col("CHANGE", 12, true, false); } if r.w < 70 { - cols = vec![col("SIZE", 10, true, false), col("%", 5, true, false), col("NAME", 0, false, false)]; + cols = vec![ + col("SIZE", 10, true, false), + col("%", 5, true, false), + col("NAME", 0, false, false), + ]; for row in rows.iter_mut() { - row.cells = vec![std::mem::take(&mut row.cells[0]), std::mem::take(&mut row.cells[1]), std::mem::take(&mut row.cells[5])]; + row.cells = vec![ + std::mem::take(&mut row.cells[0]), + std::mem::take(&mut row.cells[1]), + std::mem::take(&mut row.cells[5]), + ]; row.styles = vec![None, row.styles[1], row.styles[5]]; } } @@ -247,22 +279,56 @@ impl ExtView { let rows: Vec = st .iter() .map(|s| { - let ext = if s.ext.is_empty() { "(none)".to_string() } else { format!(".{}", s.ext) }; - let c = t.ext.as_ref().and_then(|e| e.get(&s.ext)).copied().unwrap_or(t.cat[s.cat as usize]); - let frac = if v0 > 0 { pick(m, s) as f64 / v0 as f64 } else { 0.0 }; + let ext = if s.ext.is_empty() { + "(none)".to_string() + } else { + format!(".{}", s.ext) + }; + let c = t + .ext + .as_ref() + .and_then(|e| e.get(&s.ext)) + .copied() + .unwrap_or(t.cat[s.cat as usize]); + let frac = if v0 > 0 { + pick(m, s) as f64 / v0 as f64 + } else { + 0.0 + }; TableRow { - cells: vec![textutil::sanitize(&ext), s.cat.name().into(), textutil::count(s.count), textutil::size(pick(m, s)), - textutil::percent(pick(m, s), total), String::new()], - styles: vec![Some(Style::new(c, t.bg).with(BOLD)), Some(Style::new(t.muted, t.bg)), None, None, - Some(Style::new(t.muted, t.bg)), None], + cells: vec![ + textutil::sanitize(&ext), + s.cat.name().into(), + textutil::count(s.count), + textutil::size(pick(m, s)), + textutil::percent(pick(m, s), total), + String::new(), + ], + styles: vec![ + Some(Style::new(c, t.bg).with(BOLD)), + Some(Style::new(t.muted, t.bg)), + None, + None, + Some(Style::new(t.muted, t.bg)), + None, + ], bar: frac, bar_col: c, } }) .collect(); - let cols = [col("EXTENSION", 14, false, false), col("CATEGORY", 12, false, false), col("FILES", 11, true, false), - col("SIZE", 10, true, false), col("%", 5, true, false), col("", 0, false, false)]; - let title = format!("File types under {} — Enter filters the map by extension", san(&tr.node(m.zoom).name)); + let cols = [ + col("EXTENSION", 14, false, false), + col("CATEGORY", 12, false, false), + col("FILES", 11, true, false), + col("SIZE", 10, true, false), + col("%", 5, true, false), + col("", 0, false, false), + ]; + let title = format!( + "File types under {} — Enter filters the map by extension", + san(&tr.node(m.zoom).name) + ); self.tb.paint(m, cv, r, &cols, &rows, &title); } @@ -273,7 +339,11 @@ impl ExtView { } if (k == "enter" || k == "space") && self.tb.at() < st.len() { let e = &st[self.tb.at()].ext; - let q = if e.is_empty() { "type = file AND ext = \"\"".to_string() } else { format!("ext = \"{e}\"") }; + let q = if e.is_empty() { + "type = file AND ext = \"\"".to_string() + } else { + format!("ext = \"{e}\"") + }; m.set_view_by_name("Map"); return (true, m.set_filter(&q)); } @@ -295,10 +365,25 @@ enum TopMode { Hardlinks, } -const TOP_MODES: [TopMode; 7] = [TopMode::Files, TopMode::Dirs, TopMode::Old, TopMode::New, TopMode::Count, TopMode::Sparse, TopMode::Hardlinks]; +const TOP_MODES: [TopMode; 7] = [ + TopMode::Files, + TopMode::Dirs, + TopMode::Old, + TopMode::New, + TopMode::Count, + TopMode::Sparse, + TopMode::Hardlinks, +]; -const TOP_NAMES: [&str; 7] = ["Largest files", "Largest directories (own files)", "Oldest large files", "Newest large files", - "Most files", "Sparse files", "Hard-linked files"]; +const TOP_NAMES: [&str; 7] = [ + "Largest files", + "Largest directories (own files)", + "Oldest large files", + "Newest large files", + "Most files", + "Sparse files", + "Hard-linked files", +]; const TOP_LIMIT: usize = 500; @@ -319,7 +404,9 @@ impl TopView { return; } (self.cache_key, self.computed) = (k, true); - let is_file = |n: &crate::inventory::Node| n.kind == Kind::File && n.flags & (FLAG_HARDLINK_DUP | FLAG_DELETED) == 0; + let is_file = |n: &crate::inventory::Node| { + n.kind == Kind::File && n.flags & (FLAG_HARDLINK_DUP | FLAG_DELETED) == 0 + }; let matches = |id: NodeId| m.search.result.as_ref().is_none_or(|r| r.size(id) > 0); let mut tk = TopK::new(TOP_LIMIT); let mut val: HashMap = HashMap::new(); @@ -358,7 +445,14 @@ impl TopView { } TopMode::Old | TopMode::New => { if sz >= LARGE_FILE && n.mtime > 0 { - tk.offer(id, if mode == TopMode::Old { -n.mtime } else { n.mtime }); + tk.offer( + id, + if mode == TopMode::Old { + -n.mtime + } else { + n.mtime + }, + ); val.insert(id, sz); } } @@ -390,8 +484,16 @@ impl TopView { let mut rows = Vec::with_capacity(self.ids.len()); for (i, &id) in self.ids.iter().enumerate() { let n = tr.node(id); - let val = if self.mode == TopMode::Count { textutil::count(self.vals[i]) } else { textutil::size(self.vals[i]) }; - let modified = if n.mtime > 0 { fmt_date(n.mtime) } else { String::new() }; + let val = if self.mode == TopMode::Count { + textutil::count(self.vals[i]) + } else { + textutil::size(self.vals[i]) + }; + let modified = if n.mtime > 0 { + fmt_date(n.mtime) + } else { + String::new() + }; let frac = if first > 0 && self.mode != TopMode::Old && self.mode != TopMode::New { self.vals[i] as f64 / first as f64 } else { @@ -400,12 +502,20 @@ impl TopView { let c = m.color_of(tr, id as i64); rows.push(TableRow { cells: vec![val, modified, String::new(), san(&tr.path_bytes(id))], - styles: vec![None, Some(Style::new(t.muted, t.bg)), None, Some(Style::new(c.mix(t.fg, 0.45), t.bg))], + styles: vec![ + None, + Some(Style::new(t.muted, t.bg)), + None, + Some(Style::new(c.mix(t.fg, 0.45), t.bg)), + ], bar: frac, bar_col: c, }); } - let mut title = format!("◂ {} ▸ (←/→ change list · Enter details · Space jump to it)", TOP_NAMES[self.mode as usize]); + let mut title = format!( + "◂ {} ▸ (←/→ change list · Enter details · Space jump to it)", + TOP_NAMES[self.mode as usize] + ); if m.search.result.is_some() { title += " · filtered"; } @@ -414,7 +524,12 @@ impl TopView { TopMode::Sparse => "SAVED", _ => "SIZE", }; - let cols = [col(val_title, 10, true, false), col("MODIFIED", 10, false, false), col("", 10, false, false), col("PATH", 0, false, true)]; + let cols = [ + col(val_title, 10, true, false), + col("MODIFIED", 10, false, false), + col("", 10, false, false), + col("PATH", 0, false, true), + ]; self.tb.paint(m, cv, r, &cols, &rows, &title); } @@ -487,18 +602,45 @@ impl ChangesView { Status::Unchanged => Color::default(), }; TableRow { - cells: vec![textutil::signed_size(c.delta), c.status.name().into(), textutil::size(c.old), textutil::size(c.new), - String::new(), textutil::sanitize(&c.path)], - styles: vec![Some(Style::new(col, t.bg).with(BOLD)), Some(Style::new(col, t.bg)), Some(Style::new(t.muted, t.bg)), None, None, None], + cells: vec![ + textutil::signed_size(c.delta), + c.status.name().into(), + textutil::size(c.old), + textutil::size(c.new), + String::new(), + textutil::sanitize(&c.path), + ], + styles: vec![ + Some(Style::new(col, t.bg).with(BOLD)), + Some(Style::new(col, t.bg)), + Some(Style::new(t.muted, t.bg)), + None, + None, + None, + ], bar: c.delta.abs() as f64 / max_abs as f64, bar_col: col, } }) .collect(); - let cols = [col("CHANGE", 13, true, false), col("STATUS", 9, false, false), col("OLD", 10, true, false), - col("NEW", 10, true, false), col("", 10, false, false), col("PATH", 0, false, true)]; - let (ro, rn) = (d.old.node(0).total(m.size_mode), tr.node(0).total(m.size_mode)); - let title = format!("Changes {} → {} ({})", textutil::size(ro), textutil::size(rn), textutil::signed_size(rn - ro)); + let cols = [ + col("CHANGE", 13, true, false), + col("STATUS", 9, false, false), + col("OLD", 10, true, false), + col("NEW", 10, true, false), + col("", 10, false, false), + col("PATH", 0, false, true), + ]; + let (ro, rn) = ( + d.old.node(0).total(m.size_mode), + tr.node(0).total(m.size_mode), + ); + let title = format!( + "Changes {} → {} ({})", + textutil::size(ro), + textutil::size(rn), + textutil::signed_size(rn - ro) + ); self.tb.paint(m, cv, r, &cols, &rows, &title); } @@ -511,7 +653,10 @@ impl ChangesView { let c = &self.rows.as_ref().unwrap()[self.tb.at()]; if c.node == NO_NODE { if k == "enter" || k == "space" { - let msg = format!("Removed since the old snapshot: {}", textutil::sanitize(&c.path)); + let msg = format!( + "Removed since the old snapshot: {}", + textutil::sanitize(&c.path) + ); return (true, m.info(msg)); } return (false, Cmd::None);