Add interactive snapshot comparison, CLI tests and treegen

--compare opens the TUI with growth/shrink colouring unless a report is
requested. treegen builds deterministic synthetic trees (Unicode,
hostile names, sparse files). MAPSIZE_DEBUG_PANIC verifies terminal
restoration on UI and scanner panics.
This commit is contained in:
Andy Dixon
2026-10-02 08:28:52 +01:00
parent df8b10c6dd
commit 0e5b9f7cc3
10 changed files with 288 additions and 20 deletions
+6 -5
View File
@@ -93,6 +93,12 @@ func runReport(cfg *Config) error {
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 != "" {
@@ -108,11 +114,6 @@ func runReport(cfg *Config) error {
case cfg.CSV:
return export.CSV(out, t, t.Root(), cfg.Depth)
}
if cfg.Duplicates {
if err := runDuplicates(ctx, cfg, t); err != nil {
return err
}
}
if cfg.Top > 0 {
ids := t.SortedChildren(t.Root(), cfg.SizeMode)
if len(ids) > cfg.Top {
+105
View File
@@ -0,0 +1,105 @@
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)
}
}
+9 -2
View File
@@ -34,6 +34,13 @@ func runCompare(cfg *Config) error {
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
@@ -50,8 +57,6 @@ func runCompare(cfg *Config) error {
}
func runDuplicates(ctx context.Context, cfg *Config, t *inventory.Tree) error {
// The caller holds RLock; Find takes its own read lock, which is fine
// for a RWMutex only if no writer is waiting — the scan is finished.
var f duplicate.Finder
groups, err := f.Find(ctx, t, duplicate.Options{MinSize: 1})
if err != nil {
@@ -59,6 +64,8 @@ func runDuplicates(ctx context.Context, cfg *Config, t *inventory.Tree) error {
}
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])