diff --git a/pkg/cmd/compositions/compositions_test.go b/pkg/cmd/compositions/compositions_test.go new file mode 100644 index 00000000..3e9a112a --- /dev/null +++ b/pkg/cmd/compositions/compositions_test.go @@ -0,0 +1,85 @@ +package compositions_test + +import ( + "io" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/algolia/cli/pkg/cmd/compositions" + compinternal "github.com/algolia/cli/pkg/cmd/compositions/internal" + "github.com/algolia/cli/pkg/httpmock" + "github.com/algolia/cli/pkg/interactive" + "github.com/algolia/cli/test" +) + +// wantBody is the exact composition the interactive build produces from the +// scripted answers below. It is a valid composition: objectID is pre-populated +// from the positional arg, name comes from the keyed Input, and behavior selects +// the injection variant whose main source is a search source pointing at a real +// index. description, sortingStrategy, and all the optional query parameters are +// unanswered and therefore omitted. +const wantBody = `{ + "objectID": "my-comp", + "name": "My Composition", + "behavior": { + "injection": { + "main": { + "source": { + "search": {"index": "my-index"} + } + } + } + } +}` + +// Drives `compositions upsert --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) { + r := &httpmock.Registry{} + var captured []byte + r.Register(httpmock.REST("PUT", "1/compositions/my-comp"), func(req *http.Request) (*http.Response, error) { + captured, _ = io.ReadAll(req.Body) + return httpmock.StringResponse(`{"taskID":42}`)(req) + }) + r.Register(httpmock.REST("GET", "1/compositions/my-comp/task/42"), httpmock.StringResponse(`{"status":"published"}`)) + + compinternal.PollInterval = 1 * time.Millisecond + compinternal.Timeout = 50 * time.Millisecond + t.Cleanup(func() { + compinternal.PollInterval = compinternal.DefaultPollInterval + compinternal.Timeout = compinternal.DefaultTimeout + }) + + f, out := test.NewFactory(true, r, nil, "") + f.Prompter = &interactive.ScriptedPrompter{ + Inputs: map[string]string{ + "name": "My Composition", + "index": "my-index", // behavior.injection.main.source.search.index (required) + }, + Confirms: map[string]bool{ + // Trailing "?" pins this to the source pointer confirm + // ("...main.source?") so it does not also match the deeper + // "...search.params?" confirm, whose path contains ".main.source.". + "main.source?": true, + }, + // Both unions are keyed by their leaf "(variant)" label so the deep + // source select does not collide with the top-level behavior select. + Selects: map[string]string{ + "behavior (variant)": "CompositionInjectionBehavior", + "source (variant)": "InjectionMainSearchSource", + }, + } + + cmd := compositions.NewCompositionsCmd(f) + _, err := test.Execute(cmd, "upsert my-comp --interactive", out) + require.NoError(t, err) + + assert.JSONEq(t, wantBody, string(captured)) + r.Verify(t) +} diff --git a/pkg/cmd/compositions/rules/upsert/upsert.go b/pkg/cmd/compositions/rules/upsert/upsert.go index 52ee289e..17aa7775 100644 --- a/pkg/cmd/compositions/rules/upsert/upsert.go +++ b/pkg/cmd/compositions/rules/upsert/upsert.go @@ -11,6 +11,7 @@ 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/validators" ) @@ -20,9 +21,11 @@ type UpsertOptions struct { Config config.IConfig IO *iostreams.IOStreams CompositionClient func() (*algoliaComposition.APIClient, error) + Prompter interactive.Prompter CompositionID string ObjectID string File string + Interactive bool PrintFlags *cmdutil.PrintFlags } @@ -32,6 +35,7 @@ func NewUpsertCmd(f *cmdutil.Factory) *cobra.Command { IO: f.IOStreams, Config: f.Config, CompositionClient: f.CompositionClient, + Prompter: f.Prompter, PrintFlags: cmdutil.NewPrintFlags().WithDefaultOutput("json"), } @@ -48,30 +52,63 @@ func NewUpsertCmd(f *cmdutil.Factory) *cobra.Command { # 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 `), RunE: func(cmd *cobra.Command, args []string) error { opts.CompositionID = args[0] opts.ObjectID = args[1] + + 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) }, } cmd.Flags().StringVarP(&opts.File, "file", "f", "", "JSON file path (use - for stdin)") - _ = cmd.MarkFlagRequired("file") + cmd.Flags().BoolVarP(&opts.Interactive, "interactive", "i", false, "Build the rule interactively") opts.PrintFlags.AddFlags(cmd) return cmd } -func runUpsertCmd(opts *UpsertOptions) error { +// buildRule produces the rule body either interactively or by reading and +// parsing the JSON file. +func buildRule(opts *UpsertOptions) (algoliaComposition.CompositionRule, error) { + var rule algoliaComposition.CompositionRule + + if opts.Interactive { + rule.ObjectID = opts.ObjectID + prompter := opts.Prompter + if prompter == nil { + prompter = interactive.NewSurveyPrompter(opts.IO) + } + if err := (&interactive.Builder{Prompter: prompter}).Build(&rule); err != nil { + return rule, fmt.Errorf("building rule: %w", err) + } + return rule, nil + } + raw, err := cmdutil.ReadFile(opts.File, opts.IO.In) if err != nil { - return fmt.Errorf("reading file: %w", err) + return rule, fmt.Errorf("reading file: %w", err) } - - var rule algoliaComposition.CompositionRule if err := json.Unmarshal(raw, &rule); err != nil { - return fmt.Errorf("parsing rule JSON: %w", err) + return rule, fmt.Errorf("parsing rule JSON: %w", err) + } + return rule, nil +} + +func runUpsertCmd(opts *UpsertOptions) error { + rule, err := buildRule(opts) + if err != nil { + return err } client, err := opts.CompositionClient() diff --git a/pkg/cmd/compositions/rules/upsert/upsert_test.go b/pkg/cmd/compositions/rules/upsert/upsert_test.go index 44748a81..c6d75656 100644 --- a/pkg/cmd/compositions/rules/upsert/upsert_test.go +++ b/pkg/cmd/compositions/rules/upsert/upsert_test.go @@ -11,6 +11,7 @@ import ( compinternal "github.com/algolia/cli/pkg/cmd/compositions/internal" "github.com/algolia/cli/pkg/cmd/compositions/rules/upsert" "github.com/algolia/cli/pkg/httpmock" + "github.com/algolia/cli/pkg/interactive" "github.com/algolia/cli/test" ) @@ -100,3 +101,47 @@ func TestUpsertRule_MissingArgs(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "requires a and a argument") } + +func TestUpsertRule_Interactive(t *testing.T) { + r := &httpmock.Registry{} + r.Register(httpmock.REST("PUT", "1/compositions/my-comp/rules/rule-1"), httpmock.StringResponse(`{"taskID":77}`)) + r.Register(httpmock.REST("GET", "1/compositions/my-comp/task/77"), httpmock.StringResponse(`{"status":"published"}`)) + + compinternal.PollInterval = 1 * time.Millisecond + compinternal.Timeout = 50 * time.Millisecond + t.Cleanup(func() { + compinternal.PollInterval = compinternal.DefaultPollInterval + compinternal.Timeout = compinternal.DefaultTimeout + }) + + f, out := test.NewFactory(true, r, nil, "") + // Empty script: pre-populated objectID, first behavior variant, optionals skipped. + f.Prompter = &interactive.ScriptedPrompter{} + + cmd := upsert.NewUpsertCmd(f) + _, err := test.Execute(cmd, "my-comp rule-1 --interactive", out) + require.NoError(t, err) + + // TTY factory prints a success line before the JSON, so assert on substrings. + assert.Contains(t, out.String(), `{"taskID":77}`) + assert.Contains(t, out.String(), "Upserted rule rule-1") + r.Verify(t) +} + +func TestUpsertRule_InteractiveAndFileConflict(t *testing.T) { + r := &httpmock.Registry{} + f, out := test.NewFactory(true, r, nil, "") + cmd := upsert.NewUpsertCmd(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`") +} + +func TestUpsertRule_InteractiveNoTTY(t *testing.T) { + r := &httpmock.Registry{} + f, out := test.NewFactory(false, r, nil, "") + cmd := upsert.NewUpsertCmd(f) + _, err := test.Execute(cmd, "my-comp rule-1 --interactive", out) + require.Error(t, err) + assert.Contains(t, err.Error(), "requires a terminal") +} diff --git a/pkg/cmd/compositions/search/search.go b/pkg/cmd/compositions/search/search.go index d98ff4cb..52ba28a3 100644 --- a/pkg/cmd/compositions/search/search.go +++ b/pkg/cmd/compositions/search/search.go @@ -1,14 +1,16 @@ package search import ( + "fmt" + "github.com/MakeNowJust/heredoc" algoliaComposition "github.com/algolia/algoliasearch-client-go/v4/algolia/composition" "github.com/spf13/cobra" "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" ) // SearchOptions holds the dependencies and flags for the search command. @@ -16,11 +18,13 @@ type SearchOptions struct { Config config.IConfig IO *iostreams.IOStreams CompositionClient func() (*algoliaComposition.APIClient, error) + Prompter interactive.Prompter CompositionID string Query string HitsPerPage *int32 Page *int32 Filters string + Interactive bool PrintFlags *cmdutil.PrintFlags } @@ -30,6 +34,7 @@ func NewSearchCmd(f *cmdutil.Factory) *cobra.Command { IO: f.IOStreams, Config: f.Config, CompositionClient: f.CompositionClient, + Prompter: f.Prompter, PrintFlags: cmdutil.NewPrintFlags().WithDefaultOutput("json"), } @@ -37,9 +42,9 @@ func NewSearchCmd(f *cmdutil.Factory) *cobra.Command { var page int32 cmd := &cobra.Command{ - Use: "search ", + Use: "search [query]", Short: "Search a composition", - Args: validators.ExactArgsWithMsg(2, "compositions search requires a and a argument."), + Args: cobra.RangeArgs(1, 2), Annotations: map[string]string{ "acls": "search", }, @@ -52,9 +57,23 @@ func NewSearchCmd(f *cmdutil.Factory) *cobra.Command { # Search with pagination $ algolia compositions search my-comp "shirt" --hits-per-page 20 --page 2 + + # Build the search request interactively + $ algolia compositions search my-comp --interactive `), RunE: func(cmd *cobra.Command, args []string) error { opts.CompositionID = args[0] + + if opts.Interactive { + if !opts.IO.CanPrompt() { + return cmdutil.FlagErrorf("`--interactive` requires a terminal") + } + return runSearchCmd(opts) + } + + if len(args) < 2 { + return cmdutil.FlagErrorf("a argument is required (or use `--interactive`)") + } opts.Query = args[1] if cmd.Flags().Changed("hits-per-page") { opts.HitsPerPage = &hitsPerPage @@ -69,20 +88,25 @@ func NewSearchCmd(f *cmdutil.Factory) *cobra.Command { cmd.Flags().Int32Var(&hitsPerPage, "hits-per-page", 20, "Number of hits per page") cmd.Flags().Int32Var(&page, "page", 0, "Page number") cmd.Flags().StringVar(&opts.Filters, "filters", "", "Filter expression") + cmd.Flags().BoolVarP(&opts.Interactive, "interactive", "i", false, "Build the search request interactively") opts.PrintFlags.AddFlags(cmd) return cmd } -func runSearchCmd(opts *SearchOptions) error { - client, err := opts.CompositionClient() - if err != nil { - return err - } - - p, err := opts.PrintFlags.ToPrinter() - if err != nil { - return err +// 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 + prompter := opts.Prompter + if prompter == nil { + prompter = interactive.NewSurveyPrompter(opts.IO) + } + if err := (&interactive.Builder{Prompter: prompter}).Build(&body); err != nil { + return nil, fmt.Errorf("building search request: %w", err) + } + return &body, nil } params := algoliaComposition.NewParams( @@ -97,10 +121,26 @@ func runSearchCmd(opts *SearchOptions) error { if opts.Filters != "" { params.Filters = &opts.Filters } - - reqBody := algoliaComposition.NewRequestBody( + return algoliaComposition.NewRequestBody( algoliaComposition.WithRequestBodyParams(*params), - ) + ), nil +} + +func runSearchCmd(opts *SearchOptions) error { + client, err := opts.CompositionClient() + if err != nil { + return err + } + + p, err := opts.PrintFlags.ToPrinter() + if err != nil { + return err + } + + reqBody, err := buildRequestBody(opts) + if err != nil { + return err + } opts.IO.StartProgressIndicatorWithLabel("Searching") diff --git a/pkg/cmd/compositions/search/search_test.go b/pkg/cmd/compositions/search/search_test.go index 3e982e9c..06cc2ae4 100644 --- a/pkg/cmd/compositions/search/search_test.go +++ b/pkg/cmd/compositions/search/search_test.go @@ -9,6 +9,7 @@ import ( compsearch "github.com/algolia/cli/pkg/cmd/compositions/search" "github.com/algolia/cli/pkg/httpmock" + "github.com/algolia/cli/pkg/interactive" "github.com/algolia/cli/test" ) @@ -52,11 +53,39 @@ func TestSearchComposition(t *testing.T) { } } -func TestSearchComposition_MissingArgs(t *testing.T) { +func TestSearchComposition_MissingQuery(t *testing.T) { r := &httpmock.Registry{} f, out := test.NewFactory(false, r, nil, "") cmd := compsearch.NewSearchCmd(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(), "a argument is required") +} + +func TestSearchComposition_Interactive(t *testing.T) { + r := &httpmock.Registry{} + r.Register( + httpmock.REST("POST", "1/compositions/my-comp/run"), + httpmock.StringResponse(`{"hits":[],"nbHits":0}`), + ) + + f, out := test.NewFactory(true, r, nil, "") + // Empty script: decline the optional params, producing an empty request body. + f.Prompter = &interactive.ScriptedPrompter{} + + cmd := compsearch.NewSearchCmd(f) + _, err := test.Execute(cmd, "my-comp --interactive", out) + require.NoError(t, err) + + assert.JSONEq(t, `{"hits":[],"nbHits":0,"results":null}`, strings.TrimSpace(out.String())) + r.Verify(t) +} + +func TestSearchComposition_InteractiveNoTTY(t *testing.T) { + r := &httpmock.Registry{} + f, out := test.NewFactory(false, r, nil, "") + cmd := compsearch.NewSearchCmd(f) + _, err := test.Execute(cmd, "my-comp --interactive", out) + require.Error(t, err) + assert.Contains(t, err.Error(), "requires a terminal") } diff --git a/pkg/cmd/compositions/upsert/upsert.go b/pkg/cmd/compositions/upsert/upsert.go index f531ebcb..ea39e3cb 100644 --- a/pkg/cmd/compositions/upsert/upsert.go +++ b/pkg/cmd/compositions/upsert/upsert.go @@ -11,6 +11,7 @@ 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/validators" ) @@ -20,8 +21,10 @@ type UpsertOptions struct { Config config.IConfig IO *iostreams.IOStreams CompositionClient func() (*algoliaComposition.APIClient, error) + Prompter interactive.Prompter CompositionID string File string + Interactive bool PrintFlags *cmdutil.PrintFlags } @@ -31,6 +34,7 @@ func NewUpsertCmd(f *cmdutil.Factory) *cobra.Command { IO: f.IOStreams, Config: f.Config, CompositionClient: f.CompositionClient, + Prompter: f.Prompter, PrintFlags: cmdutil.NewPrintFlags().WithDefaultOutput("json"), } @@ -47,29 +51,63 @@ func NewUpsertCmd(f *cmdutil.Factory) *cobra.Command { # Upsert from stdin $ cat body.json | algolia compositions upsert my-comp --file - + + # Build a composition interactively + $ algolia compositions upsert my-comp --interactive `), RunE: func(cmd *cobra.Command, args []string) error { opts.CompositionID = args[0] + + 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) }, } cmd.Flags().StringVarP(&opts.File, "file", "f", "", "JSON file path (use - for stdin)") - _ = cmd.MarkFlagRequired("file") + cmd.Flags().BoolVarP(&opts.Interactive, "interactive", "i", false, "Build the composition interactively") opts.PrintFlags.AddFlags(cmd) return cmd } -func runUpsertCmd(opts *UpsertOptions) error { +// buildComposition produces the composition body either interactively or by +// reading and parsing the JSON file. +func buildComposition(opts *UpsertOptions) (algoliaComposition.Composition, error) { + var comp algoliaComposition.Composition + + if opts.Interactive { + comp.ObjectID = opts.CompositionID + prompter := opts.Prompter + if prompter == nil { + prompter = interactive.NewSurveyPrompter(opts.IO) + } + builder := &interactive.Builder{Prompter: prompter} + if err := builder.Build(&comp); err != nil { + return comp, fmt.Errorf("building composition: %w", err) + } + return comp, nil + } + raw, err := cmdutil.ReadFile(opts.File, opts.IO.In) if err != nil { - return fmt.Errorf("reading file: %w", err) + return comp, fmt.Errorf("reading file: %w", err) } - - var comp algoliaComposition.Composition if err := json.Unmarshal(raw, &comp); err != nil { - return fmt.Errorf("parsing composition JSON: %w", err) + return comp, fmt.Errorf("parsing composition JSON: %w", err) + } + return comp, nil +} + +func runUpsertCmd(opts *UpsertOptions) error { + comp, err := buildComposition(opts) + if err != nil { + return err } client, err := opts.CompositionClient() diff --git a/pkg/cmd/compositions/upsert/upsert_test.go b/pkg/cmd/compositions/upsert/upsert_test.go index 33efa63d..d3a299ee 100644 --- a/pkg/cmd/compositions/upsert/upsert_test.go +++ b/pkg/cmd/compositions/upsert/upsert_test.go @@ -78,7 +78,7 @@ func TestUpsertComposition_MissingFile(t *testing.T) { cmd := upsert.NewUpsertCmd(f) _, err := test.Execute(cmd, "my-comp", out) require.Error(t, err) - assert.Contains(t, err.Error(), "file") + assert.Contains(t, err.Error(), "exactly one of `--file` or `--interactive`") } func TestUpsertComposition_InvalidJSON(t *testing.T) { @@ -99,3 +99,21 @@ func TestUpsertComposition_MissingArg(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "requires a argument") } + +func TestUpsertComposition_InteractiveAndFileConflict(t *testing.T) { + r := &httpmock.Registry{} + f, out := test.NewFactory(true, r, nil, "") + cmd := upsert.NewUpsertCmd(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`") +} + +func TestUpsertComposition_InteractiveNoTTY(t *testing.T) { + r := &httpmock.Registry{} + f, out := test.NewFactory(false, r, nil, "") // not a TTY + cmd := upsert.NewUpsertCmd(f) + _, err := test.Execute(cmd, "my-comp --interactive", out) + require.Error(t, err) + assert.Contains(t, err.Error(), "requires a terminal") +} diff --git a/pkg/cmd/factory/default.go b/pkg/cmd/factory/default.go index 2fd4f191..2fbfa1f5 100644 --- a/pkg/cmd/factory/default.go +++ b/pkg/cmd/factory/default.go @@ -13,6 +13,7 @@ import ( "github.com/algolia/cli/api/crawler" "github.com/algolia/cli/pkg/cmdutil" "github.com/algolia/cli/pkg/config" + "github.com/algolia/cli/pkg/interactive" "github.com/algolia/cli/pkg/iostreams" ) @@ -22,6 +23,7 @@ func New(appVersion string, cfg config.IConfig) *cmdutil.Factory { ExecutableName: "gh", } f.IOStreams = ioStreams(f) + f.Prompter = interactive.NewSurveyPrompter(f.IOStreams) f.SearchClient = searchClient(f, appVersion) f.CrawlerClient = crawlerClient(f) f.CompositionClient = compositionClient(f, appVersion) diff --git a/pkg/cmdutil/factory.go b/pkg/cmdutil/factory.go index a31ce15e..3d135893 100644 --- a/pkg/cmdutil/factory.go +++ b/pkg/cmdutil/factory.go @@ -10,6 +10,7 @@ import ( "github.com/algolia/cli/api/crawler" "github.com/algolia/cli/pkg/config" + "github.com/algolia/cli/pkg/interactive" "github.com/algolia/cli/pkg/iostreams" ) @@ -20,6 +21,10 @@ type Factory struct { CrawlerClient func() (*crawler.Client, error) CompositionClient func() (*composition.APIClient, error) + // Prompter is the interactive input source used by commands that support + // an --interactive mode. Defaulted to a real SurveyPrompter in factory.New; + // tests set it to an interactive.ScriptedPrompter. + Prompter interactive.Prompter ExecutableName string }