diff --git a/internal/sync/reference/adapters/adapter.go b/internal/sync/reference/adapters/adapter.go new file mode 100644 index 00000000..228e9c1b --- /dev/null +++ b/internal/sync/reference/adapters/adapter.go @@ -0,0 +1,49 @@ +package adapters + +// Adapter converts between an external prompt format and the common prompt +// representation used by reference syncing. +type Adapter interface { + Parse([]byte) (Prompt, error) + Render(Prompt) ([]byte, error) +} + +// Prompt is the format-independent data an adapter may extract. +type Prompt struct { + Mode Mode + Key string + Name string + Messages []Message +} + +// Mode identifies how the parsed prompt is represented in LaunchDarkly. +type Mode string + +const ( + ModeAgent Mode = "agent" + ModeCompletion Mode = "completion" +) + +// Valid reports whether the mode is supported by prompt sync. +func (mode Mode) Valid() bool { + return mode == ModeAgent || mode == ModeCompletion +} + +// Message is one role/content pair in a prompt. +type Message struct { + Role Role + Content string +} + +// Role identifies the speaker for a prompt message. +type Role string + +const ( + RoleSystem Role = "system" + RoleUser Role = "user" + RoleAssistant Role = "assistant" +) + +// Valid reports whether the role can be represented by LaunchDarkly. +func (role Role) Valid() bool { + return role == RoleSystem || role == RoleUser || role == RoleAssistant +} diff --git a/internal/sync/reference/adapters/plain_markdown/plain_markdown.go b/internal/sync/reference/adapters/plain_markdown/plain_markdown.go new file mode 100644 index 00000000..5f5830d6 --- /dev/null +++ b/internal/sync/reference/adapters/plain_markdown/plain_markdown.go @@ -0,0 +1,47 @@ +package plain_markdown + +import ( + "fmt" + "strings" + + "github.com/launchdarkly/ldcli/internal/sync/reference/adapters" +) + +// Adapter converts plain Markdown to and from the common prompt representation. +type Adapter struct{} + +// Parse reads raw text as one system message. Raw files do not provide mode, +// key, or name metadata. +func (Adapter) Parse(content []byte) (adapters.Prompt, error) { + body := normalizeContent(string(content)) + prompt := adapters.Prompt{} + if body != "" { + prompt.Messages = []adapters.Message{{Role: adapters.RoleSystem, Content: body}} + } + return prompt, nil +} + +// Render writes a prompt that contains at most one system message. +func (Adapter) Render(prompt adapters.Prompt) ([]byte, error) { + if len(prompt.Messages) > 1 || len(prompt.Messages) == 1 && prompt.Messages[0].Role != adapters.RoleSystem { + return nil, fmt.Errorf("plain-markdown supports at most one system message") + } + if len(prompt.Messages) == 0 { + return nil, nil + } + body := normalizeContent(prompt.Messages[0].Content) + if body == "" { + return nil, nil + } + return []byte(body + "\n"), nil +} + +// normalizeContent gives equivalent text files one platform-independent +// representation before they are fingerprinted or written. +func normalizeContent(content string) string { + content = strings.ReplaceAll(content, "\r\n", "\n") + content = strings.ReplaceAll(content, "\r", "\n") + return strings.TrimSpace(content) +} + +var _ adapters.Adapter = Adapter{} diff --git a/internal/sync/reference/adapters/plain_markdown/plain_markdown_test.go b/internal/sync/reference/adapters/plain_markdown/plain_markdown_test.go new file mode 100644 index 00000000..2df4e12e --- /dev/null +++ b/internal/sync/reference/adapters/plain_markdown/plain_markdown_test.go @@ -0,0 +1,44 @@ +package plain_markdown + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/launchdarkly/ldcli/internal/sync/reference/adapters" +) + +func TestAdapterParsesAndRendersRawPrompt(t *testing.T) { + prompt, err := (Adapter{}).Parse([]byte("\nBe helpful.\n")) + + require.NoError(t, err) + require.Equal(t, adapters.Prompt{ + Messages: []adapters.Message{{Role: adapters.RoleSystem, Content: "Be helpful."}}, + }, prompt) + + rendered, err := (Adapter{}).Render(prompt) + require.NoError(t, err) + require.Equal(t, "Be helpful.\n", string(rendered)) +} + +func TestAdapterNormalizesLineEndings(t *testing.T) { + prompt, err := (Adapter{}).Parse([]byte("First line.\r\nSecond line.\rThird line.\r\n")) + + require.NoError(t, err) + require.Equal(t, adapters.Prompt{ + Messages: []adapters.Message{{ + Role: adapters.RoleSystem, + Content: "First line.\nSecond line.\nThird line.", + }}, + }, prompt) + + rendered, err := (Adapter{}).Render(adapters.Prompt{ + Messages: []adapters.Message{{ + Role: adapters.RoleSystem, + Content: "First line.\r\nSecond line.\rThird line.", + }}, + }) + + require.NoError(t, err) + require.Equal(t, "First line.\nSecond line.\nThird line.\n", string(rendered)) +} diff --git a/internal/sync/reference/format.go b/internal/sync/reference/format.go new file mode 100644 index 00000000..e6c60e17 --- /dev/null +++ b/internal/sync/reference/format.go @@ -0,0 +1,112 @@ +package reference + +import ( + "fmt" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + "github.com/launchdarkly/ldcli/internal/sync/reference/adapters" + "github.com/launchdarkly/ldcli/internal/sync/reference/adapters/plain_markdown" +) + +// PlainMarkdown identifies the built-in plain Markdown adapter. +const PlainMarkdown = "plain-markdown" + +// ValidateFormat reports whether a reference format has a registered adapter. +func ValidateFormat(format string) error { + _, err := adapterFor(format) + return err +} + +// Parse converts referenced file content into the common adapter domain. +func Parse(format string, content []byte) (adapters.Prompt, error) { + adapter, err := adapterFor(format) + if err != nil { + return adapters.Prompt{}, err + } + return adapter.Parse(content) +} + +// ApplyToVariation merges referenced prompt content into a variation's stored metadata. +func ApplyToVariation(format string, content []byte, variation *syncdomain.Variation) (adapters.Prompt, error) { + prompt, err := Parse(format, content) + if err != nil { + return adapters.Prompt{}, err + } + if prompt.Mode != "" { + if !prompt.Mode.Valid() { + return adapters.Prompt{}, fmt.Errorf("unsupported referenced prompt mode %q", prompt.Mode) + } + variation.Mode = syncdomain.VariationMode(prompt.Mode) + } + if prompt.Key != "" { + variation.Key = prompt.Key + } + if prompt.Name != "" { + variation.Name = prompt.Name + } + + messages := make([]syncdomain.Message, 0, len(prompt.Messages)) + for _, message := range prompt.Messages { + if !message.Role.Valid() { + return adapters.Prompt{}, fmt.Errorf("unsupported referenced prompt role %q", message.Role) + } + messages = append(messages, syncdomain.Message{Role: string(message.Role), Content: message.Content}) + } + switch variation.Mode { + case syncdomain.VariationModeAgent: + if len(messages) > 1 || len(messages) == 1 && messages[0].Role != string(adapters.RoleSystem) { + return adapters.Prompt{}, fmt.Errorf("agent variation %q requires one system message from its reference", variation.Key) + } + variation.Instructions = "" + variation.Messages = nil + if len(messages) == 1 { + variation.Instructions = messages[0].Content + } + case syncdomain.VariationModeCompletion: + variation.Instructions = "" + variation.Messages = messages + default: + return adapters.Prompt{}, fmt.Errorf("referenced prompt does not specify a supported mode") + } + return prompt, nil +} + +// Render converts a variation back to the selected external file format. +func Render(format string, variation syncdomain.Variation) ([]byte, error) { + adapter, err := adapterFor(format) + if err != nil { + return nil, err + } + prompt := adapters.Prompt{Mode: adapters.Mode(variation.Mode), Key: variation.Key, Name: variation.Name} + switch variation.Mode { + case syncdomain.VariationModeAgent: + if len(variation.Messages) != 0 { + return nil, fmt.Errorf("agent variation %q cannot be represented because it contains messages", variation.Key) + } + if variation.Instructions != "" { + prompt.Messages = []adapters.Message{{Role: adapters.RoleSystem, Content: variation.Instructions}} + } + case syncdomain.VariationModeCompletion: + for _, message := range variation.Messages { + role := adapters.Role(message.Role) + if !role.Valid() { + return nil, fmt.Errorf("variation %q has unsupported message role %q", variation.Key, message.Role) + } + prompt.Messages = append(prompt.Messages, adapters.Message{Role: role, Content: message.Content}) + } + default: + return nil, fmt.Errorf("referenced prompt does not support variation mode %q", variation.Mode) + } + return adapter.Render(prompt) +} + +// adapterFor is the single registry for external prompt formats. New formats +// plug into sync by implementing adapters.Adapter and adding one case here. +func adapterFor(format string) (adapters.Adapter, error) { + switch format { + case PlainMarkdown: + return plain_markdown.Adapter{}, nil + default: + return nil, fmt.Errorf("unsupported referenced prompt format %q", format) + } +} diff --git a/internal/sync/reference/format_test.go b/internal/sync/reference/format_test.go new file mode 100644 index 00000000..3dfc61c4 --- /dev/null +++ b/internal/sync/reference/format_test.go @@ -0,0 +1,80 @@ +package reference + +import ( + "testing" + + "github.com/stretchr/testify/require" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + "github.com/launchdarkly/ldcli/internal/sync/reference/adapters" +) + +func TestPlainMarkdownAdapterReturnsCommonPromptDomain(t *testing.T) { + prompt, err := Parse(PlainMarkdown, []byte("Be helpful.")) + + require.NoError(t, err) + require.Empty(t, prompt.Mode) + require.Empty(t, prompt.Key) + require.Empty(t, prompt.Name) + require.Equal(t, []adapters.Message{{Role: adapters.RoleSystem, Content: "Be helpful."}}, prompt.Messages) +} + +func TestPlainMarkdownRoundTrip(t *testing.T) { + tests := []struct { + name string + variation syncdomain.Variation + assert func(*testing.T, syncdomain.Variation) + }{ + { + name: "agent instructions", + variation: syncdomain.Variation{Mode: syncdomain.VariationModeAgent, Key: "prompt"}, + assert: func(t *testing.T, variation syncdomain.Variation) { + require.Equal(t, "Be helpful.", variation.Instructions) + require.Empty(t, variation.Messages) + }, + }, + { + name: "completion system message", + variation: syncdomain.Variation{Mode: syncdomain.VariationModeCompletion, Key: "prompt"}, + assert: func(t *testing.T, variation syncdomain.Variation) { + require.Empty(t, variation.Instructions) + require.Equal(t, []syncdomain.Message{{Role: "system", Content: "Be helpful."}}, variation.Messages) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := ApplyToVariation(PlainMarkdown, []byte("\nBe helpful.\n"), &test.variation) + require.NoError(t, err) + test.assert(t, test.variation) + + rendered, err := Render(PlainMarkdown, test.variation) + require.NoError(t, err) + require.Equal(t, "Be helpful.\n", string(rendered)) + }) + } +} + +func TestPlainMarkdownRejectsUnrepresentableCompletion(t *testing.T) { + _, err := Render(PlainMarkdown, syncdomain.Variation{ + Mode: syncdomain.VariationModeCompletion, + Key: "prompt", + Messages: []syncdomain.Message{ + {Role: "system", Content: "System"}, + {Role: "user", Content: "User"}, + }, + }) + + require.ErrorContains(t, err, "at most one system message") +} + +func TestReferenceFormatRejectsUnknownFormat(t *testing.T) { + var variation syncdomain.Variation + err := ValidateFormat("anthropic-prompt") + require.ErrorContains(t, err, "unsupported referenced prompt format") + _, err = ApplyToVariation("anthropic-prompt", nil, &variation) + require.ErrorContains(t, err, "unsupported referenced prompt format") + _, err = Render("anthropic-prompt", variation) + require.ErrorContains(t, err, "unsupported referenced prompt format") +} diff --git a/internal/sync/repository/git.go b/internal/sync/repository/git.go new file mode 100644 index 00000000..3fa0df4f --- /dev/null +++ b/internal/sync/repository/git.go @@ -0,0 +1,76 @@ +package repository + +import ( + "errors" + "fmt" + "os" + "os/exec" + "strings" +) + +// ErrGitUnavailable reports that the Git executable cannot be used. +var ErrGitUnavailable = errors.New("git executable is unavailable") + +// GitRepository identifies a repository discovered through Git. +type GitRepository struct { + Root string +} + +type gitRunner interface { + lookPath(name string) (string, error) + output(dir string, args ...string) (stdout, stderr string, err error) +} + +type execGit struct{} + +// lookPath verifies that the Git executable is available. +func (execGit) lookPath(name string) (string, error) { + return exec.LookPath(name) +} + +// output executes Git in a working directory and returns trimmed output. +func (execGit) output(dir string, args ...string) (string, string, error) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + // Repository discovery recognizes Git's stable English "not a git + // repository" diagnostic. Fix the subprocess locale so classification does + // not change with the user's system language. + cmd.Env = append(os.Environ(), "LC_ALL=C") + + out, err := cmd.Output() + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return "", strings.TrimSpace(string(exitErr.Stderr)), err + } + return "", "", err + } + + return strings.TrimSpace(string(out)), "", nil +} + +// FindGitRepository returns the Git repository containing dir. +func FindGitRepository(dir string) (GitRepository, bool, error) { + return findGitRepository(execGit{}, dir) +} + +// findGitRepository contains the injectable repository-discovery workflow used +// by the real command and focused tests. +func findGitRepository(git gitRunner, dir string) (GitRepository, bool, error) { + if _, err := git.lookPath("git"); err != nil { + return GitRepository{}, false, fmt.Errorf("%w: %v", ErrGitUnavailable, err) + } + + root, stderr, err := git.output(dir, "rev-parse", "--show-toplevel") + if err != nil { + if strings.Contains(strings.ToLower(stderr), "not a git repository") { + return GitRepository{}, false, nil + } + if stderr != "" { + return GitRepository{}, false, fmt.Errorf("find Git repository: %s: %w", stderr, err) + } + return GitRepository{}, false, fmt.Errorf("find Git repository: %w", err) + } + + return GitRepository{Root: root}, true, nil +} diff --git a/internal/sync/repository/git_test.go b/internal/sync/repository/git_test.go new file mode 100644 index 00000000..a97df213 --- /dev/null +++ b/internal/sync/repository/git_test.go @@ -0,0 +1,130 @@ +package repository + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFindGitRepository(t *testing.T) { + git := &fakeGit{ + path: "/usr/bin/git", + outputs: map[string]gitResult{ + "rev-parse --show-toplevel": {output: "/tmp/example"}, + }, + } + + repository, found, err := findGitRepository(git, "/tmp/example/subdirectory") + + require.NoError(t, err) + require.True(t, found) + require.Equal(t, "/tmp/example", repository.Root) +} + +func TestFindGitRepositoryDoesNotRequireOrigin(t *testing.T) { + git := &fakeGit{ + path: "/usr/bin/git", + outputs: map[string]gitResult{ + "rev-parse --show-toplevel": {output: "/tmp/local-only"}, + }, + } + + repository, found, err := findGitRepository(git, "/tmp/local-only") + + require.NoError(t, err) + require.True(t, found) + require.Equal(t, "/tmp/local-only", repository.Root) +} + +func TestFindGitRepositoryRequiresGitAndInitializedRepository(t *testing.T) { + t.Run("git executable missing", func(t *testing.T) { + _, found, err := findGitRepository(&fakeGit{pathErr: errors.New("missing")}, ".") + require.False(t, found) + require.ErrorIs(t, err, ErrGitUnavailable) + }) + + t.Run("repository missing", func(t *testing.T) { + git := &fakeGit{ + path: "/usr/bin/git", + outputs: map[string]gitResult{ + "rev-parse --show-toplevel": { + stderr: "fatal: not a git repository (or any of the parent directories): .git", + err: errors.New("exit status 128"), + }, + }, + } + _, found, err := findGitRepository(git, ".") + require.NoError(t, err) + require.False(t, found) + }) +} + +func TestFindGitRepositoryReturnsOperationalError(t *testing.T) { + t.Run("includes stderr", func(t *testing.T) { + commandErr := errors.New("exit status 128") + git := &fakeGit{ + path: "/usr/bin/git", + outputs: map[string]gitResult{ + "rev-parse --show-toplevel": { + stderr: "fatal: detected dubious ownership in repository at '/tmp/example'", + err: commandErr, + }, + }, + } + + _, found, err := findGitRepository(git, "/tmp/example") + + require.False(t, found) + require.ErrorIs(t, err, commandErr) + require.ErrorContains(t, err, "detected dubious ownership") + }) + + t.Run("preserves error without stderr", func(t *testing.T) { + commandErr := errors.New("permission denied") + git := &fakeGit{ + path: "/usr/bin/git", + outputs: map[string]gitResult{ + "rev-parse --show-toplevel": {err: commandErr}, + }, + } + + _, found, err := findGitRepository(git, "/tmp/example") + + require.False(t, found) + require.ErrorIs(t, err, commandErr) + require.ErrorContains(t, err, "find Git repository") + }) +} + +type gitResult struct { + output string + stderr string + err error +} + +type fakeGit struct { + path string + pathErr error + outputs map[string]gitResult +} + +func (git *fakeGit) lookPath(string) (string, error) { + return git.path, git.pathErr +} + +func (git *fakeGit) output(_ string, args ...string) (string, string, error) { + result := git.outputs[joinArgs(args)] + return result.output, result.stderr, result.err +} + +func joinArgs(args []string) string { + var joined string + for index, arg := range args { + if index != 0 { + joined += " " + } + joined += arg + } + return joined +} diff --git a/internal/sync/source/resolver.go b/internal/sync/source/resolver.go new file mode 100644 index 00000000..8b8151f4 --- /dev/null +++ b/internal/sync/source/resolver.go @@ -0,0 +1,63 @@ +package source + +import ( + "errors" + "fmt" + "path/filepath" + + "github.com/launchdarkly/ldcli/internal/sync/repository" +) + +// ErrGitRequired reports that sync was run outside an initialized Git repository. +var ErrGitRequired = errors.New("sync must run inside an initialized Git repository") + +// Workspace identifies the repository root used by sync. +type Workspace struct { + Root string +} + +// Resolver finds the Git workspace containing a requested directory. +type Resolver struct { + findGitRepository func(string) (repository.GitRepository, bool, error) +} + +// NewResolver creates a Git-backed workspace resolver. +func NewResolver() Resolver { + return Resolver{ + findGitRepository: repository.FindGitRepository, + } +} + +// Resolve returns the canonical root of the containing Git repository. +func (resolver Resolver) Resolve(dir string) (Workspace, error) { + gitRepository, found, err := resolver.findGitRepository(dir) + if err != nil { + return Workspace{}, err + } + if !found { + return Workspace{}, ErrGitRequired + } + + root, err := canonicalPath(gitRepository.Root) + if err != nil { + return Workspace{}, err + } + + return Workspace{Root: root}, nil +} + +// canonicalPath resolves symlinks and returns an absolute, clean path so every +// sync component agrees on one repository identity. +func canonicalPath(path string) (string, error) { + absolute, err := filepath.Abs(path) + if err != nil { + return "", fmt.Errorf("resolve absolute workspace path: %w", err) + } + + resolved, err := filepath.EvalSymlinks(absolute) + if err != nil { + return "", fmt.Errorf("resolve workspace symlinks: %w", err) + } + + return filepath.Clean(resolved), nil +} diff --git a/internal/sync/source/resolver_test.go b/internal/sync/source/resolver_test.go new file mode 100644 index 00000000..5d40dd45 --- /dev/null +++ b/internal/sync/source/resolver_test.go @@ -0,0 +1,72 @@ +package source + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/launchdarkly/ldcli/internal/sync/repository" +) + +func TestResolverReturnsCanonicalRepositoryRoot(t *testing.T) { + root := t.TempDir() + resolver := Resolver{ + findGitRepository: func(string) (repository.GitRepository, bool, error) { + return repository.GitRepository{Root: root}, true, nil + }, + } + + workspace, err := resolver.Resolve(filepath.Join(root, "nested")) + + require.NoError(t, err) + expected, err := canonicalPath(root) + require.NoError(t, err) + require.Equal(t, expected, workspace.Root) +} + +func TestResolverResolvesRepositorySymlink(t *testing.T) { + root := t.TempDir() + link := filepath.Join(t.TempDir(), "repository") + require.NoError(t, os.Symlink(root, link)) + resolver := Resolver{ + findGitRepository: func(string) (repository.GitRepository, bool, error) { + return repository.GitRepository{Root: link}, true, nil + }, + } + + workspace, err := resolver.Resolve(link) + + require.NoError(t, err) + expected, err := canonicalPath(root) + require.NoError(t, err) + require.Equal(t, expected, workspace.Root) +} + +func TestResolverRequiresGitRepository(t *testing.T) { + resolver := Resolver{ + findGitRepository: func(string) (repository.GitRepository, bool, error) { + return repository.GitRepository{}, false, nil + }, + } + + _, err := resolver.Resolve(".") + + require.ErrorIs(t, err, ErrGitRequired) + require.EqualError(t, err, "sync must run inside an initialized Git repository") +} + +func TestResolverReturnsRepositoryError(t *testing.T) { + expected := errors.New("inspect repository") + resolver := Resolver{ + findGitRepository: func(string) (repository.GitRepository, bool, error) { + return repository.GitRepository{}, false, expected + }, + } + + _, err := resolver.Resolve(".") + + require.ErrorIs(t, err, expected) +}