diff --git a/go/README.md b/go/README.md index 30081577a..37310da0f 100644 --- a/go/README.md +++ b/go/README.md @@ -58,6 +58,18 @@ Hello, {{name}}!` } ``` +## Listing prompts and partials + +`DirStore.List` and `DirStore.ListPartials` return entries sorted by name and +variant. Set `Limit` to a positive value to request a page, then pass the returned +`Cursor` unchanged in the next call with the same variant filter. An empty cursor +in the result means there are no more entries. A zero or negative limit returns +all remaining entries. + +Cursors represent offsets in the sorted, filtered listing; they do not preserve +a snapshot if files are added or removed between calls. Invalid cursors return +an error, and cursors beyond the current listing return an empty page. + ## Building ```bash diff --git a/go/dotprompt/dirstore.go b/go/dotprompt/dirstore.go index 4c39739b9..2917f78aa 100644 --- a/go/dotprompt/dirstore.go +++ b/go/dotprompt/dirstore.go @@ -24,6 +24,7 @@ import ( "os" "path/filepath" "sort" + "strconv" "strings" ) @@ -72,6 +73,29 @@ const ( partialPrefix = "_" ) +func paginateItems[T any](items []T, cursor string, limit int) ([]T, string, error) { + start := 0 + if cursor != "" { + var err error + start, err = strconv.Atoi(cursor) + if err != nil || start < 0 { + return nil, "", fmt.Errorf("invalid pagination cursor: %q", cursor) + } + } + if start >= len(items) { + return items[len(items):], "", nil + } + end := len(items) + if limit > 0 && limit < len(items)-start { + end = start + limit + } + next := "" + if end < len(items) { + next = strconv.Itoa(end) + } + return items[start:end], next, nil +} + // List enumerates all prompts in the store that match the given options. // It traverses the directory structure recursively. // It ignores files starting with `_` (partials) and directories starting with `.` (hidden). @@ -131,7 +155,6 @@ func (ds *DirStore) List(options ListPromptsOptions) (ListPromptsResult[PromptRe return ListPromptsResult[PromptRef]{}, err } - // Simple pagination sort.Slice(prompts, func(i, j int) bool { if prompts[i].Name == prompts[j].Name { return prompts[i].Variant < prompts[j].Variant @@ -139,18 +162,11 @@ func (ds *DirStore) List(options ListPromptsOptions) (ListPromptsResult[PromptRe return prompts[i].Name < prompts[j].Name }) - result := ListPromptsResult[PromptRef]{ - Items: prompts, - } - // TODO(#500): meaningful cursor/limit implementation - // For now returns all as simple implementation - - if options.Limit > 0 && len(result.Items) > options.Limit { - result.Cursor = "more" // Dummy cursor for now - result.Items = result.Items[:options.Limit] + items, cursor, err := paginateItems(prompts, options.Cursor, options.Limit) + if err != nil { + return ListPromptsResult[PromptRef]{}, err } - - return result, nil + return ListPromptsResult[PromptRef]{Items: items, Cursor: cursor}, nil } // ListPartials enumerates all partials in the store that match the given options. @@ -225,17 +241,11 @@ func (ds *DirStore) ListPartials(options ListPartialsOptions) (ListPartialsResul return partials[i].Name < partials[j].Name }) - result := ListPartialsResult[PartialRef]{ - Items: partials, - } - - if options.Limit > 0 && len(result.Items) > options.Limit { - result.Cursor = "more" - result.Items = result.Items[:options.Limit] + items, cursor, err := paginateItems(partials, options.Cursor, options.Limit) + if err != nil { + return ListPartialsResult[PartialRef]{}, err } - - return result, nil - + return ListPartialsResult[PartialRef]{Items: items, Cursor: cursor}, nil } // Load retrieves a prompt by name from the store. diff --git a/go/dotprompt/dirstore_test.go b/go/dotprompt/dirstore_test.go index 62860032e..1520d8365 100644 --- a/go/dotprompt/dirstore_test.go +++ b/go/dotprompt/dirstore_test.go @@ -17,12 +17,142 @@ package dotprompt import ( + "math" "os" "path/filepath" + "slices" "strings" "testing" ) +func TestDirStorePagination(t *testing.T) { + for _, partials := range []bool{false, true} { + name := "prompts" + if partials { + name = "partials" + } + t.Run(name, func(t *testing.T) { + root := t.TempDir() + store, err := NewDirStore(root) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"b.v1", "a.v2", "nested/c", "a", "a.v1"} { + if partials { + name = filepath.Join(filepath.Dir(name), "_"+filepath.Base(name)) + } + path := filepath.Join(root, name+".prompt") + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("content"), 0644); err != nil { + t.Fatal(err) + } + } + + list := func(cursor string, limit int, variant string) ([]string, string, error) { + var names []string + if partials { + result, err := store.ListPartials(ListPartialsOptions{Cursor: cursor, Limit: limit, Variant: variant}) + for _, item := range result.Items { + name := item.Name + if item.Variant != "" { + name += "." + item.Variant + } + names = append(names, name) + } + return names, result.Cursor, err + } + result, err := store.List(ListPromptsOptions{Cursor: cursor, Limit: limit, Variant: variant}) + for _, item := range result.Items { + name := item.Name + if item.Variant != "" { + name += "." + item.Variant + } + names = append(names, name) + } + return names, result.Cursor, err + } + + for _, tc := range []struct { + name string + limit int + variant string + pages [][]string + }{ + {"single", 1, "", [][]string{{"a"}, {"a.v1"}, {"a.v2"}, {"b.v1"}, {"nested/c"}}}, + {"uneven", 2, "", [][]string{{"a", "a.v1"}, {"a.v2", "b.v1"}, {"nested/c"}}}, + {"exact", 5, "", [][]string{{"a", "a.v1", "a.v2", "b.v1", "nested/c"}}}, + {"larger", 10, "", [][]string{{"a", "a.v1", "a.v2", "b.v1", "nested/c"}}}, + {"unlimited", 0, "", [][]string{{"a", "a.v1", "a.v2", "b.v1", "nested/c"}}}, + {"negative limit", -1, "", [][]string{{"a", "a.v1", "a.v2", "b.v1", "nested/c"}}}, + {"variant filter", 1, "v1", [][]string{{"a.v1"}, {"b.v1"}}}, + {"no matches", 1, "missing", [][]string{{}}}, + } { + t.Run(tc.name, func(t *testing.T) { + cursor := "" + for i, want := range tc.pages { + got, next, err := list(cursor, tc.limit, tc.variant) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(got, want) { + t.Fatalf("page %d = %v, want %v", i, got, want) + } + if (next == "") != (i == len(tc.pages)-1) { + t.Fatalf("page %d cursor = %q", i, next) + } + cursor = next + } + }) + } + + t.Run("remaining items", func(t *testing.T) { + _, cursor, err := list("", 1, "") + if err != nil { + t.Fatal(err) + } + for _, limit := range []int{0, -1, math.MaxInt} { + got, next, err := list(cursor, limit, "") + if err != nil { + t.Fatal(err) + } + if want := []string{"a.v1", "a.v2", "b.v1", "nested/c"}; !slices.Equal(got, want) || next != "" { + t.Errorf("limit %d: items = %v, cursor = %q; want %v and no cursor", limit, got, next, want) + } + } + }) + + t.Run("invalid cursor", func(t *testing.T) { + for _, cursor := range []string{"invalid", "-1", "999999999999999999999999999999999"} { + if _, _, err := list(cursor, 1, ""); err == nil { + t.Errorf("cursor %q: expected an error", cursor) + } + } + }) + + t.Run("listing shrinks", func(t *testing.T) { + _, cursor, err := list("", 4, "") + if err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(root); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(root, 0755); err != nil { + t.Fatal(err) + } + for _, cursor := range []string{cursor, ""} { + got, next, err := list(cursor, 1, "") + if err != nil || len(got) != 0 || next != "" { + t.Errorf("cursor %q: items = %v, cursor = %q, error = %v; want an empty page", cursor, got, next, err) + } + } + }) + }) + } +} + func TestDirStore(t *testing.T) { tmpDir := t.TempDir() store, err := NewDirStore(tmpDir)