tfexec: add InitJSON (#478)

Signed-off-by: Bruno Schaatsbergen <git@bschaatsbergen.com>
Co-authored-by: Daniel Banck <dbanck@users.noreply.github.com>
diff --git a/tfexec/apply_test.go b/tfexec/apply_test.go
index fe0420a..d3c6543 100644
--- a/tfexec/apply_test.go
+++ b/tfexec/apply_test.go
@@ -154,7 +154,7 @@
 func TestApplyCmd_AllowDeferral(t *testing.T) {
 	td := t.TempDir()
 
-	tf, err := NewTerraform(td, tfVersion(t, testutil.Alpha_v1_9))
+	tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_Alpha_v1_9))
 	if err != nil {
 		t.Fatal(err)
 	}
diff --git a/tfexec/init.go b/tfexec/init.go
index c292fdc..ac5eea5 100644
--- a/tfexec/init.go
+++ b/tfexec/init.go
@@ -6,6 +6,7 @@
 import (
 	"context"
 	"fmt"
+	"io"
 	"os/exec"
 )
 
@@ -99,6 +100,21 @@
 	conf.verifyPlugins = opt.verifyPlugins
 }
 
+func (tf *Terraform) configureInitOptions(ctx context.Context, c *initConfig, opts ...InitOption) error {
+	for _, o := range opts {
+		switch o.(type) {
+		case *LockOption, *LockTimeoutOption, *VerifyPluginsOption, *GetPluginsOption:
+			err := tf.compatible(ctx, nil, tf0_15_0)
+			if err != nil {
+				return fmt.Errorf("-lock, -lock-timeout, -verify-plugins, and -get-plugins options are no longer available as of Terraform 0.15: %w", err)
+			}
+		}
+
+		o.configureInit(c)
+	}
+	return nil
+}
+
 // Init represents the terraform init subcommand.
 func (tf *Terraform) Init(ctx context.Context, opts ...InitOption) error {
 	cmd, err := tf.initCmd(ctx, opts...)
@@ -108,21 +124,71 @@
 	return tf.runTerraformCmd(ctx, cmd)
 }
 
+// InitJSON represents the terraform init subcommand with the `-json` flag.
+// Using the `-json` flag will result in
+// [machine-readable](https://developer.hashicorp.com/terraform/internals/machine-readable-ui)
+// JSON being written to the supplied `io.Writer`.
+func (tf *Terraform) InitJSON(ctx context.Context, w io.Writer, opts ...InitOption) error {
+	err := tf.compatible(ctx, tf1_9_0, nil)
+	if err != nil {
+		return fmt.Errorf("terraform init -json was added in 1.9.0: %w", err)
+	}
+
+	tf.SetStdout(w)
+
+	cmd, err := tf.initJSONCmd(ctx, opts...)
+	if err != nil {
+		return err
+	}
+
+	return tf.runTerraformCmd(ctx, cmd)
+}
+
 func (tf *Terraform) initCmd(ctx context.Context, opts ...InitOption) (*exec.Cmd, error) {
 	c := defaultInitOptions
 
-	for _, o := range opts {
-		switch o.(type) {
-		case *LockOption, *LockTimeoutOption, *VerifyPluginsOption, *GetPluginsOption:
-			err := tf.compatible(ctx, nil, tf0_15_0)
-			if err != nil {
-				return nil, fmt.Errorf("-lock, -lock-timeout, -verify-plugins, and -get-plugins options are no longer available as of Terraform 0.15: %w", err)
-			}
-		}
-
-		o.configureInit(&c)
+	err := tf.configureInitOptions(ctx, &c, opts...)
+	if err != nil {
+		return nil, err
 	}
 
+	args, err := tf.buildInitArgs(ctx, c)
+	if err != nil {
+		return nil, err
+	}
+
+	// Optional positional argument; must be last as flags precede positional arguments.
+	if c.dir != "" {
+		args = append(args, c.dir)
+	}
+
+	return tf.buildInitCmd(ctx, c, args)
+}
+
+func (tf *Terraform) initJSONCmd(ctx context.Context, opts ...InitOption) (*exec.Cmd, error) {
+	c := defaultInitOptions
+
+	err := tf.configureInitOptions(ctx, &c, opts...)
+	if err != nil {
+		return nil, err
+	}
+
+	args, err := tf.buildInitArgs(ctx, c)
+	if err != nil {
+		return nil, err
+	}
+
+	args = append(args, "-json")
+
+	// Optional positional argument; must be last as flags precede positional arguments.
+	if c.dir != "" {
+		args = append(args, c.dir)
+	}
+
+	return tf.buildInitCmd(ctx, c, args)
+}
+
+func (tf *Terraform) buildInitArgs(ctx context.Context, c initConfig) ([]string, error) {
 	args := []string{"init", "-no-color", "-input=false"}
 
 	// string opts: only pass if set
@@ -172,11 +238,10 @@
 		}
 	}
 
