diff --git a/cmd/sync/bootstrap_test.go b/cmd/sync/bootstrap_test.go new file mode 100644 index 00000000..e7927840 --- /dev/null +++ b/cmd/sync/bootstrap_test.go @@ -0,0 +1,136 @@ +package sync + +import ( + "net/url" + "os" + "path/filepath" + "strconv" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/launchdarkly/ldcli/cmd/cliflags" + "github.com/launchdarkly/ldcli/internal/config" + "github.com/launchdarkly/ldcli/internal/resources" + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + syncbootstrap "github.com/launchdarkly/ldcli/internal/sync/bootstrap" +) + +func TestRunPromptBootstrapsAndAddsVariations(t *testing.T) { + tests := map[string]struct { + createDirectory bool + add bool + dryRun bool + wantInitial bool + }{ + "missing workspace bootstraps without Git": { + wantInitial: true, + }, + "missing workspace dry run previews without Git": { + dryRun: true, + wantInitial: true, + }, + "add uses the existing workspace": { + createDirectory: true, + add: true, + wantInitial: false, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + root := t.TempDir() + if test.createDirectory { + require.NoError(t, os.Mkdir( + filepath.Join(root, syncdomain.RootDir), + 0o755, + )) + } + t.Chdir(root) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + var called bool + runner := func(options syncbootstrap.Options) error { + called = true + assert.Equal(t, test.wantInitial, options.Initial) + assert.Equal(t, test.dryRun, options.DryRun) + assert.NotNil(t, options.Catalog) + assert.NotNil(t, options.Input) + assert.NotNil(t, options.Output) + + return nil + } + + viper.Set(cliflags.AccessTokenFlag, "token") + viper.Set(cliflags.BaseURIFlag, "https://example.com") + t.Cleanup(viper.Reset) + + command := newPromptCmd(noopResourceClient{}, runner) + require.NoError(t, command.Flags().Set( + addFlag, + strconv.FormatBool(test.add), + )) + require.NoError(t, command.Flags().Set( + dryRunFlag, + strconv.FormatBool(test.dryRun), + )) + require.NoError(t, command.RunE(command, nil)) + assert.True(t, called) + if test.wantInitial { + _, err := os.Stat(config.GetConfigFile()) + assert.ErrorIs(t, err, os.ErrNotExist) + } + }) + } +} + +func TestRunPromptUsesExistingWorkspaceWithoutBootstrap(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.Mkdir( + filepath.Join(root, syncdomain.RootDir), + 0o755, + )) + t.Chdir(root) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + called := false + runner := func(syncbootstrap.Options) error { + called = true + + return nil + } + + viper.Set(cliflags.AccessTokenFlag, "token") + viper.Set(cliflags.BaseURIFlag, "https://example.com") + t.Cleanup(viper.Reset) + + command := newPromptCmd(noopResourceClient{}, runner) + require.NoError(t, command.RunE(command, nil)) + assert.False(t, called) +} + +type noopResourceClient struct{} + +var _ resources.Client = noopResourceClient{} + +func (noopResourceClient) MakeRequest( + string, + string, + string, + string, + url.Values, + []byte, + bool, +) ([]byte, error) { + return nil, nil +} + +func (noopResourceClient) MakeUnauthenticatedRequest( + string, + string, + []byte, +) ([]byte, error) { + return nil, nil +} diff --git a/cmd/sync/prompt.go b/cmd/sync/prompt.go index 71026472..47731612 100644 --- a/cmd/sync/prompt.go +++ b/cmd/sync/prompt.go @@ -14,17 +14,30 @@ import ( "github.com/launchdarkly/ldcli/internal/output" "github.com/launchdarkly/ldcli/internal/resources" syncapi "github.com/launchdarkly/ldcli/internal/sync/api" + syncbootstrap "github.com/launchdarkly/ldcli/internal/sync/bootstrap" synclocal "github.com/launchdarkly/ldcli/internal/sync/local" syncsource "github.com/launchdarkly/ldcli/internal/sync/source" ) -const dryRunFlag = "dry-run" +const ( + addFlag = "add" + dryRunFlag = "dry-run" +) + +type bootstrapRunner func(syncbootstrap.Options) error func NewPromptCmd(client resources.Client) *cobra.Command { + return newPromptCmd(client, syncbootstrap.Run) +} + +func newPromptCmd( + client resources.Client, + bootstrap bootstrapRunner, +) *cobra.Command { cmd := &cobra.Command{ Use: "prompt", Short: "Synchronize local prompt variations with LaunchDarkly", - Long: "Plan synchronization changes for local prompt variations. Use --dry-run to preview changes without creating a plan.", + Long: "Bootstrap local prompt variations from LaunchDarkly, add more variations, or preview synchronization changes.", Args: func(cmd *cobra.Command, args []string) error { if err := cobra.NoArgs(cmd, args); err != nil { return err @@ -32,9 +45,14 @@ func NewPromptCmd(client resources.Client) *cobra.Command { return validators.Validate()(cmd, args) }, - RunE: runPrompt(client), + RunE: runPrompt(client, bootstrap), } + cmd.Flags().Bool( + addFlag, + false, + "Select additional prompt variations from LaunchDarkly", + ) cmd.Flags().Bool( dryRunFlag, false, @@ -45,14 +63,56 @@ func NewPromptCmd(client resources.Client) *cobra.Command { return cmd } -func runPrompt(client resources.Client) func(*cobra.Command, []string) error { +func runPrompt( + client resources.Client, + bootstrap bootstrapRunner, +) func(*cobra.Command, []string) error { return func(cmd *cobra.Command, _ []string) error { cwd, err := os.Getwd() if err != nil { return fmt.Errorf("get working directory: %w", err) } - workspace, err := syncsource.NewResolver(config.GetConfigFile()).Resolve(cwd) + resolver := syncsource.NewResolver(config.GetConfigFile()) + root, err := resolver.ResolveRoot(cwd) + if err != nil { + return err + } + + accessToken := viper.GetString(cliflags.AccessTokenFlag) + baseURI := viper.GetString(cliflags.BaseURIFlag) + store := synclocal.NewStore(root) + + storeExists, err := store.Exists() + if err != nil { + return err + } + add, _ := cmd.Flags().GetBool(addFlag) + dryRun, _ := cmd.Flags().GetBool(dryRunFlag) + if !storeExists || add { + err := bootstrap(syncbootstrap.Options{ + Catalog: syncapi.NewCatalogClient( + client, + accessToken, + baseURI, + ), + Store: store, + Input: cmd.InOrStdin(), + Output: cmd.OutOrStdout(), + Initial: !storeExists, + DryRun: dryRun, + }) + if err != nil { + return output.NewCmdOutputError( + err, + cliflags.GetOutputKind(cmd), + ) + } + + return nil + } + + workspace, err := resolver.Resolve(cwd) if err != nil { return err } @@ -62,10 +122,9 @@ func runPrompt(client resources.Client) func(*cobra.Command, []string) error { return err } - dryRun, _ := cmd.Flags().GetBool(dryRunFlag) plans, err := syncapi.NewClient(client).Plan( - viper.GetString(cliflags.AccessTokenFlag), - viper.GetString(cliflags.BaseURIFlag), + accessToken, + baseURI, workspace.Source, dryRun, localResources, diff --git a/internal/sync/api/catalog.go b/internal/sync/api/catalog.go new file mode 100644 index 00000000..e6937f00 --- /dev/null +++ b/internal/sync/api/catalog.go @@ -0,0 +1,157 @@ +package api + +import ( + "encoding/json" + "fmt" + "maps" + "net/http" + "net/url" + + "github.com/launchdarkly/ldcli/internal/resources" + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +const catalogPageLimit = 25 + +type Project struct { + Key string `json:"key"` + Name string `json:"name"` +} + +type Config struct { + Key string `json:"key"` + Name string `json:"name"` + Mode syncdomain.VariationMode `json:"mode"` + Variations []syncdomain.Variation `json:"variations"` +} + +type CatalogClient struct { + transport resources.Client + accessToken string + baseURI string +} + +func NewCatalogClient( + transport resources.Client, + accessToken string, + baseURI string, +) CatalogClient { + return CatalogClient{ + transport: transport, + accessToken: accessToken, + baseURI: baseURI, + } +} + +func (client CatalogClient) Projects() ([]Project, error) { + endpoint, err := url.JoinPath(client.baseURI, "api/v2/projects") + if err != nil { + return nil, fmt.Errorf("build projects endpoint: %w", err) + } + + return listCatalog[Project]( + client, + endpoint, + "projects", + false, + url.Values{"sort": {"name"}}, + ) +} + +func (client CatalogClient) Configs(projectKey string) ([]Config, error) { + endpoint, err := url.JoinPath( + client.baseURI, + "api/v2/projects", + projectKey, + "ai-configs", + ) + if err != nil { + return nil, fmt.Errorf("build AI Configs endpoint: %w", err) + } + + configs, err := listCatalog[Config]( + client, + endpoint, + "AI Configs", + false, + url.Values{ + "sort": {"name"}, + "filter": {`mode anyOf ["agent","completion"]`}, + }, + ) + if err != nil { + return nil, err + } + + for index := range configs { + if err := configs[index].applyMode(); err != nil { + return nil, err + } + } + + return configs, nil +} + +func (config *Config) applyMode() error { + if config.Mode == "" { + config.Mode = syncdomain.VariationModeCompletion + } + if !config.Mode.Valid() { + return fmt.Errorf( + "AI Config %q has unsupported mode %q", + config.Key, + config.Mode, + ) + } + + for index := range config.Variations { + config.Variations[index].Mode = config.Mode + } + + return nil +} + +type catalogPage[T any] struct { + Items []T `json:"items"` + TotalCount int `json:"totalCount"` +} + +func listCatalog[T any]( + client CatalogClient, + endpoint string, + resourceName string, + beta bool, + baseQuery url.Values, +) ([]T, error) { + var items []T + + for offset := 0; ; offset += catalogPageLimit { + query := maps.Clone(baseQuery) + query.Set("limit", fmt.Sprintf("%d", catalogPageLimit)) + query.Set("offset", fmt.Sprintf("%d", offset)) + + response, err := client.transport.MakeRequest( + client.accessToken, + http.MethodGet, + endpoint, + "", + query, + nil, + beta, + ) + if err != nil { + return nil, fmt.Errorf("list %s: %w", resourceName, err) + } + + var page catalogPage[T] + if err := json.Unmarshal(response, &page); err != nil { + return nil, fmt.Errorf("decode %s response: %w", resourceName, err) + } + + items = append(items, page.Items...) + if len(page.Items) < catalogPageLimit || + (page.TotalCount > 0 && len(items) >= page.TotalCount) { + return items, nil + } + } +} diff --git a/internal/sync/api/catalog_test.go b/internal/sync/api/catalog_test.go new file mode 100644 index 00000000..c1bcf405 --- /dev/null +++ b/internal/sync/api/catalog_test.go @@ -0,0 +1,169 @@ +package api + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +func TestCatalogClientProjectsPaginatesInNameOrder(t *testing.T) { + firstPage := make([]Project, catalogPageLimit) + for index := range firstPage { + firstPage[index] = Project{ + Key: string(rune('a' + index)), + Name: string(rune('A' + index)), + } + } + + transport := &recordingClient{Responses: [][]byte{ + mustCatalogJSON(t, catalogPage[Project]{ + Items: firstPage, + TotalCount: 26, + }), + mustCatalogJSON(t, catalogPage[Project]{ + Items: []Project{{Key: "z", Name: "Z"}}, + TotalCount: 26, + }), + }} + + projects, err := NewCatalogClient( + transport, + "token", + "https://example.com", + ).Projects() + + require.NoError(t, err) + require.Len(t, projects, 26) + require.Len(t, transport.Requests, 2) + + firstRequest := transport.Requests[0] + assert.Equal(t, "GET", firstRequest.Method) + assert.Equal(t, "token", firstRequest.AccessToken) + assert.Equal(t, "https://example.com/api/v2/projects", firstRequest.Path) + assert.Equal(t, "name", firstRequest.Query.Get("sort")) + assert.Equal(t, "25", firstRequest.Query.Get("limit")) + assert.Equal(t, "0", firstRequest.Query.Get("offset")) + assert.False(t, firstRequest.IsBeta) + assert.Equal(t, "25", transport.Requests[1].Query.Get("offset")) + assert.Equal(t, "Z", projects[25].Name) +} + +func TestCatalogClientConfigsPaginatesAndAppliesMode(t *testing.T) { + transport := &recordingClient{Responses: [][]byte{ + mustCatalogJSON(t, catalogPage[Config]{ + Items: []Config{{ + Key: "support", + Name: "Support agent", + Mode: syncdomain.VariationModeAgent, + Variations: []syncdomain.Variation{{ + Key: "helpful", + Name: "Helpful", + }}, + }}, + TotalCount: 1, + }), + }} + + configs, err := NewCatalogClient( + transport, + "token", + "https://example.com", + ).Configs("project") + + require.NoError(t, err) + require.Len(t, configs, 1) + require.Len(t, configs[0].Variations, 1) + assert.Equal( + t, + syncdomain.VariationModeAgent, + configs[0].Variations[0].Mode, + ) + + request := transport.Requests[0] + assert.Equal( + t, + "https://example.com/api/v2/projects/project/ai-configs", + request.Path, + ) + assert.Equal(t, "name", request.Query.Get("sort")) + assert.Equal( + t, + `mode anyOf ["agent","completion"]`, + request.Query.Get("filter"), + ) + assert.False(t, request.IsBeta) +} + +func TestCatalogClientConfigsDefaultsCompletionMode(t *testing.T) { + transport := &recordingClient{Responses: [][]byte{ + mustCatalogJSON(t, catalogPage[Config]{ + Items: []Config{{ + Key: "completion", + Name: "Completion", + Variations: []syncdomain.Variation{{Key: "strict", Name: "Strict"}}, + }}, + TotalCount: 1, + }), + }} + + configs, err := NewCatalogClient( + transport, + "token", + "https://example.com", + ).Configs("project") + + require.NoError(t, err) + require.Len(t, configs, 1) + require.Len(t, configs[0].Variations, 1) + assert.Equal( + t, + syncdomain.VariationModeCompletion, + configs[0].Variations[0].Mode, + ) +} + +func TestCatalogClientConfigsRejectsJudgeMode(t *testing.T) { + transport := &recordingClient{Responses: [][]byte{ + mustCatalogJSON(t, catalogPage[Config]{ + Items: []Config{{ + Key: "config", + Name: "Config", + Mode: "judge", + }}, + TotalCount: 1, + }), + }} + + _, err := NewCatalogClient( + transport, + "token", + "https://example.com", + ).Configs("project") + + require.ErrorContains(t, err, `unsupported mode "judge"`) +} + +func TestCatalogClientProjectsRejectsInvalidResponse(t *testing.T) { + client := NewCatalogClient( + &recordingClient{Responses: [][]byte{[]byte(`not json`)}}, + "token", + "https://example.com", + ) + + _, err := client.Projects() + + require.ErrorContains(t, err, "decode projects response") +} + +func mustCatalogJSON(t *testing.T, value any) []byte { + t.Helper() + + data, err := json.Marshal(value) + require.NoError(t, err) + + return data +} diff --git a/internal/sync/api/client_test.go b/internal/sync/api/client_test.go index 85eebff7..85a4ebf7 100644 --- a/internal/sync/api/client_test.go +++ b/internal/sync/api/client_test.go @@ -18,6 +18,7 @@ type recordedRequest struct { Method string Path string ContentType string + Query url.Values Body []byte IsBeta bool } @@ -35,7 +36,7 @@ func (client *recordingClient) MakeRequest( method string, path string, contentType string, - _ url.Values, + query url.Values, body []byte, isBeta bool, ) ([]byte, error) { @@ -44,6 +45,7 @@ func (client *recordingClient) MakeRequest( Method: method, Path: path, ContentType: contentType, + Query: query, Body: append([]byte(nil), body...), IsBeta: isBeta, }) diff --git a/internal/sync/bootstrap/bootstrap.go b/internal/sync/bootstrap/bootstrap.go new file mode 100644 index 00000000..2ce77ff5 --- /dev/null +++ b/internal/sync/bootstrap/bootstrap.go @@ -0,0 +1,364 @@ +package bootstrap + +import ( + "cmp" + "fmt" + "io" + "os" + "path" + "slices" + "strings" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "golang.org/x/term" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + syncapi "github.com/launchdarkly/ldcli/internal/sync/api" + synclocal "github.com/launchdarkly/ldcli/internal/sync/local" +) + +type Catalog interface { + Projects() ([]syncapi.Project, error) + Configs(projectKey string) ([]syncapi.Config, error) +} + +type Options struct { + Catalog Catalog + Store synclocal.Store + Input io.Reader + Output io.Writer + Initial bool + DryRun bool +} + +type step int + +const ( + selectProject step = iota + selectConfig + selectVariations +) + +type model struct { + catalog Catalog + store synclocal.Store + dryRun bool + + step step + width int + height int + + projects list.Model + projectsReady bool + configs list.Model + configsReady bool + variations list.Model + variationsReady bool + + projectKey string + config syncapi.Config + + notice string + err error + canceled bool +} + +type projectItem struct { + project syncapi.Project +} + +func (item projectItem) Title() string { + return item.project.Name +} + +func (item projectItem) Description() string { + return item.project.Key +} + +func (item projectItem) FilterValue() string { + return item.project.Name + " " + item.project.Key +} + +type configItem struct { + config syncapi.Config +} + +func (item configItem) Title() string { + return item.config.Name +} + +func (item configItem) Description() string { + return fmt.Sprintf("%s · %s", item.config.Key, item.config.Mode) +} + +func (item configItem) FilterValue() string { + return item.config.Name + " " + item.config.Key +} + +type variationItem struct { + variation syncdomain.Variation + selected bool + existing bool +} + +func (item variationItem) Title() string { + return item.variation.Name +} + +func (item variationItem) Description() string { + if item.existing { + return item.variation.Key + " · already synced" + } + + return item.variation.Key +} + +func (item variationItem) FilterValue() string { + return item.variation.Name + " " + item.variation.Key +} + +type projectsFetchedMsg struct { + projects []syncapi.Project +} + +type configsFetchedMsg struct { + projectKey string + configs []syncapi.Config +} + +type variationsLoadedMsg struct { + projectKey string + config syncapi.Config + existing map[string]bool +} + +type errMsg struct { + step step + projectKey string + configKey string + err error +} + +func newModel(options Options) model { + return model{ + catalog: options.Catalog, + store: options.Store, + dryRun: options.DryRun, + step: selectProject, + } +} + +func (model model) Init() tea.Cmd { + return model.fetchProjects() +} + +func (model model) fetchProjects() tea.Cmd { + return func() tea.Msg { + projects, err := model.catalog.Projects() + if err != nil { + return errMsg{step: selectProject, err: err} + } + + return projectsFetchedMsg{projects: projects} + } +} + +func (model model) fetchConfigs() tea.Cmd { + projectKey := model.projectKey + + return func() tea.Msg { + configs, err := model.catalog.Configs(projectKey) + if err != nil { + return errMsg{ + step: selectConfig, + projectKey: projectKey, + err: err, + } + } + + return configsFetchedMsg{ + projectKey: projectKey, + configs: configs, + } + } +} + +func (model model) loadVariations() tea.Cmd { + projectKey := model.projectKey + config := model.config + + return func() tea.Msg { + slices.SortFunc(config.Variations, func(left, right syncdomain.Variation) int { + return cmp.Or( + cmp.Compare(strings.ToLower(left.Name), strings.ToLower(right.Name)), + cmp.Compare(left.Key, right.Key), + ) + }) + + existing := make(map[string]bool, len(config.Variations)) + for _, variation := range config.Variations { + found, err := model.store.VariationExists( + projectKey, + config.Key, + variation.Key, + ) + if err != nil { + return errMsg{ + step: selectVariations, + projectKey: projectKey, + configKey: config.Key, + err: err, + } + } + existing[variation.Key] = found + } + + return variationsLoadedMsg{ + projectKey: projectKey, + config: config, + existing: existing, + } + } +} + +func (model model) selectedVariationFiles() []synclocal.VariationFile { + var files []synclocal.VariationFile + + for _, raw := range model.variations.Items() { + item, ok := raw.(variationItem) + if !ok || !item.selected { + continue + } + + files = append(files, synclocal.VariationFile{ + ProjectKey: model.projectKey, + ConfigKey: model.config.Key, + Upsert: true, + Variation: item.variation, + }) + } + + return files +} + +func Run(options Options) error { + if !terminalStreams(options.Input, options.Output) { + return fmt.Errorf( + "interactive prompt selection requires a terminal; run this command in a terminal", + ) + } + + program := tea.NewProgram( + newModel(options), + tea.WithAltScreen(), + tea.WithInput(options.Input), + tea.WithOutput(options.Output), + ) + final, err := program.Run() + if err != nil { + return err + } + + result, ok := final.(model) + if !ok { + return fmt.Errorf("bootstrap returned an unexpected model") + } + if result.err != nil || result.canceled { + return result.err + } + + files := result.selectedVariationFiles() + return finishSelection(options, files) +} + +func finishSelection(options Options, files []synclocal.VariationFile) error { + if len(files) == 0 { + writeNoChangeSummary(options.Output, options.DryRun) + return nil + } + if options.DryRun { + previews, err := options.Store.RenderVariations(files) + if err != nil { + return err + } + writePreviews(options.Output, previews) + return nil + } + + var paths []string + var err error + if options.Initial { + paths, err = options.Store.Bootstrap(files) + } else { + paths, err = options.Store.Add(files) + } + if err != nil { + return err + } + + writeSummary(options.Output, options.Initial, len(paths)) + + return nil +} + +func terminalStreams(input io.Reader, output io.Writer) bool { + in, inputIsFile := input.(*os.File) + out, outputIsFile := output.(*os.File) + + return inputIsFile && + outputIsFile && + term.IsTerminal(int(in.Fd())) && + term.IsTerminal(int(out.Fd())) +} + +func writeNoChangeSummary(output io.Writer, dryRun bool) { + message := "No variations added; every variation in that AI Config is already synced." + if dryRun { + message = "No variations would be added; every variation in that AI Config is already synced." + } + _, _ = fmt.Fprintln(output, message) +} + +func writeSummary(output io.Writer, initial bool, count int) { + verb := "Added" + if initial { + verb = "Bootstrapped" + } + + resource := "variation file" + if count != 1 { + resource += "s" + } + + _, _ = fmt.Fprintf( + output, + "%s %d %s in %s.\n", + verb, + count, + resource, + syncdomain.RootDir, + ) +} + +func writePreviews(output io.Writer, previews []synclocal.RenderedVariationFile) { + for index, preview := range previews { + if index != 0 { + _, _ = fmt.Fprintln(output) + } + _, _ = fmt.Fprintln(output, "============================================================") + _, _ = fmt.Fprintf( + output, + "File %d of %d\nWould create: %s\n", + index+1, + len(previews), + path.Join(syncdomain.RootDir, preview.Path), + ) + _, _ = fmt.Fprintln(output, "------------------------------------------------------------") + _, _ = output.Write(preview.Content) + if len(preview.Content) == 0 || preview.Content[len(preview.Content)-1] != '\n' { + _, _ = fmt.Fprintln(output) + } + _, _ = fmt.Fprintln(output, "============================================================") + } +} diff --git a/internal/sync/bootstrap/bootstrap_test.go b/internal/sync/bootstrap/bootstrap_test.go new file mode 100644 index 00000000..1fabfa58 --- /dev/null +++ b/internal/sync/bootstrap/bootstrap_test.go @@ -0,0 +1,434 @@ +package bootstrap + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + syncapi "github.com/launchdarkly/ldcli/internal/sync/api" + synclocal "github.com/launchdarkly/ldcli/internal/sync/local" +) + +func TestModelSelectsAndWritesVariations(t *testing.T) { + root := t.TempDir() + result := newModel(Options{ + Catalog: &fakeCatalog{}, + Store: synclocal.NewStore(root), + Initial: true, + }) + result = updateModel( + t, + result, + tea.WindowSizeMsg{Width: 80, Height: 24}, + ) + result = updateModel(t, result, projectsFetchedMsg{ + projects: []syncapi.Project{{Key: "project", Name: "Project"}}, + }) + + next, command := result.handleEnter() + result = next.(model) + assert.Equal(t, selectConfig, result.step) + assert.NotNil(t, command) + + result = updateModel(t, result, configsFetchedMsg{ + projectKey: "project", + configs: []syncapi.Config{{ + Key: "assistant", + Name: "Assistant", + Mode: syncdomain.VariationModeAgent, + }}, + }) + + next, command = result.handleEnter() + result = next.(model) + assert.Equal(t, selectVariations, result.step) + assert.NotNil(t, command) + + result = updateModel(t, result, variationsLoadedMsg{ + projectKey: "project", + config: syncapi.Config{ + Key: "assistant", + Name: "Assistant", + Mode: syncdomain.VariationModeAgent, + Variations: []syncdomain.Variation{ + { + Mode: syncdomain.VariationModeAgent, + Key: "friendly", + Name: "Friendly", + }, + { + Mode: syncdomain.VariationModeAgent, + Key: "existing", + Name: "Existing", + }, + }, + }, + existing: map[string]bool{"existing": true}, + }) + + result.toggleVariation() + selected, _ := result.variationCounts() + assert.Equal(t, 1, selected) + result.selectAllVariations() + assert.False(t, result.variations.Items()[1].(variationItem).selected) + + next, command = result.handleEnter() + result = next.(model) + require.NotNil(t, command) + + files := result.selectedVariationFiles() + require.Len(t, files, 1) + paths, err := result.store.Bootstrap(files) + require.NoError(t, err) + assert.Equal( + t, + []string{"project/configs/assistant/friendly.prompt.md"}, + paths, + ) + + contents, err := os.ReadFile(filepath.Join( + root, + syncdomain.RootDir, + "project", + "configs", + "assistant", + "friendly.prompt.md", + )) + require.NoError(t, err) + assert.Contains(t, string(contents), "mode: agent") + + _, err = os.Stat(filepath.Join( + root, + syncdomain.RootDir, + "project", + "configs", + "assistant", + "existing.prompt.md", + )) + require.ErrorIs(t, err, os.ErrNotExist) +} + +func TestModelAllExistingIsNoOp(t *testing.T) { + result := newVariationModel( + t, + variationItem{ + variation: syncdomain.Variation{ + Key: "existing", + Name: "Existing", + }, + existing: true, + }, + ) + + next, command := result.handleEnter() + result = next.(model) + + assert.Equal(t, selectVariations, result.step) + selected, selectable := result.variationCounts() + assert.Zero(t, selected) + assert.Zero(t, selectable) + assert.NotNil(t, command) +} + +func TestModelRequiresVariationSelection(t *testing.T) { + result := newVariationModel( + t, + variationItem{ + variation: syncdomain.Variation{ + Key: "available", + Name: "Available", + }, + }, + ) + + next, command := result.handleEnter() + result = next.(model) + + assert.Equal(t, selectVariations, result.step) + assert.Equal( + t, + "Select at least one variation to continue.", + result.notice, + ) + assert.Nil(t, command) +} + +func TestModelFiltersLocallyWithoutQuitting(t *testing.T) { + result := newModel(Options{ + Catalog: &fakeCatalog{}, + Store: synclocal.NewStore(t.TempDir()), + }) + result = updateModel( + t, + result, + tea.WindowSizeMsg{Width: 80, Height: 24}, + ) + result = updateModel(t, result, projectsFetchedMsg{ + projects: []syncapi.Project{{Key: "project", Name: "Project"}}, + }) + + result = updateModel( + t, + result, + tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}, + ) + require.True(t, result.isFiltering()) + + result = updateModel( + t, + result, + tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}, + ) + assert.False(t, result.canceled) + assert.True(t, result.isFiltering()) +} + +func TestFetchConfigSortsVariationsAndChecksExistingFiles(t *testing.T) { + root := t.TempDir() + store := synclocal.NewStore(root) + _, err := store.Bootstrap([]synclocal.VariationFile{{ + ProjectKey: "project", + ConfigKey: "config", + Variation: syncdomain.Variation{ + Mode: syncdomain.VariationModeCompletion, + Key: "existing", + Name: "Existing", + }, + }}) + require.NoError(t, err) + + result := newModel(Options{ + Catalog: &fakeCatalog{}, + Store: store, + }) + result.projectKey = "project" + result.config = syncapi.Config{ + Key: "config", + Mode: syncdomain.VariationModeCompletion, + Variations: []syncdomain.Variation{ + {Key: "z", Name: "Zulu"}, + {Key: "existing", Name: "Alpha"}, + }, + } + + message, ok := result.loadVariations()().(variationsLoadedMsg) + require.True(t, ok) + assert.Equal(t, "existing", message.config.Variations[0].Key) + assert.True(t, message.existing["existing"]) + assert.False(t, message.existing["z"]) +} + +func TestLoadVariationsReturnsLocalInspectionError(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.Mkdir( + filepath.Join(root, syncdomain.RootDir), + 0o755, + )) + require.NoError(t, os.WriteFile( + filepath.Join(root, syncdomain.RootDir, "project"), + []byte("not a directory"), + 0o644, + )) + + result := newModel(Options{ + Catalog: &fakeCatalog{}, + Store: synclocal.NewStore(root), + }) + result.projectKey = "project" + result.config = syncapi.Config{ + Key: "config", + Variations: []syncdomain.Variation{{ + Key: "variation", + }}, + } + + message, ok := result.loadVariations()().(errMsg) + require.True(t, ok) + require.ErrorContains(t, message.err, "inspect variation variation") +} + +func TestModelAPIErrorQuitsWithError(t *testing.T) { + result := newModel(Options{ + Catalog: &fakeCatalog{}, + Store: synclocal.NewStore(t.TempDir()), + }) + + updated, command := result.Update(errMsg{err: assert.AnError}) + result = updated.(model) + + assert.ErrorIs(t, result.err, assert.AnError) + assert.NotNil(t, command) +} + +func TestModelIgnoresStaleAPIError(t *testing.T) { + result := newModel(Options{ + Catalog: &fakeCatalog{}, + Store: synclocal.NewStore(t.TempDir()), + }) + + updated, command := result.Update(errMsg{ + step: selectConfig, + projectKey: "old-project", + err: assert.AnError, + }) + result = updated.(model) + + assert.NoError(t, result.err) + assert.Nil(t, command) +} + +func TestRunRequiresTerminal(t *testing.T) { + err := Run(Options{ + Catalog: &fakeCatalog{}, + Store: synclocal.NewStore(t.TempDir()), + Input: bytes.NewBuffer(nil), + Output: bytes.NewBuffer(nil), + Initial: true, + }) + + require.ErrorContains( + t, + err, + "interactive prompt selection requires a terminal", + ) +} + +func TestFinishSelectionDryRunDoesNotCreateFiles(t *testing.T) { + root := t.TempDir() + var output bytes.Buffer + + err := finishSelection(Options{ + Store: synclocal.NewStore(root), + Output: &output, + Initial: true, + DryRun: true, + }, []synclocal.VariationFile{{ + ProjectKey: "project", + ConfigKey: "config", + Upsert: true, + Variation: syncdomain.Variation{ + Key: "variation", + Name: "Variation", + Mode: syncdomain.VariationModeAgent, + Instructions: "Be helpful.", + }, + }}) + + require.NoError(t, err) + assert.Equal( + t, + `============================================================ +File 1 of 1 +Would create: .launchdarkly/project/configs/config/variation.prompt.md +------------------------------------------------------------ +--- +formatVersion: 1 +upsert: true +mode: agent +key: variation +name: Variation +--- + +Be helpful. +============================================================ +`, + output.String(), + ) + _, err = os.Stat(filepath.Join(root, syncdomain.RootDir)) + assert.ErrorIs(t, err, os.ErrNotExist) +} + +func TestWriteSummary(t *testing.T) { + var output bytes.Buffer + + writeSummary( + &output, + true, + 2, + ) + assert.Equal( + t, + "Bootstrapped 2 variation files in .launchdarkly.\n", + output.String(), + ) + + output.Reset() + writeNoChangeSummary(&output, false) + assert.Contains(t, output.String(), "No variations added") +} + +func TestWritePreviewsPrintsEveryFile(t *testing.T) { + var output bytes.Buffer + + writePreviews(&output, []synclocal.RenderedVariationFile{ + {Path: "project/configs/config/first.prompt.md", Content: []byte("first\n")}, + {Path: "project/configs/config/second.prompt.md", Content: []byte("second\n")}, + }) + + assert.Equal( + t, + `============================================================ +File 1 of 2 +Would create: .launchdarkly/project/configs/config/first.prompt.md +------------------------------------------------------------ +first +============================================================ + +============================================================ +File 2 of 2 +Would create: .launchdarkly/project/configs/config/second.prompt.md +------------------------------------------------------------ +second +============================================================ +`, + output.String(), + ) +} + +func newVariationModel(t *testing.T, items ...variationItem) model { + t.Helper() + + raw := make([]list.Item, len(items)) + for index, item := range items { + raw[index] = item + } + + result := newModel(Options{ + Catalog: &fakeCatalog{}, + Store: synclocal.NewStore(t.TempDir()), + }) + result.step = selectVariations + result.variationsReady = true + result.variations = newVariationList(raw, 80, 20, false) + + return result +} + +func updateModel(t *testing.T, current model, message tea.Msg) model { + t.Helper() + + updated, _ := current.Update(message) + result, ok := updated.(model) + require.True(t, ok) + + return result +} + +type fakeCatalog struct{} + +var _ Catalog = &fakeCatalog{} + +func (*fakeCatalog) Projects() ([]syncapi.Project, error) { + return nil, nil +} + +func (*fakeCatalog) Configs(string) ([]syncapi.Config, error) { + return nil, nil +} diff --git a/internal/sync/bootstrap/model.go b/internal/sync/bootstrap/model.go new file mode 100644 index 00000000..2d933d03 --- /dev/null +++ b/internal/sync/bootstrap/model.go @@ -0,0 +1,362 @@ +package bootstrap + +import ( + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + syncapi "github.com/launchdarkly/ldcli/internal/sync/api" +) + +func (model model) Update(message tea.Msg) (tea.Model, tea.Cmd) { + switch message := message.(type) { + case tea.WindowSizeMsg: + model.width = message.Width + model.height = message.Height + model.resizeLists() + + case tea.KeyMsg: + switch message.String() { + case "ctrl+c": + model.canceled = true + return model, tea.Quit + case "q": + if !model.isFiltering() { + model.canceled = true + return model, tea.Quit + } + case "b": + if !model.isFiltering() { + return model.handleBack() + } + case " ": + if model.step == selectVariations && !model.isFiltering() { + return model, model.toggleVariation() + } + case "a": + if model.step == selectVariations && !model.isFiltering() { + return model, model.selectAllVariations() + } + case "enter": + if !model.isFiltering() { + return model.handleEnter() + } + } + + case projectsFetchedMsg: + model.projects = newList( + projectItems(message.projects), + "Select a LaunchDarkly project", + model.width, + model.listHeight(), + ) + model.projectsReady = true + return model, nil + + case configsFetchedMsg: + if message.projectKey != model.projectKey || + model.step != selectConfig { + return model, nil + } + + model.configs = newList( + configItems(message.configs), + "Select an AI Config", + model.width, + model.listHeight(), + ) + model.configsReady = true + return model, nil + + case variationsLoadedMsg: + if message.projectKey != model.projectKey || + message.config.Key != model.config.Key || + model.step != selectVariations { + return model, nil + } + + model.config = message.config + model.variations = newVariationList( + variationItems(message.config.Variations, message.existing), + model.width, + model.listHeight(), + model.dryRun, + ) + model.variationsReady = true + return model, nil + + case errMsg: + if message.step != model.step || + (message.step == selectConfig && + message.projectKey != model.projectKey) || + (message.step == selectVariations && + (message.projectKey != model.projectKey || + message.configKey != model.config.Key)) { + return model, nil + } + + model.err = message.err + return model, tea.Quit + } + + current := model.currentList() + if current == nil { + return model, nil + } + + updated, command := current.Update(message) + *current = updated + + return model, command +} + +func newList( + items []list.Item, + title string, + width int, + height int, +) list.Model { + result := list.New(items, themedDefaultDelegate(), width, height) + result.Title = title + configureList(&result) + + return result +} + +func newVariationList( + items []list.Item, + width int, + height int, + dryRun bool, +) list.Model { + result := list.New(items, variationDelegate{}, width, height) + result.Title = "Select prompt variations" + configureList(&result) + result.AdditionalShortHelpKeys = variationListHints(dryRun) + + return result +} + +func configureList(model *list.Model) { + applyListTheme(model) + model.KeyMap.Quit = key.NewBinding( + key.WithKeys("q"), + key.WithHelp("q", "quit"), + ) + model.AdditionalShortHelpKeys = func() []key.Binding { + return []key.Binding{ + key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "select"), + ), + key.NewBinding( + key.WithKeys("b"), + key.WithHelp("b", "back"), + ), + } + } +} + +func projectItems(projects []syncapi.Project) []list.Item { + items := make([]list.Item, len(projects)) + for index, project := range projects { + items[index] = projectItem{project: project} + } + + return items +} + +func configItems(configs []syncapi.Config) []list.Item { + items := make([]list.Item, len(configs)) + for index, config := range configs { + items[index] = configItem{config: config} + } + + return items +} + +func variationItems( + variations []syncdomain.Variation, + existing map[string]bool, +) []list.Item { + items := make([]list.Item, len(variations)) + for index, variation := range variations { + items[index] = variationItem{ + variation: variation, + existing: existing[variation.Key], + } + } + + return items +} + +func (model *model) resizeLists() { + if model.projectsReady { + model.projects.SetSize(model.width, model.listHeight()) + } + if model.configsReady { + model.configs.SetSize(model.width, model.listHeight()) + } + if model.variationsReady { + model.variations.SetSize(model.width, model.listHeight()) + } +} + +func (model model) listHeight() int { + height := model.height - 2 + if height < 5 { + return 5 + } + + return height +} + +func (model model) isFiltering() bool { + current := model.currentList() + + return current != nil && current.FilterState() == list.Filtering +} + +func (model *model) currentList() *list.Model { + switch model.step { + case selectProject: + if model.projectsReady { + return &model.projects + } + case selectConfig: + if model.configsReady { + return &model.configs + } + case selectVariations: + if model.variationsReady { + return &model.variations + } + } + + return nil +} + +func (model model) handleBack() (tea.Model, tea.Cmd) { + model.notice = "" + + switch model.step { + case selectConfig: + model.step = selectProject + model.configsReady = false + model.configs = list.Model{} + case selectVariations: + model.step = selectConfig + model.variationsReady = false + model.variations = list.Model{} + } + + return model, nil +} + +func (model model) handleEnter() (tea.Model, tea.Cmd) { + model.notice = "" + + switch model.step { + case selectProject: + if !model.projectsReady || len(model.projects.VisibleItems()) == 0 { + return model, nil + } + + selected, ok := model.projects.SelectedItem().(projectItem) + if !ok { + return model, nil + } + + model.projectKey = selected.project.Key + model.configsReady = false + model.configs = list.Model{} + model.step = selectConfig + + return model, model.fetchConfigs() + + case selectConfig: + if !model.configsReady || len(model.configs.VisibleItems()) == 0 { + return model, nil + } + + selected, ok := model.configs.SelectedItem().(configItem) + if !ok { + return model, nil + } + + model.config = selected.config + model.variationsReady = false + model.variations = list.Model{} + model.step = selectVariations + + return model, model.loadVariations() + + case selectVariations: + if !model.variationsReady { + return model, nil + } + + selected, selectable := model.variationCounts() + if selected == 0 { + if selectable == 0 { + return model, tea.Quit + } + + model.notice = "Select at least one variation to continue." + return model, nil + } + + return model, tea.Quit + } + + return model, nil +} + +func (model *model) toggleVariation() tea.Cmd { + item, ok := model.variations.SelectedItem().(variationItem) + if !ok || item.existing { + return nil + } + + item.selected = !item.selected + items := model.variations.Items() + items[model.variations.Index()] = item + model.notice = "" + + return model.variations.SetItems(items) +} + +func (model *model) selectAllVariations() tea.Cmd { + items := model.variations.Items() + for index, raw := range items { + item, ok := raw.(variationItem) + if !ok || item.existing { + continue + } + + item.selected = true + items[index] = item + } + + model.notice = "" + + return model.variations.SetItems(items) +} + +func (model model) variationCounts() (selected int, selectable int) { + for _, raw := range model.variations.Items() { + item, ok := raw.(variationItem) + if !ok { + continue + } + if item.selected { + selected++ + } + if !item.existing { + selectable++ + } + } + + return selected, selectable +} diff --git a/internal/sync/bootstrap/view.go b/internal/sync/bootstrap/view.go new file mode 100644 index 00000000..77e5eca7 --- /dev/null +++ b/internal/sync/bootstrap/view.go @@ -0,0 +1,170 @@ +package bootstrap + +import ( + "fmt" + "io" + "strings" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var ( + selectionColor = lipgloss.Color("2") + selectedStyle = lipgloss.NewStyle().Foreground(selectionColor) + noticeStyle = lipgloss.NewStyle().Bold(true) + mutedStyle = lipgloss.NewStyle().Faint(true) +) + +type variationDelegate struct{} + +func (variationDelegate) Height() int { + return 2 +} + +func (variationDelegate) Spacing() int { + return 1 +} + +func (variationDelegate) Update(tea.Msg, *list.Model) tea.Cmd { + return nil +} + +func (variationDelegate) Render( + writer io.Writer, + model list.Model, + index int, + raw list.Item, +) { + item, ok := raw.(variationItem) + if !ok { + return + } + + cursor := " " + if index == model.Index() { + cursor = "> " + } + + checkbox := "[ ]" + if item.selected { + checkbox = "[x]" + } + if item.existing { + checkbox = "[-]" + } + + title := fmt.Sprintf("%s%s %s", cursor, checkbox, item.Title()) + description := " " + item.Description() + + switch { + case item.existing: + _, _ = fmt.Fprintln(writer, mutedStyle.Render(title)) + _, _ = fmt.Fprint(writer, mutedStyle.Render(description)) + case index == model.Index(): + _, _ = fmt.Fprintln(writer, selectedStyle.Render(title)) + _, _ = fmt.Fprint(writer, selectedStyle.Render(description)) + default: + _, _ = fmt.Fprintln(writer, title) + _, _ = fmt.Fprint(writer, description) + } +} + +func themedDefaultDelegate() list.DefaultDelegate { + delegate := list.NewDefaultDelegate() + delegate.Styles.SelectedTitle = delegate.Styles.SelectedTitle. + Foreground(selectionColor). + BorderForeground(selectionColor) + delegate.Styles.SelectedDesc = delegate.Styles.SelectedDesc. + Foreground(selectionColor). + BorderForeground(selectionColor) + delegate.Styles.FilterMatch = delegate.Styles.FilterMatch. + Foreground(selectionColor) + + return delegate +} + +func applyListTheme(model *list.Model) { + model.Styles.Title = lipgloss.NewStyle().Bold(true).Padding(0, 1) + model.Styles.FilterPrompt = lipgloss.NewStyle() + model.Styles.FilterCursor = lipgloss.NewStyle().Foreground(selectionColor) + model.Styles.StatusBarActiveFilter = lipgloss.NewStyle() + model.Styles.ActivePaginationDot = model.Styles.ActivePaginationDot. + Foreground(selectionColor) + model.FilterInput.PromptStyle = model.Styles.FilterPrompt + model.FilterInput.Cursor.Style = model.Styles.FilterCursor + model.Paginator.ActiveDot = model.Styles.ActivePaginationDot.String() +} + +func variationListHints(dryRun bool) func() []key.Binding { + action := "write" + if dryRun { + action = "preview" + } + + return func() []key.Binding { + return []key.Binding{ + key.NewBinding( + key.WithKeys("space"), + key.WithHelp("space", "toggle"), + ), + key.NewBinding( + key.WithKeys("a"), + key.WithHelp("a", "select all"), + ), + key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", action), + ), + key.NewBinding( + key.WithKeys("b"), + key.WithHelp("b", "back"), + ), + } + } +} + +func (model model) View() string { + if model.err != nil || model.canceled { + return "" + } + + switch model.step { + case selectProject: + if !model.projectsReady { + return loadingView("Loading projects") + } + return model.projects.View() + + case selectConfig: + if !model.configsReady { + return loadingView("Loading AI Configs") + } + return model.configs.View() + + case selectVariations: + if !model.variationsReady { + return loadingView("Loading prompt variations") + } + + var view strings.Builder + view.WriteString(model.variations.View()) + selected, _ := model.variationCounts() + _, _ = fmt.Fprintf(&view, "\nSelected: %d", selected) + if model.notice != "" { + view.WriteString("\n") + view.WriteString(noticeStyle.Render(model.notice)) + } + + return view.String() + + default: + return "" + } +} + +func loadingView(label string) string { + return fmt.Sprintf("\n %s...\n", label) +} diff --git a/internal/sync/local/store.go b/internal/sync/local/store.go index efbba00a..a9e2a61c 100644 --- a/internal/sync/local/store.go +++ b/internal/sync/local/store.go @@ -21,6 +21,11 @@ type VariationFile struct { Variation syncdomain.Variation } +type RenderedVariationFile struct { + Path string + Content []byte +} + type Store struct { root string } @@ -96,17 +101,11 @@ func (s Store) Add(resources []VariationFile) ([]string, error) { return s.createVariations(resources) } -func (s Store) createVariations(resources []VariationFile) ([]string, error) { - type pendingFile struct { - absolute string - relative string - data []byte - } - - pending := make([]pendingFile, 0, len(resources)) +func (s Store) RenderVariations(resources []VariationFile) ([]RenderedVariationFile, error) { + rendered := make([]RenderedVariationFile, 0, len(resources)) seen := make(map[string]struct{}, len(resources)) for _, resource := range resources { - path, err := s.variationPath( + absolute, err := s.variationPath( resource.ProjectKey, resource.ConfigKey, resource.Variation.Key, @@ -114,31 +113,40 @@ func (s Store) createVariations(resources []VariationFile) ([]string, error) { if err != nil { return nil, err } - if _, ok := seen[path]; ok { + if _, ok := seen[absolute]; ok { return nil, fmt.Errorf("variation %q was selected more than once", resource.Variation.Key) } - seen[path] = struct{}{} + seen[absolute] = struct{}{} - data, err := marshalVariationFile(resource) + content, err := marshalVariationFile(resource) if err != nil { return nil, err } - pending = append(pending, pendingFile{ - absolute: path, - relative: filepath.ToSlash(strings.TrimPrefix(path, s.root+string(filepath.Separator))), - data: data, + rendered = append(rendered, RenderedVariationFile{ + Path: filepath.ToSlash(strings.TrimPrefix(absolute, s.root+string(filepath.Separator))), + Content: content, }) } + return rendered, nil +} + +func (s Store) createVariations(resources []VariationFile) ([]string, error) { + rendered, err := s.RenderVariations(resources) + if err != nil { + return nil, err + } + var created []string - for _, file := range pending { - if err := createFile(file.absolute, file.data); err != nil { + for _, file := range rendered { + absolute := filepath.Join(s.root, filepath.FromSlash(file.Path)) + if err := createFile(absolute, file.Content); err != nil { for index := len(created) - 1; index >= 0; index-- { _ = os.Remove(filepath.Join(s.root, filepath.FromSlash(created[index]))) } return nil, err } - created = append(created, file.relative) + created = append(created, file.Path) } return created, nil diff --git a/internal/sync/local/store_test.go b/internal/sync/local/store_test.go index eb042f34..a49dfb8e 100644 --- a/internal/sync/local/store_test.go +++ b/internal/sync/local/store_test.go @@ -81,6 +81,31 @@ func TestStore_BootstrapRoundTripsSupportedModes(t *testing.T) { } } +func TestStore_RenderVariationsMatchesWrittenFile(t *testing.T) { + root := t.TempDir() + store := NewStore(root) + resource := localVariation("preview") + + rendered, err := store.RenderVariations([]VariationFile{resource}) + + require.NoError(t, err) + require.Len(t, rendered, 1) + assert.Equal(t, "project/configs/config/preview.prompt.md", rendered[0].Path) + _, err = os.Stat(filepath.Join(root, syncdomain.RootDir)) + assert.ErrorIs(t, err, os.ErrNotExist) + + paths, err := store.Bootstrap([]VariationFile{resource}) + require.NoError(t, err) + assert.Equal(t, []string{rendered[0].Path}, paths) + content, err := os.ReadFile(filepath.Join( + root, + syncdomain.RootDir, + filepath.FromSlash(rendered[0].Path), + )) + require.NoError(t, err) + assert.Equal(t, rendered[0].Content, content) +} + func TestStore_AddNeverOverwritesExistingVariation(t *testing.T) { root := t.TempDir() store := NewStore(root) diff --git a/internal/sync/source/resolver.go b/internal/sync/source/resolver.go index 106aedb0..9e1046a6 100644 --- a/internal/sync/source/resolver.go +++ b/internal/sync/source/resolver.go @@ -31,26 +31,19 @@ func NewResolver(configFile string) Resolver { } } +func (resolver Resolver) ResolveRoot(dir string) (string, error) { + root, _, _, err := resolver.resolveRoot(dir) + + return root, err +} + func (resolver Resolver) Resolve(dir string) (Workspace, error) { - gitRepository, found, err := resolver.findGitSource(dir) + root, gitSource, found, err := resolver.resolveRoot(dir) if err != nil { return Workspace{}, err } if found { - root, err := canonicalPath(gitRepository.Root) - if err != nil { - return Workspace{}, err - } - - return Workspace{ - Root: root, - Source: gitRepository.Source, - }, nil - } - - root, err := localWorkspaceRoot(dir) - if err != nil { - return Workspace{}, err + return Workspace{Root: root, Source: gitSource}, nil } localSyncID, err := resolver.ensureLocalSyncID(resolver.configFile) @@ -69,6 +62,30 @@ func (resolver Resolver) Resolve(dir string) (Workspace, error) { return Workspace{Root: root, Source: source}, nil } +func (resolver Resolver) resolveRoot( + dir string, +) (string, syncdomain.Source, bool, error) { + gitRepository, found, err := resolver.findGitSource(dir) + if err != nil { + return "", syncdomain.Source{}, false, err + } + if found { + root, err := canonicalPath(gitRepository.Root) + if err != nil { + return "", syncdomain.Source{}, false, err + } + + return root, gitRepository.Source, true, nil + } + + root, err := localWorkspaceRoot(dir) + if err != nil { + return "", syncdomain.Source{}, false, err + } + + return root, syncdomain.Source{}, false, nil +} + func localWorkspaceRoot(dir string) (string, error) { root, err := canonicalPath(dir) if err != nil { diff --git a/internal/sync/source/resolver_test.go b/internal/sync/source/resolver_test.go index 97b46343..0fd90148 100644 --- a/internal/sync/source/resolver_test.go +++ b/internal/sync/source/resolver_test.go @@ -82,6 +82,21 @@ func TestResolverUsesCurrentDirectoryBeforeBootstrap(t *testing.T) { ) } +func TestResolverRootDoesNotCreateLocalIdentity(t *testing.T) { + root := t.TempDir() + resolver := localResolver("local-sync-id") + resolver.ensureLocalSyncID = func(string) (string, error) { + t.Fatal("resolving a root must not create a local sync ID") + + return "", nil + } + + resolved, err := resolver.ResolveRoot(root) + + require.NoError(t, err) + assert.Equal(t, requireCanonicalPath(t, root), resolved) +} + func TestResolverCanonicalizesSymlinkedWorkspace(t *testing.T) { root := t.TempDir() require.NoError(t, os.Mkdir(filepath.Join(root, syncdomain.RootDir), 0o755))