▍ humdrum codex / ticktock v0.0.2

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
+}