diff --git a/internal/command/arguments/workspace.go b/internal/command/arguments/workspace.go new file mode 100644 index 0000000000..5f6a2eb289 --- /dev/null +++ b/internal/command/arguments/workspace.go @@ -0,0 +1,47 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package arguments + +import ( + "errors" + + "github.com/hashicorp/terraform/internal/tfdiags" +) + +// Workspace represents the command-line arguments common between all workspace subcommands. +// +// Subcommands that accept additional arguments should have a specific struct that embeds this struct. +type Workspace struct { + // ViewType specifies which output format to use + ViewType ViewType +} + +type WorkspaceList struct { + Workspace +} + +// ParseWorkspaceList processes CLI arguments, returning a WorkspaceList value and errors. +// If errors are encountered, an WorkspaceList value is still returned representing +// the best effort interpretation of the arguments. +func ParseWorkspaceList(args []string) (*WorkspaceList, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + + cmdFlags := defaultFlagSet("workspace list") + if err := cmdFlags.Parse(args); err != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Failed to parse command-line flags", + err.Error(), + )) + } + + // `workspace list` takes no positional arguments. Historically there was a DIR argument that was replaced with the -chdir flag. + // Here we replicate the old behaviour of suggesting the user to use -chdir if they provide any positional arguments. + args = cmdFlags.Args() + if len(args) != 0 { + diags = diags.Append(errors.New("Too many command line arguments. Did you mean to use -chdir?")) + } + + return &WorkspaceList{Workspace: Workspace{ViewType: ViewHuman}}, diags +} diff --git a/internal/command/arguments/workspace_test.go b/internal/command/arguments/workspace_test.go new file mode 100644 index 0000000000..94e6101ab5 --- /dev/null +++ b/internal/command/arguments/workspace_test.go @@ -0,0 +1,87 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package arguments + +import ( + "testing" + + "github.com/hashicorp/terraform/internal/tfdiags" +) + +func TestParseWorkspaceList_valid(t *testing.T) { + testCases := map[string]struct { + args []string + want *WorkspaceList + }{ + "defaults": { + nil, + &WorkspaceList{ + Workspace: Workspace{ + ViewType: ViewHuman, + }, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseWorkspaceList(tc.args) + if len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } + if *got != *tc.want { + t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) + } + }) + } +} + +func TestParseWorkspaceList_invalid(t *testing.T) { + testCases := map[string]struct { + args []string + want *WorkspaceList + wantDiags tfdiags.Diagnostics + }{ + "unknown flag": { + []string{"-boop"}, + &WorkspaceList{ + Workspace: Workspace{ + ViewType: ViewHuman, + }, + }, + tfdiags.Diagnostics{ + tfdiags.Sourceless( + tfdiags.Error, + "Failed to parse command-line flags", + "flag provided but not defined: -boop", + ), + }, + }, + "too many arguments": { + []string{"bar", "baz"}, + &WorkspaceList{ + Workspace: Workspace{ + ViewType: ViewHuman, + }, + }, + tfdiags.Diagnostics{ + tfdiags.Sourceless( + tfdiags.Error, + "Too many command line arguments. Did you mean to use -chdir?", + "", // No detail + ), + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, gotDiags := ParseWorkspaceList(tc.args) + if *got != *tc.want { + t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) + } + tfdiags.AssertDiagnosticsMatch(t, gotDiags, tc.wantDiags) + }) + } +} diff --git a/internal/command/views/workspace.go b/internal/command/views/workspace.go new file mode 100644 index 0000000000..190f938c72 --- /dev/null +++ b/internal/command/views/workspace.go @@ -0,0 +1,13 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package views + +import ( + "github.com/hashicorp/terraform/internal/tfdiags" +) + +// The WorkspaceList view is used for the `workspace list` subcommand. +type WorkspaceList interface { + List(selected string, list []string, diags tfdiags.Diagnostics) +} diff --git a/internal/command/workspace_command_test.go b/internal/command/workspace_command_test.go index 765bd3d1d7..798e72d727 100644 --- a/internal/command/workspace_command_test.go +++ b/internal/command/workspace_command_test.go @@ -17,11 +17,13 @@ import ( "github.com/hashicorp/terraform/internal/backend/local" "github.com/hashicorp/terraform/internal/backend/remote-state/inmem" "github.com/hashicorp/terraform/internal/command/arguments" + "github.com/hashicorp/terraform/internal/command/views" "github.com/hashicorp/terraform/internal/command/workdir" "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/states/statefile" "github.com/hashicorp/terraform/internal/states/statemgr" + "github.com/hashicorp/terraform/internal/terminal" ) func TestWorkspace_allCommands_pluggableStateStore(t *testing.T) { @@ -108,6 +110,7 @@ func TestWorkspace_allCommands_pluggableStateStore(t *testing.T) { //// List Workspaces ui = new(cli.MockUi) meta.Ui = ui + meta.WorkingDir = workdir.NewDir(".") listCmd := &WorkspaceListCommand{ Meta: meta, } @@ -381,11 +384,9 @@ func TestWorkspace_createAndList(t *testing.T) { // create multiple workspaces for _, env := range envs { ui := new(cli.MockUi) - view, _ := testView(t) newCmd := &WorkspaceNewCommand{ Meta: Meta{ Ui: ui, - View: view, WorkingDir: workdir.NewDir("."), }, } @@ -396,12 +397,11 @@ func TestWorkspace_createAndList(t *testing.T) { listCmd := &WorkspaceListCommand{} ui := new(cli.MockUi) - view, _ := testView(t) listCmd.Meta = Meta{ Ui: ui, - View: view, WorkingDir: workdir.NewDir("."), } + if code := listCmd.Run(nil); code != 0 { t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter) } @@ -523,10 +523,8 @@ func TestWorkspace_createInvalid(t *testing.T) { // list workspaces to make sure none were created listCmd := &WorkspaceListCommand{} ui := new(cli.MockUi) - view, _ := testView(t) listCmd.Meta = Meta{ Ui: ui, - View: view, WorkingDir: workdir.NewDir("."), } @@ -873,7 +871,6 @@ func TestWorkspace_cannotDeleteDefaultWorkspace(t *testing.T) { ui = cli.NewMockUi() listCmd.Meta = Meta{ Ui: ui, - View: view, WorkingDir: workdir.NewDir("."), } @@ -1035,11 +1032,9 @@ func TestWorkspace_envCommandDeprecationWarnings(t *testing.T) { // Assert `terraform env list` returns expected deprecation warning ui = new(cli.MockUi) - view, _ = testView(t) listCmd := &WorkspaceListCommand{ Meta: Meta{ Ui: ui, - View: view, WorkingDir: workdir.NewDir("."), }, LegacyName: true, @@ -1148,8 +1143,8 @@ func TestWorkspace_extraArgError(t *testing.T) { if code := listCmd.Run(args); code != 1 { t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter) } - expectedError = "Too many command line arguments. Did you mean to use -chdir?\n" - if ui.ErrorWriter.String() != expectedError { + expectedError = "Error: Too many command line arguments. Did you mean to use -chdir?\n" + if !strings.Contains(ui.ErrorWriter.String(), expectedError) { t.Fatalf("expected error to include \"%s\" but was missing, got: %s", expectedError, ui.ErrorWriter.String()) } @@ -1191,3 +1186,216 @@ func TestWorkspace_extraArgError(t *testing.T) { t.Fatalf("expected error to include %s but was missing, got: %s", expectedError, ui.ErrorWriter.String()) } } + +// Test human output from commands, with color enabled or disabled +func TestWorkspace_humanOutput(t *testing.T) { + newMeta := func(colourEnabled bool) (Meta, *cli.MockUi, *views.View, func(t *testing.T) *terminal.TestOutput) { + ui := new(cli.MockUi) + view, done := testView(t) + return Meta{ + Ui: ui, + View: view, + Color: colourEnabled, + WorkingDir: workdir.NewDir("."), + }, ui, view, done + } + + // Create a temporary working directory that is empty + td := t.TempDir() + t.Chdir(td) + + envsSet1 := []string{"test_a", "test_b", "test_c"} + envsSet2 := []string{"test_d", "test_e", "test_f"} + + // Assert output from creating a workspace with color enabled + for _, env := range envsSet1 { + useColor := true + meta, ui, _, _ := newMeta(useColor) + newCmd := &WorkspaceNewCommand{ + Meta: meta, + } + if code := newCmd.Run([]string{env}); code != 0 { + t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter) + } + + expectedOutput := fmt.Sprintf("\x1b[0m\x1b[32m\x1b[1mCreated and switched to workspace \"%s\"!\x1b[0m\x1b[32m\n\nYou're now on a new, empty workspace. Workspaces isolate their state,\nso if you run \"terraform plan\" Terraform will not see any existing state\nfor this configuration.\x1b[0m\n", env) + if ui.OutputWriter.String() != expectedOutput { + t.Fatalf("want: %s\ngot: %s", expectedOutput, ui.OutputWriter.String()) + } + } + + // Assert output from creating a workspace with color disabled + for _, env := range envsSet2 { + useColor := false + meta, ui, _, _ := newMeta(useColor) + newCmd := &WorkspaceNewCommand{ + Meta: meta, + } + if code := newCmd.Run([]string{env}); code != 0 { + t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter) + } + + expectedOutput := fmt.Sprintf("Created and switched to workspace \"%s\"!\n\nYou're now on a new, empty workspace. Workspaces isolate their state,\nso if you run \"terraform plan\" Terraform will not see any existing state\nfor this configuration.\n", env) + if ui.OutputWriter.String() != expectedOutput { + t.Fatalf("want: %s\ngot: %s", expectedOutput, ui.OutputWriter.String()) + } + } + + // NOTE: the last-created workspace will be selected: test_f + + // Assert output from listing workspaces with color enabled + useColor := true + meta, ui, _, _ := newMeta(useColor) + listCmd := &WorkspaceListCommand{ + Meta: meta, + } + if code := listCmd.Run(nil); code != 0 { + t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter) + } + actual := ui.OutputWriter.String() + expectedOutput := " default\n test_a\n test_b\n test_c\n test_d\n test_e\n* test_f\n\n" + if actual != expectedOutput { + t.Fatalf("\nexpected: %q\nactual: %q", expectedOutput, actual) + } + + // Assert output from listing workspaces with color disabled + useColor = false + meta, ui, _, _ = newMeta(useColor) + listCmd = &WorkspaceListCommand{ + Meta: meta, + } + if code := listCmd.Run(nil); code != 0 { + t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter) + } + actual = ui.OutputWriter.String() + expectedOutput = " default\n test_a\n test_b\n test_c\n test_d\n test_e\n* test_f\n\n" + if actual != expectedOutput { + t.Fatalf("\nexpected: %q\nactual: %q", expectedOutput, actual) + } + + // Assert output from showing the current workspace with color enabled + useColor = true + meta, ui, _, _ = newMeta(useColor) + showCmd := &WorkspaceShowCommand{ + Meta: meta, + } + if code := showCmd.Run(nil); code != 0 { + t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter) + } + actual = ui.OutputWriter.String() + expectedOutput = "test_f\n" + if actual != expectedOutput { + t.Fatalf("\nexpected: %q\nactual: %q", expectedOutput, actual) + } + + // Assert output from showing the current workspace with color disabled + useColor = false + meta, ui, _, _ = newMeta(useColor) + showCmd = &WorkspaceShowCommand{ + Meta: meta, + } + if code := showCmd.Run(nil); code != 0 { + t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter) + } + actual = ui.OutputWriter.String() + expectedOutput = "test_f\n" + if actual != expectedOutput { + t.Fatalf("\nexpected: %q\nactual: %q", expectedOutput, actual) + } + + // Assert output from selecting a workspace with color enabled + useColor = true + meta, ui, _, _ = newMeta(useColor) + selectCmd := &WorkspaceSelectCommand{ + Meta: meta, + } + args := []string{"test_a"} + if code := selectCmd.Run(args); code != 0 { + t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter) + } + actual = ui.OutputWriter.String() + expectedOutput = "\x1b[0m\x1b[32mSwitched to workspace \"test_a\".\x1b[0m\n" + if actual != expectedOutput { + t.Fatalf("want: %s\ngot: %s", expectedOutput, actual) + } + + // Assert output from selecting a workspace with color disabled + useColor = false + meta, ui, _, _ = newMeta(useColor) + selectCmd = &WorkspaceSelectCommand{ + Meta: meta, + } + args = []string{"test_b"} + if code := selectCmd.Run(args); code != 0 { + t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter) + } + actual = ui.OutputWriter.String() + expectedOutput = "Switched to workspace \"test_b\".\n" + if actual != expectedOutput { + t.Fatalf("want: %s\ngot: %s", expectedOutput, actual) + } + + // Assert output from deleting a workspace with color enabled + useColor = true + meta, ui, _, _ = newMeta(useColor) + deleteCmd := &WorkspaceDeleteCommand{ + Meta: meta, + } + args = []string{"test_c"} + if code := deleteCmd.Run(args); code != 0 { + t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter) + } + actual = ui.OutputWriter.String() + expectedOutput = "\x1b[0m\x1b[32mDeleted workspace \"test_c\"!\x1b[0m\n" + if actual != expectedOutput { + t.Fatalf("want: %s\ngot: %s", expectedOutput, actual) + } + + // Assert output from deleting a workspace with color disabled + useColor = false + meta, ui, _, _ = newMeta(useColor) + deleteCmd = &WorkspaceDeleteCommand{ + Meta: meta, + } + args = []string{"test_d"} + if code := deleteCmd.Run(args); code != 0 { + t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter) + } + actual = ui.OutputWriter.String() + expectedOutput = "Deleted workspace \"test_d\"!\n" + if actual != expectedOutput { + t.Fatalf("want: %s\ngot: %s", expectedOutput, actual) + } + + // Assert error output from deleting a non-existent workspace with color enabled + useColor = true + meta, ui, _, _ = newMeta(useColor) + deleteCmd = &WorkspaceDeleteCommand{ + Meta: meta, + } + args = []string{"foobar"} + if code := deleteCmd.Run(args); code != 1 { + t.Fatalf("expected error but got code %d:\n\n%s\n\n%s", code, ui.OutputWriter, ui.ErrorWriter) + } + actual = ui.ErrorWriter.String() + expectedOutput = "\x1b[31mWorkspace \"foobar\" doesn't exist.\n\nYou can create this workspace with the \"new\" subcommand \nor include the \"-or-create\" flag with the \"select\" subcommand.\x1b[0m\x1b[0m\n" + if actual != expectedOutput { + t.Fatalf("want: %s\ngot: %s", expectedOutput, actual) + } + + // Assert error output from deleting a non-existent workspace with color disabled + useColor = false + meta, ui, _, _ = newMeta(useColor) + deleteCmd = &WorkspaceDeleteCommand{ + Meta: meta, + } + args = []string{"foobar"} + if code := deleteCmd.Run(args); code != 1 { + t.Fatalf("expected error but got code %d:\n\n%s\n\n%s", code, ui.OutputWriter, ui.ErrorWriter) + } + actual = ui.ErrorWriter.String() + expectedOutput = "Workspace \"foobar\" doesn't exist.\n\nYou can create this workspace with the \"new\" subcommand \nor include the \"-or-create\" flag with the \"select\" subcommand.\n" + if actual != expectedOutput { + t.Fatalf("want: %s\ngot: %s", expectedOutput, actual) + } +} diff --git a/internal/command/workspace_list.go b/internal/command/workspace_list.go index 3d1a559349..f53e0d1920 100644 --- a/internal/command/workspace_list.go +++ b/internal/command/workspace_list.go @@ -8,7 +8,10 @@ import ( "fmt" "strings" + "github.com/hashicorp/cli" "github.com/hashicorp/terraform/internal/command/arguments" + "github.com/hashicorp/terraform/internal/command/views" + "github.com/hashicorp/terraform/internal/tfdiags" "github.com/posener/complete" ) @@ -17,29 +20,43 @@ type WorkspaceListCommand struct { LegacyName bool } -func (c *WorkspaceListCommand) Run(args []string) int { - args = c.Meta.process(args) +func (c *WorkspaceListCommand) Run(rawArgs []string) int { + var diags tfdiags.Diagnostics + + // c.Meta.process removes global flags (-no-color, -compact-warnings) and uses them to configure the Ui and View. + // + // Other command implementations remove those arguments via arguments.ParseView, instead. That is only possible if views + // are used for both human and machine output. This command still uses cli.Ui for human output, so c.Meta.process is necessary. + rawArgs = c.Meta.process(rawArgs) + + // Parse command-specific arguments. + args, diags := arguments.ParseWorkspaceList(rawArgs) + + // Prepare the view + // + // Note - here the view uses: + // - cli.Ui for human output + // - view.View for machine-readable output + // + // Note: We don't call c.View.Configure here after obtaining the view because it's already called in c.Meta.process. + // TODO: When we migrate human output to use views fully instead of cli.Ui we would replace using c.Meta.process with arguments.ParseView. + // arguments.ParseView returns a 'common' View that can be used as an argument in the c.View.Configure method. + view := newWorkspaceList(args.ViewType, c.View, c.Ui, &c.Meta) + + // Warn against using `terraform env` commands envCommandShowWarning(c.Ui, c.LegacyName) - cmdFlags := c.Meta.defaultFlagSet("workspace list") - cmdFlags.Usage = func() { c.Ui.Error(c.Help()) } - if err := cmdFlags.Parse(args); err != nil { - c.Ui.Error(fmt.Sprintf("Error parsing command-line flags: %s\n", err.Error())) - return 1 - } - - args = cmdFlags.Args() - configPath, err := ModulePath(args) - if err != nil { - c.Ui.Error(err.Error()) + // Now the view is ready, process any error diagnostics from parsing arguments. + if diags.HasErrors() { + view.List("", nil, diags) return 1 } // Load the backend - view := arguments.ViewHuman - b, diags := c.backend(configPath, view) + configPath := c.WorkingDir.RootModuleDir() + b, diags := c.backend(configPath, args.ViewType) if diags.HasErrors() { - c.showDiagnostics(diags) + view.List("", nil, diags) return 1 } @@ -49,34 +66,26 @@ func (c *WorkspaceListCommand) Run(args []string) int { states, wDiags := b.Workspaces() diags = diags.Append(wDiags) if wDiags.HasErrors() { - c.Ui.Error(wDiags.Err().Error()) + view.List("", nil, diags) return 1 } - c.showDiagnostics(diags) // output warnings, if any env, isOverridden := c.WorkspaceOverridden() - if len(states) != 0 { - var out bytes.Buffer - for _, s := range states { - if s == env { - out.WriteString("* ") - } else { - out.WriteString(" ") - } - out.WriteString(s + "\n") - } - - c.Ui.Output(out.String()) - } else { - // Warn that no states exist - c.showDiagnostics(warnNoEnvsExistDiag(env)) - } - if isOverridden { - c.Ui.Output(envIsOverriddenNote) + warn := tfdiags.Sourceless( + tfdiags.Warning, + envIsOverriddenNote, + "", + ) + diags = diags.Append(warn) } + // Print: + // 1. Diagnostics + // 2. The list of workspaces, highlighting the current workspace + view.List(env, states, diags) + return 0 } @@ -101,3 +110,49 @@ Usage: terraform [global options] workspace list func (c *WorkspaceListCommand) Synopsis() string { return "List Workspaces" } + +type workspaceListHuman struct { + ui cli.Ui + meta *Meta +} + +// List is used to assemble the list of Workspaces and log it via Output +func (v *workspaceListHuman) List(selected string, list []string, diags tfdiags.Diagnostics) { + // Print diags above output + v.meta.showDiagnostics(diags) + + // Print list + if len(list) > 0 { + var out bytes.Buffer + for _, s := range list { + if s == selected { + out.WriteString("* ") + } else { + out.WriteString(" ") + } + out.WriteString(s + "\n") + } + v.ui.Output(out.String()) + } else { + // Warn that no states exist + v.meta.showDiagnostics(warnNoEnvsExistDiag(selected)) + } +} + +// newWorkspaceList returns a views.WorkspaceList interface. +// +// When human-readable output is migrated from cli.Ui to views.View this method should be deleted and +// replaced with using a views.NewWorkspaceList method. +func newWorkspaceList(vt arguments.ViewType, view *views.View, ui cli.Ui, meta *Meta) views.WorkspaceList { + switch vt { + case arguments.ViewJSON: + panic("JSON output is not supported for workspace list command") + case arguments.ViewHuman: + return &workspaceListHuman{ + ui: ui, + meta: meta, + } + default: + panic(fmt.Sprintf("unknown view type %v", vt)) + } +}