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
46 changes: 45 additions & 1 deletion cmd/engram/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -628,6 +629,8 @@ func main() {
cmdSearch(cfg)
case "save":
cmdSave(cfg)
case "delete":
cmdDelete(cfg)
case "timeline":
cmdTimeline(cfg)
case "conflicts":
Expand Down Expand Up @@ -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 <observation_id> [--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 <observation_id> [--before N] [--after N]")
Expand Down Expand Up @@ -2286,6 +2329,7 @@ Commands:
tui Launch interactive terminal UI
search <query> Search memories [--type TYPE] [--project PROJECT] [--scope SCOPE] [--limit N]
save <title> <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]
Expand Down
100 changes: 100 additions & 0 deletions cmd/engram/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading