diff --git a/cmd/shared/helpers.go b/cmd/shared/helpers.go new file mode 100644 index 0000000000000000000000000000000000000000..c934ae2be5b4f0b27658eb99af711fe0281e4e1c --- /dev/null +++ b/cmd/shared/helpers.go @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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 +} diff --git a/internal/cli/environment.go b/internal/cli/environment.go new file mode 100644 index 0000000000000000000000000000000000000000..c66bcdb0a63df523498bbe61eb114771abbdc308 --- /dev/null +++ b/internal/cli/environment.go @@ -0,0 +1,122 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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 +} diff --git a/internal/cli/plan.go b/internal/cli/plan.go new file mode 100644 index 0000000000000000000000000000000000000000..9488bb9635fe21a04062a539ad5d9f77197c1843 --- /dev/null +++ b/internal/cli/plan.go @@ -0,0 +1,168 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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(), "_", " ") +} diff --git a/internal/cli/plan_test.go b/internal/cli/plan_test.go new file mode 100644 index 0000000000000000000000000000000000000000..1e02da836cf894b4cb20cf13cef892a0ce349e9e --- /dev/null +++ b/internal/cli/plan_test.go @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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") + } +} diff --git a/internal/db/db_test.go b/internal/db/db_test.go new file mode 100644 index 0000000000000000000000000000000000000000..51d105f6fe10ea31e71f987f1a063544ba90a157 --- /dev/null +++ b/internal/db/db_test.go @@ -0,0 +1,591 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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) + } +}