Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 32 additions & 22 deletions go/dotprompt/dirstore.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"os"
"path/filepath"
"sort"
"strconv"
"strings"
)

Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -131,26 +155,18 @@ 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
}
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.
Expand Down Expand Up @@ -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.
Expand Down
130 changes: 130 additions & 0 deletions go/dotprompt/dirstore_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading