Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id> # tripwire: does this scratch hold a secret?
Expand Down Expand Up @@ -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 <id>` — the secret tripwire

AI coding agents and tired humans leak API keys and `.env` dumps into throwaway
Expand Down
1 change: 1 addition & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ func NewRootCommand() *cobra.Command {
newPromoteCommand(),
newReapCommand(),
newDoctorCommand(),
newStatsCommand(),
newDedupCommand(),
newScanCommand(),
newTUICommand(),
Expand Down
87 changes: 87 additions & 0 deletions internal/cli/stats.go
Original file line number Diff line number Diff line change
@@ -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,
}
}
74 changes: 74 additions & 0 deletions internal/cli/stats_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
170 changes: 170 additions & 0 deletions internal/render/stats.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading