@@ -0,0 +1,58 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package shared
+
+import (
+ "fmt"
+ "os"
+ "strings"
+
+ "git.secluded.site/np/internal/cli"
+ "git.secluded.site/np/internal/session"
+ "github.com/spf13/cobra"
+)
+
+// Environment extracts the CLI environment from the command context.
+func Environment(cmd *cobra.Command) (*cli.Environment, error) {
+ env, ok := cli.FromContext(cmd.Context())
+ if !ok || env == nil {
+ return nil, fmt.Errorf("environment not initialised")
+ }
+ return env, nil
+}
+
+// ActiveSession resolves the current session for cmd, printing guidance when absent.
+func ActiveSession(cmd *cobra.Command, env *cli.Environment) (session.Document, bool, error) {
+ cwd, err := os.Getwd()
+ if err != nil {
+ return session.Document{}, false, fmt.Errorf("determine working directory: %w", err)
+ }
+
+ doc, found, err := env.ActiveSession(cmd.Context(), cwd)
+ if err != nil {
+ return session.Document{}, false, err
+ }
+ if !found {
+ fmt.Fprintln(cmd.OutOrStdout(), "No active session. Start one with `np s`.")
+ return session.Document{}, false, nil
+ }
+ return doc, true, nil
+}
+
+// CommandString returns the full invocation string for logging events.
+func CommandString() string {
+ return strings.Join(os.Args, " ")
+}
+
+// PrintPlan renders the plan for sid, returning the state used for rendering.
+func PrintPlan(cmd *cobra.Command, env *cli.Environment, sid string) (cli.PlanState, error) {
+ state, err := cli.BuildPlanState(cmd.Context(), env, sid)
+ if err != nil {
+ return cli.PlanState{}, err
+ }
+
+ fmt.Fprintln(cmd.OutOrStdout(), cli.RenderPlan(state))
+ return state, nil
+}
@@ -0,0 +1,122 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package cli
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "git.secluded.site/np/internal/db"
+ "git.secluded.site/np/internal/event"
+ "git.secluded.site/np/internal/goal"
+ "git.secluded.site/np/internal/session"
+ "git.secluded.site/np/internal/task"
+ "git.secluded.site/np/internal/timeutil"
+)
+
+// Environment aggregates long-lived services needed by CLI commands.
+type Environment struct {
+ DB *db.Database
+ Clock timeutil.Clock
+ SessionStore *session.Store
+ GoalStore *goal.Store
+ TaskStore *task.Store
+ EventStore *event.Store
+}
+
+// OpenEnvironment initialises storage and domain stores for CLI commands.
+func OpenEnvironment(opts db.Options, clock timeutil.Clock) (*Environment, error) {
+ database, err := db.Open(opts)
+ if err != nil {
+ return nil, fmt.Errorf("cli: open database: %w", err)
+ }
+
+ if clock == nil {
+ clock = timeutil.UTCClock{}
+ }
+
+ env := &Environment{
+ DB: database,
+ Clock: clock,
+ SessionStore: session.NewStore(database, clock),
+ GoalStore: goal.NewStore(database, clock),
+ TaskStore: task.NewStore(database, clock),
+ EventStore: event.NewStore(database, clock),
+ }
+ return env, nil
+}
+
+// Close releases resources allocated by the environment.
+func (e *Environment) Close() error {
+ if e == nil || e.DB == nil {
+ return nil
+ }
+ return e.DB.Close()
+}
+
+// ActiveSession resolves the active session for path (or parent paths).
+func (e *Environment) ActiveSession(ctx context.Context, path string) (session.Document, bool, error) {
+ if e == nil || e.SessionStore == nil {
+ return session.Document{}, false, errors.New("cli: environment not initialised")
+ }
+ return e.SessionStore.ActiveByPath(ctx, path)
+}
+
+// LoadGoal retrieves the goal for sid. When absent, ok is false.
+func (e *Environment) LoadGoal(ctx context.Context, sid string) (goal.Document, bool, error) {
+ if e == nil || e.GoalStore == nil {
+ return goal.Document{}, false, errors.New("cli: environment not initialised")
+ }
+ doc, err := e.GoalStore.Get(ctx, sid)
+ if err != nil {
+ if errors.Is(err, goal.ErrNotFound) {
+ return goal.Document{}, false, nil
+ }
+ return goal.Document{}, false, err
+ }
+ return doc, true, nil
+}
+
+// LoadTasks returns tasks for sid sorted by creation order.
+func (e *Environment) LoadTasks(ctx context.Context, sid string) ([]task.Task, error) {
+ if e == nil || e.TaskStore == nil {
+ return nil, errors.New("cli: environment not initialised")
+ }
+ return e.TaskStore.List(ctx, sid)
+}
+
+// LoadTasksByStatus returns tasks filtered by status. When status is empty,
+// all tasks are returned.
+func (e *Environment) LoadTasksByStatus(ctx context.Context, sid string, status task.Status) ([]task.Task, error) {
+ if e == nil || e.TaskStore == nil {
+ return nil, errors.New("cli: environment not initialised")
+ }
+ if status == "" {
+ return e.TaskStore.List(ctx, sid)
+ }
+ return e.TaskStore.ListByStatus(ctx, sid, status)
+}
+
+type contextKey string
+
+const environmentContextKey contextKey = "git.secluded.site/np/internal/cli/environment"
+
+// WithEnvironment stores env inside ctx for retrieval by subcommands.
+func WithEnvironment(ctx context.Context, env *Environment) context.Context {
+ return context.WithValue(ctx, environmentContextKey, env)
+}
+
+// FromContext extracts an Environment that was previously embedded with WithEnvironment.
+func FromContext(ctx context.Context) (*Environment, bool) {
+ if ctx == nil {
+ return nil, false
+ }
+ env, ok := ctx.Value(environmentContextKey).(*Environment)
+ if !ok || env == nil {
+ return nil, false
+ }
+ return env, true
+}
@@ -0,0 +1,168 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package cli
+
+import (
+ "context"
+ "errors"
+ "sort"
+ "strings"
+
+ "git.secluded.site/np/internal/goal"
+ "git.secluded.site/np/internal/task"
+)
+
+var statusIcons = map[task.Status]string{
+ task.StatusPending: "☐",
+ task.StatusInProgress: "⟳",
+ task.StatusCompleted: "☑",
+ task.StatusFailed: "☒",
+ task.StatusCancelled: "⊗",
+}
+
+var legendBaseOrder = []task.Status{
+ task.StatusPending,
+ task.StatusInProgress,
+ task.StatusCompleted,
+}
+
+var legendOptionalOrder = []task.Status{
+ task.StatusFailed,
+ task.StatusCancelled,
+}
+
+// PlanState captures goal/tasks for rendering.
+type PlanState struct {
+ Goal *goal.Document
+ Tasks []task.Task
+}
+
+// BuildPlanState aggregates goal and tasks for sid using env.
+func BuildPlanState(ctx context.Context, env *Environment, sid string) (PlanState, error) {
+ if env == nil {
+ return PlanState{}, errors.New("cli: environment not initialised")
+ }
+
+ tasks, err := env.LoadTasks(ctx, sid)
+ if err != nil {
+ return PlanState{}, err
+ }
+
+ state := PlanState{
+ Tasks: tasks,
+ }
+
+ goalDoc, ok, err := env.LoadGoal(ctx, sid)
+ if err != nil {
+ return PlanState{}, err
+ }
+ if ok {
+ state.Goal = &goalDoc
+ }
+
+ return state, nil
+}
+
+// RenderPlan produces the textual plan layout consumed by LLM agents.
+func RenderPlan(state PlanState) string {
+ var b strings.Builder
+
+ if state.Goal != nil {
+ b.WriteString(strings.TrimSpace(state.Goal.Title))
+ b.WriteString("\n")
+
+ if desc := strings.TrimSpace(state.Goal.Description); desc != "" {
+ b.WriteString("\n")
+ writeIndentedBlock(&b, desc, "")
+ b.WriteString("\n")
+ } else {
+ b.WriteString("\n")
+ }
+ } else {
+ b.WriteString("No goal set yet\n\n")
+ }
+
+ legend := buildLegend(state.Tasks)
+ if legend != "" {
+ b.WriteString("Legend: ")
+ b.WriteString(legend)
+ b.WriteString("\n")
+ }
+
+ if len(state.Tasks) == 0 {
+ b.WriteString("No tasks yet.\n")
+ return b.String()
+ }
+
+ sorted := make([]task.Task, len(state.Tasks))
+ copy(sorted, state.Tasks)
+ sort.Slice(sorted, func(i, j int) bool {
+ if sorted[i].CreatedSeq != sorted[j].CreatedSeq {
+ return sorted[i].CreatedSeq < sorted[j].CreatedSeq
+ }
+ if !sorted[i].CreatedAt.Equal(sorted[j].CreatedAt) {
+ return sorted[i].CreatedAt.Before(sorted[j].CreatedAt)
+ }
+ return sorted[i].ID < sorted[j].ID
+ })
+
+ for _, t := range sorted {
+ icon := statusIcons[t.Status]
+ if icon == "" {
+ icon = "?"
+ }
+ b.WriteString(icon)
+ b.WriteString(" ")
+ b.WriteString(strings.TrimSpace(t.Title))
+ b.WriteString(" [")
+ b.WriteString(strings.TrimSpace(t.ID))
+ b.WriteString("]\n")
+
+ if desc := strings.TrimSpace(t.Description); desc != "" {
+ writeIndentedBlock(&b, desc, " ")
+ }
+ }
+
+ return b.String()
+}
+
+func buildLegend(tasks []task.Task) string {
+ present := map[task.Status]bool{}
+ for _, t := range tasks {
+ present[t.Status] = true
+ }
+
+ var parts []string
+ for _, status := range legendBaseOrder {
+ parts = append(parts, legendEntry(status))
+ }
+ for _, status := range legendOptionalOrder {
+ if present[status] {
+ parts = append(parts, legendEntry(status))
+ }
+ }
+ return strings.Join(parts, " ")
+}
+
+func legendEntry(status task.Status) string {
+ icon := statusIcons[status]
+ if icon == "" {
+ icon = "?"
+ }
+ return icon + " " + statusLabel(status)
+}
+
+func writeIndentedBlock(b *strings.Builder, text string, prefix string) {
+ lines := strings.Split(text, "\n")
+ for _, line := range lines {
+ b.WriteString(prefix)
+ b.WriteString(strings.TrimSpace(line))
+ b.WriteString("\n")
+ }
+}
+
+func statusLabel(status task.Status) string {
+ return strings.ReplaceAll(status.String(), "_", " ")
+}
@@ -0,0 +1,101 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package cli_test
+
+import (
+ "strings"
+ "testing"
+ "time"
+
+ "git.secluded.site/np/internal/cli"
+ "git.secluded.site/np/internal/goal"
+ "git.secluded.site/np/internal/task"
+)
+
+func TestRenderPlanWithGoalAndTasks(t *testing.T) {
+ title := "Build reliable planning workflow"
+ description := "Capture context from ticket and operator input.\nPrioritise determinism."
+ goalDoc := goal.Document{
+ Title: title,
+ Description: description,
+ UpdatedAt: time.Now(),
+ }
+
+ tasks := []task.Task{
+ {
+ ID: "alpha1",
+ Title: "Add initial goal",
+ Description: "Use `np g s` to capture summary.",
+ Status: task.StatusCompleted,
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ CreatedSeq: 1,
+ },
+ {
+ ID: "beta2",
+ Title: "Implement task list",
+ Description: "Track progress for each major change.\nRemember to cite files.",
+ Status: task.StatusInProgress,
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ CreatedSeq: 2,
+ },
+ {
+ ID: "gamma3",
+ Title: "Polish output",
+ Description: "Ensure legend only includes relevant statuses.",
+ Status: task.StatusPending,
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ CreatedSeq: 3,
+ },
+ {
+ ID: "delta4",
+ Title: "Document edge cases",
+ Description: "Explain handling for cancelled operations.",
+ Status: task.StatusCancelled,
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ CreatedSeq: 4,
+ },
+ }
+
+ result := cli.RenderPlan(cli.PlanState{
+ Goal: &goalDoc,
+ Tasks: tasks,
+ })
+
+ expectedLegend := "Legend: ☐ pending ⟳ in progress ☑ completed ⊗ cancelled"
+ if !strings.Contains(result, expectedLegend) {
+ t.Fatalf("expected legend %q in output:\n%s", expectedLegend, result)
+ }
+
+ if !strings.Contains(result, title) {
+ t.Fatalf("expected goal title in output")
+ }
+
+ if !strings.Contains(result, "Add initial goal [alpha1]") {
+ t.Fatalf("expected completed task line")
+ }
+
+ if !strings.Contains(result, " Track progress for each major change.") {
+ t.Fatalf("expected indented description line")
+ }
+
+ if strings.Contains(result, "☒ failed") {
+ t.Fatalf("failed legend entry should not appear without failed tasks")
+ }
+}
+
+func TestRenderPlanWithoutGoalOrTasks(t *testing.T) {
+ result := cli.RenderPlan(cli.PlanState{})
+
+ if !strings.Contains(result, "No goal set yet") {
+ t.Fatalf("expected placeholder goal")
+ }
+ if !strings.Contains(result, "No tasks yet.") {
+ t.Fatalf("expected placeholder task message")
+ }
+}
@@ -0,0 +1,591 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package db
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+ "time"
+)
+
+func openTestDB(t *testing.T) *Database {
+ t.Helper()
+
+ database, err := Open(Options{Path: t.TempDir()})
+ if err != nil {
+ t.Fatalf("open db: %v", err)
+ }
+
+ t.Cleanup(func() {
+ if err := database.Close(); err != nil {
+ t.Fatalf("closing db: %v", err)
+ }
+ })
+
+ return database
+}
+
+func TestDatabasePathAndClose(t *testing.T) {
+ database := openTestDB(t)
+ if database.Path() == "" {
+ t.Fatalf("Path() returned empty string")
+ }
+ if err := database.Close(); err != nil {
+ t.Fatalf("Close() returned error on first call: %v", err)
+ }
+ // Second close should be a noop.
+ if err := database.Close(); err != nil {
+ t.Fatalf("Close() returned error on second call: %v", err)
+ }
+}
+
+func TestDatabaseViewErrorMapping(t *testing.T) {
+ database := openTestDB(t)
+
+ err := database.View(context.Background(), func(txn *Txn) error {
+ _, err := txn.Get([]byte("missing"))
+ return err
+ })
+ if !errors.Is(err, ErrKeyNotFound) {
+ t.Fatalf("expected ErrKeyNotFound, got %v", err)
+ }
+
+ aborted := database.View(context.Background(), func(txn *Txn) error {
+ return txn.Abort()
+ })
+ if aborted != nil {
+ t.Fatalf("expected nil after abort, got %v", aborted)
+ }
+
+ readonlyErr := database.View(context.Background(), func(txn *Txn) error {
+ return txn.Set([]byte("k"), []byte("v"))
+ })
+ if !errors.Is(readonlyErr, ErrReadOnly) {
+ t.Fatalf("expected ErrReadOnly inside view, got %v", readonlyErr)
+ }
+}
+
+func TestDatabaseUpdateLifecycle(t *testing.T) {
+ database := openTestDB(t)
+
+ type payload struct {
+ Message string
+ Count int
+ }
+ p := payload{Message: "hello", Count: 42}
+
+ err := database.Update(context.Background(), func(txn *Txn) error {
+ if err := txn.Set([]byte("alpha"), []byte("bravo")); err != nil {
+ return err
+ }
+ if err := txn.SetJSON([]byte("payload"), p); err != nil {
+ return err
+ }
+ if _, err := txn.IncrementUint64([]byte("counter"), 5); err != nil {
+ return err
+ }
+ // Seed a value with an invalid length for later IncrementUint64 failure.
+ if err := txn.Set([]byte("counter-invalid"), []byte("oops")); err != nil {
+ return err
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("Update() returned error: %v", err)
+ }
+
+ err = database.View(context.Background(), func(txn *Txn) error {
+ val, err := txn.Get([]byte("alpha"))
+ if err != nil {
+ return err
+ }
+ if string(val) != "bravo" {
+ t.Fatalf("unexpected value: %q", val)
+ }
+
+ var decoded payload
+ if err := txn.GetJSON([]byte("payload"), &decoded); err != nil {
+ return err
+ }
+ if decoded != p {
+ t.Fatalf("unexpected payload: %#v", decoded)
+ }
+
+ exists, err := txn.Exists([]byte("alpha"))
+ if err != nil {
+ return err
+ }
+ if !exists {
+ t.Fatalf("expected Exists to report true")
+ }
+
+ expected := map[string]string{
+ "alpha": "bravo",
+ "payload": string(mustJSON(p)),
+ }
+ collected := make(map[string]string)
+ iterErr := txn.Iterate(IterateOptions{PrefetchValues: true}, func(item Item) error {
+ key := item.KeyString()
+ val, err := item.Value()
+ if err != nil {
+ return err
+ }
+ collected[key] = string(val)
+ return nil
+ })
+ if iterErr != nil {
+ return iterErr
+ }
+
+ for key, want := range expected {
+ got, ok := collected[key]
+ if !ok {
+ t.Fatalf("missing key %q in iteration results", key)
+ }
+ if got != want {
+ t.Fatalf("unexpected value for %q: got %q want %q", key, got, want)
+ }
+ }
+
+ abortErr := txn.Iterate(IterateOptions{}, func(Item) error {
+ return ErrTxnAborted
+ })
+ if abortErr != nil {
+ t.Fatalf("expected nil when aborting iteration, got %v", abortErr)
+ }
+
+ prefixCount := 0
+ prefixErr := txn.Iterate(IterateOptions{Prefix: []byte("a")}, func(item Item) error {
+ if !strings.HasPrefix(item.KeyString(), "a") {
+ t.Fatalf("expected prefix match, got %q", item.KeyString())
+ }
+ prefixCount++
+ return nil
+ })
+ if prefixErr != nil {
+ t.Fatalf("prefix iteration error: %v", prefixErr)
+ }
+ if prefixCount == 0 {
+ t.Fatalf("expected at least one prefix item, got %d", prefixCount)
+ }
+
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("View() returned error: %v", err)
+ }
+
+ err = database.Update(context.Background(), func(txn *Txn) error {
+ val, err := txn.IncrementUint64([]byte("counter"), 7)
+ if err != nil {
+ return err
+ }
+ if val != 12 {
+ t.Fatalf("expected counter=12, got %d", val)
+ }
+
+ _, err = txn.IncrementUint64([]byte("counter-invalid"), 1)
+ if err == nil {
+ t.Fatalf("expected error when incrementing invalid counter")
+ }
+
+ return txn.Delete([]byte("alpha"))
+ })
+ if err != nil {
+ t.Fatalf("Update() increment phase error: %v", err)
+ }
+
+ err = database.View(context.Background(), func(txn *Txn) error {
+ ok, err := txn.Exists([]byte("alpha"))
+ if err != nil {
+ return err
+ }
+ if ok {
+ t.Fatalf("expected alpha to be deleted")
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("View after delete error: %v", err)
+ }
+}
+
+func TestDatabaseUpdateErrorMapping(t *testing.T) {
+ database := openTestDB(t)
+
+ err := database.Update(context.Background(), func(txn *Txn) error {
+ _, err := txn.Get([]byte("missing"))
+ return err
+ })
+ if !errors.Is(err, ErrKeyNotFound) {
+ t.Fatalf("expected ErrKeyNotFound, got %v", err)
+ }
+
+ aborted := database.Update(context.Background(), func(txn *Txn) error {
+ return txn.Abort()
+ })
+ if aborted != nil {
+ t.Fatalf("expected nil after abort, got %v", aborted)
+ }
+}
+
+func TestDatabaseUpdateReadOnly(t *testing.T) {
+ path := t.TempDir()
+ writable, err := Open(Options{Path: path})
+ if err != nil {
+ t.Fatalf("prepare writable db: %v", err)
+ }
+ if err := writable.Close(); err != nil {
+ t.Fatalf("close writable db: %v", err)
+ }
+
+ database, err := Open(Options{Path: path, ReadOnly: true})
+ if err != nil {
+ t.Fatalf("open read-only db: %v", err)
+ }
+ t.Cleanup(func() {
+ if err := database.Close(); err != nil {
+ t.Fatalf("close db: %v", err)
+ }
+ })
+
+ err = database.Update(context.Background(), func(txn *Txn) error {
+ return txn.Set([]byte("k"), []byte("v"))
+ })
+ if !errors.Is(err, ErrReadOnly) {
+ t.Fatalf("expected ErrReadOnly for read-only DB, got %v", err)
+ }
+}
+
+func TestDatabaseUpdateRetryRespectsContext(t *testing.T) {
+ database := openTestDB(t)
+
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel() // cancel immediately
+
+ err := database.Update(ctx, func(txn *Txn) error {
+ return txn.Set([]byte("k"), []byte("v"))
+ })
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("expected context.Canceled, got %v", err)
+ }
+}
+
+func TestIterateReverseOrder(t *testing.T) {
+ database := openTestDB(t)
+
+ keys := [][]byte{
+ []byte("alpha"),
+ []byte("beta"),
+ []byte("gamma"),
+ }
+ err := database.Update(context.Background(), func(txn *Txn) error {
+ for _, key := range keys {
+ if err := txn.Set(key, []byte(key)); err != nil {
+ return err
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("populate keys: %v", err)
+ }
+
+ var seen []string
+ err = database.View(context.Background(), func(txn *Txn) error {
+ return txn.Iterate(IterateOptions{Reverse: true}, func(item Item) error {
+ seen = append(seen, item.KeyString())
+ return nil
+ })
+ })
+ if err != nil {
+ t.Fatalf("reverse iteration: %v", err)
+ }
+
+ expected := []string{"gamma", "beta", "alpha"}
+ if len(seen) != len(expected) {
+ t.Fatalf("unexpected number of items: got %d want %d", len(seen), len(expected))
+ }
+ for i, want := range expected {
+ if seen[i] != want {
+ t.Fatalf("unexpected key at %d: got %q want %q", i, seen[i], want)
+ }
+ }
+}
+
+func TestIncrementUint64InitialValue(t *testing.T) {
+ database := openTestDB(t)
+ var result uint64
+ err := database.Update(context.Background(), func(txn *Txn) error {
+ var err error
+ result, err = txn.IncrementUint64([]byte("counter"), 3)
+ return err
+ })
+ if err != nil {
+ t.Fatalf("increment: %v", err)
+ }
+ if result != 3 {
+ t.Fatalf("expected counter=3, got %d", result)
+ }
+}
+
+type spyLogger struct {
+ errors int
+ warnings int
+ infos int
+ debugs int
+}
+
+func (s *spyLogger) Errorf(string, ...any) { s.errors++ }
+func (s *spyLogger) Warningf(string, ...any) { s.warnings++ }
+func (s *spyLogger) Infof(string, ...any) { s.infos++ }
+func (s *spyLogger) Debugf(string, ...any) { s.debugs++ }
+
+func TestBadgerLoggerAdapter(t *testing.T) {
+ logger := &spyLogger{}
+ adapter := badgerLoggerAdapter{logger: logger}
+ adapter.Errorf("error")
+ adapter.Warningf("warn")
+ adapter.Infof("info")
+ adapter.Debugf("debug")
+
+ if logger.errors != 1 || logger.warnings != 1 || logger.infos != 1 || logger.debugs != 1 {
+ t.Fatalf("logger counts unexpected: %+v", logger)
+ }
+}
+
+func TestEnsureDir(t *testing.T) {
+ base := t.TempDir()
+ target := filepath.Join(base, "a", "b")
+ if err := ensureDir(target); err != nil {
+ t.Fatalf("ensureDir: %v", err)
+ }
+ info, err := os.Stat(target)
+ if err != nil {
+ t.Fatalf("stat target: %v", err)
+ }
+ if !info.IsDir() {
+ t.Fatalf("expected directory, got file")
+ }
+}
+
+func mustJSON(v any) []byte {
+ data, err := json.Marshal(v)
+ if err != nil {
+ panic(err)
+ }
+ return data
+}
+
+func TestDefaultPath(t *testing.T) {
+ path, err := DefaultPath()
+ if err != nil {
+ t.Fatalf("DefaultPath() error: %v", err)
+ }
+ if path == "" {
+ t.Fatalf("DefaultPath() returned empty string")
+ }
+}
+
+func TestOptionsApplyDefaults(t *testing.T) {
+ t.Run("DefaultsApplied", func(t *testing.T) {
+ opts, err := (Options{}).applyDefaults()
+ if err != nil {
+ t.Fatalf("applyDefaults: %v", err)
+ }
+ if opts.Path == "" {
+ t.Fatalf("expected Path to be populated")
+ }
+ if opts.Logger == nil {
+ t.Fatalf("expected Logger to be non-nil")
+ }
+ if opts.MaxTxnRetries != defaultTxnMaxRetries {
+ t.Fatalf("expected MaxTxnRetries=%d, got %d", defaultTxnMaxRetries, opts.MaxTxnRetries)
+ }
+ if opts.ConflictBackoff != defaultConflictBackoff {
+ t.Fatalf("expected ConflictBackoff=%s, got %s", defaultConflictBackoff, opts.ConflictBackoff)
+ }
+ if !opts.SyncWrites {
+ t.Fatalf("expected SyncWrites default true")
+ }
+ })
+
+ t.Run("NegativeRetries", func(t *testing.T) {
+ _, err := (Options{MaxTxnRetries: -1}).applyDefaults()
+ if err == nil {
+ t.Fatalf("expected error for negative MaxTxnRetries")
+ }
+ })
+
+ t.Run("RespectExistingValues", func(t *testing.T) {
+ opts, err := (Options{
+ Path: "/tmp/custom",
+ Logger: &spyLogger{},
+ MaxTxnRetries: 3,
+ ConflictBackoff: time.Second,
+ ReadOnly: true,
+ }).applyDefaults()
+ if err != nil {
+ t.Fatalf("applyDefaults: %v", err)
+ }
+ if opts.Path != "/tmp/custom" {
+ t.Fatalf("expected Path to be preserved, got %q", opts.Path)
+ }
+ if opts.MaxTxnRetries != 3 {
+ t.Fatalf("expected MaxTxnRetries=3, got %d", opts.MaxTxnRetries)
+ }
+ if opts.ConflictBackoff != time.Second {
+ t.Fatalf("expected ConflictBackoff=1s, got %s", opts.ConflictBackoff)
+ }
+ if opts.SyncWrites {
+ t.Fatalf("expected SyncWrites to remain false for read-only")
+ }
+ if _, ok := opts.Logger.(*spyLogger); !ok {
+ t.Fatalf("expected Logger to remain spyLogger")
+ }
+ })
+}
+
+func TestCanonicalizeDir(t *testing.T) {
+ dir := t.TempDir()
+ canonical, err := CanonicalizeDir(dir)
+ if err != nil {
+ t.Fatalf("CanonicalizeDir error: %v", err)
+ }
+ if canonical == "" {
+ t.Fatalf("expected non-empty canonical path")
+ }
+ if runtime.GOOS != "windows" && !strings.HasPrefix(canonical, "/") {
+ t.Fatalf("expected absolute path, got %q", canonical)
+ }
+}
+
+func TestDirHashConsistency(t *testing.T) {
+ dir := t.TempDir()
+ canonical, err := CanonicalizeDir(dir)
+ if err != nil {
+ t.Fatalf("canonicalize: %v", err)
+ }
+ hash1 := DirHash(canonical)
+ hash2 := DirHash(canonical)
+ if hash1 != hash2 {
+ t.Fatalf("expected consistent hashes, got %q vs %q", hash1, hash2)
+ }
+ if len(hash1) != 64 {
+ t.Fatalf("expected 64 hex chars, got %d", len(hash1))
+ }
+}
+
+func TestCanonicalizeAndHash(t *testing.T) {
+ dir := t.TempDir()
+ canonical, hash, err := CanonicalizeAndHash(dir)
+ if err != nil {
+ t.Fatalf("CanonicalizeAndHash error: %v", err)
+ }
+ if canonical == "" || hash == "" {
+ t.Fatalf("expected non-empty canonical/hash")
+ }
+}
+
+func TestParentWalk(t *testing.T) {
+ dir := t.TempDir()
+ canonical, err := CanonicalizeDir(dir)
+ if err != nil {
+ t.Fatalf("canonicalize: %v", err)
+ }
+ parents := ParentWalk(canonical)
+ if len(parents) == 0 {
+ t.Fatalf("expected at least one parent")
+ }
+ if parents[0] != canonical {
+ t.Fatalf("expected first element to be input path")
+ }
+ seenRoot := false
+ for _, p := range parents {
+ if p == "/" || (runtime.GOOS == "windows" && len(p) == 3 && p[1] == ':' && p[2] == '/') {
+ seenRoot = true
+ }
+ }
+ if !seenRoot {
+ t.Fatalf("expected parent list to reach root, got %v", parents)
+ }
+}
+
+func TestKeysAndPrefixes(t *testing.T) {
+ if got := string(KeySchemaVersion()); got != "meta/schema_version" {
+ t.Fatalf("unexpected schema key: %q", got)
+ }
+ if got := string(KeyDirActive("hash")); got != "dir/hash/active" {
+ t.Fatalf("unexpected dir active key: %q", got)
+ }
+ if got := string(KeyDirArchived("hash", "ts", "sid")); got != "dir/hash/archived/ts/sid" {
+ t.Fatalf("unexpected dir archived key: %q", got)
+ }
+ if got := string(KeyIdxActive("sid")); got != "idx/active/sid" {
+ t.Fatalf("unexpected idx active key: %q", got)
+ }
+ if got := string(KeyIdxArchived("ts", "sid")); got != "idx/archived/ts/sid" {
+ t.Fatalf("unexpected idx archived key: %q", got)
+ }
+ if got := string(KeySessionMeta("sid")); got != "s/sid/meta" {
+ t.Fatalf("unexpected session meta key: %q", got)
+ }
+ if got := string(KeySessionGoal("sid")); got != "s/sid/goal" {
+ t.Fatalf("unexpected session goal key: %q", got)
+ }
+ if got := string(KeySessionTask("sid", "tid")); got != "s/sid/task/tid" {
+ t.Fatalf("unexpected session task key: %q", got)
+ }
+ if got := string(KeySessionTaskStatusIndex("sid", "pending", "tid")); got != "s/sid/idx/status/pending/tid" {
+ t.Fatalf("unexpected status idx key: %q", got)
+ }
+ if got := string(KeySessionEventSeq("sid")); got != "s/sid/meta/evt_seq" {
+ t.Fatalf("unexpected event seq key: %q", got)
+ }
+ if got := string(KeySessionEvent("sid", 10)); got != "s/sid/evt/000000000000000a" {
+ t.Fatalf("unexpected session event key: %q", got)
+ }
+ if prefix := string(PrefixSessionTasks("sid")); prefix != "s/sid/task" {
+ t.Fatalf("unexpected tasks prefix: %q", prefix)
+ }
+ if prefix := string(PrefixSessionStatusIndex("sid", "pending")); prefix != "s/sid/idx/status/pending" {
+ t.Fatalf("unexpected status prefix: %q", prefix)
+ }
+ if prefix := string(PrefixSessionEvents("sid")); prefix != "s/sid/evt" {
+ t.Fatalf("unexpected events prefix: %q", prefix)
+ }
+ if prefix := string(PrefixDirArchived("hash")); prefix != "dir/hash/archived" {
+ t.Fatalf("unexpected dir archived prefix: %q", prefix)
+ }
+ if prefix := string(PrefixIdxActive()); prefix != "idx/active" {
+ t.Fatalf("unexpected idx active prefix: %q", prefix)
+ }
+ if prefix := string(PrefixIdxArchived()); prefix != "idx/archived" {
+ t.Fatalf("unexpected idx archived prefix: %q", prefix)
+ }
+}
+
+func TestEncodingHelpers(t *testing.T) {
+ var buf [8]byte
+ putUint64(buf[:], 123)
+ if got := readUint64(buf[:]); got != 123 {
+ t.Fatalf("unexpected roundtrip: got %d want 123", got)
+ }
+ if _, err := decodeUint64([]byte{1, 2}); err == nil {
+ t.Fatalf("expected error for short buffer")
+ }
+ if hex := Uint64Hex(10); hex != "000000000000000a" {
+ t.Fatalf("unexpected Uint64Hex: %q", hex)
+ }
+ if hex := encodeHex([]byte{0xde, 0xad}); hex != "dead" {
+ t.Fatalf("unexpected encodeHex: %q", hex)
+ }
+}