Skip to content
Merged
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
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module github.com/launchdarkly/ldcli
go 1.25

require (
github.com/adrg/frontmatter v0.2.0
github.com/adrg/xdg v0.5.3
github.com/atotto/clipboard v0.1.4
github.com/blacktop/go-dwarf v1.0.14
Expand Down Expand Up @@ -42,6 +43,7 @@ require (
)

require (
github.com/BurntSushi/toml v1.3.2 // indirect
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
Expand Down Expand Up @@ -106,4 +108,5 @@ require (
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/tools v0.42.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)
5 changes: 5 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,12 @@ cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RX
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/adrg/frontmatter v0.2.0 h1:/DgnNe82o03riBd1S+ZDjd43wAmC6W35q67NHeLkPd4=
github.com/adrg/frontmatter v0.2.0/go.mod h1:93rQCj3z3ZlwyxxpQioRKC1wDLto4aXHrbqIsnH9wmE=
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
Expand Down Expand Up @@ -648,6 +652,7 @@ gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
Expand Down
5 changes: 2 additions & 3 deletions internal/sync/fingerprint.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"strings"
)

const variationFingerprintSchema = "launchdarkly.config.variation/v1"
Expand All @@ -26,13 +25,13 @@ func FingerprintVariation(projectKey, lookupKey string, variation Variation) (st
}
switch normalized.Mode {
case VariationModeAgent:
normalized.Instructions = strings.TrimSpace(normalized.Instructions)
normalized.Instructions = NormalizePromptText(normalized.Instructions)
normalized.Messages = nil
case VariationModeCompletion:
normalized.Instructions = ""
normalized.Messages = append([]Message(nil), normalized.Messages...)
for index := range normalized.Messages {
normalized.Messages[index].Content = strings.TrimSpace(normalized.Messages[index].Content)
normalized.Messages[index].Content = NormalizePromptText(normalized.Messages[index].Content)
}
}

Expand Down
39 changes: 39 additions & 0 deletions internal/sync/fingerprint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,45 @@ func TestFingerprintVariationNormalizesRenderedPromptWhitespace(t *testing.T) {
require.Equal(t, "\n Be concise. \n", completion.Messages[0].Content)
}

func TestFingerprintVariationNormalizesPromptLineEndings(t *testing.T) {
tests := map[string]struct {
windows Variation
unix Variation
}{
"agent": {
windows: Variation{
Mode: VariationModeAgent, Key: "agent", Name: "Agent",
Instructions: "First line.\r\nSecond line.\rThird line.",
},
unix: Variation{
Mode: VariationModeAgent, Key: "agent", Name: "Agent",
Instructions: "First line.\nSecond line.\nThird line.",
},
},
"completion": {
windows: Variation{
Mode: VariationModeCompletion, Key: "completion", Name: "Completion",
Messages: []Message{{Role: "system", Content: "First line.\r\nSecond line.\rThird line."}},
},
unix: Variation{
Mode: VariationModeCompletion, Key: "completion", Name: "Completion",
Messages: []Message{{Role: "system", Content: "First line.\nSecond line.\nThird line."}},
},
},
}

for name, test := range tests {
t.Run(name, func(t *testing.T) {
windowsFingerprint, err := FingerprintVariation("project", "config/"+name, test.windows)
require.NoError(t, err)
unixFingerprint, err := FingerprintVariation("project", "config/"+name, test.unix)
require.NoError(t, err)

require.Equal(t, unixFingerprint, windowsFingerprint)
})
}
}

func TestValidateDirectAPIVariationSupportsModelConfigVersion(t *testing.T) {
base := Variation{Mode: VariationModeAgent, Key: "default", Name: "Default"}

Expand Down
146 changes: 146 additions & 0 deletions internal/sync/local/compile.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package local

import (
"cmp"
"errors"
"io/fs"
"os"
"path"
"slices"
"strings"

syncdomain "github.com/launchdarkly/ldcli/internal/sync"
)

// ErrNoDirectory reports that no local sync directory exists.
var ErrNoDirectory = errors.New(".launchdarkly directory not found")

// ParseError identifies the local file that could not be compiled.
type ParseError struct {
Path string
Err error
}

// Error includes the repository-relative file that could not be compiled.
func (e ParseError) Error() string {
return e.Path + ": " + e.Err.Error()
}

// Unwrap exposes the underlying syntax or validation error.
func (e ParseError) Unwrap() error {
return e.Err
}

// Compile reads local resources from an arbitrary filesystem.
func Compile(fsys fs.FS) ([]syncdomain.SyncedResource, error) {
return compile(fsys, func(reference Reference) ([]byte, error) {
return readReferenceFromFS(fsys, reference)
})
}

// CompileWorkspace compiles local resources and safely resolves references
// within the Git repository.
func CompileWorkspace(repositoryRoot string) ([]syncdomain.SyncedResource, error) {
return compile(os.DirFS(repositoryRoot), func(reference Reference) ([]byte, error) {
return readWorkspaceReference(repositoryRoot, reference)
})
}

// compile walks every managed project and delegates reference loading to the
// caller so tests and real workspaces share the same parser.
func compile(fsys fs.FS, readReference func(Reference) ([]byte, error)) ([]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

// Directories immediately below .launchdarkly are project scopes. Files at
// the root, including the manifest, are handled by their owning packages.
for _, entry := range entries {
if !entry.IsDir() {
continue
}

variations, err := compileProjectVariations(fsys, entry.Name(), readReference)
if err != nil {
return nil, err
}

resources = append(resources, variations...)
}

slices.SortFunc(resources, compareResources)

return resources, nil
}

// compileProjectVariations turns every supported wrapper in one project into
// the common resource representation consumed by reconciliation.
func compileProjectVariations(
fsys fs.FS,
projectKey string,
readReference func(Reference) ([]byte, error),
) ([]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 {
// A project may legitimately contain no resources of this kind.
if name == dir && errors.Is(err, fs.ErrNotExist) {
return nil
}
return err
}
if entry.IsDir() {
return nil
}

relPath := strings.TrimPrefix(name, dir+"/")
// Ignore files owned by other resource kinds. Each compiler recognizes
// only its own directory shape and suffix.
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,
ReadReference: readReference,
})
if err != nil {
// Preserve the managed path so users can locate malformed content
// while callers can still inspect the parser error through Unwrap.
return ParseError{Path: name, Err: err}
}

resources = append(resources, resource)

return nil
})
if err != nil {
return nil, err
}

return resources, nil
}

// compareResources provides deterministic project and lookup-key ordering.
func compareResources(a, b syncdomain.SyncedResource) int {
return cmp.Or(
cmp.Compare(a.ProjectKey, b.ProjectKey),
cmp.Compare(a.LookupKey, b.LookupKey),
)
}
Loading
Loading