diff --git a/cmd/engram/main.go b/cmd/engram/main.go index 9ba2a5e0..896dc90c 100644 --- a/cmd/engram/main.go +++ b/cmd/engram/main.go @@ -88,7 +88,8 @@ var ( storeSearch = func(s *store.Store, query string, opts store.SearchOptions) ([]store.SearchResult, error) { return s.Search(query, opts) } - storeAddObservation = func(s *store.Store, p store.AddObservationParams) (int64, error) { return s.AddObservation(p) } + storeAddObservation = func(s *store.Store, p store.AddObservationParams) (int64, error) { return s.AddObservation(p) } + storeDeleteObservation = func(s *store.Store, id int64, hard bool) error { return s.DeleteObservation(id, hard) } storeTimeline = func(s *store.Store, observationID int64, before, after int) (*store.TimelineResult, error) { return s.Timeline(observationID, before, after) } @@ -628,6 +629,8 @@ func main() { cmdSearch(cfg) case "save": cmdSave(cfg) + case "delete": + cmdDelete(cfg) case "timeline": cmdTimeline(cfg) case "conflicts": @@ -1052,6 +1055,46 @@ func cmdSave(cfg store.Config) { fmt.Printf("Memory saved: #%d %q (%s)\n", id, title, typ) } +func cmdDelete(cfg store.Config) { + if len(os.Args) < 3 { + fmt.Fprintln(os.Stderr, "usage: engram delete [--hard]") + exitFunc(1) + return + } + + id, err := strconv.ParseInt(os.Args[2], 10, 64) + if err != nil { + fmt.Fprintf(os.Stderr, "error: invalid observation id %q\n", os.Args[2]) + exitFunc(1) + return + } + + hard := false + for i := 3; i < len(os.Args); i++ { + if os.Args[i] == "--hard" { + hard = true + } + } + + s, err := storeNew(cfg) + if err != nil { + fatal(err) + return + } + defer s.Close() + + if err := storeDeleteObservation(s, id, hard); err != nil { + fatal(err) + return + } + + kind := "soft-deleted" + if hard { + kind = "hard-deleted" + } + fmt.Printf("Observation #%d %s\n", id, kind) +} + func cmdTimeline(cfg store.Config) { if len(os.Args) < 3 { fmt.Fprintln(os.Stderr, "usage: engram timeline [--before N] [--after N]") @@ -2286,6 +2329,7 @@ Commands: tui Launch interactive terminal UI search Search memories [--type TYPE] [--project PROJECT] [--scope SCOPE] [--limit N] save <msg> Save a memory [--type TYPE] [--project PROJECT] [--scope SCOPE] + delete <obs_id> Delete an observation [--hard] (soft-delete by default; --hard removes permanently) timeline <obs_id> Show chronological context around an observation [--before N] [--after N] conflicts <sub> Inspect and manage memory conflict relations list [--project P] [--status S] [--since RFC3339] [--limit N] diff --git a/cmd/engram/main_test.go b/cmd/engram/main_test.go index 8091bb24..a7ac8772 100644 --- a/cmd/engram/main_test.go +++ b/cmd/engram/main_test.go @@ -1456,3 +1456,103 @@ func TestObsidianExportWatchModeCallsInjectedWatcher(t *testing.T) { t.Fatalf("expected non-nil Logf in WatcherConfig") } } + +// ─── Delete command tests ───────────────────────────────────────────────────── + +func TestCmdDeleteSoftDeleteSuccess(t *testing.T) { + cfg := testConfig(t) + id := mustSeedObservation(t, cfg, "s-del", "proj-del", "decision", "to-delete", "delete me", "project") + + withArgs(t, "engram", "delete", strconv.FormatInt(id, 10)) + stdout, stderr := captureOutput(t, func() { cmdDelete(cfg) }) + if stderr != "" { + t.Fatalf("expected no stderr, got: %q", stderr) + } + if !strings.Contains(stdout, "deleted") { + t.Fatalf("expected deletion confirmation, got: %q", stdout) + } + if !strings.Contains(stdout, strconv.FormatInt(id, 10)) { + t.Fatalf("expected id in output, got: %q", stdout) + } +} + +func TestCmdDeleteHardDeleteSuccess(t *testing.T) { + cfg := testConfig(t) + id := mustSeedObservation(t, cfg, "s-del2", "proj-del2", "decision", "hard-delete", "hard delete me", "project") + + withArgs(t, "engram", "delete", strconv.FormatInt(id, 10), "--hard") + stdout, stderr := captureOutput(t, func() { cmdDelete(cfg) }) + if stderr != "" { + t.Fatalf("expected no stderr, got: %q", stderr) + } + if !strings.Contains(stdout, "deleted") { + t.Fatalf("expected deletion confirmation, got: %q", stdout) + } + if !strings.Contains(stdout, strconv.FormatInt(id, 10)) { + t.Fatalf("expected id in output, got: %q", stdout) + } +} + +func TestCmdDeleteNonExistentID(t *testing.T) { + cfg := testConfig(t) + + exited := false + oldExit := exitFunc + exitFunc = func(code int) { exited = true } + t.Cleanup(func() { exitFunc = oldExit }) + + withArgs(t, "engram", "delete", "999999") + _, stderr := captureOutput(t, func() { cmdDelete(cfg) }) + + if !exited { + t.Fatalf("expected exitFunc to be called for non-existent observation") + } + if !strings.Contains(stderr, "not found") && !strings.Contains(stderr, "observation") { + t.Fatalf("expected not-found error in stderr, got: %q", stderr) + } +} + +func TestCmdDeleteMissingIDArg(t *testing.T) { + cfg := testConfig(t) + + exited := false + oldExit := exitFunc + exitFunc = func(code int) { exited = true } + t.Cleanup(func() { exitFunc = oldExit }) + + withArgs(t, "engram", "delete") + _, stderr := captureOutput(t, func() { cmdDelete(cfg) }) + + if !exited { + t.Fatalf("expected exitFunc to be called when no ID arg provided") + } + if !strings.Contains(stderr, "usage") { + t.Fatalf("expected usage message in stderr, got: %q", stderr) + } +} + +func TestCmdDeleteInvalidIDArg(t *testing.T) { + cfg := testConfig(t) + + exited := false + oldExit := exitFunc + exitFunc = func(code int) { exited = true } + t.Cleanup(func() { exitFunc = oldExit }) + + withArgs(t, "engram", "delete", "not-a-number") + _, stderr := captureOutput(t, func() { cmdDelete(cfg) }) + + if !exited { + t.Fatalf("expected exitFunc to be called for invalid id") + } + if !strings.Contains(stderr, "invalid") { + t.Fatalf("expected invalid id error in stderr, got: %q", stderr) + } +} + +func TestCmdDeleteInUsage(t *testing.T) { + stdout, _ := captureOutput(t, func() { printUsage() }) + if !strings.Contains(stdout, "delete") { + t.Fatalf("expected 'delete' in usage output, got: %q", stdout) + } +}