-	// optional positional argument
-	if c.dir != "" {
-		args = append(args, c.dir)
-	}
+	return args, nil
+}
 
+func (tf *Terraform) buildInitCmd(ctx context.Context, c initConfig, args []string) (*exec.Cmd, error) {
 	mergeEnv := map[string]string{}
 	if c.reattachInfo != nil {
 		reattachStr, err := c.reattachInfo.marshalString()
diff --git a/tfexec/init_test.go b/tfexec/init_test.go
index 46b29cf..6f11bab 100644
--- a/tfexec/init_test.go
+++ b/tfexec/init_test.go
@@ -127,3 +127,57 @@
 		}, nil, initCmd)
 	})
 }
+
+func TestInitJSONCmd(t *testing.T) {
+	td := t.TempDir()
+
+	tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_v1_9))
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// empty env, to avoid environ mismatch in testing
+	tf.SetEnv(map[string]string{})
+
+	t.Run("defaults", func(t *testing.T) {
+		// defaults
+		initCmd, err := tf.initJSONCmd(context.Background())
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		assertCmd(t, []string{
+			"init",
+			"-no-color",
+			"-input=false",
+			"-backend=true",
+			"-get=true",
+			"-upgrade=false",
+			"-json",
+		}, nil, initCmd)
+	})
+
+	t.Run("override all defaults", func(t *testing.T) {
+		initCmd, err := tf.initJSONCmd(context.Background(), Backend(false), BackendConfig("confpath1"), BackendConfig("confpath2"), FromModule("testsource"), Get(false), PluginDir("testdir1"), PluginDir("testdir2"), Reconfigure(true), Upgrade(true), Dir("initdir"))
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		assertCmd(t, []string{
+			"init",
+			"-no-color",
+			"-input=false",
+			"-from-module=testsource",
+			"-backend=false",
+			"-get=false",
+			"-upgrade=true",
+			"-reconfigure",
+			"-backend-config=confpath1",
+			"-backend-config=confpath2",
+			"-plugin-dir=testdir1",
+			"-plugin-dir=testdir2",
+			"-json",
+			"initdir",
+		}, nil, initCmd)
+	})
+}
diff --git a/tfexec/internal/e2etest/init_test.go b/tfexec/internal/e2etest/init_test.go
index 1bcc0f9..896e72f 100644
--- a/tfexec/internal/e2etest/init_test.go
+++ b/tfexec/internal/e2etest/init_test.go
@@ -5,11 +5,14 @@
 
 import (
 	"context"
+	"io"
+	"regexp"
 	"testing"
 
 	"github.com/hashicorp/go-version"
 
 	"github.com/hashicorp/terraform-exec/tfexec"
+	"github.com/hashicorp/terraform-exec/tfexec/internal/testutil"
 )
 
 func TestInit(t *testing.T) {
@@ -20,3 +23,48 @@
 		}
 	})
 }
