diff --git a/internal/awsconfig/awsconfig.go b/internal/awsconfig/awsconfig.go index bc47cbe9..65cbe950 100644 --- a/internal/awsconfig/awsconfig.go +++ b/internal/awsconfig/awsconfig.go @@ -8,7 +8,6 @@ import ( "net/url" "os" "path/filepath" - "strings" "gopkg.in/ini.v1" @@ -77,17 +76,6 @@ func (s profileStatus) anyNeeded() bool { return s.configNeeded || s.credsNeeded } -func (s profileStatus) filesToModify() []string { - var files []string - if s.configNeeded { - files = append(files, "~/.aws/config") - } - if s.credsNeeded { - files = append(files, "~/.aws/credentials") - } - return files -} - // checkProfileStatus determines which AWS profile files need to be written or updated. func checkProfileStatus(configPath, credsPath, resolvedHost string) (profileStatus, error) { configNeeded, err := configNeedsWrite(configPath, resolvedHost) @@ -220,10 +208,9 @@ func Setup(ctx context.Context, sink output.Sink, interactive bool, resolvedHost return nil } - files := strings.Join(status.filesToModify(), " and ") responseCh := make(chan output.InputResponse, 1) output.EmitUserInputRequest(sink, output.UserInputRequestEvent{ - Prompt: fmt.Sprintf("Set up LocalStack AWS profile in %s?", files), + Prompt: "Configure AWS profile in ~/.aws/?", Options: []output.InputOption{{Key: "y", Label: "Y"}, {Key: "n", Label: "n"}}, ResponseCh: responseCh, }) @@ -245,7 +232,7 @@ func Setup(ctx context.Context, sink output.Sink, interactive bool, resolvedHost return nil } } - output.EmitSuccess(sink, fmt.Sprintf("LocalStack AWS profile written to %s", files)) + output.EmitSuccess(sink, "AWS profile successfully configured") output.EmitNote(sink, fmt.Sprintf("Try: aws s3 mb s3://test --profile %s", profileName)) case <-ctx.Done(): return ctx.Err() diff --git a/internal/awsconfig/awsconfig_test.go b/internal/awsconfig/awsconfig_test.go index 52630829..41737fb2 100644 --- a/internal/awsconfig/awsconfig_test.go +++ b/internal/awsconfig/awsconfig_test.go @@ -341,39 +341,3 @@ func TestIsValidLocalStackEndpoint(t *testing.T) { } } -func TestFilesToModify(t *testing.T) { - tests := []struct { - name string - status profileStatus - wantFiles []string - }{ - { - name: "both needed", - status: profileStatus{configNeeded: true, credsNeeded: true}, - wantFiles: []string{"~/.aws/config", "~/.aws/credentials"}, - }, - { - name: "config only", - status: profileStatus{configNeeded: true, credsNeeded: false}, - wantFiles: []string{"~/.aws/config"}, - }, - { - name: "credentials only", - status: profileStatus{configNeeded: false, credsNeeded: true}, - wantFiles: []string{"~/.aws/credentials"}, - }, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - got := tc.status.filesToModify() - if len(got) != len(tc.wantFiles) { - t.Fatalf("got %v, want %v", got, tc.wantFiles) - } - for i, want := range tc.wantFiles { - if got[i] != want { - t.Errorf("files[%d]: got %q, want %q", i, got[i], want) - } - } - }) - } -} diff --git a/internal/output/plain_format.go b/internal/output/plain_format.go index 861dda97..8b7db72f 100644 --- a/internal/output/plain_format.go +++ b/internal/output/plain_format.go @@ -58,11 +58,9 @@ func formatUserInputRequest(e UserInputRequestEvent) string { return FormatPrompt(e.Prompt, e.Options) } -// FormatPrompt formats a prompt string with its options into a display line. -func FormatPrompt(prompt string, options []InputOption) string { - lines := strings.Split(prompt, "\n") - firstLine := lines[0] - rest := lines[1:] +// FormatPromptLabels formats option labels into a suffix string. +// Returns " (label)" for a single option, " [a/b]" for multiple, or "" for none. +func FormatPromptLabels(options []InputOption) string { labels := make([]string, 0, len(options)) for _, opt := range options { if opt.Label != "" { @@ -72,15 +70,19 @@ func FormatPrompt(prompt string, options []InputOption) string { switch len(labels) { case 0: - if len(rest) == 0 { - return firstLine - } - return strings.Join(append([]string{firstLine}, rest...), "\n") + return "" case 1: - firstLine = fmt.Sprintf("%s (%s)", firstLine, labels[0]) + return fmt.Sprintf(" (%s)", labels[0]) default: - firstLine = fmt.Sprintf("%s [%s]", firstLine, strings.Join(labels, "/")) + return fmt.Sprintf(" [%s]", strings.Join(labels, "/")) } +} + +// FormatPrompt formats a prompt string with its options into a display line. +func FormatPrompt(prompt string, options []InputOption) string { + lines := strings.Split(prompt, "\n") + firstLine := lines[0] + FormatPromptLabels(options) + rest := lines[1:] if len(rest) == 0 { return firstLine diff --git a/internal/ui/components/input_prompt.go b/internal/ui/components/input_prompt.go index 5a8f44b8..c57732ae 100644 --- a/internal/ui/components/input_prompt.go +++ b/internal/ui/components/input_prompt.go @@ -1,6 +1,8 @@ package components import ( + "strings" + "github.com/localstack/lstk/internal/output" "github.com/localstack/lstk/internal/ui/styles" ) @@ -36,5 +38,24 @@ func (p InputPrompt) View() string { return "" } - return styles.SecondaryMessage.Render(output.FormatPrompt(p.prompt, p.options)) + lines := strings.Split(p.prompt, "\n") + firstLine := lines[0] + + var sb strings.Builder + + // "?" prefix in secondary color + sb.WriteString(styles.Secondary.Render("? ")) + + sb.WriteString(styles.Message.Render(firstLine)) + + if suffix := output.FormatPromptLabels(p.options); suffix != "" { + sb.WriteString(styles.Secondary.Render(suffix)) + } + + if len(lines) > 1 { + sb.WriteString("\n") + sb.WriteString(styles.SecondaryMessage.Render(strings.Join(lines[1:], "\n"))) + } + + return sb.String() } diff --git a/internal/ui/components/input_prompt_test.go b/internal/ui/components/input_prompt_test.go new file mode 100644 index 00000000..18a8576e --- /dev/null +++ b/internal/ui/components/input_prompt_test.go @@ -0,0 +1,85 @@ +package components + +import ( + "strings" + "testing" + + "github.com/localstack/lstk/internal/output" +) + +func TestInputPromptView(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + prompt string + options []output.InputOption + contains []string + excludes []string + }{ + { + name: "hidden returns empty", + prompt: "", + options: nil, + contains: nil, + }, + { + name: "no options", + prompt: "Continue?", + options: nil, + contains: []string{"?", "Continue?"}, + excludes: []string{"(", "["}, + }, + { + name: "single option shows parentheses", + prompt: "Continue?", + options: []output.InputOption{{Key: "enter", Label: "Press ENTER"}}, + contains: []string{"?", "Continue?", "(Press ENTER)"}, + }, + { + name: "multiple options shows brackets", + prompt: "Configure AWS profile?", + options: []output.InputOption{ + {Key: "y", Label: "Y"}, + {Key: "n", Label: "n"}, + }, + contains: []string{"?", "Configure AWS profile?", "[Y/n]"}, + }, + { + name: "multi-line prompt renders trailing lines", + prompt: "First line\nSecond line\nThird line", + options: []output.InputOption{{Key: "y", Label: "Y"}}, + contains: []string{"?", "First line", "Second line", "Third line", "(Y)"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + p := NewInputPrompt() + + if tc.prompt == "" && tc.options == nil { + view := p.View() + if view != "" { + t.Fatalf("expected empty view when hidden, got: %q", view) + } + return + } + + p = p.Show(tc.prompt, tc.options) + view := p.View() + + for _, s := range tc.contains { + if !strings.Contains(view, s) { + t.Errorf("expected view to contain %q, got: %q", s, view) + } + } + for _, s := range tc.excludes { + if strings.Contains(view, s) { + t.Errorf("expected view NOT to contain %q, got: %q", s, view) + } + } + }) + } +} diff --git a/test/integration/awsconfig_test.go b/test/integration/awsconfig_test.go index e4552478..df406829 100644 --- a/test/integration/awsconfig_test.go +++ b/test/integration/awsconfig_test.go @@ -57,7 +57,7 @@ func TestStartPromptsToCreateAWSProfileWhenMissing(t *testing.T) { // Wait for the AWS profile prompt. require.Eventually(t, func() bool { - return bytes.Contains(out.Bytes(), []byte("Set up LocalStack AWS profile")) + return bytes.Contains(out.Bytes(), []byte("Configure AWS profile in")) }, 2*time.Minute, 200*time.Millisecond, "AWS profile prompt should appear") // Press Y to confirm. @@ -66,7 +66,7 @@ func TestStartPromptsToCreateAWSProfileWhenMissing(t *testing.T) { // Wait for the success message. require.Eventually(t, func() bool { - return bytes.Contains(out.Bytes(), []byte("LocalStack AWS profile written")) + return bytes.Contains(out.Bytes(), []byte("AWS profile successfully configured")) }, 10*time.Second, 200*time.Millisecond, "success message should appear") // Verify files were written to the isolated home dir, not the real one. @@ -133,7 +133,7 @@ func TestStartSkipsAWSProfilePromptWhenAlreadyConfigured(t *testing.T) { _ = cmd.Wait() <-outputCh - assert.NotContains(t, out.String(), "Set up LocalStack AWS profile", + assert.NotContains(t, out.String(), "Configure AWS profile in", "profile prompt should not appear when profile is already correctly configured") }