Refactor `workspace list` human output to use a views-like architecture, so adding JSON output is possible without breaking changes (#38392)

* test: Add a test that asserts human output from workspace commands with colour enabled or disabled

* refactor: Update `workspace list` to use cli.Ui in a Views-like way for human output. Update how output is returned by the command to use a single List method.

* refactor: Make `workspace list` command parse its arguments using the `arguments` package

* refactor: Replace use of `ModulePath` with ` c.WorkingDir.RootModuleDir()` to separate concerns

* test: Update tests that feature the `workspace list` command to include a WorkingDir value.

This is necessary after the changes in b32b60f6a7854c8f891ef2d78fd02536093ad590

* feat: Detect unexpected arguments and flags using the arguments package.

* refactor: Remove duplicate call to c.View.Configure, add code comments explaining code.

* fix: Make sure argument parsing errors are handled as soon as the view is usable.

* test: Update TestWorkspace_extraArgError to account for new validation via the arguments package

* fix: Update outdated copyright headers

* test: Update test name

* refactor: Reintroduce the old behaviour when unexpected positional arguments were present by using a specific ParseWorkspaceList method
pull/38375/merge
Sarah French 1 month ago committed by GitHub
parent a28750d8d1
commit c975e0cd78
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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
}

@ -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)
})
}
}

@ -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)
}

@ -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)
}
}

@ -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))
}
}

Loading…
Cancel
Save