+
+func TestInitJSON_TF18AndEarlier(t *testing.T) {
+	versions := []string{
+		testutil.Latest011,
+		testutil.Latest012,
+		testutil.Latest013,
+		testutil.Latest_v1_6,
+		testutil.Latest_v1_7,
+		testutil.Latest_v1_8,
+	}
+
+	runTestWithVersions(t, versions, "basic", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) {
+		err := tf.Init(context.Background())
+		if err != nil {
+			t.Fatalf("error running Init in test directory: %s", err)
+		}
+
+		re := regexp.MustCompile("terraform init -json was added in 1.9.0")
+
+		err = tf.InitJSON(context.Background(), io.Discard)
+		if err != nil && !re.MatchString(err.Error()) {
+			t.Fatalf("error running Init: %s", err)
+		}
+	})
+}
+
+func TestInitJSON_TF19AndLater(t *testing.T) {
+	versions := []string{
+		testutil.Latest_v1_9,
+		testutil.Latest_Alpha_v1_9,
+		testutil.Latest_Alpha_v1_10,
+	}
+
+	runTestWithVersions(t, versions, "basic", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) {
+		err := tf.Init(context.Background())
+		if err != nil {
+			t.Fatalf("error running Init in test directory: %s", err)
+		}
+
+		err = tf.InitJSON(context.Background(), io.Discard)
+		if err != nil {
+			t.Fatalf("error running Init: %s", err)
+		}
+	})
+}
diff --git a/tfexec/internal/testutil/tfcache.go b/tfexec/internal/testutil/tfcache.go
index 82d4ef0..2f79fc2 100644
--- a/tfexec/internal/testutil/tfcache.go
+++ b/tfexec/internal/testutil/tfcache.go
@@ -15,18 +15,21 @@
 )
 
 const (
-	Latest011   = "0.11.15"
-	Latest012   = "0.12.31"
-	Latest013   = "0.13.7"
-	Latest014   = "0.14.11"
-	Latest015   = "0.15.5"
-	Latest_v1   = "1.0.11"
-	Latest_v1_1 = "1.1.9"
-	Latest_v1_5 = "1.5.3"
-	Latest_v1_6 = "1.6.0-alpha20230719"
-
-	Beta_v1_8  = "1.8.0-beta1"
-	Alpha_v1_9 = "1.9.0-alpha20240404"
+	Latest011          = "0.11.15"
+	Latest012          = "0.12.31"
+	Latest013          = "0.13.7"
+	Latest014          = "0.14.11"
+	Latest015          = "0.15.5"
+	Latest_v1          = "1.0.11"
+	Latest_v1_1        = "1.1.9"
+	Latest_v1_5        = "1.5.3"
+	Latest_v1_6        = "1.6.6"
+	Latest_v1_7        = "1.7.5"
+	Latest_v1_8        = "1.8.5"
+	Latest_Beta_v1_8   = "1.8.0-beta1"
+	Latest_v1_9        = "1.9.7"
+	Latest_Alpha_v1_9  = "1.9.0-alpha20240516"
+	Latest_Alpha_v1_10 = "1.10.0-alpha20240926"
 )
 
 const appendUserAgent = "tfexec-testutil"
diff --git a/tfexec/plan_test.go b/tfexec/plan_test.go
index e48dbe4..91c13f5 100644
--- a/tfexec/plan_test.go
+++ b/tfexec/plan_test.go
@@ -182,7 +182,7 @@
 func TestPlanCmd_AllowDeferral(t *testing.T) {
 	td := t.TempDir()
 
-	tf, err := NewTerraform(td, tfVersion(t, testutil.Alpha_v1_9))
+	tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_Alpha_v1_9))
 	if err != nil {
 		t.Fatal(err)
 	}
diff --git a/tfexec/version_test.go b/tfexec/version_test.go
index 4d7db85..cf4a774 100644
--- a/tfexec/version_test.go
+++ b/tfexec/version_test.go
@@ -300,16 +300,16 @@
 		tfVersion     *version.Version
 		expectedError error
 	}{
-		"experiments-enabled-in-1.9.0-alpha20240404": {
-			tfVersion: version.Must(version.NewVersion(testutil.Alpha_v1_9)),
+		"experiments-enabled-in-alphas": {
+			tfVersion: version.Must(version.NewVersion(testutil.Latest_Alpha_v1_9)),
 		},
-		"experiments-disabled-in-1.8.0-beta1": {
-			tfVersion:     version.Must(version.NewVersion(testutil.Beta_v1_8)),
-			expectedError: errors.New("experiments are not enabled in version 1.8.0-beta1, as it's not an alpha or dev build"),
+		"experiments-disabled-in-betas": {
+			tfVersion:     version.Must(version.NewVersion(testutil.Latest_Beta_v1_8)),
+			expectedError: fmt.Errorf("experiments are not enabled in version %s, as it's not an alpha or dev build", testutil.Latest_Beta_v1_8),
 		},
-		"experiments-disabled-in-1.5.3": {
+		"experiments-disabled-in-stable": {
 			tfVersion:     version.Must(version.NewVersion(testutil.Latest_v1_5)),
-			expectedError: errors.New("experiments are not enabled in version 1.5.3, as it's not an alpha or dev build"),
+			expectedError: fmt.Errorf("experiments are not enabled in version %s, as it's not an alpha or dev build", testutil.Latest_v1_5),
 		},
 	}
 	for name, testCase := range testCases {