From 9f7be8c24c95d8aabe3a3c953bbaea5365e779c9 Mon Sep 17 00:00:00 2001 From: Ben Kalmus Date: Thu, 22 Oct 2026 16:35:56 +0100 Subject: [PATCH 1/4] feat(cmdutil): complete ids for valid args Co-Authored-By: Claude Sonnet 5.5 --- pkg/cmdutil/valid_args.go | 64 ++++++++++++++++++++++++++++++++++ pkg/cmdutil/valid_args_test.go | 61 ++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 pkg/cmdutil/valid_args_test.go diff --git a/pkg/cmdutil/valid_args.go b/pkg/cmdutil/valid_args.go index a7e809b7..04063d90 100644 --- a/pkg/cmdutil/valid_args.go +++ b/pkg/cmdutil/valid_args.go @@ -3,6 +3,7 @@ package cmdutil import ( "fmt" + "github.com/algolia/algoliasearch-client-go/v4/algolia/composition" "github.com/algolia/algoliasearch-client-go/v4/algolia/search" "github.com/algolia/cli/api/crawler" "github.com/spf13/cobra" @@ -30,6 +31,69 @@ func IndexNames( } } +// CompositionIDs returns a function to complete composition object IDs from the given composition client. +// It completes only the first positional argument (the composition id). +func CompositionIDs( + clientF func() (*composition.APIClient, error), +) func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + client, err := clientF() + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + res, err := client.ListCompositions(client.NewApiListCompositionsRequest()) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + ids := make([]string, 0, len(res.Items)) + for _, comp := range res.Items { + ids = append(ids, comp.ObjectID) + } + return ids, cobra.ShellCompDirectiveNoFileComp + } +} + +// CompositionAndRuleIDs completes the composition id on the first argument and, +// once that is set, the rule ids of that composition on the second. +func CompositionAndRuleIDs( + clientF func() (*composition.APIClient, error), +) func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + client, err := clientF() + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + switch len(args) { + case 0: + res, err := client.ListCompositions(client.NewApiListCompositionsRequest()) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + ids := make([]string, 0, len(res.Items)) + for _, comp := range res.Items { + ids = append(ids, comp.ObjectID) + } + return ids, cobra.ShellCompDirectiveNoFileComp + case 1: + res, err := client.SearchCompositionRules(client.NewApiSearchCompositionRulesRequest(args[0])) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + ids := make([]string, 0, len(res.Hits)) + for _, hit := range res.Hits { + ids = append(ids, hit.ObjectID) + } + return ids, cobra.ShellCompDirectiveNoFileComp + default: + return nil, cobra.ShellCompDirectiveNoFileComp + } + } +} + // CrawlerIDs returns a function to list the crawler IDs from the given crawler client. func CrawlerIDs( clientF func() (*crawler.Client, error), diff --git a/pkg/cmdutil/valid_args_test.go b/pkg/cmdutil/valid_args_test.go new file mode 100644 index 00000000..a4a59d1c --- /dev/null +++ b/pkg/cmdutil/valid_args_test.go @@ -0,0 +1,61 @@ +package cmdutil + +import ( + "testing" + + "github.com/algolia/algoliasearch-client-go/v4/algolia/composition" + "github.com/algolia/algoliasearch-client-go/v4/algolia/transport" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/algolia/cli/pkg/httpmock" +) + +func TestCompositionIDs(t *testing.T) { + r := &httpmock.Registry{} + r.Register( + httpmock.REST("GET", "1/compositions"), + httpmock.StringResponse(`{"items":[{"objectID":"c1"},{"objectID":"c2"}],"nbPages":1}`), + ) + + clientF := func() (*composition.APIClient, error) { + return composition.NewClientWithConfig(composition.CompositionConfiguration{ + Configuration: transport.Configuration{AppID: "default", ApiKey: "default", Requester: r}, + }) + } + + complete := CompositionIDs(clientF) + + ids, directive := complete(nil, nil, "") + assert.Equal(t, []string{"c1", "c2"}, ids) + assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + + // only the first positional argument is completed. + ids, _ = complete(nil, []string{"c1"}, "") + require.Nil(t, ids) +} + +func TestCompositionAndRuleIDs(t *testing.T) { + r := &httpmock.Registry{} + r.Register(httpmock.REST("GET", "1/compositions"), + httpmock.StringResponse(`{"items":[{"objectID":"c1"},{"objectID":"c2"}],"nbPages":1}`)) + r.Register(httpmock.REST("POST", "1/compositions/c1/rules/search"), + httpmock.StringResponse(`{"hits":[{"objectID":"r1"},{"objectID":"r2"}],"nbHits":2,"page":0,"nbPages":1}`)) + + clientF := func() (*composition.APIClient, error) { + return composition.NewClientWithConfig(composition.CompositionConfiguration{ + Configuration: transport.Configuration{AppID: "default", ApiKey: "default", Requester: r}, + }) + } + + complete := CompositionAndRuleIDs(clientF) + + // first arg completes composition ids. + ids, _ := complete(nil, nil, "") + assert.Equal(t, []string{"c1", "c2"}, ids) + + // second arg completes the chosen composition's rule ids. + ids, _ = complete(nil, []string{"c1"}, "") + assert.Equal(t, []string{"r1", "r2"}, ids) +} From 3ba724b923358d336aea6b5d8923204f8691bf5c Mon Sep 17 00:00:00 2001 From: Ben Kalmus Date: Thu, 22 Oct 2026 16:35:56 +0100 Subject: [PATCH 2/4] feat(compositions): add shared select and suggest helpers Co-Authored-By: Claude Sonnet 5.5 --- pkg/cmd/compositions/internal/select.go | 95 +++++++++ pkg/cmd/compositions/internal/select_test.go | 36 ++++ pkg/cmd/compositions/internal/suggest.go | 188 ++++++++++++++++++ pkg/cmd/compositions/internal/suggest_test.go | 99 +++++++++ 4 files changed, 418 insertions(+) create mode 100644 pkg/cmd/compositions/internal/select.go create mode 100644 pkg/cmd/compositions/internal/select_test.go create mode 100644 pkg/cmd/compositions/internal/suggest.go create mode 100644 pkg/cmd/compositions/internal/suggest_test.go diff --git a/pkg/cmd/compositions/internal/select.go b/pkg/cmd/compositions/internal/select.go new file mode 100644 index 00000000..6e6cb709 --- /dev/null +++ b/pkg/cmd/compositions/internal/select.go @@ -0,0 +1,95 @@ +package internal + +import ( + "errors" + + "github.com/algolia/algoliasearch-client-go/v4/algolia/composition" + + "github.com/algolia/cli/pkg/cmdutil" + "github.com/algolia/cli/pkg/interactive" +) + +// ResolveCompositionID returns the id from args, or prompts a selector over +// existing compositions when the argument is omitted on a terminal. +func ResolveCompositionID( + args []string, + canPrompt bool, + prompter interactive.Prompter, + clientF func() (*composition.APIClient, error), +) (string, error) { + if len(args) > 0 { + return args[0], nil + } + if !canPrompt { + return "", cmdutil.FlagErrorf("a argument is required") + } + return SelectCompositionID(prompter, clientF) +} + +// ResolveCompositionAndRuleID resolves a composition id and a rule id from args, +// prompting selectors for whichever is omitted on a terminal. The rule list is +// scoped to the resolved composition. +func ResolveCompositionAndRuleID( + args []string, + canPrompt bool, + prompter interactive.Prompter, + clientF func() (*composition.APIClient, error), +) (string, string, error) { + compositionID, err := ResolveCompositionID(args, canPrompt, prompter, clientF) + if err != nil { + return "", "", err + } + if len(args) >= 2 { + return compositionID, args[1], nil + } + if !canPrompt { + return "", "", cmdutil.FlagErrorf("a argument is required") + } + ruleID, err := SelectRuleID(prompter, clientF, compositionID) + if err != nil { + return "", "", err + } + return compositionID, ruleID, nil +} + +// SelectRuleID lists the rules of a composition and prompts the user to pick one. +func SelectRuleID(prompter interactive.Prompter, clientF func() (*composition.APIClient, error), compositionID string) (string, error) { + client, err := clientF() + if err != nil { + return "", err + } + res, err := client.SearchCompositionRules(client.NewApiSearchCompositionRulesRequest(compositionID)) + if err != nil { + return "", err + } + if len(res.Hits) == 0 { + return "", errors.New("no rules found for composition " + compositionID) + } + ids := make([]string, len(res.Hits)) + for i, hit := range res.Hits { + ids[i] = hit.ObjectID + } + return prompter.Select("Rule", ids) +} + +// SelectCompositionID lists existing compositions and prompts the user to pick +// one, returning its object id. Used when a command is called without the +// composition-id argument on a terminal. +func SelectCompositionID(prompter interactive.Prompter, clientF func() (*composition.APIClient, error)) (string, error) { + client, err := clientF() + if err != nil { + return "", err + } + res, err := client.ListCompositions(client.NewApiListCompositionsRequest()) + if err != nil { + return "", err + } + if len(res.Items) == 0 { + return "", errors.New("no compositions found") + } + ids := make([]string, len(res.Items)) + for i, comp := range res.Items { + ids[i] = comp.ObjectID + } + return prompter.Select("Composition", ids) +} diff --git a/pkg/cmd/compositions/internal/select_test.go b/pkg/cmd/compositions/internal/select_test.go new file mode 100644 index 00000000..3072a2f4 --- /dev/null +++ b/pkg/cmd/compositions/internal/select_test.go @@ -0,0 +1,36 @@ +package internal_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + compinternal "github.com/algolia/cli/pkg/cmd/compositions/internal" + "github.com/algolia/cli/pkg/httpmock" + "github.com/algolia/cli/pkg/interactive" +) + +func TestResolveCompositionID_FromArg(t *testing.T) { + // an explicit arg is returned as-is, no client or prompt needed. + id, err := compinternal.ResolveCompositionID([]string{"c1"}, true, nil, nil) + require.NoError(t, err) + assert.Equal(t, "c1", id) +} + +func TestResolveCompositionID_NoArgNoTTY(t *testing.T) { + _, err := compinternal.ResolveCompositionID(nil, false, nil, nil) + assert.Error(t, err) +} + +func TestResolveCompositionID_Selector(t *testing.T) { + r := &httpmock.Registry{} + r.Register(httpmock.REST("GET", "1/compositions"), + httpmock.StringResponse(`{"items":[{"objectID":"alpha"},{"objectID":"beta"}],"nbPages":1}`)) + + prompter := &interactive.ScriptedPrompter{Selects: map[string]string{"Composition": "beta"}} + + id, err := compinternal.ResolveCompositionID(nil, true, prompter, compositionClientFor(r)) + require.NoError(t, err) + assert.Equal(t, "beta", id) +} diff --git a/pkg/cmd/compositions/internal/suggest.go b/pkg/cmd/compositions/internal/suggest.go new file mode 100644 index 00000000..2fd55576 --- /dev/null +++ b/pkg/cmd/compositions/internal/suggest.go @@ -0,0 +1,188 @@ +package internal + +import ( + "strings" + "sync" + + "github.com/algolia/algoliasearch-client-go/v4/algolia/composition" + "github.com/algolia/algoliasearch-client-go/v4/algolia/search" + + "github.com/algolia/cli/pkg/interactive" +) + +// indexFields hold an index name as their value (sortingStrategy maps sort +// labels to index names). +var indexFields = map[string]bool{"index": true, "indexName": true, "sortingStrategy": true} + +// attributeFields hold a single attribute name per entry, completed from the +// in-scope index's settings. +var attributeFields = map[string]bool{ + "attributesToRetrieve": true, + "restrictSearchableAttributes": true, + "attributesToHighlight": true, + "attributesToSnippet": true, + "disableExactOnAttributes": true, + "disableTypoToleranceOnAttributes": true, + "facets": true, +} + +// SuggestDeps carries what the composition suggester needs to reach live data. +// Any nil client disables the suggestions that depend on it. +type SuggestDeps struct { + Search func() (*search.APIClient, error) + Composition func() (*composition.APIClient, error) + CompositionID string +} + +// CompositionSuggester completes composition text fields from live data: +// - index fields with the application's index names, +// - attribute fields with the in-scope index's attributes (scope["index"]), +// - filters with "attribute:" templates from the in-scope index, +// - sortBy with the composition's sorting-strategy labels, +// - the injected item key with a static format hint. +// +// Results are cached for the session and any error yields no suggestions. +func CompositionSuggester(deps SuggestDeps) interactive.SuggestFunc { + indices := onceIndices(deps.Search) + attributes := indexAttributes(deps.Search) + sortLabels := onceSortLabels(deps.Composition, deps.CompositionID) + + return func(_, jsonName string, scope map[string]string) []string { + switch { + case jsonName == "key": + return []string{"my-injected-group-1"} + case jsonName == "sortBy": + return sortLabels() + case jsonName == "filters": + return filterTemplates(attributes, scopeIndex(scope)) + case indexFields[jsonName]: + return indices() + case attributeFields[jsonName]: + return attributes(scopeIndex(scope)) + } + return nil + } +} + +func scopeIndex(scope map[string]string) string { + if idx := scope["index"]; idx != "" { + return idx + } + return scope["indexName"] +} + +// filterTemplates turns an index's attributes into "attribute:" starting points +// so a filter expression can be built from a real attribute. +func filterTemplates(attributes func(string) []string, index string) []string { + if index == "" { + return nil + } + attrs := attributes(index) + out := make([]string, 0, len(attrs)) + for _, a := range attrs { + out = append(out, a+":") + } + return out +} + +func onceIndices(searchClient func() (*search.APIClient, error)) func() []string { + if searchClient == nil { + return func() []string { return nil } + } + load := sync.OnceValues(func() ([]string, error) { + client, err := searchClient() + if err != nil { + return nil, err + } + res, err := client.ListIndices(client.NewApiListIndicesRequest()) + if err != nil { + return nil, err + } + names := make([]string, 0, len(res.Items)) + for _, idx := range res.Items { + names = append(names, idx.Name) + } + return names, nil + }) + return func() []string { names, _ := load(); return names } +} + +func indexAttributes(searchClient func() (*search.APIClient, error)) func(string) []string { + if searchClient == nil { + return func(string) []string { return nil } + } + var mu sync.Mutex + cache := map[string][]string{} + return func(index string) []string { + if index == "" { + return nil + } + mu.Lock() + defer mu.Unlock() + if cached, ok := cache[index]; ok { + return cached + } + attrs := fetchAttributes(searchClient, index) + cache[index] = attrs + return attrs + } +} + +func onceSortLabels(compositionClient func() (*composition.APIClient, error), compositionID string) func() []string { + if compositionClient == nil || compositionID == "" { + return func() []string { return nil } + } + load := sync.OnceValues(func() ([]string, error) { + client, err := compositionClient() + if err != nil { + return nil, err + } + comp, err := client.GetComposition(client.NewApiGetCompositionRequest(compositionID)) + if err != nil || comp.SortingStrategy == nil { + return nil, err + } + labels := make([]string, 0, len(*comp.SortingStrategy)) + for label := range *comp.SortingStrategy { + labels = append(labels, label) + } + return labels, nil + }) + return func() []string { labels, _ := load(); return labels } +} + +// fetchAttributes returns an index's searchable and facet attribute names, with +// any modifiers (like unordered(name)) stripped. Returns nil on any error. +func fetchAttributes(searchClient func() (*search.APIClient, error), index string) []string { + client, err := searchClient() + if err != nil { + return nil + } + settings, err := client.GetSettings(client.NewApiGetSettingsRequest(index)) + if err != nil { + return nil + } + + seen := map[string]bool{} + var attrs []string + for _, list := range [][]string{settings.SearchableAttributes, settings.AttributesForFaceting} { + for _, a := range list { + name := bareAttribute(a) + if name != "" && !seen[name] { + seen[name] = true + attrs = append(attrs, name) + } + } + } + return attrs +} + +// bareAttribute strips Algolia attribute modifiers, so "unordered(name)" and +// "filterOnly(brand)" become "name" and "brand". +func bareAttribute(a string) string { + if open := strings.IndexByte(a, '('); open != -1 { + if close := strings.IndexByte(a[open:], ')'); close != -1 { + return strings.TrimSpace(a[open+1 : open+close]) + } + } + return strings.TrimSpace(a) +} diff --git a/pkg/cmd/compositions/internal/suggest_test.go b/pkg/cmd/compositions/internal/suggest_test.go new file mode 100644 index 00000000..b87b4fa3 --- /dev/null +++ b/pkg/cmd/compositions/internal/suggest_test.go @@ -0,0 +1,99 @@ +package internal_test + +import ( + "testing" + + "github.com/algolia/algoliasearch-client-go/v4/algolia/composition" + "github.com/algolia/algoliasearch-client-go/v4/algolia/search" + "github.com/algolia/algoliasearch-client-go/v4/algolia/transport" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + compinternal "github.com/algolia/cli/pkg/cmd/compositions/internal" + "github.com/algolia/cli/pkg/httpmock" +) + +func searchClientFor(r *httpmock.Registry) func() (*search.APIClient, error) { + return func() (*search.APIClient, error) { + return search.NewClientWithConfig(search.SearchConfiguration{ + Configuration: transport.Configuration{AppID: "default", ApiKey: "default", Requester: r}, + }) + } +} + +func compositionClientFor(r *httpmock.Registry) func() (*composition.APIClient, error) { + return func() (*composition.APIClient, error) { + return composition.NewClientWithConfig(composition.CompositionConfiguration{ + Configuration: transport.Configuration{AppID: "default", ApiKey: "default", Requester: r}, + }) + } +} + +func TestCompositionSuggester_Indices(t *testing.T) { + r := &httpmock.Registry{} + r.Register(httpmock.REST("GET", "1/indexes"), + httpmock.JSONResponse(search.ListIndicesResponse{Items: []search.FetchedIndex{{Name: "alpha"}, {Name: "beta"}}})) + + suggest := compinternal.CompositionSuggester(compinternal.SuggestDeps{Search: searchClientFor(r)}) + require.NotNil(t, suggest) + + assert.Equal(t, []string{"alpha", "beta"}, suggest("Composition.index", "index", nil)) + assert.Equal(t, []string{"alpha", "beta"}, suggest("MainRecommend.indexName", "indexName", nil)) + assert.Equal(t, []string{"alpha", "beta"}, suggest("Composition.sortingStrategy", "sortingStrategy", nil)) + assert.Nil(t, suggest("Composition.name", "name", nil)) +} + +func TestCompositionSuggester_Attributes(t *testing.T) { + r := &httpmock.Registry{} + r.Register(httpmock.REST("GET", "1/indexes/alpha/settings"), + httpmock.JSONResponse(search.SettingsResponse{ + SearchableAttributes: []string{"title", "unordered(brand)"}, + AttributesForFaceting: []string{"filterOnly(category)"}, + })) + + suggest := compinternal.CompositionSuggester(compinternal.SuggestDeps{Search: searchClientFor(r)}) + + got := suggest("params.attributesToRetrieve", "attributesToRetrieve", map[string]string{"index": "alpha"}) + assert.Equal(t, []string{"title", "brand", "category"}, got) + assert.Nil(t, suggest("params.attributesToRetrieve", "attributesToRetrieve", map[string]string{})) +} + +func TestCompositionSuggester_Filters(t *testing.T) { + r := &httpmock.Registry{} + r.Register(httpmock.REST("GET", "1/indexes/alpha/settings"), + httpmock.JSONResponse(search.SettingsResponse{ + SearchableAttributes: []string{"title"}, + AttributesForFaceting: []string{"brand"}, + })) + + suggest := compinternal.CompositionSuggester(compinternal.SuggestDeps{Search: searchClientFor(r)}) + + // filters offer "attribute:" templates from the in-scope index. + got := suggest("params.filters", "filters", map[string]string{"index": "alpha"}) + assert.Equal(t, []string{"title:", "brand:"}, got) + assert.Nil(t, suggest("params.filters", "filters", nil)) +} + +func TestCompositionSuggester_SortBy(t *testing.T) { + r := &httpmock.Registry{} + r.Register(httpmock.REST("GET", "1/compositions/my-comp"), + httpmock.StringResponse(`{"objectID":"my-comp","sortingStrategy":{"Price (asc)":"idx_asc"}}`)) + + suggest := compinternal.CompositionSuggester(compinternal.SuggestDeps{ + Composition: compositionClientFor(r), + CompositionID: "my-comp", + }) + + assert.Equal(t, []string{"Price (asc)"}, suggest("params.sortBy", "sortBy", nil)) +} + +func TestCompositionSuggester_InjectedItemKey(t *testing.T) { + suggest := compinternal.CompositionSuggester(compinternal.SuggestDeps{}) + assert.Equal(t, []string{"my-injected-group-1"}, suggest("injectedItems[0].key", "key", nil)) +} + +func TestCompositionSuggester_EmptyDeps(t *testing.T) { + suggest := compinternal.CompositionSuggester(compinternal.SuggestDeps{}) + require.NotNil(t, suggest) + assert.Nil(t, suggest("Composition.index", "index", nil)) +} From 1646b61139569b827fa1bd3ee65d02cc53075090 Mon Sep 17 00:00:00 2001 From: Ben Kalmus Date: Thu, 22 Oct 2026 16:35:56 +0100 Subject: [PATCH 3/4] feat(compositions): use id selectors and insert shape in rules commands Co-Authored-By: Claude Sonnet 5.5 --- pkg/cmd/compositions/rules/delete/delete.go | 19 ++-- .../compositions/rules/delete/delete_test.go | 2 +- pkg/cmd/compositions/rules/get/get.go | 23 ++-- pkg/cmd/compositions/rules/get/get_test.go | 2 +- pkg/cmd/compositions/rules/list/list.go | 18 +++- pkg/cmd/compositions/rules/list/list_test.go | 2 +- pkg/cmd/compositions/rules/rules.go | 3 +- pkg/cmd/compositions/rules/upsert/upsert.go | 101 +++++++++++++----- .../compositions/rules/upsert/upsert_test.go | 16 +-- 9 files changed, 132 insertions(+), 54 deletions(-) diff --git a/pkg/cmd/compositions/rules/delete/delete.go b/pkg/cmd/compositions/rules/delete/delete.go index 6940c325..32271f02 100644 --- a/pkg/cmd/compositions/rules/delete/delete.go +++ b/pkg/cmd/compositions/rules/delete/delete.go @@ -10,9 +10,9 @@ import ( compinternal "github.com/algolia/cli/pkg/cmd/compositions/internal" "github.com/algolia/cli/pkg/cmdutil" "github.com/algolia/cli/pkg/config" + "github.com/algolia/cli/pkg/interactive" "github.com/algolia/cli/pkg/iostreams" "github.com/algolia/cli/pkg/prompt" - "github.com/algolia/cli/pkg/validators" ) // DeleteOptions holds dependencies and flags for the rules delete command. @@ -20,6 +20,7 @@ type DeleteOptions struct { Config config.IConfig IO *iostreams.IOStreams CompositionClient func() (*algoliaComposition.APIClient, error) + Prompter interactive.Prompter CompositionID string ObjectID string DoConfirm bool @@ -32,13 +33,15 @@ func NewDeleteCmd(f *cmdutil.Factory) *cobra.Command { IO: f.IOStreams, Config: f.Config, CompositionClient: f.CompositionClient, + Prompter: f.Prompter, PrintFlags: cmdutil.NewPrintFlags().WithDefaultOutput("json"), } cmd := &cobra.Command{ - Use: "delete ", - Short: "Delete a composition rule", - Args: validators.ExactArgsWithMsg(2, "compositions rules delete requires a and a argument."), + Use: "delete [composition-id] [rule-id]", + Short: "Delete a composition rule", + Args: cobra.MaximumNArgs(2), + ValidArgsFunction: cmdutil.CompositionAndRuleIDs(opts.CompositionClient), Annotations: map[string]string{ "acls": "editSettings", }, @@ -50,8 +53,12 @@ func NewDeleteCmd(f *cmdutil.Factory) *cobra.Command { $ algolia compositions rules delete my-comp rule-1 --confirm `), RunE: func(cmd *cobra.Command, args []string) error { - opts.CompositionID = args[0] - opts.ObjectID = args[1] + compositionID, ruleID, err := compinternal.ResolveCompositionAndRuleID(args, opts.IO.CanPrompt(), opts.Prompter, opts.CompositionClient) + if err != nil { + return err + } + opts.CompositionID = compositionID + opts.ObjectID = ruleID if !opts.DoConfirm { if !opts.IO.CanPrompt() { diff --git a/pkg/cmd/compositions/rules/delete/delete_test.go b/pkg/cmd/compositions/rules/delete/delete_test.go index 2629bc71..423888c8 100644 --- a/pkg/cmd/compositions/rules/delete/delete_test.go +++ b/pkg/cmd/compositions/rules/delete/delete_test.go @@ -84,5 +84,5 @@ func TestDeleteRule_MissingArgs(t *testing.T) { cmd := delete.NewDeleteCmd(f) _, err := test.Execute(cmd, "my-comp", out) require.Error(t, err) - assert.Contains(t, err.Error(), "requires a and a argument") + assert.Contains(t, err.Error(), "argument is required") } diff --git a/pkg/cmd/compositions/rules/get/get.go b/pkg/cmd/compositions/rules/get/get.go index 6294de90..438f0d49 100644 --- a/pkg/cmd/compositions/rules/get/get.go +++ b/pkg/cmd/compositions/rules/get/get.go @@ -5,10 +5,11 @@ import ( algoliaComposition "github.com/algolia/algoliasearch-client-go/v4/algolia/composition" "github.com/spf13/cobra" + compinternal "github.com/algolia/cli/pkg/cmd/compositions/internal" "github.com/algolia/cli/pkg/cmdutil" "github.com/algolia/cli/pkg/config" + "github.com/algolia/cli/pkg/interactive" "github.com/algolia/cli/pkg/iostreams" - "github.com/algolia/cli/pkg/validators" ) // GetOptions holds dependencies and flags for the rules get command. @@ -16,6 +17,7 @@ type GetOptions struct { Config config.IConfig IO *iostreams.IOStreams CompositionClient func() (*algoliaComposition.APIClient, error) + Prompter interactive.Prompter CompositionID string ObjectID string PrintFlags *cmdutil.PrintFlags @@ -27,23 +29,32 @@ func NewGetCmd(f *cmdutil.Factory) *cobra.Command { IO: f.IOStreams, Config: f.Config, CompositionClient: f.CompositionClient, + Prompter: f.Prompter, PrintFlags: cmdutil.NewPrintFlags().WithDefaultOutput("json"), } cmd := &cobra.Command{ - Use: "get ", - Short: "Get a composition rule by ID", - Args: validators.ExactArgsWithMsg(2, "compositions rules get requires a and a argument."), + Use: "get [composition-id] [rule-id]", + Short: "Get a composition rule by ID", + Args: cobra.MaximumNArgs(2), + ValidArgsFunction: cmdutil.CompositionAndRuleIDs(opts.CompositionClient), Annotations: map[string]string{ "acls": "search", }, Example: heredoc.Doc(` # Get rule "rule-1" for composition "my-comp" $ algolia compositions rules get my-comp rule-1 + + # Pick a composition and rule from lists + $ algolia compositions rules get `), RunE: func(cmd *cobra.Command, args []string) error { - opts.CompositionID = args[0] - opts.ObjectID = args[1] + compositionID, ruleID, err := compinternal.ResolveCompositionAndRuleID(args, opts.IO.CanPrompt(), opts.Prompter, opts.CompositionClient) + if err != nil { + return err + } + opts.CompositionID = compositionID + opts.ObjectID = ruleID return runGetCmd(opts) }, } diff --git a/pkg/cmd/compositions/rules/get/get_test.go b/pkg/cmd/compositions/rules/get/get_test.go index e5aaf9ce..b0eda77a 100644 --- a/pkg/cmd/compositions/rules/get/get_test.go +++ b/pkg/cmd/compositions/rules/get/get_test.go @@ -52,5 +52,5 @@ func TestGetRule_MissingArgs(t *testing.T) { cmd := get.NewGetCmd(f) _, err := test.Execute(cmd, "my-comp", out) require.Error(t, err) - assert.Contains(t, err.Error(), "requires a and a argument") + assert.Contains(t, err.Error(), "argument is required") } diff --git a/pkg/cmd/compositions/rules/list/list.go b/pkg/cmd/compositions/rules/list/list.go index b4c20a01..089c6f58 100644 --- a/pkg/cmd/compositions/rules/list/list.go +++ b/pkg/cmd/compositions/rules/list/list.go @@ -5,10 +5,11 @@ import ( algoliaComposition "github.com/algolia/algoliasearch-client-go/v4/algolia/composition" "github.com/spf13/cobra" + compinternal "github.com/algolia/cli/pkg/cmd/compositions/internal" "github.com/algolia/cli/pkg/cmdutil" "github.com/algolia/cli/pkg/config" + "github.com/algolia/cli/pkg/interactive" "github.com/algolia/cli/pkg/iostreams" - "github.com/algolia/cli/pkg/validators" ) // ListOptions holds dependencies and flags for the rules list command. @@ -16,6 +17,7 @@ type ListOptions struct { Config config.IConfig IO *iostreams.IOStreams CompositionClient func() (*algoliaComposition.APIClient, error) + Prompter interactive.Prompter CompositionID string PrintFlags *cmdutil.PrintFlags } @@ -26,13 +28,15 @@ func NewListCmd(f *cmdutil.Factory) *cobra.Command { IO: f.IOStreams, Config: f.Config, CompositionClient: f.CompositionClient, + Prompter: f.Prompter, PrintFlags: cmdutil.NewPrintFlags().WithDefaultOutput("json"), } cmd := &cobra.Command{ - Use: "list ", - Short: "List rules for a composition", - Args: validators.ExactArgsWithMsg(1, "compositions rules list requires a argument."), + Use: "list [composition-id]", + Short: "List rules for a composition", + Args: cobra.MaximumNArgs(1), + ValidArgsFunction: cmdutil.CompositionIDs(opts.CompositionClient), Annotations: map[string]string{ "acls": "search", }, @@ -41,7 +45,11 @@ func NewListCmd(f *cmdutil.Factory) *cobra.Command { $ algolia compositions rules list my-comp `), RunE: func(cmd *cobra.Command, args []string) error { - opts.CompositionID = args[0] + id, err := compinternal.ResolveCompositionID(args, opts.IO.CanPrompt(), opts.Prompter, opts.CompositionClient) + if err != nil { + return err + } + opts.CompositionID = id return runListCmd(opts) }, } diff --git a/pkg/cmd/compositions/rules/list/list_test.go b/pkg/cmd/compositions/rules/list/list_test.go index 990a826e..22d65dcf 100644 --- a/pkg/cmd/compositions/rules/list/list_test.go +++ b/pkg/cmd/compositions/rules/list/list_test.go @@ -59,5 +59,5 @@ func TestListRules_MissingArg(t *testing.T) { cmd := list.NewListCmd(f) _, err := test.Execute(cmd, "", out) require.Error(t, err) - assert.Contains(t, err.Error(), "requires a argument") + assert.Contains(t, err.Error(), "argument is required") } diff --git a/pkg/cmd/compositions/rules/rules.go b/pkg/cmd/compositions/rules/rules.go index cd606fa4..070e812f 100644 --- a/pkg/cmd/compositions/rules/rules.go +++ b/pkg/cmd/compositions/rules/rules.go @@ -20,7 +20,8 @@ func NewRulesCmd(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(list.NewListCmd(f)) cmd.AddCommand(get.NewGetCmd(f)) - cmd.AddCommand(upsert.NewUpsertCmd(f)) + cmd.AddCommand(upsert.NewInsertCmd(f)) + cmd.AddCommand(upsert.NewUpdateCmd(f)) cmd.AddCommand(delete.NewDeleteCmd(f)) return cmd } diff --git a/pkg/cmd/compositions/rules/upsert/upsert.go b/pkg/cmd/compositions/rules/upsert/upsert.go index 3275fac5..c7d796c3 100644 --- a/pkg/cmd/compositions/rules/upsert/upsert.go +++ b/pkg/cmd/compositions/rules/upsert/upsert.go @@ -6,6 +6,7 @@ import ( "github.com/MakeNowJust/heredoc" algoliaComposition "github.com/algolia/algoliasearch-client-go/v4/algolia/composition" + "github.com/algolia/algoliasearch-client-go/v4/algolia/search" "github.com/spf13/cobra" compinternal "github.com/algolia/cli/pkg/cmd/compositions/internal" @@ -21,6 +22,7 @@ type UpsertOptions struct { Config config.IConfig IO *iostreams.IOStreams CompositionClient func() (*algoliaComposition.APIClient, error) + SearchClient func() (*search.APIClient, error) Prompter interactive.Prompter CompositionID string ObjectID string @@ -29,52 +31,97 @@ type UpsertOptions struct { PrintFlags *cmdutil.PrintFlags } -// NewUpsertCmd returns the `compositions rules upsert` command. -func NewUpsertCmd(f *cmdutil.Factory) *cobra.Command { - opts := &UpsertOptions{ +func newOptions(f *cmdutil.Factory) *UpsertOptions { + return &UpsertOptions{ IO: f.IOStreams, Config: f.Config, CompositionClient: f.CompositionClient, + SearchClient: f.SearchClient, Prompter: f.Prompter, PrintFlags: cmdutil.NewPrintFlags().WithDefaultOutput("json"), } +} + +func addBodyFlags(cmd *cobra.Command, opts *UpsertOptions) { + cmd.Flags().StringVarP(&opts.File, "file", "f", "", "JSON file path (use - for stdin)") + cmd.Flags().BoolVarP(&opts.Interactive, "interactive", "i", false, "Build the rule interactively") + opts.PrintFlags.AddFlags(cmd) +} + +// validateAndRun checks the file/interactive flags then writes the rule. +func validateAndRun(opts *UpsertOptions) error { + if opts.Interactive == (opts.File != "") { + return cmdutil.FlagErrorf("exactly one of `--file` or `--interactive` is required") + } + if opts.Interactive && !opts.IO.CanPrompt() { + return cmdutil.FlagErrorf("`--interactive` requires a terminal; use `--file` instead") + } + return runUpsertCmd(opts) +} + +// NewInsertCmd returns the `compositions rules insert` command: create a rule +// under a new id. Ids are typed, with no selector. +func NewInsertCmd(f *cmdutil.Factory) *cobra.Command { + opts := newOptions(f) cmd := &cobra.Command{ - Use: "upsert ", - Short: "Create or update a composition rule", - Args: validators.ExactArgsWithMsg(2, "compositions rules upsert requires a and a argument."), + Use: "insert ", + Short: "Create a composition rule", + Args: validators.ExactArgsWithMsg(2, "compositions rules insert requires a and a argument."), + ValidArgsFunction: cmdutil.CompositionIDs(opts.CompositionClient), Annotations: map[string]string{ "acls": "editSettings", }, Example: heredoc.Doc(` - # Upsert a rule from a JSON file - $ algolia compositions rules upsert my-comp rule-1 --file rule.json + # Create a rule from a JSON file + $ algolia compositions rules insert my-comp rule-1 --file rule.json - # Upsert from stdin - $ cat rule.json | algolia compositions rules upsert my-comp rule-1 --file - - - # Build a rule interactively - $ algolia compositions rules upsert my-comp rule-1 --interactive + # Build a new rule interactively + $ algolia compositions rules insert my-comp rule-1 --interactive `), RunE: func(cmd *cobra.Command, args []string) error { opts.CompositionID = args[0] opts.ObjectID = args[1] + return validateAndRun(opts) + }, + } - if opts.Interactive == (opts.File != "") { - return cmdutil.FlagErrorf("exactly one of `--file` or `--interactive` is required") - } - if opts.Interactive && !opts.IO.CanPrompt() { - return cmdutil.FlagErrorf("`--interactive` requires a terminal; use `--file` instead") - } + addBodyFlags(cmd, opts) + return cmd +} + +// NewUpdateCmd returns the `compositions rules update` command: edit an existing +// rule, selecting the composition and rule from lists when omitted on a terminal. +func NewUpdateCmd(f *cmdutil.Factory) *cobra.Command { + opts := newOptions(f) - return runUpsertCmd(opts) + cmd := &cobra.Command{ + Use: "update [composition-id] [rule-id]", + Short: "Update an existing composition rule", + Args: cobra.MaximumNArgs(2), + ValidArgsFunction: cmdutil.CompositionAndRuleIDs(opts.CompositionClient), + Annotations: map[string]string{ + "acls": "editSettings", }, - } + Example: heredoc.Doc(` + # Update a rule from a JSON file + $ algolia compositions rules update my-comp rule-1 --file rule.json - cmd.Flags().StringVarP(&opts.File, "file", "f", "", "JSON file path (use - for stdin)") - cmd.Flags().BoolVarP(&opts.Interactive, "interactive", "i", false, "Build the rule interactively") + # Pick a composition and rule to update, then build it interactively + $ algolia compositions rules update --interactive + `), + RunE: func(cmd *cobra.Command, args []string) error { + compositionID, ruleID, err := compinternal.ResolveCompositionAndRuleID(args, opts.IO.CanPrompt(), opts.Prompter, opts.CompositionClient) + if err != nil { + return err + } + opts.CompositionID = compositionID + opts.ObjectID = ruleID + return validateAndRun(opts) + }, + } - opts.PrintFlags.AddFlags(cmd) + addBodyFlags(cmd, opts) return cmd } @@ -85,7 +132,11 @@ func buildRule(opts *UpsertOptions) (algoliaComposition.CompositionRule, error) if opts.Interactive { rule.ObjectID = opts.ObjectID - if err := (&interactive.Builder{Prompter: opts.Prompter}).Build(&rule); err != nil { + builder := &interactive.Builder{ + Prompter: opts.Prompter, + Suggest: compinternal.CompositionSuggester(compinternal.SuggestDeps{Search: opts.SearchClient, Composition: opts.CompositionClient, CompositionID: opts.CompositionID}), + } + if err := builder.Build(&rule); err != nil { return rule, fmt.Errorf("building rule: %w", err) } return rule, nil diff --git a/pkg/cmd/compositions/rules/upsert/upsert_test.go b/pkg/cmd/compositions/rules/upsert/upsert_test.go index c6d75656..5fb83018 100644 --- a/pkg/cmd/compositions/rules/upsert/upsert_test.go +++ b/pkg/cmd/compositions/rules/upsert/upsert_test.go @@ -32,7 +32,7 @@ func TestUpsertRule(t *testing.T) { }) f, out := test.NewFactory(false, r, nil, "") - cmd := upsert.NewUpsertCmd(f) + cmd := upsert.NewInsertCmd(f) out.InBuf.WriteString(validRuleBody) _, err := test.Execute(cmd, "my-comp rule-1 --file -", out) require.NoError(t, err) @@ -57,7 +57,7 @@ func TestUpsertRule_WaitsForPublished(t *testing.T) { }) f, out := test.NewFactory(false, r, nil, "") - cmd := upsert.NewUpsertCmd(f) + cmd := upsert.NewInsertCmd(f) out.InBuf.WriteString(validRuleBody) _, err := test.Execute(cmd, "my-comp rule-1 --file -", out) require.NoError(t, err) @@ -77,7 +77,7 @@ func TestUpsertRule_WaitsForPublished(t *testing.T) { func TestUpsertRule_MissingFile(t *testing.T) { r := &httpmock.Registry{} f, out := test.NewFactory(false, r, nil, "") - cmd := upsert.NewUpsertCmd(f) + cmd := upsert.NewInsertCmd(f) _, err := test.Execute(cmd, "my-comp rule-1", out) require.Error(t, err) assert.Contains(t, err.Error(), "file") @@ -86,7 +86,7 @@ func TestUpsertRule_MissingFile(t *testing.T) { func TestUpsertRule_InvalidJSON(t *testing.T) { r := &httpmock.Registry{} f, out := test.NewFactory(false, r, nil, "") - cmd := upsert.NewUpsertCmd(f) + cmd := upsert.NewInsertCmd(f) out.InBuf.WriteString(`not-json`) _, err := test.Execute(cmd, "my-comp rule-1 --file -", out) require.Error(t, err) @@ -96,7 +96,7 @@ func TestUpsertRule_InvalidJSON(t *testing.T) { func TestUpsertRule_MissingArgs(t *testing.T) { r := &httpmock.Registry{} f, out := test.NewFactory(false, r, nil, "") - cmd := upsert.NewUpsertCmd(f) + cmd := upsert.NewInsertCmd(f) _, err := test.Execute(cmd, "--file -", out) require.Error(t, err) assert.Contains(t, err.Error(), "requires a and a argument") @@ -118,7 +118,7 @@ func TestUpsertRule_Interactive(t *testing.T) { // Empty script: pre-populated objectID, first behavior variant, optionals skipped. f.Prompter = &interactive.ScriptedPrompter{} - cmd := upsert.NewUpsertCmd(f) + cmd := upsert.NewInsertCmd(f) _, err := test.Execute(cmd, "my-comp rule-1 --interactive", out) require.NoError(t, err) @@ -131,7 +131,7 @@ func TestUpsertRule_Interactive(t *testing.T) { func TestUpsertRule_InteractiveAndFileConflict(t *testing.T) { r := &httpmock.Registry{} f, out := test.NewFactory(true, r, nil, "") - cmd := upsert.NewUpsertCmd(f) + cmd := upsert.NewInsertCmd(f) _, err := test.Execute(cmd, "my-comp rule-1 --interactive --file rule.json", out) require.Error(t, err) assert.Contains(t, err.Error(), "exactly one of `--file` or `--interactive`") @@ -140,7 +140,7 @@ func TestUpsertRule_InteractiveAndFileConflict(t *testing.T) { func TestUpsertRule_InteractiveNoTTY(t *testing.T) { r := &httpmock.Registry{} f, out := test.NewFactory(false, r, nil, "") - cmd := upsert.NewUpsertCmd(f) + cmd := upsert.NewInsertCmd(f) _, err := test.Execute(cmd, "my-comp rule-1 --interactive", out) require.Error(t, err) assert.Contains(t, err.Error(), "requires a terminal") From 902f2e444ce2223e1f3c6b5bcaac00f7a22135e3 Mon Sep 17 00:00:00 2001 From: Ben Kalmus Date: Thu, 22 Oct 2026 16:35:56 +0100 Subject: [PATCH 4/4] feat(compositions): use id selectors and insert shape in compositions commands Co-Authored-By: Claude Sonnet 5.5 --- pkg/cmd/compositions/compositions.go | 3 +- pkg/cmd/compositions/compositions_test.go | 6 +- pkg/cmd/compositions/delete/delete.go | 17 +++- pkg/cmd/compositions/delete/delete_test.go | 2 +- pkg/cmd/compositions/get/get.go | 21 +++- pkg/cmd/compositions/get/get_test.go | 2 +- pkg/cmd/compositions/search/search.go | 41 ++++++-- .../sortingstrategy/sortingstrategy.go | 19 +++- .../sortingstrategy/sortingstrategy_test.go | 2 +- pkg/cmd/compositions/upsert/upsert.go | 98 ++++++++++++++----- pkg/cmd/compositions/upsert/upsert_test.go | 14 +-- 11 files changed, 162 insertions(+), 63 deletions(-) diff --git a/pkg/cmd/compositions/compositions.go b/pkg/cmd/compositions/compositions.go index 8fe87e1a..75fa2ec6 100644 --- a/pkg/cmd/compositions/compositions.go +++ b/pkg/cmd/compositions/compositions.go @@ -23,7 +23,8 @@ func NewCompositionsCmd(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(list.NewListCmd(f)) cmd.AddCommand(get.NewGetCmd(f)) - cmd.AddCommand(upsert.NewUpsertCmd(f)) + cmd.AddCommand(upsert.NewInsertCmd(f)) + cmd.AddCommand(upsert.NewUpdateCmd(f)) cmd.AddCommand(sortingstrategy.NewSortingStrategyCmd(f)) cmd.AddCommand(delete.NewDeleteCmd(f)) cmd.AddCommand(compsearch.NewSearchCmd(f)) diff --git a/pkg/cmd/compositions/compositions_test.go b/pkg/cmd/compositions/compositions_test.go index 3e9a112a..36496bce 100644 --- a/pkg/cmd/compositions/compositions_test.go +++ b/pkg/cmd/compositions/compositions_test.go @@ -36,11 +36,11 @@ const wantBody = `{ } }` -// Drives `compositions upsert --interactive` through the real command tree with +// Drives `compositions insert --interactive` through the real command tree with // a label-keyed ScriptedPrompter on the Factory. Answers are keyed by a unique // substring of the prompt label, so adding or reordering SDK fields does not // break the INPUT side: unmatched prompts fall back to safe defaults (skip). -func TestCompositions_UpsertInteractive(t *testing.T) { +func TestCompositions_InsertInteractive(t *testing.T) { r := &httpmock.Registry{} var captured []byte r.Register(httpmock.REST("PUT", "1/compositions/my-comp"), func(req *http.Request) (*http.Response, error) { @@ -77,7 +77,7 @@ func TestCompositions_UpsertInteractive(t *testing.T) { } cmd := compositions.NewCompositionsCmd(f) - _, err := test.Execute(cmd, "upsert my-comp --interactive", out) + _, err := test.Execute(cmd, "insert my-comp --interactive", out) require.NoError(t, err) assert.JSONEq(t, wantBody, string(captured)) diff --git a/pkg/cmd/compositions/delete/delete.go b/pkg/cmd/compositions/delete/delete.go index 1ecb9c8a..fd975a70 100644 --- a/pkg/cmd/compositions/delete/delete.go +++ b/pkg/cmd/compositions/delete/delete.go @@ -10,9 +10,9 @@ import ( compinternal "github.com/algolia/cli/pkg/cmd/compositions/internal" "github.com/algolia/cli/pkg/cmdutil" "github.com/algolia/cli/pkg/config" + "github.com/algolia/cli/pkg/interactive" "github.com/algolia/cli/pkg/iostreams" "github.com/algolia/cli/pkg/prompt" - "github.com/algolia/cli/pkg/validators" ) // DeleteOptions holds the dependencies and flags for the delete command. @@ -20,6 +20,7 @@ type DeleteOptions struct { Config config.IConfig IO *iostreams.IOStreams CompositionClient func() (*algoliaComposition.APIClient, error) + Prompter interactive.Prompter CompositionID string DoConfirm bool PrintFlags *cmdutil.PrintFlags @@ -31,13 +32,15 @@ func NewDeleteCmd(f *cmdutil.Factory) *cobra.Command { IO: f.IOStreams, Config: f.Config, CompositionClient: f.CompositionClient, + Prompter: f.Prompter, PrintFlags: cmdutil.NewPrintFlags().WithDefaultOutput("json"), } cmd := &cobra.Command{ - Use: "delete ", - Short: "Delete a composition", - Args: validators.ExactArgsWithMsg(1, "compositions delete requires a argument."), + Use: "delete [composition-id]", + Short: "Delete a composition", + Args: cobra.MaximumNArgs(1), + ValidArgsFunction: cmdutil.CompositionIDs(opts.CompositionClient), Annotations: map[string]string{ "acls": "editSettings", }, @@ -49,7 +52,11 @@ func NewDeleteCmd(f *cmdutil.Factory) *cobra.Command { $ algolia compositions delete my-comp --confirm `), RunE: func(cmd *cobra.Command, args []string) error { - opts.CompositionID = args[0] + id, err := compinternal.ResolveCompositionID(args, opts.IO.CanPrompt(), opts.Prompter, opts.CompositionClient) + if err != nil { + return err + } + opts.CompositionID = id if !opts.DoConfirm { if !opts.IO.CanPrompt() { diff --git a/pkg/cmd/compositions/delete/delete_test.go b/pkg/cmd/compositions/delete/delete_test.go index 81730d93..1c3c695c 100644 --- a/pkg/cmd/compositions/delete/delete_test.go +++ b/pkg/cmd/compositions/delete/delete_test.go @@ -84,5 +84,5 @@ func TestDeleteComposition_MissingArg(t *testing.T) { cmd := delete.NewDeleteCmd(f) _, err := test.Execute(cmd, "", out) require.Error(t, err) - assert.Contains(t, err.Error(), "requires a argument") + assert.Contains(t, err.Error(), "a argument is required") } diff --git a/pkg/cmd/compositions/get/get.go b/pkg/cmd/compositions/get/get.go index cd02d94f..081e212c 100644 --- a/pkg/cmd/compositions/get/get.go +++ b/pkg/cmd/compositions/get/get.go @@ -5,10 +5,11 @@ import ( algoliaComposition "github.com/algolia/algoliasearch-client-go/v4/algolia/composition" "github.com/spf13/cobra" + compinternal "github.com/algolia/cli/pkg/cmd/compositions/internal" "github.com/algolia/cli/pkg/cmdutil" "github.com/algolia/cli/pkg/config" + "github.com/algolia/cli/pkg/interactive" "github.com/algolia/cli/pkg/iostreams" - "github.com/algolia/cli/pkg/validators" ) // GetOptions holds the dependencies and flags for the get command. @@ -16,6 +17,7 @@ type GetOptions struct { Config config.IConfig IO *iostreams.IOStreams CompositionClient func() (*algoliaComposition.APIClient, error) + Prompter interactive.Prompter CompositionID string PrintFlags *cmdutil.PrintFlags } @@ -26,22 +28,31 @@ func NewGetCmd(f *cmdutil.Factory) *cobra.Command { IO: f.IOStreams, Config: f.Config, CompositionClient: f.CompositionClient, + Prompter: f.Prompter, PrintFlags: cmdutil.NewPrintFlags().WithDefaultOutput("json"), } cmd := &cobra.Command{ - Use: "get ", - Short: "Get a composition by ID", - Args: validators.ExactArgsWithMsg(1, "compositions get requires a argument."), + Use: "get [composition-id]", + Short: "Get a composition by ID", + Args: cobra.MaximumNArgs(1), + ValidArgsFunction: cmdutil.CompositionIDs(opts.CompositionClient), Annotations: map[string]string{ "acls": "search", }, Example: heredoc.Doc(` # Get the composition with ID "my-comp" $ algolia compositions get my-comp + + # Pick a composition to get from a list + $ algolia compositions get `), RunE: func(cmd *cobra.Command, args []string) error { - opts.CompositionID = args[0] + id, err := compinternal.ResolveCompositionID(args, opts.IO.CanPrompt(), opts.Prompter, opts.CompositionClient) + if err != nil { + return err + } + opts.CompositionID = id return runGetCmd(opts) }, } diff --git a/pkg/cmd/compositions/get/get_test.go b/pkg/cmd/compositions/get/get_test.go index b41fca5b..f9572ff5 100644 --- a/pkg/cmd/compositions/get/get_test.go +++ b/pkg/cmd/compositions/get/get_test.go @@ -52,5 +52,5 @@ func TestGetComposition_MissingArg(t *testing.T) { cmd := get.NewGetCmd(f) _, err := test.Execute(cmd, "", out) require.Error(t, err) - assert.Contains(t, err.Error(), "requires a argument") + assert.Contains(t, err.Error(), "a argument is required") } diff --git a/pkg/cmd/compositions/search/search.go b/pkg/cmd/compositions/search/search.go index 84522339..aff56e78 100644 --- a/pkg/cmd/compositions/search/search.go +++ b/pkg/cmd/compositions/search/search.go @@ -5,8 +5,10 @@ import ( "github.com/MakeNowJust/heredoc" algoliaComposition "github.com/algolia/algoliasearch-client-go/v4/algolia/composition" + "github.com/algolia/algoliasearch-client-go/v4/algolia/search" "github.com/spf13/cobra" + compinternal "github.com/algolia/cli/pkg/cmd/compositions/internal" "github.com/algolia/cli/pkg/cmdutil" "github.com/algolia/cli/pkg/config" "github.com/algolia/cli/pkg/interactive" @@ -18,6 +20,7 @@ type SearchOptions struct { Config config.IConfig IO *iostreams.IOStreams CompositionClient func() (*algoliaComposition.APIClient, error) + SearchClient func() (*search.APIClient, error) Prompter interactive.Prompter CompositionID string Query string @@ -34,6 +37,7 @@ func NewSearchCmd(f *cmdutil.Factory) *cobra.Command { IO: f.IOStreams, Config: f.Config, CompositionClient: f.CompositionClient, + SearchClient: f.SearchClient, Prompter: f.Prompter, PrintFlags: cmdutil.NewPrintFlags().WithDefaultOutput("json"), } @@ -42,9 +46,10 @@ func NewSearchCmd(f *cmdutil.Factory) *cobra.Command { var page int32 cmd := &cobra.Command{ - Use: "search [query]", - Short: "Search a composition", - Args: cobra.RangeArgs(1, 2), + Use: "search [query]", + Short: "Search a composition", + Args: cobra.RangeArgs(0, 2), + ValidArgsFunction: cmdutil.CompositionIDs(opts.CompositionClient), Annotations: map[string]string{ "acls": "search", }, @@ -62,14 +67,15 @@ func NewSearchCmd(f *cmdutil.Factory) *cobra.Command { $ algolia compositions search my-comp --interactive `), RunE: func(cmd *cobra.Command, args []string) error { - opts.CompositionID = args[0] + id, err := compinternal.ResolveCompositionID(args, opts.IO.CanPrompt(), opts.Prompter, opts.CompositionClient) + if err != nil { + return err + } + opts.CompositionID = id if opts.Interactive { - if !opts.IO.CanPrompt() { - return cmdutil.FlagErrorf("`--interactive` requires a terminal") - } - if len(args) > 1 || cmd.Flags().Changed("hits-per-page") || cmd.Flags().Changed("page") || cmd.Flags().Changed("filters") { - return cmdutil.FlagErrorf("`--interactive` builds the whole request; omit the query argument and the --hits-per-page/--page/--filters flags") + if err := validateInteractive(cmd, args, opts.IO.CanPrompt()); err != nil { + return err } return runSearchCmd(opts) } @@ -97,12 +103,27 @@ func NewSearchCmd(f *cmdutil.Factory) *cobra.Command { return cmd } +// validateInteractive rejects `--interactive` without a terminal or combined with request flags, since the prompts build the whole request. +func validateInteractive(cmd *cobra.Command, args []string, canPrompt bool) error { + if !canPrompt { + return cmdutil.FlagErrorf("`--interactive` requires a terminal") + } + if len(args) > 1 || cmd.Flags().Changed("hits-per-page") || cmd.Flags().Changed("page") || cmd.Flags().Changed("filters") { + return cmdutil.FlagErrorf("`--interactive` builds the whole request; omit the query argument and the --hits-per-page/--page/--filters flags") + } + return nil +} + // buildRequestBody assembles the search request body, interactively when // requested or from the query argument and flags otherwise. func buildRequestBody(opts *SearchOptions) (*algoliaComposition.RequestBody, error) { if opts.Interactive { var body algoliaComposition.RequestBody - if err := (&interactive.Builder{Prompter: opts.Prompter}).Build(&body); err != nil { + builder := &interactive.Builder{ + Prompter: opts.Prompter, + Suggest: compinternal.CompositionSuggester(compinternal.SuggestDeps{Search: opts.SearchClient, Composition: opts.CompositionClient, CompositionID: opts.CompositionID}), + } + if err := builder.Build(&body); err != nil { return nil, fmt.Errorf("building search request: %w", err) } return &body, nil diff --git a/pkg/cmd/compositions/sortingstrategy/sortingstrategy.go b/pkg/cmd/compositions/sortingstrategy/sortingstrategy.go index 291a2392..fffbe769 100644 --- a/pkg/cmd/compositions/sortingstrategy/sortingstrategy.go +++ b/pkg/cmd/compositions/sortingstrategy/sortingstrategy.go @@ -6,6 +6,7 @@ import ( "github.com/MakeNowJust/heredoc" algoliaComposition "github.com/algolia/algoliasearch-client-go/v4/algolia/composition" + "github.com/algolia/algoliasearch-client-go/v4/algolia/search" "github.com/spf13/cobra" compinternal "github.com/algolia/cli/pkg/cmd/compositions/internal" @@ -13,7 +14,6 @@ import ( "github.com/algolia/cli/pkg/config" "github.com/algolia/cli/pkg/interactive" "github.com/algolia/cli/pkg/iostreams" - "github.com/algolia/cli/pkg/validators" ) // Options holds dependencies and flags for the sorting-strategy command. @@ -21,6 +21,7 @@ type Options struct { Config config.IConfig IO *iostreams.IOStreams CompositionClient func() (*algoliaComposition.APIClient, error) + SearchClient func() (*search.APIClient, error) Prompter interactive.Prompter CompositionID string File string @@ -34,6 +35,7 @@ func NewSortingStrategyCmd(f *cmdutil.Factory) *cobra.Command { IO: f.IOStreams, Config: f.Config, CompositionClient: f.CompositionClient, + SearchClient: f.SearchClient, Prompter: f.Prompter, PrintFlags: cmdutil.NewPrintFlags().WithDefaultOutput("json"), } @@ -46,7 +48,8 @@ func NewSortingStrategyCmd(f *cmdutil.Factory) *cobra.Command { indices (or replicas) that implement them. These labels are what the ` + "`sortBy`" + ` field on composition rules and search params selects at runtime. `), - Args: validators.ExactArgsWithMsg(1, "compositions sorting-strategy requires a argument."), + Args: cobra.MaximumNArgs(1), + ValidArgsFunction: cmdutil.CompositionIDs(opts.CompositionClient), Annotations: map[string]string{ "acls": "editSettings", }, @@ -61,7 +64,11 @@ func NewSortingStrategyCmd(f *cmdutil.Factory) *cobra.Command { $ algolia compositions sorting-strategy my-comp --interactive `), RunE: func(cmd *cobra.Command, args []string) error { - opts.CompositionID = args[0] + id, err := compinternal.ResolveCompositionID(args, opts.IO.CanPrompt(), opts.Prompter, opts.CompositionClient) + if err != nil { + return err + } + opts.CompositionID = id if opts.Interactive == (opts.File != "") { return cmdutil.FlagErrorf("exactly one of `--file` or `--interactive` is required") @@ -90,7 +97,11 @@ func buildStrategy(opts *Options) (map[string]string, error) { var doc struct { SortingStrategy map[string]string `json:"sortingStrategy"` } - if err := (&interactive.Builder{Prompter: opts.Prompter}).Build(&doc); err != nil { + builder := &interactive.Builder{ + Prompter: opts.Prompter, + Suggest: compinternal.CompositionSuggester(compinternal.SuggestDeps{Search: opts.SearchClient, Composition: opts.CompositionClient, CompositionID: opts.CompositionID}), + } + if err := builder.Build(&doc); err != nil { return nil, fmt.Errorf("building sorting strategy: %w", err) } return doc.SortingStrategy, nil diff --git a/pkg/cmd/compositions/sortingstrategy/sortingstrategy_test.go b/pkg/cmd/compositions/sortingstrategy/sortingstrategy_test.go index 1f1ca261..bc66bdf8 100644 --- a/pkg/cmd/compositions/sortingstrategy/sortingstrategy_test.go +++ b/pkg/cmd/compositions/sortingstrategy/sortingstrategy_test.go @@ -96,5 +96,5 @@ func TestSortingStrategy_MissingArg(t *testing.T) { cmd := sortingstrategy.NewSortingStrategyCmd(f) _, err := test.Execute(cmd, "--file -", out) require.Error(t, err) - assert.Contains(t, err.Error(), "requires a argument") + assert.Contains(t, err.Error(), "a argument is required") } diff --git a/pkg/cmd/compositions/upsert/upsert.go b/pkg/cmd/compositions/upsert/upsert.go index ed60c401..a20538e3 100644 --- a/pkg/cmd/compositions/upsert/upsert.go +++ b/pkg/cmd/compositions/upsert/upsert.go @@ -6,6 +6,7 @@ import ( "github.com/MakeNowJust/heredoc" algoliaComposition "github.com/algolia/algoliasearch-client-go/v4/algolia/composition" + "github.com/algolia/algoliasearch-client-go/v4/algolia/search" "github.com/spf13/cobra" compinternal "github.com/algolia/cli/pkg/cmd/compositions/internal" @@ -21,6 +22,7 @@ type UpsertOptions struct { Config config.IConfig IO *iostreams.IOStreams CompositionClient func() (*algoliaComposition.APIClient, error) + SearchClient func() (*search.APIClient, error) Prompter interactive.Prompter CompositionID string File string @@ -28,51 +30,94 @@ type UpsertOptions struct { PrintFlags *cmdutil.PrintFlags } -// NewUpsertCmd returns the `compositions upsert` command. -func NewUpsertCmd(f *cmdutil.Factory) *cobra.Command { - opts := &UpsertOptions{ +func newOptions(f *cmdutil.Factory) *UpsertOptions { + return &UpsertOptions{ IO: f.IOStreams, Config: f.Config, CompositionClient: f.CompositionClient, + SearchClient: f.SearchClient, Prompter: f.Prompter, PrintFlags: cmdutil.NewPrintFlags().WithDefaultOutput("json"), } +} + +func addBodyFlags(cmd *cobra.Command, opts *UpsertOptions) { + cmd.Flags().StringVarP(&opts.File, "file", "f", "", "JSON file path (use - for stdin)") + cmd.Flags().BoolVarP(&opts.Interactive, "interactive", "i", false, "Build the composition interactively") + opts.PrintFlags.AddFlags(cmd) +} + +// validateAndRun checks the file/interactive flags then writes the composition. +func validateAndRun(opts *UpsertOptions) error { + if opts.Interactive == (opts.File != "") { + return cmdutil.FlagErrorf("exactly one of `--file` or `--interactive` is required") + } + if opts.Interactive && !opts.IO.CanPrompt() { + return cmdutil.FlagErrorf("`--interactive` requires a terminal; use `--file` instead") + } + return runUpsertCmd(opts) +} + +// NewInsertCmd returns the `compositions insert` command: create a composition +// under a new id. The id is typed, with no selector over existing ones. +func NewInsertCmd(f *cmdutil.Factory) *cobra.Command { + opts := newOptions(f) cmd := &cobra.Command{ - Use: "upsert ", - Short: "Create or update a composition", - Args: validators.ExactArgsWithMsg(1, "compositions upsert requires a argument."), + Use: "insert ", + Short: "Create a composition", + Args: validators.ExactArgsWithMsg(1, "compositions insert requires a argument."), Annotations: map[string]string{ "acls": "editSettings", }, Example: heredoc.Doc(` - # Upsert a composition from a JSON file - $ algolia compositions upsert my-comp --file body.json + # Create a composition from a JSON file + $ algolia compositions insert my-comp --file body.json - # Upsert from stdin - $ cat body.json | algolia compositions upsert my-comp --file - - - # Build a composition interactively - $ algolia compositions upsert my-comp --interactive + # Build a new composition interactively + $ algolia compositions insert my-comp --interactive `), RunE: func(cmd *cobra.Command, args []string) error { opts.CompositionID = args[0] + return validateAndRun(opts) + }, + } - if opts.Interactive == (opts.File != "") { - return cmdutil.FlagErrorf("exactly one of `--file` or `--interactive` is required") - } - if opts.Interactive && !opts.IO.CanPrompt() { - return cmdutil.FlagErrorf("`--interactive` requires a terminal; use `--file` instead") - } + addBodyFlags(cmd, opts) + return cmd +} + +// NewUpdateCmd returns the `compositions update` command: edit an existing +// composition, selectable from a list when the id is omitted on a terminal. +func NewUpdateCmd(f *cmdutil.Factory) *cobra.Command { + opts := newOptions(f) - return runUpsertCmd(opts) + cmd := &cobra.Command{ + Use: "update [composition-id]", + Short: "Update an existing composition", + Args: cobra.MaximumNArgs(1), + ValidArgsFunction: cmdutil.CompositionIDs(opts.CompositionClient), + Annotations: map[string]string{ + "acls": "editSettings", }, - } + Example: heredoc.Doc(` + # Update a composition from a JSON file + $ algolia compositions update my-comp --file body.json - cmd.Flags().StringVarP(&opts.File, "file", "f", "", "JSON file path (use - for stdin)") - cmd.Flags().BoolVarP(&opts.Interactive, "interactive", "i", false, "Build the composition interactively") + # Pick a composition to update, then build it interactively + $ algolia compositions update --interactive + `), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := compinternal.ResolveCompositionID(args, opts.IO.CanPrompt(), opts.Prompter, opts.CompositionClient) + if err != nil { + return err + } + opts.CompositionID = id + return validateAndRun(opts) + }, + } - opts.PrintFlags.AddFlags(cmd) + addBodyFlags(cmd, opts) return cmd } @@ -83,7 +128,10 @@ func buildComposition(opts *UpsertOptions) (algoliaComposition.Composition, erro if opts.Interactive { comp.ObjectID = opts.CompositionID - builder := &interactive.Builder{Prompter: opts.Prompter} + builder := &interactive.Builder{ + Prompter: opts.Prompter, + Suggest: compinternal.CompositionSuggester(compinternal.SuggestDeps{Search: opts.SearchClient, Composition: opts.CompositionClient, CompositionID: opts.CompositionID}), + } if err := builder.Build(&comp); err != nil { return comp, fmt.Errorf("building composition: %w", err) } diff --git a/pkg/cmd/compositions/upsert/upsert_test.go b/pkg/cmd/compositions/upsert/upsert_test.go index d3a299ee..66e4f34d 100644 --- a/pkg/cmd/compositions/upsert/upsert_test.go +++ b/pkg/cmd/compositions/upsert/upsert_test.go @@ -30,7 +30,7 @@ func TestUpsertComposition(t *testing.T) { }) f, out := test.NewFactory(false, r, nil, "") - cmd := upsert.NewUpsertCmd(f) + cmd := upsert.NewInsertCmd(f) out.InBuf.WriteString(validCompBody) _, err := test.Execute(cmd, "my-comp --file -", out) require.NoError(t, err) @@ -55,7 +55,7 @@ func TestUpsertComposition_WaitsForPublished(t *testing.T) { }) f, out := test.NewFactory(false, r, nil, "") - cmd := upsert.NewUpsertCmd(f) + cmd := upsert.NewInsertCmd(f) out.InBuf.WriteString(validCompBody) _, err := test.Execute(cmd, "my-comp --file -", out) require.NoError(t, err) @@ -75,7 +75,7 @@ func TestUpsertComposition_WaitsForPublished(t *testing.T) { func TestUpsertComposition_MissingFile(t *testing.T) { r := &httpmock.Registry{} f, out := test.NewFactory(false, r, nil, "") - cmd := upsert.NewUpsertCmd(f) + cmd := upsert.NewInsertCmd(f) _, err := test.Execute(cmd, "my-comp", out) require.Error(t, err) assert.Contains(t, err.Error(), "exactly one of `--file` or `--interactive`") @@ -84,7 +84,7 @@ func TestUpsertComposition_MissingFile(t *testing.T) { func TestUpsertComposition_InvalidJSON(t *testing.T) { r := &httpmock.Registry{} f, out := test.NewFactory(false, r, nil, "") - cmd := upsert.NewUpsertCmd(f) + cmd := upsert.NewInsertCmd(f) out.InBuf.WriteString(`not-json`) _, err := test.Execute(cmd, "my-comp --file -", out) require.Error(t, err) @@ -94,7 +94,7 @@ func TestUpsertComposition_InvalidJSON(t *testing.T) { func TestUpsertComposition_MissingArg(t *testing.T) { r := &httpmock.Registry{} f, out := test.NewFactory(false, r, nil, "") - cmd := upsert.NewUpsertCmd(f) + cmd := upsert.NewInsertCmd(f) _, err := test.Execute(cmd, "--file -", out) require.Error(t, err) assert.Contains(t, err.Error(), "requires a argument") @@ -103,7 +103,7 @@ func TestUpsertComposition_MissingArg(t *testing.T) { func TestUpsertComposition_InteractiveAndFileConflict(t *testing.T) { r := &httpmock.Registry{} f, out := test.NewFactory(true, r, nil, "") - cmd := upsert.NewUpsertCmd(f) + cmd := upsert.NewInsertCmd(f) _, err := test.Execute(cmd, "my-comp --interactive --file body.json", out) require.Error(t, err) assert.Contains(t, err.Error(), "exactly one of `--file` or `--interactive`") @@ -112,7 +112,7 @@ func TestUpsertComposition_InteractiveAndFileConflict(t *testing.T) { func TestUpsertComposition_InteractiveNoTTY(t *testing.T) { r := &httpmock.Registry{} f, out := test.NewFactory(false, r, nil, "") // not a TTY - cmd := upsert.NewUpsertCmd(f) + cmd := upsert.NewInsertCmd(f) _, err := test.Execute(cmd, "my-comp --interactive", out) require.Error(t, err) assert.Contains(t, err.Error(), "requires a terminal")