diff --git a/README.md b/README.md index c284956..f02fce6 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ sp reap --dry-run # preview what's about to die sp reap # sweep expired → morgue, purge old morgue items 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 ls --json | jq '.[].id' # machine-readable output for scripting sp completion zsh > "${fpath[1]}/_sp" # tab-completion for your shell sp resurrect # changed your mind? pull it back @@ -34,9 +35,9 @@ sp promote # the good ones: graduate a scratch into yo `sp cat`, `sp open`, `sp rm` (soft-delete), `sp resurrect`, and `sp ls --morgue` (M4); **automatic reaping** — `sp reap`, with human-friendly TTLs and a `--dry-run` preview (M5); and a read-only **`sp doctor`** store health check -plus scripting polish — **`sp ls --json`** and **`sp completion`** for -bash/zsh/fish, and **`sp promote`** to graduate a scratch into your repo -(M6, in progress). +plus scripting polish — **`sp ls --json`** / **`sp doctor --json`** and +**`sp completion`** for bash/zsh/fish, and **`sp promote`** to graduate a scratch +into your repo (M6, in progress). ### `sp new [name]` @@ -215,6 +216,7 @@ anything. It reports three things: sp doctor # colorized: green when healthy, amber/red when it finds drift sp doctor --no-color # plain, script-friendly sp doctor | cat # piped output is plain text +sp doctor --json # stable JSON object for scripting (no color, no flavor) ``` A clean store prints a one-line bill of health. When `doctor` finds something, @@ -222,6 +224,13 @@ it lists each orphan and missing file and points you at the safe next steps (`sp resurrect` what you want to keep, or remove stray files by hand) — it won't tidy up on its own, in keeping with the never-destructive-by-surprise rule. +For scripting, `--json` emits a single stable object mirroring the report: a +top-level `healthy` flag, the live/morgue counts, tracked/orphan/total sizes +(raw bytes plus human strings), and `orphans`/`missing` arrays (always arrays, +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 completion ` Prints a shell completion script to stdout so `sp`'s commands and flags diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index a01b53c..4704616 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -9,6 +9,7 @@ import ( func newDoctorCommand() *cobra.Command { var noColor bool + var asJSON bool cmd := &cobra.Command{ Use: "doctor", @@ -22,19 +23,22 @@ func newDoctorCommand() *cobra.Command { " • footprint — how many live/morgue scratches you have and how much\n" + " disk the content occupies.\n\n" + "doctor is read-only: it diagnoses but never moves or deletes anything.\n" + - "Use `sp resurrect` to keep something, or remove stray files by hand.", + "Use `sp resurrect` to keep something, or remove stray files by hand.\n\n" + + "Pass --json for a stable, machine-readable object (no color, no flavor)\n" + + "suitable for scripting: `sp doctor --json | jq '.healthy'`.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - return runDoctor(cmd, noColor) + return runDoctor(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 runDoctor(cmd *cobra.Command, noColor bool) error { +func runDoctor(cmd *cobra.Command, noColor, asJSON bool) error { st, err := store.Open() if err != nil { return err @@ -46,9 +50,16 @@ func runDoctor(cmd *cobra.Command, noColor bool) error { } out := cmd.OutOrStdout() - color := !noColor && isTerminal(out) + data := toReportData(diag) + + // --json is intentionally color-free and personality-free; never tint it, + // regardless of TTY or --no-color, matching `sp ls --json`. + if asJSON { + return render.DoctorReportJSON(out, data) + } - return render.DoctorReport(out, toReportData(diag), color) + color := !noColor && isTerminal(out) + return render.DoctorReport(out, data, color) } // toReportData flattens the store's Diagnosis into the render layer's plain diff --git a/internal/cli/doctor_json_test.go b/internal/cli/doctor_json_test.go new file mode 100644 index 0000000..c152ef5 --- /dev/null +++ b/internal/cli/doctor_json_test.go @@ -0,0 +1,113 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestDoctorJSONHealthyStore: `sp doctor --json` on a clean store emits a single +// valid, colorless JSON object with healthy=true and empty (non-null) drift +// arrays, so scripts can gate on `.healthy` without inspecting the report text. +func TestDoctorJSONHealthyStore(t *testing.T) { + s := newSession(t) + s.newScratchID("tidy") + + out, err := s.run("doctor", "--json") + if err != nil { + t.Fatalf("doctor --json: %v (out=%s)", err, out) + } + + // The JSON path is pure data: personality and color stay out of it, even on + // a store that would otherwise print the "doctor is in" flavor line. + if strings.Contains(out, "\x1b[") { + t.Errorf("doctor --json must be colorless; got %q", out) + } + if strings.Contains(out, "doctor is in") || strings.Contains(out, "frowns") { + t.Errorf("doctor --json must not carry personality; got %q", out) + } + + var rec struct { + Healthy bool `json:"healthy"` + LiveCount int `json:"liveCount"` + MorgueCount int `json:"morgueCount"` + Orphans []struct { + Path string `json:"path"` + } `json:"orphans"` + Missing []struct { + ID string `json:"id"` + } `json:"missing"` + } + if err := json.Unmarshal([]byte(out), &rec); err != nil { + t.Fatalf("doctor --json is not valid JSON: %v (%q)", err, out) + } + + if !rec.Healthy { + t.Errorf("clean store should report healthy=true; got %+v", rec) + } + if rec.LiveCount != 1 { + t.Errorf("liveCount = %d, want 1", rec.LiveCount) + } + if len(rec.Orphans) != 0 || len(rec.Missing) != 0 { + t.Errorf("clean store should have no drift; orphans=%d missing=%d", len(rec.Orphans), len(rec.Missing)) + } +} + +// TestDoctorJSONReportsDrift: an orphaned file and a missing-content entry both +// surface through `sp doctor --json`, healthy flips to false, and the run stays +// read-only (the orphan is still on disk afterward). +func TestDoctorJSONReportsDrift(t *testing.T) { + s := newSession(t) + id := s.newScratchID("ghost") + + // Remove the indexed file → missing content. + indexed := filepath.Join(s.home, "scratches", id+".txt") + if err := os.Remove(indexed); err != nil { + t.Fatalf("remove indexed content: %v", err) + } + // Drop a file nothing indexes → orphan. + orphan := filepath.Join(s.home, "scratches", "deadbeef.md") + if err := os.WriteFile(orphan, []byte("loose\n"), 0o600); err != nil { + t.Fatalf("write orphan: %v", err) + } + + out, err := s.run("doctor", "--json") + if err != nil { + t.Fatalf("doctor --json: %v (out=%s)", err, out) + } + + var rec struct { + Healthy bool `json:"healthy"` + Orphans []struct { + Path string `json:"path"` + Area string `json:"area"` + Size int64 `json:"size"` + SizeHuman string `json:"sizeHuman"` + } `json:"orphans"` + Missing []struct { + ID string `json:"id"` + Name string `json:"name"` + ExpectedPath string `json:"expectedPath"` + } `json:"missing"` + } + if err := json.Unmarshal([]byte(out), &rec); err != nil { + t.Fatalf("doctor --json is not valid JSON: %v (%q)", err, out) + } + + if rec.Healthy { + t.Errorf("store with orphan + missing must report healthy=false; got %q", out) + } + if len(rec.Orphans) != 1 || !strings.HasSuffix(rec.Orphans[0].Path, "deadbeef.md") { + t.Errorf("expected one orphan ending deadbeef.md; got %#v", rec.Orphans) + } + if len(rec.Missing) != 1 || rec.Missing[0].ID != id || rec.Missing[0].Name != "ghost" { + t.Errorf("expected missing entry for %q/ghost; got %#v", id, rec.Missing) + } + + // Read-only: doctor must not tidy the orphan it reported. + if _, err := os.Stat(orphan); err != nil { + t.Errorf("doctor must not delete the orphan; stat err = %v", err) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 3dd4153..c876a8b 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -7,9 +7,10 @@ // morgue items past the grace window (with `--dry-run`). // M6 begins the polish pass with `sp doctor`: a read-only store health check // that reconciles the index against what's actually on disk. M6 also adds -// `--json` output to `sp ls` for scripting, `sp completion` to generate -// bash/zsh/fish completion scripts, and `sp promote`: graduate a scratch out of -// the store into the working tree so the good ones escape the reaper. +// `--json` output to `sp ls` and `sp doctor` for scripting, `sp completion` to +// generate bash/zsh/fish completion scripts, and `sp promote`: graduate a +// scratch out of the store into the working tree so the good ones escape the +// reaper. package cli import ( diff --git a/internal/render/json.go b/internal/render/json.go index 4cd8cba..4ebe995 100644 --- a/internal/render/json.go +++ b/internal/render/json.go @@ -150,6 +150,93 @@ func tagsOrEmpty(tags []string) []string { return tags } +// DoctorJSON is the scriptable record for `sp doctor --json`: the store's +// footprint and record counts plus any drift between the index and the +// filesystem. It mirrors the human report's information without its wording or +// color, so `sp doctor --json | jq '.healthy'` (or `.orphans`) stays a stable, +// scriptable contract. Sizes carry both raw bytes and a human string, matching +// the ls views, and the slices are always non-nil so the shape never flips to +// null on a clean store. +type DoctorJSON struct { + // Healthy is the one-field summary: true when there are no orphans and no + // missing content, so a script can gate on `.healthy` without inspecting the + // arrays. + Healthy bool `json:"healthy"` + // LiveCount and MorgueCount are how many index records are in each set. + LiveCount int `json:"liveCount"` + MorgueCount int `json:"morgueCount"` + // TrackedSize is the bytes held by content that has an index entry; + // OrphanSize is the bytes held by orphaned files; TotalSize is their sum. + // Each carries a human companion so both scripts and eyeballs are served. + TrackedSize int64 `json:"trackedSize"` + TrackedSizeHuman string `json:"trackedSizeHuman"` + OrphanSize int64 `json:"orphanSize"` + OrphanSizeHuman string `json:"orphanSizeHuman"` + TotalSize int64 `json:"totalSize"` + TotalSizeHuman string `json:"totalSizeHuman"` + // Orphans are content files with no index entry; Missing are index entries + // with no content file. Both mirror the human report's sections. + Orphans []DoctorOrphanJSON `json:"orphans"` + Missing []DoctorMissingJSON `json:"missing"` +} + +// DoctorOrphanJSON is the scriptable view of an orphaned content file: bytes on +// disk the index has forgotten how to describe. +type DoctorOrphanJSON struct { + Path string `json:"path"` + Area string `json:"area"` + Size int64 `json:"size"` + SizeHuman string `json:"sizeHuman"` +} + +// DoctorMissingJSON is the scriptable view of an index entry whose content file +// is gone: the id/name to name it and where the content was expected. +type DoctorMissingJSON struct { + ID string `json:"id"` + Name string `json:"name"` + ExpectedPath string `json:"expectedPath"` +} + +// DoctorReportJSON writes a DoctorReportData to w as a single DoctorJSON object. +// Like the ls JSON paths it is intentionally color- and personality-free: pure +// data so `sp doctor --json` is a stable scripting contract. The orphan/missing +// slices are always emitted as arrays (never null) so consumers can iterate +// unconditionally, and ordering is inherited from the store's already-sorted +// Diagnosis (orphans by path, missing by id). +func DoctorReportJSON(w io.Writer, d DoctorReportData) error { + orphans := make([]DoctorOrphanJSON, 0, len(d.Orphans)) + for _, o := range d.Orphans { + orphans = append(orphans, DoctorOrphanJSON{ + Path: o.Path, + Area: o.Area, + Size: o.Size, + SizeHuman: humanSize(o.Size), + }) + } + missing := make([]DoctorMissingJSON, 0, len(d.Missing)) + for _, m := range d.Missing { + missing = append(missing, DoctorMissingJSON{ + ID: m.ID, + Name: m.Name, + ExpectedPath: m.ExpectedPath, + }) + } + rec := DoctorJSON{ + Healthy: d.healthy(), + LiveCount: d.LiveCount, + MorgueCount: d.MorgueCount, + TrackedSize: d.TrackedSize, + TrackedSizeHuman: humanSize(d.TrackedSize), + OrphanSize: d.OrphanSize, + OrphanSizeHuman: humanSize(d.OrphanSize), + TotalSize: d.totalSize(), + TotalSizeHuman: humanSize(d.totalSize()), + Orphans: orphans, + Missing: missing, + } + return writeJSON(w, rec) +} + // statusString maps a lifecycle bucket to its JSON status token. func statusString(l lifecycle) string { switch l { diff --git a/internal/render/json_test.go b/internal/render/json_test.go index 2b4ad65..11c6a04 100644 --- a/internal/render/json_test.go +++ b/internal/render/json_test.go @@ -137,3 +137,98 @@ func TestMorgueTableJSONPurgeFields(t *testing.T) { t.Errorf("old1 purgeHuman = %q, want \"now\"", recs[1].PurgeHuman) } } + +func TestDoctorReportJSONHealthyStore(t *testing.T) { + data := DoctorReportData{ + LiveCount: 3, + MorgueCount: 1, + TrackedSize: 4096, + } + + var buf bytes.Buffer + if err := DoctorReportJSON(&buf, data); err != nil { + t.Fatalf("DoctorReportJSON: %v", err) + } + + // The JSON path is pure data: no color, no tombstone personality. + if bytes.Contains(buf.Bytes(), []byte("\x1b[")) { + t.Errorf("doctor --json must be colorless; got %q", buf.String()) + } + + var rec DoctorJSON + if err := json.Unmarshal(buf.Bytes(), &rec); err != nil { + t.Fatalf("invalid JSON: %v (%q)", err, buf.String()) + } + + if !rec.Healthy { + t.Errorf("clean store should report healthy=true; got %+v", rec) + } + if rec.LiveCount != 3 || rec.MorgueCount != 1 { + t.Errorf("counts = live %d / morgue %d, want 3 / 1", rec.LiveCount, rec.MorgueCount) + } + if rec.TotalSize != 4096 { + t.Errorf("totalSize = %d, want 4096 (tracked only, no orphans)", rec.TotalSize) + } + // Slices are always arrays, never null, so scripts can iterate unconditionally. + if rec.Orphans == nil || len(rec.Orphans) != 0 { + t.Errorf("healthy store orphans should be empty non-nil array; got %#v", rec.Orphans) + } + if rec.Missing == nil || len(rec.Missing) != 0 { + t.Errorf("healthy store missing should be empty non-nil array; got %#v", rec.Missing) + } +} + +func TestDoctorReportJSONReportsDrift(t *testing.T) { + data := DoctorReportData{ + LiveCount: 2, + MorgueCount: 0, + TrackedSize: 1024, + OrphanSize: 512, + Orphans: []DoctorOrphan{ + {Path: "/store/scratches/deadbeef.md", Area: "scratches", Size: 512}, + }, + Missing: []DoctorMissing{ + {ID: "ab12cd", Name: "ghost", ExpectedPath: "/store/scratches/ab12cd.txt"}, + }, + } + + var buf bytes.Buffer + if err := DoctorReportJSON(&buf, data); err != nil { + t.Fatalf("DoctorReportJSON: %v", err) + } + + var rec DoctorJSON + if err := json.Unmarshal(buf.Bytes(), &rec); err != nil { + t.Fatalf("invalid JSON: %v (%q)", err, buf.String()) + } + + if rec.Healthy { + t.Errorf("store with orphan + missing must report healthy=false") + } + // TotalSize denormalizes tracked + orphan so a script needn't add them. + if rec.TotalSize != 1536 { + t.Errorf("totalSize = %d, want 1536 (1024 tracked + 512 orphan)", rec.TotalSize) + } + if rec.OrphanSize != 512 { + t.Errorf("orphanSize = %d, want 512", rec.OrphanSize) + } + + if len(rec.Orphans) != 1 { + t.Fatalf("expected 1 orphan, got %d", len(rec.Orphans)) + } + o := rec.Orphans[0] + if o.Path != "/store/scratches/deadbeef.md" || o.Area != "scratches" || o.Size != 512 { + t.Errorf("orphan record = %+v, want path/area/size to match input", o) + } + if o.SizeHuman == "" { + t.Errorf("orphan should carry a human size string alongside raw bytes") + } + + if len(rec.Missing) != 1 { + t.Fatalf("expected 1 missing, got %d", len(rec.Missing)) + } + m := rec.Missing[0] + if m.ID != "ab12cd" || m.Name != "ghost" || m.ExpectedPath != "/store/scratches/ab12cd.txt" { + t.Errorf("missing record = %+v, want id/name/expectedPath to match input", m) + } +}