From a7015fde19bcf4c460260ef9ba6f41f568f6d248 Mon Sep 17 00:00:00 2001 From: rwrife Date: Mon, 6 Jul 2026 20:50:18 +0000 Subject: [PATCH] Give command confirmations tombstone-flavored personality (#6) M6 polish: bring the direct command confirmation lines (new/open/rm/resurrect/promote) up to the same tasteful tombstone voice the render layer already has, without disturbing the stable output anchors tests and scripts rely on. - sp new now notes the scratch's 'borrowed time' + a compact expiry countdown - sp open: 'back on the slab'; sp rm: 'buried in the morgue, not gone, just resting' - sp resurrect: 'claws its way out of the morgue'; sp promote: 'escapes the reaper' - new helpers lifespanNote/humanCountdown are pure + unit-tested - personality_test.go pins the flavor and guards the anchors (created scratch / morgue / live again / promoted scratch) - README rm example refreshed to match Anchors preserved; build/vet/gofmt/tests all green. --- README.md | 2 +- internal/cli/lifecycle.go | 6 +- internal/cli/new.go | 33 ++++++++- internal/cli/personality_test.go | 113 +++++++++++++++++++++++++++++++ internal/cli/promote.go | 2 +- 5 files changed, 150 insertions(+), 6 deletions(-) create mode 100644 internal/cli/personality_test.go diff --git a/README.md b/README.md index cb3c95f..8376e77 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ relocates the file and stamps a deletion time. Restore it any time with `sp resurrect`. ```bash -sp rm 1a2b # → moved to the morgue; printed restore hint +sp rm 1a2b # → buried in the morgue (not gone, just resting); printed restore hint ``` ### `sp resurrect ` — bring it back diff --git a/internal/cli/lifecycle.go b/internal/cli/lifecycle.go index b0b384a..6b5b586 100644 --- a/internal/cli/lifecycle.go +++ b/internal/cli/lifecycle.go @@ -105,7 +105,7 @@ func runOpen(cmd *cobra.Command, ref string) error { } } - fmt.Fprintf(out, "opened scratch %s (%s)\n", sc.ID, displayName(sc)) + fmt.Fprintf(out, "opened scratch %s (%s) — back on the slab\n", sc.ID, displayName(sc)) return nil } @@ -140,7 +140,7 @@ func runRm(cmd *cobra.Command, ref string) error { return err } fmt.Fprintf(cmd.OutOrStdout(), - "moved scratch %s (%s) to the morgue — restore with `sp resurrect %s`\n", + "buried scratch %s (%s) in the morgue — not gone, just resting; restore with `sp resurrect %s`\n", sc.ID, displayName(sc), sc.ID) return nil } @@ -177,7 +177,7 @@ func runResurrect(cmd *cobra.Command, ref string) error { return err } fmt.Fprintf(cmd.OutOrStdout(), - "resurrected scratch %s (%s) — it's live again\n", sc.ID, displayName(sc)) + "resurrected scratch %s (%s) — it claws its way out of the morgue and is live again\n", sc.ID, displayName(sc)) return nil } diff --git a/internal/cli/new.go b/internal/cli/new.go index 105fc4c..ffd6bec 100644 --- a/internal/cli/new.go +++ b/internal/cli/new.go @@ -109,10 +109,41 @@ func runNew(cmd *cobra.Command, name string, f newFlags) error { } } - fmt.Fprintf(out, "created scratch %s (%s)\n", sc.ID, sc.Name) + fmt.Fprintf(out, "created scratch %s (%s) — %s\n", sc.ID, sc.Name, lifespanNote(sc.ExpiresAt, time.Now())) return nil } +// lifespanNote renders a one-clause, tombstone-flavored reminder of when a +// freshly created scratch is due to be swept. It's deliberately terse so the +// confirmation line stays a single glanceable sentence, and it never fires for +// a scratch with no expiry (belt-and-suspenders — new scratches always get +// one). now is passed in so the wording is deterministic for tests. +func lifespanNote(expiresAt, now time.Time) string { + if expiresAt.IsZero() { + return "it'll live in the store until you reap it" + } + return "living on borrowed time, " + humanCountdown(expiresAt.Sub(now)) +} + +// humanCountdown phrases a time-until-expiry span for prose ("expires in ~7d"), +// or notes it's already due when the deadline has passed. It leans on the same +// compact day/hour/minute feel as the ls table without importing render. +func humanCountdown(d time.Duration) string { + if d <= 0 { + return "and already due for the reaper" + } + switch { + case d >= 24*time.Hour: + return fmt.Sprintf("expires in ~%dd", int(d/(24*time.Hour))) + case d >= time.Hour: + return fmt.Sprintf("expires in ~%dh", int(d/time.Hour)) + case d >= time.Minute: + return fmt.Sprintf("expires in ~%dm", int(d/time.Minute)) + default: + return "expires within the minute" + } +} + // openInEditor launches $EDITOR on path, wiring it to the current stdio so an // interactive editor works. It returns a friendly error when $EDITOR is unset // so the caller can fall back gracefully. diff --git a/internal/cli/personality_test.go b/internal/cli/personality_test.go new file mode 100644 index 0000000..647d381 --- /dev/null +++ b/internal/cli/personality_test.go @@ -0,0 +1,113 @@ +package cli + +import ( + "strings" + "testing" + "time" +) + +// The M6 polish pass gives the direct command confirmation lines the same +// tombstone-flavored voice the render layer already had. These tests pin that +// flavor in place (so it can't silently regress to clinical wording) while also +// guarding the stable substrings other tests and scripts rely on. + +func TestNewConfirmationHasFlavorAndLifespan(t *testing.T) { + s := newSession(t) + out, err := s.run("new", "note", "--no-edit", "--ext", "txt", "--ttl", "7d") + if err != nil { + t.Fatalf("new: %v (out=%s)", err, out) + } + // Stable anchor other tests depend on. + if !strings.Contains(out, "created scratch") { + t.Errorf("new should confirm creation with the stable anchor; got %q", out) + } + // Flavor: a freshly created scratch is reminded of its mortality. + if !strings.Contains(out, "borrowed time") { + t.Errorf("new should note the scratch's borrowed time; got %q", out) + } + // And the countdown should reflect a multi-day TTL. Measured from "now", + // a 7d TTL can floor to ~6d after the sub-second elapsed, so assert on the + // day-granularity shape rather than a brittle exact count. + if !strings.Contains(out, "expires in ~") || !strings.HasSuffix(strings.TrimSpace(out), "d") { + t.Errorf("new should surface a multi-day expiry countdown; got %q", out) + } +} + +func TestOpenConfirmationHasFlavor(t *testing.T) { + s := newSession(t) + id := s.newScratchID("reopen-me") + // Use a no-op editor so open takes the success path and prints its + // confirmation line (with EDITOR unset it would fall back to printing the + // path instead). + t.Setenv("EDITOR", "true") + out, err := s.run("open", id) + if err != nil { + t.Fatalf("open: %v (out=%s)", err, out) + } + if !strings.Contains(out, "opened scratch") { + t.Errorf("open should confirm with the stable anchor; got %q", out) + } + if !strings.Contains(out, "slab") { + t.Errorf("open should carry the tombstone flavor; got %q", out) + } +} + +func TestRmAndResurrectKeepAnchorsAndFlavor(t *testing.T) { + s := newSession(t) + id := s.newScratchID("cycle") + + rmOut, err := s.run("rm", id) + if err != nil { + t.Fatalf("rm: %v (out=%s)", err, rmOut) + } + // Anchor: existing tests assert on "morgue". + if !strings.Contains(rmOut, "morgue") { + t.Errorf("rm should mention the morgue; got %q", rmOut) + } + // Flavor + the restore hint must survive. + if !strings.Contains(rmOut, "buried") || !strings.Contains(rmOut, "sp resurrect") { + t.Errorf("rm should be flavored and still hint at restore; got %q", rmOut) + } + + resOut, err := s.run("resurrect", id) + if err != nil { + t.Fatalf("resurrect: %v (out=%s)", err, resOut) + } + // Anchor: existing tests assert on "live again". + if !strings.Contains(resOut, "live again") { + t.Errorf("resurrect should confirm the scratch is live again; got %q", resOut) + } + if !strings.Contains(resOut, "morgue") { + t.Errorf("resurrect should reference clawing out of the morgue; got %q", resOut) + } +} + +func TestHumanCountdownWording(t *testing.T) { + cases := []struct { + name string + d time.Duration + want string + }{ + {"days", 7 * 24 * time.Hour, "expires in ~7d"}, + {"hours", 5 * time.Hour, "expires in ~5h"}, + {"minutes", 12 * time.Minute, "expires in ~12m"}, + {"sub-minute", 20 * time.Second, "expires within the minute"}, + {"already-due", -time.Hour, "already due for the reaper"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := humanCountdown(tc.d); !strings.Contains(got, tc.want) { + t.Errorf("humanCountdown(%v) = %q, want it to contain %q", tc.d, got, tc.want) + } + }) + } +} + +func TestLifespanNoteFallsBackWithoutExpiry(t *testing.T) { + // Defensive path: a zero expiry (shouldn't happen for real scratches) + // still yields a sensible, non-panicking line. + got := lifespanNote(time.Time{}, time.Now()) + if !strings.Contains(got, "reap it") { + t.Errorf("lifespanNote with no expiry should mention reaping; got %q", got) + } +} diff --git a/internal/cli/promote.go b/internal/cli/promote.go index 6565246..004938b 100644 --- a/internal/cli/promote.go +++ b/internal/cli/promote.go @@ -97,7 +97,7 @@ func runPromote(cmd *cobra.Command, ref, dest string, f promoteFlags) error { } out := cmd.OutOrStdout() - fmt.Fprintf(out, "promoted scratch %s (%s) \u2192 %s\n", sc.ID, displayName(sc), target) + fmt.Fprintf(out, "promoted scratch %s (%s) \u2192 %s \u2014 it escapes the reaper and joins the working tree\n", sc.ID, displayName(sc), target) if !f.noOpen { if oerr := openInEditor(cmd, target); oerr != nil {