diff --git a/src/pkg/stacks/manager_test.go b/src/pkg/stacks/manager_test.go index 6212da884..4ca758f57 100644 --- a/src/pkg/stacks/manager_test.go +++ b/src/pkg/stacks/manager_test.go @@ -783,7 +783,7 @@ func TestGetStack(t *testing.T) { }, }, interactiveResponses: map[string]string{ - "stack": "existingstack", + "stack": "existingstack (gcp)", }, expectedStack: &Parameters{ Name: "existingstack", diff --git a/src/pkg/stacks/selector.go b/src/pkg/stacks/selector.go index f46122314..5a8a92100 100644 --- a/src/pkg/stacks/selector.go +++ b/src/pkg/stacks/selector.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "slices" + "strings" "github.com/DefangLabs/defang/src/pkg/elicitations" "github.com/DefangLabs/defang/src/pkg/term" @@ -52,21 +53,19 @@ func (ss *stackSelector) SelectStack(ctx context.Context, opts SelectStackOption return nil, errors.New("no stacks available to select") } } - + labelMap := MakeStackSelectorLabels(stackList) stackLabels := make([]string, 0, len(stackList)+1) stackNames := make([]string, 0, len(stackList)) - labelMap := make(map[string]string) - for _, s := range stackList { - var label string - if s.DeployedAt.IsZero() { - label = s.Name - } else { - label = fmt.Sprintf("%s (deployed %s)", s.Name, s.DeployedAt.Format("Jan 2 2006")) + for _, stack := range stackList { + for label, name := range labelMap { + if name == stack.Name { + stackLabels = append(stackLabels, label) + stackNames = append(stackNames, name) + break + } } - stackLabels = append(stackLabels, label) - stackNames = append(stackNames, s.Name) - labelMap[label] = s.Name } + if opts.AllowStackCreation { stackLabels = append(stackLabels, CreateNewStack) } @@ -125,3 +124,76 @@ func printStacksInfoMessage(stacks []string) { } term.Printf("To skip this prompt, run this command with --stack=%s\n", "") } + +func MakeStackSelectorLabels(stacks []ListItem) map[string]string { + partsList := stackLabelParts(stacks) + partsList = reduceStackLabelParts(partsList) + + labelMap := make(map[string]string) + for i, parts := range partsList { + label := formatStackLabelParts(parts) + labelMap[label] = stacks[i].Name + } + return labelMap +} + +func stackLabelParts(stacks []ListItem) [][]string { + partsList := make([][]string, 0, len(stacks)) + for _, s := range stacks { + var deployedAt string + if !s.DeployedAt.IsZero() { + deployedAt = "last deployed " + s.DeployedAt.Format("Jan 2 2006") + } + parts := []string{ + s.Name, + s.Provider.String(), + s.Region, + deployedAt, + } + partsList = append(partsList, parts) + } + return partsList +} + +func reduceStackLabelParts(partsList [][]string) [][]string { + if len(partsList) <= 1 { + return partsList + } + // iterate over the partsList, + // if all stacks have the same value for a given part index, remove that part from all labels + for i := 0; i < len(partsList[0]); i++ { + same := true + value := partsList[0][i] + for _, part := range partsList { + if part[i] != value { + same = false + break + } + } + if same { + for j := 0; j < len(partsList); j++ { + partsList[j] = append(partsList[j][:i], partsList[j][i+1:]...) + } + i-- // adjust index since we removed a part + } + } + + return partsList +} + +func formatStackLabelParts(parts []string) string { + // remove any empty parts + nonEmptyParts := make([]string, 0, len(parts)) + for _, part := range parts { + if part != "" { + nonEmptyParts = append(nonEmptyParts, part) + } + } + if len(nonEmptyParts) == 0 { + return "" + } + if len(nonEmptyParts) == 1 { + return nonEmptyParts[0] + } + return fmt.Sprintf("%s (%s)", nonEmptyParts[0], strings.Join(nonEmptyParts[1:], ", ")) +} diff --git a/src/pkg/stacks/selector_test.go b/src/pkg/stacks/selector_test.go index ddb7438b7..a50c4d33e 100644 --- a/src/pkg/stacks/selector_test.go +++ b/src/pkg/stacks/selector_test.go @@ -104,8 +104,8 @@ func TestStackSelector_SelectStack_ExistingStack(t *testing.T) { mockSM.On("List", ctx).Return(existingStacks, nil) // Mock user selecting existing stack - expectedOptions := []string{"production", "development"} - mockEC.On("RequestEnum", ctx, "Select a stack", "stack", expectedOptions).Return("production", nil) + expectedOptions := []string{"production (us-west-2)", "development (us-east-1)"} + mockEC.On("RequestEnum", ctx, "Select a stack", "stack", expectedOptions).Return("production (us-west-2)", nil) // Expected params based on ToParameters() conversion expectedParams := &Parameters{ @@ -143,8 +143,8 @@ func TestStackSelector_SelectOrCreateStack_ExistingStack(t *testing.T) { mockSM.On("List", ctx).Return(existingStacks, nil) // Mock user selecting existing stack - expectedOptions := []string{"production", "development", CreateNewStack} - mockEC.On("RequestEnum", ctx, "Select a stack", "stack", expectedOptions).Return("production", nil) + expectedOptions := []string{"production (us-west-2)", "development (us-east-1)", CreateNewStack} + mockEC.On("RequestEnum", ctx, "Select a stack", "stack", expectedOptions).Return("production (us-west-2)", nil) // Expected params based on ToParameters() conversion expectedParams := &Parameters{ @@ -183,7 +183,7 @@ func TestStackSelector_SelectStack_CreateNewStack(t *testing.T) { mockSM.On("List", ctx).Return(existingStacks, nil) // Mock user selecting to create new stack - expectedOptions := []string{"production", CreateNewStack} + expectedOptions := []string{"production (aws, us-west-2)", CreateNewStack} mockEC.On("RequestEnum", ctx, "Select a stack", "stack", expectedOptions).Return(CreateNewStack, nil) // Mock wizard parameter collection - provider selection @@ -351,7 +351,7 @@ func TestStackSelector_SelectStack_ElicitationError(t *testing.T) { mockSM.On("List", ctx).Return(existingStacks, nil) // Mock error during elicitation - expectedOptions := []string{"production"} + expectedOptions := []string{"production (aws, us-west-2)"} mockEC.On("RequestEnum", ctx, "Select a stack", "stack", expectedOptions).Return("", errors.New("user cancelled selection")) selector := NewSelector(mockEC, mockSM) @@ -382,7 +382,7 @@ func TestStackSelector_SelectStack_WizardError(t *testing.T) { mockSM.On("List", ctx).Return(existingStacks, nil) // Mock user selecting to create new stack - expectedOptions := []string{"production", CreateNewStack} + expectedOptions := []string{"production (aws, us-west-2)", CreateNewStack} mockEC.On("RequestEnum", ctx, "Select a stack", "stack", expectedOptions).Return(CreateNewStack, nil) // Mock wizard parameter collection - provider selection fails @@ -419,7 +419,7 @@ func TestStackSelector_SelectStack_CreateStackError(t *testing.T) { mockSM.On("List", ctx).Return(existingStacks, nil) // Mock user selecting to create new stack - expectedOptions := []string{"production", CreateNewStack} + expectedOptions := []string{"production (aws, us-west-2)", CreateNewStack} mockEC.On("RequestEnum", ctx, "Select a stack", "stack", expectedOptions).Return(CreateNewStack, nil) // Mock wizard parameter collection - provider selection @@ -468,3 +468,72 @@ func TestStackSelector_SelectStack_CreateStackError(t *testing.T) { mockSM.AssertExpectations(t) mockProfileLister.AssertExpectations(t) } + +func TestMakeStackSelectorLabels(t *testing.T) { + tests := []struct { + name string + stacks []ListItem + wantLabels []string + }{ + { + name: "no stacks", + stacks: []ListItem{}, + wantLabels: []string{}, + }, + { + name: "one stack - present all fields", + stacks: []ListItem{ + {Parameters: Parameters{Name: "production", Provider: "aws", Region: "us-west-2"}}, + }, + wantLabels: []string{"production (aws, us-west-2)"}, + }, + { + name: "hide redundant provider", + stacks: []ListItem{ + {Parameters: Parameters{Name: "production", Provider: "aws", Region: "us-west-2"}}, + {Parameters: Parameters{Name: "development", Provider: "aws", Region: "us-east-1"}}, + }, + wantLabels: []string{ + "production (us-west-2)", + "development (us-east-1)", + }, + }, + { + name: "hide redundant provider and region", + stacks: []ListItem{ + {Parameters: Parameters{Name: "prod-us-west-2", Provider: "aws", Region: "us-west-2"}}, + {Parameters: Parameters{Name: "dev-us-west-2", Provider: "aws", Region: "us-west-2"}}, + }, + wantLabels: []string{ + "prod-us-west-2", + "dev-us-west-2", + }, + }, + { + name: "mixed redundancy", + stacks: []ListItem{ + {Parameters: Parameters{Name: "prod-us-west-2", Provider: "aws", Region: "us-west-2"}}, + {Parameters: Parameters{Name: "dev-us-east-1", Provider: "aws", Region: "us-east-1"}}, + {Parameters: Parameters{Name: "gcp-stack", Provider: "gcp", Region: "us-central1"}}, + }, + wantLabels: []string{ + "prod-us-west-2 (aws, us-west-2)", + "dev-us-east-1 (aws, us-east-1)", + "gcp-stack (gcp, us-central1)", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + labels := MakeStackSelectorLabels(tt.stacks) + + // Extract labels into a slice for easier comparison + var gotLabels []string + for label := range labels { + gotLabels = append(gotLabels, label) + } + + assert.ElementsMatch(t, tt.wantLabels, gotLabels) + }) + } +}