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
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id> # changed your mind? pull it back
Expand All @@ -34,9 +35,9 @@ sp promote <id> # 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]`

Expand Down Expand Up @@ -215,13 +216,21 @@ 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,
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 <bash|zsh|fish>`

Prints a shell completion script to stdout so `sp`'s commands and flags
Expand Down
21 changes: 16 additions & 5 deletions internal/cli/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

func newDoctorCommand() *cobra.Command {
var noColor bool
var asJSON bool

cmd := &cobra.Command{
Use: "doctor",
Expand All @@ -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
Expand All @@ -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
Expand Down
113 changes: 113 additions & 0 deletions internal/cli/doctor_json_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
7 changes: 4 additions & 3 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
87 changes: 87 additions & 0 deletions internal/render/json.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading