diff --git a/internal/sync/bootstrap/attachments.go b/internal/sync/bootstrap/attachments.go new file mode 100644 index 000000000..c08207d34 --- /dev/null +++ b/internal/sync/bootstrap/attachments.go @@ -0,0 +1,142 @@ +package bootstrap + +import ( + "errors" + "fmt" + "slices" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + syncapi "github.com/launchdarkly/ldcli/internal/sync/api" + syncinteractive "github.com/launchdarkly/ldcli/internal/sync/interactive" + synclocal "github.com/launchdarkly/ldcli/internal/sync/local" + syncmanifest "github.com/launchdarkly/ldcli/internal/sync/manifest" +) + +// addAttachments writes the files of the selected tools or skills from +// LaunchDarkly and records their baseline. It checks and reads every +// selection before it writes, so an error writes nothing. +func addAttachments(options Options) error { + selections := options.Selections + if len(selections) == 0 { + var canceled bool + var err error + if selections, canceled, err = promptForAttachment(options); err != nil || canceled { + return err + } + } + attachments, err := readNewAttachments(options, selections) + if err != nil { + return err + } + if options.DryRun { + previews := make([]synclocal.RenderedFile, 0, len(attachments)) + for _, attachment := range attachments { + preview, err := synclocal.RenderAttachment(attachment) + if err != nil { + return err + } + previews = append(previews, preview) + } + writePreviews(options.Output, previews, options.Diff) + return nil + } + return writeAttachments(options, attachments) +} + +// promptForAttachment asks the user for a project and for one tool or skill. +// ProjectKey skips the project question. The bool result is true when the +// user cancels. +func promptForAttachment(options Options) ([]syncdomain.ResourceID, bool, error) { + kind := syncdomain.AttachmentKind(options.Kind) + arguments := "--project and --key" + if options.ProjectKey != "" { + arguments = "--key values" + } + if err := syncinteractive.RequireTerminal(options.Input, options.Output, options.NoInput, arguments, string(kind)+" selection"); err != nil { + return nil, false, err + } + + projectKey := options.ProjectKey + if projectKey == "" { + project, canceled, err := syncinteractive.SelectProject(options.Input, options.Output, options.Catalog) + if err != nil || canceled { + return nil, canceled, err + } + projectKey = project.Key + } + attachment, canceled, err := syncinteractive.SelectAttachment(options.Input, options.Output, options.Attachments, projectKey, kind) + if err != nil || canceled { + return nil, canceled, err + } + return []syncdomain.ResourceID{attachment.ID(projectKey)}, false, nil +} + +// readNewAttachments reads each selected tool or skill from LaunchDarkly. A +// selection that this repository already syncs, or that LaunchDarkly does not +// have, is an error. The local checks come first, so they need no read. +func readNewAttachments(options Options, selections []syncdomain.ResourceID) ([]syncdomain.SyncedAttachment, error) { + lock, err := syncmanifest.ReadLock(options.Store) + if err != nil { + return nil, err + } + for _, id := range selections { + exists, err := options.Store.AttachmentExists(id) + switch { + case err != nil: + return nil, err + case exists: + path, err := synclocal.AttachmentPath(id) + if err != nil { + return nil, err + } + return nil, fmt.Errorf("%s is already synced. Edit %s and run ldcli sync %ss.", id.Label(), path, id.Kind) + case slices.ContainsFunc(lock.Resources, func(resource syncmanifest.Resource) bool { return resource.ID() == id }): + return nil, fmt.Errorf("%s has a sync.lock entry but no local file. Run ldcli sync %ss to restore it.", id.Label(), id.Kind) + } + } + + attachments := make([]syncdomain.SyncedAttachment, 0, len(selections)) + for _, id := range selections { + attachment, err := options.Attachments.ReadAttachment(id.ProjectKey, syncdomain.AttachmentKind(id.Kind), id.LookupKey) + if syncapi.IsNotFound(err) { + return nil, fmt.Errorf("%s does not exist in LaunchDarkly.", id.Label()) + } + if err != nil { + return nil, err + } + attachments = append(attachments, syncdomain.SyncedAttachment{ProjectKey: id.ProjectKey, Attachment: attachment}) + } + return attachments, nil +} + +// writeAttachments writes the files and records their baseline. If the +// baseline save fails, it removes the files again. +func writeAttachments(options Options, attachments []syncdomain.SyncedAttachment) error { + projectKeys := make([]string, 0, len(attachments)) + for _, attachment := range attachments { + projectKeys = append(projectKeys, attachment.ProjectKey) + } + baseline, err := options.Baselines.Load(projectKeys) + if err != nil { + return err + } + + paths, err := options.Store.ReplaceAttachments(attachments) + if err != nil { + return err + } + next := baseline.Lock.Clone() + for _, attachment := range attachments { + if err = next.SetAttachments(attachment.ProjectKey, []syncdomain.Attachment{attachment.Attachment}); err != nil { + break + } + } + if err == nil { + _, err = options.Baselines.Save(baseline, next) + } + if err != nil { + return errors.Join(err, options.Store.RollbackCreation(synclocal.Creation{CreatedPaths: paths})) + } + writeSummary(options.Output, options.Initial, len(paths), string(options.Kind)) + return nil +} diff --git a/internal/sync/bootstrap/attachments_test.go b/internal/sync/bootstrap/attachments_test.go new file mode 100644 index 000000000..b6e16caad --- /dev/null +++ b/internal/sync/bootstrap/attachments_test.go @@ -0,0 +1,191 @@ +package bootstrap + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "testing" + + "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" + syncmanifest "github.com/launchdarkly/ldcli/internal/sync/manifest" +) + +func TestRunAddsToolsAndSkillsFromLaunchDarkly(t *testing.T) { + for _, test := range []struct { + kind syncdomain.Kind + initial bool + wantOutput string + }{ + {kind: syncdomain.KindTool, initial: true, wantOutput: "Bootstrapped 1 tool file in .launchdarkly.\n"}, + {kind: syncdomain.KindSkill, wantOutput: "Added 1 skill file in .launchdarkly.\n"}, + } { + t.Run(string(test.kind), func(t *testing.T) { + root := t.TempDir() + attachment := testAttachment(test.kind) + id := attachment.ID("project") + baselines := &memoryManifestStore{manifest: syncmanifest.New()} + var output bytes.Buffer + + err := Run(Options{ + Kind: test.kind, Attachments: &fakeAttachments{attachments: []syncdomain.Attachment{attachment}}, + Store: synclocal.NewStore(root), Baselines: baselines, Output: &output, Initial: test.initial, + Selections: []syncdomain.ResourceID{id}, NoInput: true, + }) + + require.NoError(t, err) + assert.Equal(t, test.wantOutput, output.String()) + local, err := synclocal.CompileAttachments(root, func(syncdomain.ResourceID) bool { return true }) + require.NoError(t, err) + require.Len(t, local, 1) + assert.True(t, syncdomain.SameAttachmentContent(attachment, local[0].Attachment)) + fingerprint, err := syncdomain.FingerprintAttachment("project", attachment) + require.NoError(t, err) + assert.Equal(t, []syncmanifest.Resource{{ + ResourceKind: test.kind, ProjectKey: "project", LookupKey: id.LookupKey, Fingerprint: fingerprint, + }}, baselines.manifest.Resources) + }) + } +} + +func TestRunRefusesAToolBeforeAnyWrite(t *testing.T) { + search := testAttachment(syncdomain.KindTool) + id := search.ID("project") + tests := map[string]struct { + local bool + tracked bool + launchDarkly []syncdomain.Attachment + want string + }{ + "missing in LaunchDarkly": {want: "tool project/search does not exist in LaunchDarkly."}, + "already synced": { + local: true, launchDarkly: []syncdomain.Attachment{search}, + want: "tool project/search is already synced. Edit .launchdarkly/project/tools/search.json and run ldcli sync tools.", + }, + "lock entry without a file": { + tracked: true, launchDarkly: []syncdomain.Attachment{search}, + want: "tool project/search has a sync.lock entry but no local file. Run ldcli sync tools to restore it.", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + root := t.TempDir() + store := synclocal.NewStore(root) + if test.local { + _, err := store.ReplaceAttachments([]syncdomain.SyncedAttachment{{ProjectKey: "project", Attachment: search}}) + require.NoError(t, err) + } + if test.tracked { + require.NoError(t, os.MkdirAll(filepath.Join(root, syncdomain.RootDir), 0o755)) + lock := syncmanifest.New() + require.NoError(t, lock.SetAttachments("project", []syncdomain.Attachment{search})) + require.NoError(t, syncmanifest.WriteLock(store, lock)) + } + baselines := &memoryManifestStore{manifest: syncmanifest.New()} + + err := Run(Options{ + Kind: syncdomain.KindTool, Attachments: &fakeAttachments{attachments: test.launchDarkly}, + Store: store, Baselines: baselines, Output: &bytes.Buffer{}, Selections: []syncdomain.ResourceID{id}, + }) + + require.EqualError(t, err, test.want) + assert.Empty(t, baselines.manifest.Resources) + files, err := synclocal.SourceFiles(root) + require.NoError(t, err) + if !test.local { + assert.NotContains(t, files, ".launchdarkly/project/tools/search.json") + } + }) + } +} + +func TestRunPreviewsToolsAndSkillsInADryRun(t *testing.T) { + root := t.TempDir() + skill := testAttachment(syncdomain.KindSkill) + var output bytes.Buffer + + err := Run(Options{ + Kind: syncdomain.KindSkill, Attachments: &fakeAttachments{attachments: []syncdomain.Attachment{skill}}, + Store: synclocal.NewStore(root), Baselines: &memoryManifestStore{manifest: syncmanifest.New()}, + Output: &output, DryRun: true, Diff: true, Selections: []syncdomain.ResourceID{skill.ID("project")}, + }) + + require.NoError(t, err) + assert.Equal(t, `1 file to create: + .launchdarkly/project/skills/summarize.md +6 + +.launchdarkly/project/skills/summarize.md + --- + key: summarize + description: Summarize a ticket + --- + + Write three bullets. +`, output.String()) + _, err = os.Stat(filepath.Join(root, syncdomain.RootDir)) + require.ErrorIs(t, err, os.ErrNotExist, "a dry run writes nothing") +} + +func TestRunRemovesNewToolFilesWhenTheBaselineSaveFails(t *testing.T) { + root := t.TempDir() + search := testAttachment(syncdomain.KindTool) + + err := Run(Options{ + Kind: syncdomain.KindTool, Attachments: &fakeAttachments{attachments: []syncdomain.Attachment{search}}, + Store: synclocal.NewStore(root), Baselines: failingManifestStore{}, Output: &bytes.Buffer{}, + Selections: []syncdomain.ResourceID{search.ID("project")}, + }) + + require.EqualError(t, err, "write manifest") + _, err = os.Stat(filepath.Join(root, syncdomain.RootDir)) + require.ErrorIs(t, err, os.ErrNotExist) +} + +func TestRunNamesTheToolFlagsWithoutInput(t *testing.T) { + options := Options{ + Kind: syncdomain.KindTool, Attachments: &fakeAttachments{}, Store: synclocal.NewStore(t.TempDir()), + Input: &bytes.Buffer{}, Output: &bytes.Buffer{}, NoInput: true, + } + require.EqualError(t, Run(options), "--project and --key are required with --no-input") + + options.ProjectKey = "project" + require.EqualError(t, Run(options), "--key values are required with --no-input") +} + +func testAttachment(kind syncdomain.Kind) syncdomain.Attachment { + if kind == syncdomain.KindSkill { + return syncdomain.Attachment{Kind: syncdomain.AttachmentSkill, Version: 2, Skill: &syncdomain.Skill{ + Key: "summarize", Name: "Summarize", Description: "Summarize a ticket", Markdown: "Write three bullets.\n", + }} + } + description := "Search documentation" + return syncdomain.Attachment{Kind: syncdomain.AttachmentTool, Version: 4, Tool: &syncdomain.Tool{ + Key: "search", Description: &description, Schema: map[string]any{"type": "object"}, + }} +} + +// fakeAttachments is LaunchDarkly with the given tools and skills. +type fakeAttachments struct { + attachments []syncdomain.Attachment +} + +func (fake *fakeAttachments) ReadAttachment(_ string, kind syncdomain.AttachmentKind, key string) (syncdomain.Attachment, error) { + for _, attachment := range fake.attachments { + if attachment.Kind == kind && attachment.Key() == key { + return attachment, nil + } + } + return syncdomain.Attachment{}, errors.New(`{"code":"not_found","statusCode":404}`) +} + +func (fake *fakeAttachments) SearchAttachments( + string, syncdomain.AttachmentKind, string, int, int, +) (syncapi.Page[syncdomain.Attachment], error) { + return syncapi.Page[syncdomain.Attachment]{Items: fake.attachments, TotalCount: len(fake.attachments)}, nil +} diff --git a/internal/sync/bootstrap/bootstrap.go b/internal/sync/bootstrap/bootstrap.go index 15c766041..6c0063cf8 100644 --- a/internal/sync/bootstrap/bootstrap.go +++ b/internal/sync/bootstrap/bootstrap.go @@ -1,5 +1,5 @@ -// Package bootstrap adds LaunchDarkly variations to the local workspace. The -// first add creates the .launchdarkly directory. +// Package bootstrap adds LaunchDarkly variations, tools, and skills to the +// local workspace. The first add creates the .launchdarkly directory. package bootstrap import ( @@ -27,13 +27,17 @@ type Catalog interface { Config(projectKey, configKey string) (syncapi.Config, error) } -// AttachmentReader reads the tools and skills that a variation uses. +// AttachmentReader reads the tools and skills that a variation uses, and +// searches the tools or skills that the user adds. type AttachmentReader interface { + syncinteractive.AttachmentSearcher ReadAttachment(projectKey string, kind syncdomain.AttachmentKind, key string) (syncdomain.Attachment, error) } // Options are the dependencies and the input of one add. type Options struct { + // Kind is the kind of resource to add. The empty kind is a variation. + Kind syncdomain.Kind Catalog Catalog Attachments AttachmentReader Store synclocal.Store @@ -48,13 +52,18 @@ type Options struct { // ProjectKey and ConfigKey skip the project and the config questions. ProjectKey string ConfigKey string - // Selections are the variations to add. If it is empty, Run asks the user. + // Selections are the resources to add. If it is empty, Run asks the user. Selections []syncdomain.ResourceID NoInput bool } -// Run writes the files of the selected variations and records their baseline. +// Run writes the files of the selected resources and records their baseline. func Run(options Options) error { + switch options.Kind { + case syncdomain.KindTool, syncdomain.KindSkill: + return addAttachments(options) + } + var files []synclocal.VariationFile var err error if len(options.Selections) != 0 { @@ -287,7 +296,7 @@ func finishSelection(options Options, files []synclocal.VariationFile) error { return errors.Join(err, options.Store.RollbackCreation(creation)) } - writeSummary(options.Output, options.Initial, len(creation.VariationPaths)) + writeSummary(options.Output, options.Initial, len(creation.VariationPaths), "variation") return nil } @@ -339,13 +348,13 @@ func writeNoChangeSummary(output io.Writer, dryRun bool) { _ = syncconsole.New(output).Line(message) } -// writeSummary reports how many variation files the add created. -func writeSummary(output io.Writer, initial bool, count int) { +// writeSummary reports how many files of kind the add created. +func writeSummary(output io.Writer, initial bool, count int, kind string) { verb := "Added" if initial { verb = "Bootstrapped" } - noun := "variation file" + noun := kind + " file" if count != 1 { noun += "s" } diff --git a/internal/sync/bootstrap/bootstrap_test.go b/internal/sync/bootstrap/bootstrap_test.go index b0d4b5aaa..8851b5177 100644 --- a/internal/sync/bootstrap/bootstrap_test.go +++ b/internal/sync/bootstrap/bootstrap_test.go @@ -423,6 +423,7 @@ func TestWriteSummary(t *testing.T) { &output, true, 2, + "variation", ) assert.Equal( t, diff --git a/internal/sync/interactive/catalog.go b/internal/sync/interactive/catalog.go index d2076debc..373294c0d 100644 --- a/internal/sync/interactive/catalog.go +++ b/internal/sync/interactive/catalog.go @@ -18,6 +18,15 @@ type ConfigSearcher interface { SearchConfigs(projectKey, query string, modes []syncdomain.VariationMode, limit, offset int) (syncapi.Page[syncapi.Config], error) } +// AttachmentSearcher searches the tools or the skills in a project. +type AttachmentSearcher interface { + SearchAttachments( + projectKey string, kind syncdomain.AttachmentKind, query string, limit, offset int, + ) (syncapi.Page[syncdomain.Attachment], error) +} + +const attachmentPageSize = 25 + // SelectProject asks the user to choose a project. The bool result is true // when the user cancels. func SelectProject(input io.Reader, output io.Writer, catalog ProjectSearcher) (syncapi.Project, bool, error) { @@ -68,3 +77,45 @@ func SelectConfig( }, }) } + +// SelectAttachment asks the user to search for a tool or a skill and choose +// it. The bool result is true when the user cancels. +func SelectAttachment( + input io.Reader, + output io.Writer, + searcher AttachmentSearcher, + projectKey string, + kind syncdomain.AttachmentKind, +) (syncdomain.Attachment, bool, error) { + name := string(kind) + return SearchSelect(SearchOptions[syncdomain.Attachment]{ + Input: input, + Output: output, + SearchTitle: "Search " + name + "s", + SearchPlaceholder: "Name or key", + SelectTitle: "Choose a " + name, + ItemName: name + "s", + PageSize: attachmentPageSize, + Fetch: func(query string, limit, offset int) ([]syncdomain.Attachment, int, error) { + page, err := searcher.SearchAttachments(projectKey, kind, query, limit, offset) + return page.Items, page.TotalCount, err + }, + Choice: attachmentChoice, + }) +} + +// attachmentChoice shows the name of a skill, or the key of a tool, which has no name. +func attachmentChoice(attachment syncdomain.Attachment) Choice[syncdomain.Attachment] { + if attachment.Skill != nil { + return Choice[syncdomain.Attachment]{ + Title: attachment.Skill.Name, + Description: fmt.Sprintf("Key: %s · Latest version: %d", attachment.Key(), attachment.Version), + Value: attachment, + } + } + return Choice[syncdomain.Attachment]{ + Title: "Key: " + attachment.Key(), + Description: fmt.Sprintf("Latest version: %d", attachment.Version), + Value: attachment, + } +} diff --git a/internal/sync/local/attachment.go b/internal/sync/local/attachment.go index ea3b57f53..96ae98c03 100644 --- a/internal/sync/local/attachment.go +++ b/internal/sync/local/attachment.go @@ -204,6 +204,16 @@ func renderAttachmentFiles(files []VariationFile) ([]RenderedFile, error) { return rendered, nil } +// RenderAttachment renders the file of one tool or skill without writing it. +func RenderAttachment(attachment syncdomain.SyncedAttachment) (RenderedFile, error) { + filePath, err := attachmentPath(attachment.ProjectKey, attachment.Attachment.Kind, attachment.Attachment.Key()) + if err != nil { + return RenderedFile{}, err + } + content, err := renderAttachment(attachment.Attachment) + return RenderedFile{Path: filePath, Content: content}, err +} + func renderAttachment(attachment syncdomain.Attachment) ([]byte, error) { switch { case attachment.Kind == syncdomain.AttachmentTool && attachment.Tool != nil: diff --git a/internal/sync/local/layout.go b/internal/sync/local/layout.go index 29506dc6b..b5e7ed5eb 100644 --- a/internal/sync/local/layout.go +++ b/internal/sync/local/layout.go @@ -62,6 +62,16 @@ func ParseManagedPath(file string) (syncdomain.ResourceID, bool) { return id, true } +// AttachmentPath returns the path of a tool or skill file relative to the +// repository root, in the form that ParseManagedPath reads. +func AttachmentPath(id syncdomain.ResourceID) (string, error) { + relativePath, err := attachmentPath(id.ProjectKey, syncdomain.AttachmentKind(id.Kind), id.LookupKey) + if err != nil { + return "", err + } + return path.Join(syncdomain.RootDir, relativePath), nil +} + // variationPath returns the path of a variation file relative to the managed // directory. It makes sure that each key is a safe path segment. func variationPath(projectKey, configKey, variationKey string) (string, error) { diff --git a/internal/sync/local/replace.go b/internal/sync/local/replace.go index b10f19105..1556d1b8e 100644 --- a/internal/sync/local/replace.go +++ b/internal/sync/local/replace.go @@ -57,15 +57,11 @@ func (store Store) ReplaceVariations(replacements []VariationReplacement) ([]str func (store Store) ReplaceAttachments(attachments []syncdomain.SyncedAttachment) ([]string, error) { rendered := make([]RenderedFile, 0, len(attachments)) for _, attachment := range attachments { - filePath, err := attachmentPath(attachment.ProjectKey, attachment.Attachment.Kind, attachment.Attachment.Key()) + file, err := RenderAttachment(attachment) if err != nil { return nil, err } - content, err := renderAttachment(attachment.Attachment) - if err != nil { - return nil, err - } - rendered = append(rendered, RenderedFile{Path: filePath, Content: content}) + rendered = append(rendered, file) } files, err := store.prepareAttachmentFiles(rendered) if err != nil { diff --git a/internal/sync/local/store.go b/internal/sync/local/store.go index 8b5182172..2cfa10492 100644 --- a/internal/sync/local/store.go +++ b/internal/sync/local/store.go @@ -83,14 +83,29 @@ func (store Store) VariationExists(projectKey, configKey, variationKey string) ( if err != nil { return false, err } - _, err = os.Stat(store.absolute(relativePath)) + return store.fileExists(relativePath, "variation "+variationKey) +} + +// AttachmentExists reports whether the file of a tool or a skill exists. +func (store Store) AttachmentExists(id syncdomain.ResourceID) (bool, error) { + relativePath, err := attachmentPath(id.ProjectKey, syncdomain.AttachmentKind(id.Kind), id.LookupKey) + if err != nil { + return false, err + } + return store.fileExists(relativePath, id.Label()) +} + +// fileExists reports whether a managed file exists. label names the file in +// an error. +func (store Store) fileExists(relativePath, label string) (bool, error) { + _, err := os.Stat(store.absolute(relativePath)) switch { case err == nil: return true, nil case errors.Is(err, os.ErrNotExist): return false, nil default: - return false, fmt.Errorf("inspect variation %s: %w", variationKey, err) + return false, fmt.Errorf("inspect %s: %w", label, err) } } diff --git a/internal/sync/prompt/attach.go b/internal/sync/prompt/attach.go index 443b55726..491183e62 100644 --- a/internal/sync/prompt/attach.go +++ b/internal/sync/prompt/attach.go @@ -12,8 +12,6 @@ import ( synclocal "github.com/launchdarkly/ldcli/internal/sync/local" ) -const attachmentSearchPageSize = 25 - var errAttachmentCanceled = errors.New("attachment selection canceled") // attachOptions are the input of one attach. An empty field means that the @@ -177,20 +175,7 @@ func selectAttachment(client syncapi.Client, projectKey string, options attachOp return syncdomain.Attachment{}, fmt.Errorf("%s key is required without interactive input", options.Kind) } - kind := string(options.Kind) - attachment, canceled, err := syncinteractive.SearchSelect(syncinteractive.SearchOptions[syncdomain.Attachment]{ - Input: options.Input, - Output: options.Output, - SearchTitle: "Search " + kind + "s", - SearchPlaceholder: "Name or key", - SelectTitle: "Choose a " + kind, - ItemName: kind + "s", - PageSize: attachmentSearchPageSize, - Fetch: func(query string, limit, offset int) ([]syncdomain.Attachment, int, error) { - return searchAttachmentPage(client, projectKey, options.Kind, query, limit, offset) - }, - Choice: attachmentChoice, - }) + attachment, canceled, err := syncinteractive.SelectAttachment(options.Input, options.Output, client, projectKey, options.Kind) if err != nil { return syncdomain.Attachment{}, err } @@ -199,30 +184,3 @@ func selectAttachment(client syncapi.Client, projectKey string, options attachOp } return client.ReadAttachment(projectKey, options.Kind, attachment.Key()) } - -func searchAttachmentPage( - client syncapi.Client, - projectKey string, - kind syncdomain.AttachmentKind, - query string, - limit, offset int, -) ([]syncdomain.Attachment, int, error) { - page, err := client.SearchAttachments(projectKey, kind, query, limit, offset) - return page.Items, page.TotalCount, err -} - -// attachmentChoice shows the name of a skill, or the key of a tool, which has no name. -func attachmentChoice(attachment syncdomain.Attachment) syncinteractive.Choice[syncdomain.Attachment] { - if attachment.Skill != nil { - return syncinteractive.Choice[syncdomain.Attachment]{ - Title: attachment.Skill.Name, - Description: fmt.Sprintf("Key: %s · Latest version: %d", attachment.Key(), attachment.Version), - Value: attachment, - } - } - return syncinteractive.Choice[syncdomain.Attachment]{ - Title: "Key: " + attachment.Key(), - Description: fmt.Sprintf("Latest version: %d", attachment.Version), - Value: attachment, - } -} diff --git a/internal/sync/prompt/runner_test.go b/internal/sync/prompt/runner_test.go index 0179931cf..f5adec066 100644 --- a/internal/sync/prompt/runner_test.go +++ b/internal/sync/prompt/runner_test.go @@ -312,23 +312,23 @@ func TestAttachToVariationPreservesStandaloneAttachmentEdits(t *testing.T) { func TestEmptyAttachmentSearchReturnsNoResults(t *testing.T) { client := syncapi.NewClient(emptyAttachmentClient{}, "token", "https://example.com") - attachments, totalCount, err := searchAttachmentPage(client, "project", syncdomain.AttachmentTool, "missing", 25, 0) + page, err := client.SearchAttachments("project", syncdomain.AttachmentTool, "missing", 25, 0) require.NoError(t, err) - assert.Empty(t, attachments) - assert.Zero(t, totalCount) + assert.Empty(t, page.Items) + assert.Zero(t, page.TotalCount) } func TestToolSearchPageReturnsTrueLatestVersion(t *testing.T) { transport := &toolSearchClient{} client := syncapi.NewClient(transport, "token", "https://example.com") - attachments, totalCount, err := searchAttachmentPage(client, "project", syncdomain.AttachmentTool, "old description", 25, 0) + page, err := client.SearchAttachments("project", syncdomain.AttachmentTool, "old description", 25, 0) require.NoError(t, err) - require.Len(t, attachments, 1) - assert.Equal(t, 3, attachments[0].Version) - assert.Equal(t, 1, totalCount) + require.Len(t, page.Items, 1) + assert.Equal(t, 3, page.Items[0].Version) + assert.Equal(t, 1, page.TotalCount) assert.Equal(t, 2, transport.requests) }