diff --git a/cmd/treegen/main.go b/cmd/treegen/main.go new file mode 100644 index 0000000..c33b4cd --- /dev/null +++ b/cmd/treegen/main.go @@ -0,0 +1,130 @@ +// 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/internal/app/app.go b/internal/app/app.go index 5c1180e..b6b077e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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 { diff --git a/internal/app/app_test.go b/internal/app/app_test.go new file mode 100644 index 0000000..5bf8d13 --- /dev/null +++ b/internal/app/app_test.go @@ -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) + } +} diff --git a/internal/app/modes.go b/internal/app/modes.go index 936ec9e..120f471 100644 --- a/internal/app/modes.go +++ b/internal/app/modes.go @@ -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]) diff --git a/internal/tui/actions.go b/internal/tui/actions.go index c761975..973e774 100644 --- a/internal/tui/actions.go +++ b/internal/tui/actions.go @@ -212,7 +212,7 @@ func (v *dupView) paint(m *Model, cv *Canvas, r treemap.Rect) { if d.err != nil { title += " — stopped: " + textutil.Sanitize(d.err.Error()) } - v.tb.paint(m, cv, r, []column{{"WASTED", 10, true}, {"COPIES", 16, false}, {"", 0, false}}, tr, title) + 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) { diff --git a/internal/tui/infoview.go b/internal/tui/infoview.go index 1798e85..cbf69c6 100644 --- a/internal/tui/infoview.go +++ b/internal/tui/infoview.go @@ -131,14 +131,14 @@ func (v *infoView) paint(m *Model, cv *Canvas, r treemap.Rect) { if e.Name != "" { p += "/" + e.Name } - rows[i] = tableRow{cells: []string{e.Kind.String(), textutil.Sanitize(e.Msg), textutil.Sanitize(p)}, - styles: []*Style{{FG: t.Err, BG: t.Bg}, {FG: t.Muted, BG: t.Bg}, nil}, bar: -1} + 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", 20, false}, {"DETAIL", 24, false}, {"PATH", 0, false}}, rows, title) + 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) { diff --git a/internal/tui/model.go b/internal/tui/model.go index 6ac36f6..df930db 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -150,6 +150,11 @@ func newModel(opts Options, s *scan.Scanner, t *inventory.Tree, cancel context.C 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} @@ -271,6 +276,9 @@ func (m *Model) update(msg tea.Msg) tea.Cmd { 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 diff --git a/internal/tui/run.go b/internal/tui/run.go index cd36353..c3dae65 100644 --- a/internal/tui/run.go +++ b/internal/tui/run.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "os" + "runtime/debug" + "time" tea "charm.land/bubbletea/v2" "github.com/charmbracelet/colorprofile" @@ -47,6 +49,17 @@ func Run(opts Options) error { 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() diff --git a/internal/tui/table.go b/internal/tui/table.go index e4a82f6..f53263b 100644 --- a/internal/tui/table.go +++ b/internal/tui/table.go @@ -9,9 +9,10 @@ import ( // column describes one table column. type column struct { - title string - width int // 0 = flexible (takes the remaining space) - right bool + 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. @@ -129,6 +130,8 @@ func (tb *table) paint(m *Model, cv *Canvas, r treemap.Rect, cols []column, rows 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) } diff --git a/internal/tui/views.go b/internal/tui/views.go index 076afe9..356e4a1 100644 --- a/internal/tui/views.go +++ b/internal/tui/views.go @@ -75,12 +75,12 @@ func (v *listView) paint(m *Model, cv *Canvas, r treemap.Rect) { rows[i].cells[4] = textutil.SignedSize(m.opts.Diff.Delta(id, m.sizeMode)) } } - cols := []column{{"SIZE", 10, true}, {"%", 5, true}, {"", 12, false}, {"FILES", 10, true}, {"MODIFIED", 10, false}, {"NAME", 0, false}} + 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} + cols[4] = column{"CHANGE", 12, true, false} } if r.W < 70 { - cols = []column{{"SIZE", 10, true}, {"%", 5, true}, {"NAME", 0, false}} + 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]} @@ -160,7 +160,7 @@ func (v *extView) paint(m *Model, cv *Canvas, r treemap.Rect) { bar: frac, barCol: col, } } - cols := []column{{"EXTENSION", 14, false}, {"CATEGORY", 12, false}, {"FILES", 11, true}, {"SIZE", 10, true}, {"%", 5, true}, {"", 0, false}} + 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))) } @@ -342,7 +342,7 @@ func (v *topView) paint(m *Model, cv *Canvas, r treemap.Rect) { case topSparse: valTitle = "SAVED" } - cols := []column{{valTitle, 10, true}, {"MODIFIED", 10, false}, {"", 10, false}, {"PATH", 0, false}} + 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) } @@ -413,7 +413,8 @@ func (v *changesView) paint(m *Model, cv *Canvas, r treemap.Rect) { bar: float64(abs64(c.Delta)) / float64(maxAbs), barCol: col, } } - cols := []column{{"CHANGE", 13, true}, {"STATUS", 9, false}, {"OLD", 10, true}, {"NEW", 10, true}, {"", 10, false}, {"PATH", 0, false}} + 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)