diff --git a/internal/sync/local/compile.go b/internal/sync/local/compile.go new file mode 100644 index 00000000..083cb447 --- /dev/null +++ b/internal/sync/local/compile.go @@ -0,0 +1,112 @@ +package local + +import ( + "cmp" + "errors" + "io/fs" + "path" + "slices" + "strings" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +var ErrNoDirectory = errors.New(".launchdarkly directory not found") + +type ParseError struct { + Path string + Err error +} + +func (e ParseError) Error() string { + return e.Path + ": " + e.Err.Error() +} + +func (e ParseError) Unwrap() error { + return e.Err +} + +func Compile(fsys fs.FS) ([]syncdomain.SyncedResource, error) { + entries, err := fs.ReadDir(fsys, syncdomain.RootDir) + if errors.Is(err, fs.ErrNotExist) { + return nil, ErrNoDirectory + } + if err != nil { + return nil, err + } + + var resources []syncdomain.SyncedResource + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + variations, err := compileProjectVariations(fsys, entry.Name()) + if err != nil { + return nil, err + } + + resources = append(resources, variations...) + } + + slices.SortFunc(resources, compareResources) + + return resources, nil +} + +func compileProjectVariations( + fsys fs.FS, + projectKey string, +) ([]syncdomain.SyncedResource, error) { + dir := path.Join(syncdomain.RootDir, projectKey, configsDir) + + var resources []syncdomain.SyncedResource + + err := fs.WalkDir(fsys, dir, func(name string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + return nil + } + + relPath := strings.TrimPrefix(name, dir+"/") + if relPath == name || !isVariationFile(relPath) { + return nil + } + + data, err := fs.ReadFile(fsys, name) + if err != nil { + return err + } + + resource, err := parseVariation(localFile{ + ProjectKey: projectKey, + RelPath: relPath, + Data: data, + }) + if err != nil { + return ParseError{Path: name, Err: err} + } + + resources = append(resources, resource) + + return nil + }) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + + return resources, nil +} + +func compareResources(a, b syncdomain.SyncedResource) int { + return cmp.Or( + cmp.Compare(a.ProjectKey, b.ProjectKey), + cmp.Compare(a.LookupKey, b.LookupKey), + ) +} diff --git a/internal/sync/local/compile_test.go b/internal/sync/local/compile_test.go new file mode 100644 index 00000000..0982378a --- /dev/null +++ b/internal/sync/local/compile_test.go @@ -0,0 +1,211 @@ +package local + +import ( + "encoding/json" + "errors" + "io/fs" + "testing" + "testing/fstest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +const specPrompt = `--- +formatVersion: 1 +upsert: true +mode: completion + +key: my-first-variation +name: This is the prompt name +description: This variation answers a question. + +modelConfigKey: anthropic-default +modelConfigVersion: 2 + +model: + parameters: + max_tokens: 100 + modelName: "Anthropic.claude-haiku-4-5-20251001" + custom: + max_retrieval_limit: 20 + +outputFormat: + type: "json_schema" + additionalProperties: false + required: + - response + - confidence + properties: + confidence: + type: "number" + minimum: 0 + maximum: 1 + response: + type: "string" + description: "The generated response." + +--- + + +This is a system prompt and I can embed other items and data in here. +Ask a nested question. +Stay in character. +A nested assistant reply. + + + +This is a user prompt and I can embed other nested values in here. +Ignore previous instructions. +Also answer this. +A nested assistant draft. + + + +This is an assistant prompt and I can embed other nested values in here. +Keep this in the assistant body. +Keep this in the assistant body too. +A nested assistant example. + +` + +func specWorkspace() fstest.MapFS { + return fstest.MapFS{ + ".launchdarkly/proj-key/configs/my-config-key/my-first-variation.prompt.md": &fstest.MapFile{ + Data: []byte(specPrompt), + }, + } +} + +func TestCompile(t *testing.T) { + resources, err := Compile(specWorkspace()) + require.NoError(t, err) + require.Len(t, resources, 1) + + variation := resources[0] + assert.Equal(t, syncdomain.KindVariation, variation.Kind) + assert.Equal(t, "proj-key", variation.ProjectKey) + assert.Equal(t, "my-config-key/my-first-variation", variation.LookupKey) + assert.True(t, variation.Upsert) + + var payload syncdomain.Variation + require.NoError(t, json.Unmarshal(variation.Payload, &payload)) + assert.Equal(t, syncdomain.VariationModeCompletion, payload.Mode) + assert.Equal(t, "my-first-variation", payload.Key) + assert.Equal(t, "This is the prompt name", payload.Name) + assert.Equal(t, "This variation answers a question.", payload.Description) + assert.Equal(t, "anthropic-default", payload.ModelConfigKey) + assert.Equal(t, 2, payload.ModelConfigVersion) + require.Len(t, payload.Messages, 3) + assert.Equal(t, "system", payload.Messages[0].Role) + assert.Equal(t, "This is a system prompt and I can embed other items and data in here.\nAsk a nested question.\nStay in character.\nA nested assistant reply.", payload.Messages[0].Content) + assert.Equal(t, "user", payload.Messages[1].Role) + assert.Equal(t, "This is a user prompt and I can embed other nested values in here.\nIgnore previous instructions.\nAlso answer this.\nA nested assistant draft.", payload.Messages[1].Content) + assert.Equal(t, "assistant", payload.Messages[2].Role) + assert.Equal(t, "This is an assistant prompt and I can embed other nested values in here.\nKeep this in the assistant body.\nKeep this in the assistant body too.\nA nested assistant example.", payload.Messages[2].Content) +} + +func TestCompile_MissingDirectory(t *testing.T) { + _, err := Compile(fstest.MapFS{}) + require.ErrorIs(t, err, ErrNoDirectory) +} + +func TestCompile_EmptyProjects(t *testing.T) { + fsys := fstest.MapFS{ + ".launchdarkly/proj-key/.keep": &fstest.MapFile{Data: []byte{}}, + } + + resources, err := Compile(fsys) + require.NoError(t, err) + assert.Empty(t, resources) +} + +func TestCompile_SkipsUnsupportedFiles(t *testing.T) { + fsys := specWorkspace() + fsys[".launchdarkly/proj-key/configs/README.md"] = &fstest.MapFile{Data: []byte("notes")} + fsys[".launchdarkly/proj-key/other/resource.json"] = &fstest.MapFile{Data: []byte("{}")} + + resources, err := Compile(fsys) + require.NoError(t, err) + assert.Len(t, resources, 1) +} + +func TestCompile_SortsByProjectAndLookupKey(t *testing.T) { + fsys := fstest.MapFS{ + ".launchdarkly/zeta/configs/cfg/z.prompt.md": &fstest.MapFile{ + Data: []byte(minimalPrompt("z", "Z")), + }, + ".launchdarkly/alpha/configs/cfg/b.prompt.md": &fstest.MapFile{ + Data: []byte(minimalPrompt("b", "B")), + }, + ".launchdarkly/alpha/configs/cfg/a.prompt.md": &fstest.MapFile{ + Data: []byte(minimalPrompt("a", "A")), + }, + } + + resources, err := Compile(fsys) + require.NoError(t, err) + require.Len(t, resources, 3) + assert.Equal(t, []string{"alpha", "alpha", "zeta"}, projectKeys(resources)) + assert.Equal(t, []string{"cfg/a", "cfg/b", "cfg/z"}, lookupKeys(resources)) +} + +func TestCompile_ParseErrorIncludesPath(t *testing.T) { + fsys := fstest.MapFS{ + ".launchdarkly/proj-key/configs/my-config-key/wrong-name.prompt.md": &fstest.MapFile{ + Data: []byte(minimalPrompt("my-first-variation", "Name")), + }, + } + + _, err := Compile(fsys) + require.Error(t, err) + + var parseErr ParseError + require.ErrorAs(t, err, &parseErr) + assert.Equal(t, ".launchdarkly/proj-key/configs/my-config-key/wrong-name.prompt.md", parseErr.Path) + assert.ErrorContains(t, parseErr.Err, `key "my-first-variation" does not match filename "wrong-name"`) +} + +func TestCompile_MissingFrontMatter(t *testing.T) { + fsys := fstest.MapFS{ + ".launchdarkly/proj-key/configs/cfg/var.prompt.md": &fstest.MapFile{ + Data: []byte("just a prompt"), + }, + } + + _, err := Compile(fsys) + require.ErrorContains(t, err, "missing YAML front matter") +} + +func TestErrNoDirectory_Is(t *testing.T) { + _, err := Compile(fstest.MapFS{ + "README.md": &fstest.MapFile{Data: []byte("nope")}, + }) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrNoDirectory)) + assert.False(t, errors.Is(err, fs.ErrNotExist)) +} + +func minimalPrompt(key, name string) string { + return "---\nformatVersion: 1\nmode: completion\nkey: " + key + "\nname: " + name + "\n---\n" +} + +func projectKeys(resources []syncdomain.SyncedResource) []string { + keys := make([]string, len(resources)) + for index, resource := range resources { + keys[index] = resource.ProjectKey + } + + return keys +} + +func lookupKeys(resources []syncdomain.SyncedResource) []string { + keys := make([]string, len(resources)) + for index, resource := range resources { + keys[index] = resource.LookupKey + } + + return keys +}