feat(store): Add/Remove/SafeReplace with restore-on-failure
3f8f1ae52a123216373bf171d644c63940c3c348
humdrum-tiv <45084903+humdrum-tiv@users.noreply.github.com> · 2026-07-07 16:48
parent 46020227
2 files changed
internal/store/store_test.go +76 −0
@@ -1,6 +1,8 @@
package store
import (
+ "fmt"
+ "strings"
"testing"
"time"
)
@@ -61,3 +63,77 @@ if err != nil || got != nil {
t.Fatalf("empty output: got (%v, %v), want (nil, nil)", got, err)
}
}
+
+func mkEntry() Entry {
+ st := time.Date(2026, 7, 7, 9, 1, 0, 0, time.Local)
+ et := time.Date(2026, 7, 7, 9, 51, 0, 0, time.Local)
+ return Entry{Start: st, End: et, Project: "ARCHER", Description: "Admin",
+ Tags: []string{"emails", ""}, Notes: "cleared inbox"}
+}
+
+func TestAddArgv(t *testing.T) {
+ fr := &fakeRunner{}
+ if err := New(fr).Add(mkEntry()); err != nil {
+ t.Fatal(err)
+ }
+ got := strings.Join(fr.calls[0], " ")
+ want := "tock add -p ARCHER -d Admin -s 2026-07-07 09:01 -e 2026-07-07 09:51 --tag emails --note cleared inbox"
+ if got != want {
+ t.Fatalf("argv:\n got=%q\nwant=%q", got, want)
+ }
+}
+
+func TestSafeReplaceHappyPath(t *testing.T) {
+ fr := &fakeRunner{}
+ up := mkEntry()
+ up.Description = "Admin: sorted"
+ if err := New(fr).SafeReplace("2026-07-07-01", up, mkEntry()); err != nil {
+ t.Fatal(err)
+ }
+ if len(fr.calls) != 2 {
+ t.Fatalf("want 2 calls (remove, add), got %d", len(fr.calls))
+ }
+ if fr.calls[0][1] != "remove" || fr.calls[0][2] != "2026-07-07-01" {
+ t.Errorf("first call not remove of the key: %v", fr.calls[0])
+ }
+ if fr.calls[1][1] != "add" {
+ t.Errorf("second call should be add: %v", fr.calls[1])
+ }
+}
+
+// restoreRunner fails the Nth "add" (0-based among add calls).
+type restoreRunner struct {
+ calls [][]string
+ failAdd int
+ adds int
+}
+
+func (r *restoreRunner) Run(name string, args ...string) ([]byte, error) {
+ r.calls = append(r.calls, append([]string{name}, args...))
+ if len(args) > 0 && args[0] == "add" {
+ r.adds++
+ if r.adds-1 == r.failAdd {
+ return nil, fmt.Errorf("boom")
+ }
+ }
+ return nil, nil
+}
+
+func TestSafeReplaceRestoresOriginalOnAddFailure(t *testing.T) {
+ rr := &restoreRunner{failAdd: 0} // first add (the updated one) fails
+ orig := mkEntry()
+ up := mkEntry()
+ up.Description = "bad edit"
+ err := New(rr).SafeReplace("2026-07-07-01", up, orig)
+ if err == nil {
+ t.Fatal("want error when the updated Add fails")
+ }
+ // calls: remove, add(updated→fail), add(original→restore)
+ if len(rr.calls) != 3 {
+ t.Fatalf("want 3 calls, got %d: %v", len(rr.calls), rr.calls)
+ }
+ last := strings.Join(rr.calls[2], " ")
+ if !strings.Contains(last, "-d Admin ") {
+ t.Errorf("original not restored; last call = %q", last)
+ }
+}
internal/store/tock.go +41 −0
@@ -70,3 +70,44 @@ entries[i].Key = fmt.Sprintf("%s-%02d", date, i+1)
}
return entries, nil
}
+
+func fmtStamp(t time.Time) string {
+ return t.Format("2006-01-02 15:04")
+}
+
+// Add creates a tock entry.
+func (t *TockStore) Add(e Entry) error {
+ args := []string{"add", "-p", e.Project, "-d", e.Description,
+ "-s", fmtStamp(e.Start), "-e", fmtStamp(e.End)}
+ for _, tag := range e.Tags {
+ if tag = strings.TrimSpace(tag); tag != "" {
+ args = append(args, "--tag", tag)
+ }
+ }
+ if strings.TrimSpace(e.Notes) != "" {
+ args = append(args, "--note", e.Notes)
+ }
+ _, err := t.R.Run("tock", args...)
+ return err
+}
+
+// Remove deletes the entry with the given date-index Key.
+func (t *TockStore) Remove(key string) error {
+ _, err := t.R.Run("tock", "remove", key, "-y")
+ return err
+}
+
+// SafeReplace removes then re-adds an entry. If the replacement Add fails it
+// restores the original, so an edit can never silently delete an entry.
+func (t *TockStore) SafeReplace(key string, updated, original Entry) error {
+ if err := t.Remove(key); err != nil {
+ return fmt.Errorf("remove %s: %w", key, err)
+ }
+ if err := t.Add(updated); err != nil {
+ if rerr := t.Add(original); rerr != nil {
+ return fmt.Errorf("add failed (%v) AND restore failed (%v); entry %s may be lost", err, rerr, key)
+ }
+ return fmt.Errorf("edit rejected, original restored: %w", err)
+ }
+ return nil
+}