diff --git a/README.md b/README.md index e108b5b..537a5cc 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ sp reap # sweep expired → morgue, purge old morgu sp reap --install-cron # print a daily-reap crontab line (no daemon) sp doctor # check store health (orphans, missing files, size) sp doctor --json | jq -e '.healthy' # gate a script on store health +sp stats # fun store metrics: footprint, oldest survivor, tags +sp stats --json | jq '.totalBytes' # bytes kept out of /tmp, for scripting sp ls --json | jq '.[].id' # machine-readable output for scripting sp completion zsh > "${fpath[1]}/_sp" # tab-completion for your shell sp scan # tripwire: does this scratch hold a secret? @@ -284,6 +286,34 @@ never null). Gate a script on the store's health without parsing prose: `sp doctor --json | jq -e '.healthy'`, or list drift with `sp doctor --json | jq '.orphans[].path'`. +### `sp stats` — fun store metrics + +Takes the store's pulse and prints the little numbers that make the whole scheme +feel worth it. Like `doctor`, it's **read-only** and derives everything from the +index — no new counters, nothing changed. It reports: + +- **living** — how many scratches you're keeping and the bytes they hold. +- **oldest survivor** — the scratch that has dodged the reaper longest, and for + how long. +- **morgue** — recoverable soft-deleted bytes, plus how many are already past + the grace window (one `sp reap` from gone). +- **footprint** — total bytes that passed through the store instead of rotting + loose in `/tmp` (as far as the index can account for — v0.1 keeps no all-time + counters, so this is the current live + morgue footprint, honestly labeled). +- **top tags** — your most-used labels, ranked. + +```bash +sp stats # colorized report with a little tombstone flavor +sp stats --no-color # plain, script-friendly +sp stats --json # stable JSON object for scripting (no color, no flavor) +``` + +An empty store gets a friendly zero-state, not a wall of zeros. For scripting, +`--json` emits a single stable object: live/morgue counts, raw bytes plus human +strings for each size, a `graceSeconds` field, an `oldest` sub-object (`null` +when there are no live scratches), and a `tags` array (always an array, never +null). Pull the headline number with `sp stats --json | jq '.totalBytes'`. + ### `sp scan ` — the secret tripwire AI coding agents and tired humans leak API keys and `.env` dumps into throwaway diff --git a/internal/cli/root.go b/internal/cli/root.go index 2d894bb..194fea4 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -54,6 +54,7 @@ func NewRootCommand() *cobra.Command { newPromoteCommand(), newReapCommand(), newDoctorCommand(), + newStatsCommand(), newDedupCommand(), newScanCommand(), newTUICommand(), diff --git a/internal/cli/stats.go b/internal/cli/stats.go new file mode 100644 index 0000000..20edbdb --- /dev/null +++ b/internal/cli/stats.go @@ -0,0 +1,87 @@ +package cli + +import ( + "github.com/spf13/cobra" + + "github.com/rwrife/scratchpatch/internal/render" + "github.com/rwrife/scratchpatch/internal/store" +) + +func newStatsCommand() *cobra.Command { + var noColor bool + var asJSON bool + + cmd := &cobra.Command{ + Use: "stats", + Short: "Fun store metrics: footprint, oldest survivor, morgue rot, tag breakdown", + Long: "Take the store's pulse. stats reports the little numbers that make the\n" + + "whole scheme feel worth it:\n\n" + + " • living — how many scratches you're keeping and the bytes they hold.\n" + + " • oldest survivor — the scratch that has dodged the reaper longest.\n" + + " • morgue — recoverable soft-deleted bytes, and how many are already\n" + + " past the grace window (one `sp reap` from gone).\n" + + " • footprint — total bytes that passed through the store instead of\n" + + " rotting loose in /tmp (as far as the index can account for).\n" + + " • top tags — your most-used labels.\n\n" + + "stats is read-only and derives everything from the index; it adds no\n" + + "new counters and changes nothing.\n\n" + + "Pass --json for a stable, machine-readable object (no color, no flavor):\n" + + "`sp stats --json | jq '.totalBytes'`.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runStats(cmd, noColor, asJSON) + }, + } + + cmd.Flags().BoolVar(&noColor, "no-color", false, "force plain output even on a TTY") + cmd.Flags().BoolVar(&asJSON, "json", false, "emit a JSON object instead of a report (for scripting)") + + return cmd +} + +func runStats(cmd *cobra.Command, noColor, asJSON bool) error { + st, err := store.Open() + if err != nil { + return err + } + + stats, err := st.Stats() + if err != nil { + return err + } + + out := cmd.OutOrStdout() + data := toStatsData(stats) + + // --json is intentionally color- and personality-free, matching the other + // scriptable paths. + if asJSON { + return render.StatsReportJSON(out, data) + } + + color := !noColor && isTerminal(out) + return render.StatsReport(out, data, color) +} + +// toStatsData flattens the store's Stats into the render layer's plain view, so +// render never has to import the store package — the same adapter pattern +// doctor.go and reap.go use. +func toStatsData(s store.Stats) render.StatsData { + tags := make([]render.StatsTag, len(s.Tags)) + for i, t := range s.Tags { + tags[i] = render.StatsTag{Tag: t.Tag, Count: t.Count} + } + return render.StatsData{ + LiveCount: s.LiveCount, + LiveBytes: s.LiveBytes, + MorgueCount: s.MorgueCount, + MorgueBytes: s.MorgueBytes, + PurgeableCount: s.PurgeableCount, + TotalBytes: s.TotalBytes, + OldestID: s.OldestID, + OldestName: s.OldestName, + OldestAge: s.OldestAge, + Tags: tags, + Grace: s.Grace, + } +} diff --git a/internal/cli/stats_test.go b/internal/cli/stats_test.go new file mode 100644 index 0000000..bef89d4 --- /dev/null +++ b/internal/cli/stats_test.go @@ -0,0 +1,74 @@ +package cli + +import ( + "encoding/json" + "strings" + "testing" +) + +// TestStatsEmptyStore: a fresh store gives a friendly zero-state, not a crash +// or a wall of zeros. +func TestStatsEmptyStore(t *testing.T) { + s := newSession(t) + out, err := s.run("stats", "--no-color") + if err != nil { + t.Fatalf("stats: %v (out=%s)", err, out) + } + if !strings.Contains(out, "empty") { + t.Errorf("empty store should report a zero-state; got %q", out) + } +} + +// TestStatsCountsLiveScratches: a couple of live scratches show up in the +// living line and the footprint. +func TestStatsCountsLiveScratches(t *testing.T) { + s := newSession(t) + s.newScratchID("one") + s.newScratchID("two") + + out, err := s.run("stats", "--no-color") + if err != nil { + t.Fatalf("stats: %v (out=%s)", err, out) + } + if !strings.Contains(out, "2 scratches") { + t.Errorf("stats should count both live scratches; got %q", out) + } + if !strings.Contains(out, "oldest survivor") { + t.Errorf("stats should name an oldest survivor; got %q", out) + } +} + +// TestStatsJSON: --json emits a stable object with the expected fields and no +// color. +func TestStatsJSON(t *testing.T) { + s := newSession(t) + s.newScratchID("solo") + + out, err := s.run("stats", "--json") + if err != nil { + t.Fatalf("stats --json: %v (out=%s)", err, out) + } + if strings.Contains(out, "\x1b[") { + t.Errorf("--json must be color-free; got %q", out) + } + var got struct { + LiveCount int `json:"liveCount"` + TotalBytes int64 `json:"totalBytes"` + Oldest *struct { + ID string `json:"id"` + } `json:"oldest"` + Tags []any `json:"tags"` + } + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("unmarshal: %v\n%s", err, out) + } + if got.LiveCount != 1 { + t.Errorf("LiveCount = %d, want 1", got.LiveCount) + } + if got.Oldest == nil || got.Oldest.ID == "" { + t.Errorf("oldest survivor should be present; got %+v", got.Oldest) + } + if got.Tags == nil { + t.Errorf("tags should serialize as [] not null") + } +} diff --git a/internal/render/stats.go b/internal/render/stats.go new file mode 100644 index 0000000..239113f --- /dev/null +++ b/internal/render/stats.go @@ -0,0 +1,170 @@ +// stats.go renders `sp stats`: the store's fun-but-useful metrics as a +// tombstone-flavored report (human) or a stable object (--json). +// +// As with the doctor and ls renderers, render takes flattened plain data +// (StatsData) rather than importing the store package, keeping the dependency +// arrow one-way (cli/store → render). The JSON path carries no color and no +// personality — pure data so `sp stats --json | jq` stays a scripting contract. +package render + +import ( + "fmt" + "io" + "strings" + "time" +) + +// StatsTag is a render-facing tag/count pair for the breakdown line. +type StatsTag struct { + Tag string + Count int +} + +// StatsData is the flattened metrics view render prints. The cli layer adapts +// the store's Stats into this so render never learns the store's types. +type StatsData struct { + LiveCount int + LiveBytes int64 + MorgueCount int + MorgueBytes int64 + PurgeableCount int + TotalBytes int64 + + // OldestID is empty when the store has no live scratches. + OldestID string + OldestName string + OldestAge time.Duration + + Tags []StatsTag + Grace time.Duration +} + +// empty reports whether the store holds nothing at all (no live, no morgue). +func (d StatsData) empty() bool { + return d.LiveCount == 0 && d.MorgueCount == 0 +} + +// StatsReport writes the human-readable, optionally-colored stats report to w. +// An empty store gets a friendly zero-state rather than a wall of zeros. When +// color is set the headline leans on the fresh/soon palette for a little life. +func StatsReport(w io.Writer, d StatsData, color bool) error { + pal := defaultPalette() + var b strings.Builder + + if d.empty() { + writeLine(&b, "the crypt is empty — no scratches living or dead. "+ + "Nothing has rotted, because nothing exists yet. `sp new` to begin.", + color, pal.fresh) + _, err := io.WriteString(w, b.String()) + return err + } + + // Headline: the store's whole footprint — bytes rescued from a lonely death + // in /tmp. + writeLine(&b, fmt.Sprintf("scratchpatch is guarding %s across %s live and %s in the morgue "+ + "(%s that isn't rotting loose in /tmp)", + humanSize(d.TotalBytes), countScratches(d.LiveCount), + countScratches(d.MorgueCount), humanSize(d.TotalBytes)), + color, pal.header) + + // The living. + writeLine(&b, fmt.Sprintf("living: %s, %s on disk", + countScratches(d.LiveCount), humanSize(d.LiveBytes)), color, pal.fresh) + + // Oldest survivor — the scratch that has dodged the reaper longest. + if d.OldestID != "" { + writeLine(&b, fmt.Sprintf("oldest survivor: %s %s, clinging on for %s", + d.OldestID, nameOrDash(d.OldestName), humanAge(d.OldestAge)), + color, pal.fresh) + } + + // The dead (recoverable) — and how many are already on death row. + morgueLine := fmt.Sprintf("morgue: %s, %s recoverable", + countScratches(d.MorgueCount), humanSize(d.MorgueBytes)) + if d.PurgeableCount > 0 { + morgueLine += fmt.Sprintf(" — %d past the %s grace window (reaping will hard-delete them)", + d.PurgeableCount, humanAge(d.Grace)) + } + writeLine(&b, morgueLine, color, pal.soon) + + // Tag breakdown, top-N by count. + if len(d.Tags) > 0 { + parts := make([]string, 0, len(d.Tags)) + for _, t := range d.Tags { + parts = append(parts, fmt.Sprintf("%s (%d)", t.Tag, t.Count)) + } + writeLine(&b, "top tags: "+strings.Join(parts, ", "), color, pal.header) + } + + _, err := io.WriteString(w, b.String()) + return err +} + +// StatsTagJSON is the scriptable tag/count pair. +type StatsTagJSON struct { + Tag string `json:"tag"` + Count int `json:"count"` +} + +// StatsJSON is the scriptable object for `sp stats --json`. It mirrors the +// human report's information without wording or color, with raw bytes plus a +// human companion for each size and an oldest-survivor sub-object that is null +// when the store has no live scratches. Slices are always non-nil so the shape +// never flips to null. +type StatsJSON struct { + LiveCount int `json:"liveCount"` + LiveBytes int64 `json:"liveBytes"` + LiveBytesHuman string `json:"liveBytesHuman"` + MorgueCount int `json:"morgueCount"` + MorgueBytes int64 `json:"morgueBytes"` + MorgueBytesHuman string `json:"morgueBytesHuman"` + PurgeableCount int `json:"purgeableCount"` + TotalBytes int64 `json:"totalBytes"` + TotalBytesHuman string `json:"totalBytesHuman"` + GraceSeconds int64 `json:"graceSeconds"` + + // Oldest is the oldest living scratch, or null when there are none. + Oldest *StatsOldestJSON `json:"oldest"` + + Tags []StatsTagJSON `json:"tags"` +} + +// StatsOldestJSON describes the oldest surviving scratch for the JSON view. +type StatsOldestJSON struct { + ID string `json:"id"` + Name string `json:"name"` + AgeSeconds int64 `json:"ageSeconds"` + AgeHuman string `json:"ageHuman"` +} + +// StatsReportJSON writes a StatsData to w as a single StatsJSON object. Like the +// other --json paths it is color- and personality-free. +func StatsReportJSON(w io.Writer, d StatsData) error { + tags := make([]StatsTagJSON, 0, len(d.Tags)) + for _, t := range d.Tags { + tags = append(tags, StatsTagJSON{Tag: t.Tag, Count: t.Count}) + } + + rec := StatsJSON{ + LiveCount: d.LiveCount, + LiveBytes: d.LiveBytes, + LiveBytesHuman: humanSize(d.LiveBytes), + MorgueCount: d.MorgueCount, + MorgueBytes: d.MorgueBytes, + MorgueBytesHuman: humanSize(d.MorgueBytes), + PurgeableCount: d.PurgeableCount, + TotalBytes: d.TotalBytes, + TotalBytesHuman: humanSize(d.TotalBytes), + GraceSeconds: int64(d.Grace / time.Second), + Tags: tags, + } + if d.OldestID != "" { + rec.Oldest = &StatsOldestJSON{ + ID: d.OldestID, + Name: d.OldestName, + AgeSeconds: int64(d.OldestAge / time.Second), + AgeHuman: humanAge(d.OldestAge), + } + } + return writeJSON(w, rec) +} diff --git a/internal/render/stats_test.go b/internal/render/stats_test.go new file mode 100644 index 0000000..1a77668 --- /dev/null +++ b/internal/render/stats_test.go @@ -0,0 +1,99 @@ +package render + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + "time" +) + +func TestStatsReportEmptyStore(t *testing.T) { + var b bytes.Buffer + if err := StatsReport(&b, StatsData{}, false); err != nil { + t.Fatalf("StatsReport: %v", err) + } + out := b.String() + if !strings.Contains(out, "empty") { + t.Errorf("empty store report should mention emptiness, got %q", out) + } + if strings.Contains(out, "\x1b[") { + t.Errorf("color=false output should carry no escape codes: %q", out) + } +} + +func TestStatsReportPopulated(t *testing.T) { + d := StatsData{ + LiveCount: 2, + LiveBytes: 2048, + MorgueCount: 1, + MorgueBytes: 512, + PurgeableCount: 1, + TotalBytes: 2560, + OldestID: "deadbeef", + OldestName: "notes", + OldestAge: 100 * time.Hour, + Tags: []StatsTag{{"work", 2}, {"todo", 1}}, + Grace: 72 * time.Hour, + } + var b bytes.Buffer + if err := StatsReport(&b, d, false); err != nil { + t.Fatalf("StatsReport: %v", err) + } + out := b.String() + for _, want := range []string{"deadbeef", "notes", "oldest survivor", "work (2)", "todo (1)", "grace"} { + if !strings.Contains(out, want) { + t.Errorf("report missing %q; got:\n%s", want, out) + } + } +} + +func TestStatsReportJSONShape(t *testing.T) { + d := StatsData{ + LiveCount: 1, + LiveBytes: 1024, + MorgueCount: 0, + TotalBytes: 1024, + OldestID: "abc123", + OldestName: "x", + OldestAge: 2 * time.Hour, + Tags: []StatsTag{{"a", 1}}, + Grace: 72 * time.Hour, + } + var b bytes.Buffer + if err := StatsReportJSON(&b, d); err != nil { + t.Fatalf("StatsReportJSON: %v", err) + } + var got StatsJSON + if err := json.Unmarshal(b.Bytes(), &got); err != nil { + t.Fatalf("unmarshal: %v\n%s", err, b.String()) + } + if got.LiveBytesHuman == "" || got.TotalBytesHuman == "" { + t.Error("human size companions should be populated") + } + if got.GraceSeconds != int64((72 * time.Hour).Seconds()) { + t.Errorf("GraceSeconds = %d", got.GraceSeconds) + } + if got.Oldest == nil || got.Oldest.ID != "abc123" { + t.Fatalf("Oldest should be present with id abc123, got %+v", got.Oldest) + } + if len(got.Tags) != 1 || got.Tags[0].Tag != "a" { + t.Errorf("Tags = %+v", got.Tags) + } +} + +func TestStatsReportJSONNullOldestWhenEmpty(t *testing.T) { + var b bytes.Buffer + if err := StatsReportJSON(&b, StatsData{Grace: time.Hour}); err != nil { + t.Fatalf("StatsReportJSON: %v", err) + } + // Oldest must serialize as null (not an empty object) when there are no + // live scratches, and tags must be [] not null. + s := b.String() + if !strings.Contains(s, "\"oldest\": null") { + t.Errorf("oldest should be null on empty store: %s", s) + } + if !strings.Contains(s, "\"tags\": []") { + t.Errorf("tags should be [] on empty store: %s", s) + } +} diff --git a/internal/store/stats.go b/internal/store/stats.go new file mode 100644 index 0000000..2203185 --- /dev/null +++ b/internal/store/stats.go @@ -0,0 +1,140 @@ +// stats.go computes fun-but-useful metrics about the store: how much rot the +// morgue is holding for you, your oldest surviving scratch, the bytes that have +// passed through instead of rotting in /tmp, and a tag breakdown. +// +// Like doctor, Stats is read-only: it derives everything from the index (and +// the record sizes it already carries) without touching content or adding new +// persistent counters. scratchpatch does not track lifetime create/reap totals +// on disk in v0.1, so this reports what the index can honestly account for and +// labels nothing it can't measure — the "bytes that passed through the store" +// number is the live + morgue footprint the index currently knows about, not a +// fabricated all-time tally. +package store + +import ( + "sort" + "time" + + "github.com/rwrife/scratchpatch/internal/index" +) + +// TagCount is a single tag and how many scratches carry it, used for the +// top-N breakdown in the stats report. +type TagCount struct { + Tag string + Count int +} + +// Stats is the read-only metrics snapshot `sp stats` reports. It is plain data; +// the render layer decides how to phrase and color it. Times/durations are +// captured as of the moment Stats was computed. +type Stats struct { + // LiveCount / LiveBytes are the count and total content size of live + // scratches (the current footprint the reaper hasn't touched). + LiveCount int + LiveBytes int64 + + // MorgueCount / MorgueBytes are the count and total size of soft-deleted + // scratches still recoverable in the morgue. + MorgueCount int + MorgueBytes int64 + + // PurgeableCount is how many morgue items are already past their grace + // window and would be hard-deleted on the next `sp reap`. + PurgeableCount int + + // TotalBytes is LiveBytes + MorgueBytes: the bytes currently held by the + // store — i.e. what has passed through instead of rotting loose in /tmp, + // as far as the index can account for. + TotalBytes int64 + + // OldestID / OldestName / OldestAge describe the oldest living scratch + // ("oldest survivor"). OldestID is empty when there are no live scratches. + OldestID string + OldestName string + OldestAge time.Duration + + // Tags is the top tags by count, most-common first (ties broken + // alphabetically). Empty when no live scratch carries a tag. + Tags []TagCount + + // Grace is the configured morgue grace window, surfaced so the report can + // contextualize PurgeableCount. + Grace time.Duration +} + +// topTags is how many entries the tag breakdown keeps. Small on purpose — this +// is a glanceable stat line, not a full census. +const topTags = 5 + +// Stats computes the store's metrics from the index as of now. It is read-only +// and never changes the store. Live vs morgue is decided per record; sizes come +// from the size the index recorded at last write. +func (s *Store) Stats() (Stats, error) { + all, err := s.idx.List() + if err != nil { + return Stats{}, err + } + + now := time.Now() + st := Stats{Grace: s.cfg.Grace} + tagHits := make(map[string]int) + + var oldest index.Scratch + haveOldest := false + + for _, sc := range all { + if sc.Morgued() { + st.MorgueCount++ + st.MorgueBytes += sc.Size + if !now.Before(sc.DeletedAt.Add(s.cfg.Grace)) { + st.PurgeableCount++ + } + continue + } + + // Live scratch: count it, size it, tag it, and track the oldest. + st.LiveCount++ + st.LiveBytes += sc.Size + for _, t := range sc.Tags { + tagHits[t]++ + } + if !haveOldest || sc.CreatedAt.Before(oldest.CreatedAt) { + oldest = sc + haveOldest = true + } + } + + st.TotalBytes = st.LiveBytes + st.MorgueBytes + + if haveOldest { + st.OldestID = oldest.ID + st.OldestName = oldest.Name + st.OldestAge = now.Sub(oldest.CreatedAt) + } + + st.Tags = topTagCounts(tagHits) + return st, nil +} + +// topTagCounts turns a tag→count map into the top-N slice, ordered by count +// desc then tag asc for a stable, sensible ranking. +func topTagCounts(hits map[string]int) []TagCount { + if len(hits) == 0 { + return nil + } + counts := make([]TagCount, 0, len(hits)) + for tag, n := range hits { + counts = append(counts, TagCount{Tag: tag, Count: n}) + } + sort.SliceStable(counts, func(i, j int) bool { + if counts[i].Count != counts[j].Count { + return counts[i].Count > counts[j].Count + } + return counts[i].Tag < counts[j].Tag + }) + if len(counts) > topTags { + counts = counts[:topTags] + } + return counts +} diff --git a/internal/store/stats_test.go b/internal/store/stats_test.go new file mode 100644 index 0000000..8c83a97 --- /dev/null +++ b/internal/store/stats_test.go @@ -0,0 +1,143 @@ +package store + +import ( + "testing" + "time" + + "github.com/rwrife/scratchpatch/internal/index" +) + +// seedTagged creates a live scratch with the given tags and forces its +// CreatedAt so oldest-survivor selection is deterministic. +func seedTagged(t *testing.T, s *Store, name string, created time.Time, tags []string) index.Scratch { + t.Helper() + sc, _, err := s.Create(CreateOptions{Name: name, Ext: "txt", Tags: tags}) + if err != nil { + t.Fatalf("Create(%q): %v", name, err) + } + sc.CreatedAt = created + sc.Size = 100 + if err := s.Index().Put(sc); err != nil { + t.Fatalf("Put: %v", err) + } + got, _ := s.Index().Get(sc.ID) + return got +} + +func TestStatsEmptyStore(t *testing.T) { + s, _ := OpenWith(testConfig(t)) + st, err := s.Stats() + if err != nil { + t.Fatalf("Stats: %v", err) + } + if st.LiveCount != 0 || st.MorgueCount != 0 || st.TotalBytes != 0 { + t.Errorf("empty store should be all zero, got %+v", st) + } + if st.OldestID != "" { + t.Errorf("empty store should have no oldest survivor, got %q", st.OldestID) + } + if len(st.Tags) != 0 { + t.Errorf("empty store should have no tags, got %v", st.Tags) + } + if st.Grace != s.Config().Grace { + t.Errorf("Grace = %v, want %v", st.Grace, s.Config().Grace) + } +} + +func TestStatsMixedLiveAndMorgue(t *testing.T) { + s, _ := OpenWith(testConfig(t)) + now := time.Now() + + // Three live scratches; middle one is the oldest survivor. + seedTagged(t, s, "young", now.Add(-1*time.Hour), []string{"a"}) + oldest := seedTagged(t, s, "old", now.Add(-100*time.Hour), []string{"a", "b"}) + seedTagged(t, s, "mid", now.Add(-10*time.Hour), []string{"b"}) + + // A morgue item well past grace (purgeable) and one still within grace. + del1 := now.Add(-10 * 24 * time.Hour) + seedMorgued(t, s, "purgeable", del1, "x\n") + del2 := now.Add(-1 * time.Hour) + seedMorgued(t, s, "fresh-dead", del2, "y\n") + + st, err := s.Stats() + if err != nil { + t.Fatalf("Stats: %v", err) + } + + if st.LiveCount != 3 { + t.Errorf("LiveCount = %d, want 3", st.LiveCount) + } + if st.MorgueCount != 2 { + t.Errorf("MorgueCount = %d, want 2", st.MorgueCount) + } + if st.PurgeableCount != 1 { + t.Errorf("PurgeableCount = %d, want 1", st.PurgeableCount) + } + if st.LiveBytes != 300 { + t.Errorf("LiveBytes = %d, want 300", st.LiveBytes) + } + if st.TotalBytes != st.LiveBytes+st.MorgueBytes { + t.Errorf("TotalBytes = %d, want %d", st.TotalBytes, st.LiveBytes+st.MorgueBytes) + } + if st.OldestID != oldest.ID { + t.Errorf("OldestID = %q, want %q (the oldest survivor)", st.OldestID, oldest.ID) + } + if st.OldestName != "old" { + t.Errorf("OldestName = %q, want %q", st.OldestName, "old") + } + if st.OldestAge < 90*time.Hour { + t.Errorf("OldestAge = %v, want ~100h", st.OldestAge) + } +} + +func TestStatsTagBreakdownOrdered(t *testing.T) { + s, _ := OpenWith(testConfig(t)) + now := time.Now() + // "a" appears 3x, "b" 2x, "c" 1x → expect that ranking. + seedTagged(t, s, "s1", now.Add(-1*time.Hour), []string{"a", "b", "c"}) + seedTagged(t, s, "s2", now.Add(-2*time.Hour), []string{"a", "b"}) + seedTagged(t, s, "s3", now.Add(-3*time.Hour), []string{"a"}) + + st, err := s.Stats() + if err != nil { + t.Fatalf("Stats: %v", err) + } + if len(st.Tags) != 3 { + t.Fatalf("Tags len = %d, want 3: %+v", len(st.Tags), st.Tags) + } + want := []TagCount{{"a", 3}, {"b", 2}, {"c", 1}} + for i, w := range want { + if st.Tags[i] != w { + t.Errorf("Tags[%d] = %+v, want %+v", i, st.Tags[i], w) + } + } +} + +func TestStatsMorgueScratchesNotTagged(t *testing.T) { + s, _ := OpenWith(testConfig(t)) + now := time.Now() + // Live scratch with a tag; a morgued one whose tag must NOT count toward + // the (living) breakdown. + seedTagged(t, s, "live", now.Add(-1*time.Hour), []string{"keep"}) + m := seedTagged(t, s, "dead", now.Add(-2*time.Hour), []string{"gone"}) + if _, err := s.MoveToMorgue(m); err != nil { + t.Fatalf("MoveToMorgue: %v", err) + } + + st, err := s.Stats() + if err != nil { + t.Fatalf("Stats: %v", err) + } + if st.LiveCount != 1 || st.MorgueCount != 1 { + t.Fatalf("counts = live %d / morgue %d, want 1/1", st.LiveCount, st.MorgueCount) + } + if len(st.Tags) != 1 || st.Tags[0].Tag != "keep" { + t.Errorf("only living tags should count, got %+v", st.Tags) + } + // Oldest survivor is the live one, since the morgued scratch is excluded. + live, _ := s.Index().List() + _ = live + if st.OldestName != "live" { + t.Errorf("OldestName = %q, want %q", st.OldestName, "live") + } +}