Adding ApplyJSON(), DestroyJSON(), PlanJSON() and RefreshJSON() functions (#354)

* Adding ApplyJSON(), DestroyJSON(), PlanJSON() and RefreshJSON() functions (#353)

* Adding version compatibility check and e2e tests (#353)

* Add CHANGELOG entry (#353)

* Do not override TF version with version from matrix (#353)

* Consolidating runTest() (#353)

* Apply suggestions from code review

Co-authored-by: Radek Simko <radek.simko@gmail.com>

* Refactoring ...JSONCmd() functions to append -json flag as final flag in terraform command (#353)

* Adding comments to indicate that ...JSON() functions are likely to be removed in the future (#353)

Co-authored-by: Radek Simko <radek.simko@gmail.com>
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 586a470..a2afc25 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,9 @@
+# 0.18.0 (unreleased)
+
+ENHANCEMENTS:
+
+- tfexec: Add `(Terraform).ApplyJSON()`, `(Terraform).DestroyJSON()`, `(Terraform).PlanJSON()` and `(Terraform).RefreshJSON()` methods ([#354](https://github.com/hashicorp/terraform-exec/pull/354))
+
 # 0.17.3 (August 31, 2022)
 
 Please note that terraform-exec now requires Go 1.18.
diff --git a/tfexec/apply.go b/tfexec/apply.go
index 40d9e69..6dfdb97 100644
--- a/tfexec/apply.go
+++ b/tfexec/apply.go
@@ -3,6 +3,7 @@
 import (
 	"context"
 	"fmt"
+	"io"
 	"os/exec"
 	"strconv"
 )
@@ -99,6 +100,27 @@
 	return tf.runTerraformCmd(ctx, cmd)
 }
 
+// ApplyJSON represents the terraform apply 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`. ApplyJSON is likely to be
+// removed in a future major version in favour of Apply returning JSON by default.
+func (tf *Terraform) ApplyJSON(ctx context.Context, w io.Writer, opts ...ApplyOption) error {
+	err := tf.compatible(ctx, tf0_15_3, nil)
+	if err != nil {
+		return fmt.Errorf("terraform apply -json was added in 0.15.3: %w", err)
+	}
+
+	tf.SetStdout(w)
+
+	cmd, err := tf.applyJSONCmd(ctx, opts...)
+	if err != nil {
+		return err
+	}
+
+	return tf.runTerraformCmd(ctx, cmd)
+}
+
 func (tf *Terraform) applyCmd(ctx context.Context, opts ...ApplyOption) (*exec.Cmd, error) {
 	c := defaultApplyOptions
 
@@ -106,6 +128,32 @@
 		o.configureApply(&c)
 	}
 
+	args, err := tf.buildApplyArgs(ctx, c)
+	if err != nil {
+		return nil, err
+	}
+
+	return tf.buildApplyCmd(ctx, c, args)
+}
+
+func (tf *Terraform) applyJSONCmd(ctx context.Context, opts ...ApplyOption) (*exec.Cmd, error) {
+	c := defaultApplyOptions
+
+	for _, o := range opts {
+		o.configureApply(&c)
+	}
+
+	args, err := tf.buildApplyArgs(ctx, c)
+	if err != nil {
+		return nil, err
+	}
+
+	args = append(args, "-json")
+
+	return tf.buildApplyCmd(ctx, c, args)
+}
+
+func (tf *Terraform) buildApplyArgs(ctx context.Context, c applyConfig) ([]string, error) {
 	args := []string{"apply", "-no-color", "-auto-approve", "-input=false"}
 
 	// string opts: only pass if set
@@ -151,6 +199,10 @@
 		}
 	}
 
+	return args, nil
+}
+
+func (tf *Terraform) buildApplyCmd(ctx context.Context, c applyConfig, args []string) (*exec.Cmd, error) {
 	// string argument: pass if set
 	if c.dirOrPlan != "" {
 		args = append(args, c.dirOrPlan)
diff --git a/tfexec/apply_test.go b/tfexec/apply_test.go
index 1cf2f56..50f3203 100644
--- a/tfexec/apply_test.go
+++ b/tfexec/apply_test.go
@@ -65,3 +65,63 @@
 		}, nil, applyCmd)
 	})
 }
+
+func TestApplyJSONCmd(t *testing.T) {
+	td := t.TempDir()
+
+	tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_v1))
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// empty env, to avoid environ mismatch in testing
+	tf.SetEnv(map[string]string{})
+
+	t.Run("basic", func(t *testing.T) {
+		applyCmd, err := tf.applyJSONCmd(context.Background(),
+			Backup("testbackup"),
+			LockTimeout("200s"),
+			State("teststate"),
+			StateOut("teststateout"),
+			VarFile("foo.tfvars"),
+			VarFile("bar.tfvars"),
+			Lock(false),
+			Parallelism(99),
+			Refresh(false),
+			Replace("aws_instance.test"),
+			Replace("google_pubsub_topic.test"),
+			Target("target1"),
+			Target("target2"),
+			Var("var1=foo"),
+			Var("var2=bar"),
+			DirOrPlan("testfile"),
+		)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		assertCmd(t, []string{
+			"apply",
+			"-no-color",
+			"-auto-approve",
+			"-input=false",
+			"-backup=testbackup",
+			"-lock-timeout=200s",
+			"-state=teststate",
+			"-state-out=teststateout",
+			"-var-file=foo.tfvars",
+			"-var-file=bar.tfvars",
+			"-lock=false",
+			"-parallelism=99",
+			"-refresh=false",
+			"-replace=aws_instance.test",
+			"-replace=google_pubsub_topic.test",
+			"-target=target1",
+			"-target=target2",
+			"-var", "var1=foo",
+			"-var", "var2=bar",
+			"-json",
+			"testfile",
+		}, nil, applyCmd)
+	})
+}
diff --git a/tfexec/destroy.go b/tfexec/destroy.go
index 8011c0b..189db7e 100644
--- a/tfexec/destroy.go
+++ b/tfexec/destroy.go
@@ -3,6 +3,7 @@
 import (
 	"context"
 	"fmt"
+	"io"
 	"os/exec"
 	"strconv"
 )
@@ -95,6 +96,27 @@
 	return tf.runTerraformCmd(ctx, cmd)
 }
 
+// DestroyJSON represents the terraform destroy 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`. DestroyJSON is likely to be
+// removed in a future major version in favour of Destroy returning JSON by default.
+func (tf *Terraform) DestroyJSON(ctx context.Context, w io.Writer, opts ...DestroyOption) error {
+	err := tf.compatible(ctx, tf0_15_3, nil)
+	if err != nil {
+		return fmt.Errorf("terraform destroy -json was added in 0.15.3: %w", err)
+	}
+
+	tf.SetStdout(w)
+
+	cmd, err := tf.destroyJSONCmd(ctx, opts...)
+	if err != nil {
+		return err
+	}
+
+	return tf.runTerraformCmd(ctx, cmd)
+}
+
 func (tf *Terraform) destroyCmd(ctx context.Context, opts ...DestroyOption) (*exec.Cmd, error) {
 	c := defaultDestroyOptions
 
@@ -102,6 +124,25 @@
 		o.configureDestroy(&c)
 	}
 
+	args := tf.buildDestroyArgs(c)
+
+	return tf.buildDestroyCmd(ctx, c, args)
+}
+
+func (tf *Terraform) destroyJSONCmd(ctx context.Context, opts ...DestroyOption) (*exec.Cmd, error) {
+	c := defaultDestroyOptions
+
+	for _, o := range opts {
+		o.configureDestroy(&c)
+	}
+
+	args := tf.buildDestroyArgs(c)
+	args = append(args, "-json")
+
+	return tf.buildDestroyCmd(ctx, c, args)
+}
+
+func (tf *Terraform) buildDestroyArgs(c destroyConfig) []string {
 	args := []string{"destroy", "-no-color", "-auto-approve", "-input=false"}
 
 	// string opts: only pass if set
@@ -138,6 +179,10 @@
 		}
 	}
 
+	return args
+}
+
+func (tf *Terraform) buildDestroyCmd(ctx context.Context, c destroyConfig, args []string) (*exec.Cmd, error) {
 	// optional positional argument
 	if c.dir != "" {
 		args = append(args, c.dir)
diff --git a/tfexec/destroy_test.go b/tfexec/destroy_test.go
index eb28f58..aa93bcb 100644
--- a/tfexec/destroy_test.go
+++ b/tfexec/destroy_test.go
@@ -63,3 +63,62 @@
 		}, nil, destroyCmd)
 	})
 }
+
+func TestDestroyJSONCmd(t *testing.T) {
+	td := t.TempDir()
+
+	tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_v1))
+	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) {
+		destroyCmd, err := tf.destroyJSONCmd(context.Background())
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		assertCmd(t, []string{
+			"destroy",
+			"-no-color",
+			"-auto-approve",
+			"-input=false",
+			"-lock-timeout=0s",
+			"-lock=true",
+			"-parallelism=10",
+			"-refresh=true",
+			"-json",
+		}, nil, destroyCmd)
+	})
+
+	t.Run("override all defaults", func(t *testing.T) {
+		destroyCmd, err := tf.destroyJSONCmd(context.Background(), Backup("testbackup"), LockTimeout("200s"), State("teststate"), StateOut("teststateout"), VarFile("testvarfile"), Lock(false), Parallelism(99), Refresh(false), Target("target1"), Target("target2"), Var("var1=foo"), Var("var2=bar"), Dir("destroydir"))
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		assertCmd(t, []string{
+			"destroy",
+			"-no-color",
+			"-auto-approve",
+			"-input=false",
+			"-backup=testbackup",
+			"-lock-timeout=200s",
+			"-state=teststate",
+			"-state-out=teststateout",
+			"-var-file=testvarfile",
+			"-lock=false",
+			"-parallelism=99",
+			"-refresh=false",
+			"-target=target1",
+			"-target=target2",
+			"-var", "var1=foo",
+			"-var", "var2=bar",
+			"-json",
+			"destroydir",
+		}, nil, destroyCmd)
+	})
+}
diff --git a/tfexec/internal/e2etest/apply_test.go b/tfexec/internal/e2etest/apply_test.go
index 8c3154a..5211a3d 100644
--- a/tfexec/internal/e2etest/apply_test.go
+++ b/tfexec/internal/e2etest/apply_test.go
@@ -2,11 +2,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 TestApply(t *testing.T) {
@@ -22,3 +25,37 @@
 		}
 	})
 }
+
+func TestApplyJSON_TF014AndEarlier(t *testing.T) {
+	versions := []string{testutil.Latest011, testutil.Latest012, testutil.Latest013, testutil.Latest014}
+
+	runTestWithVersions(t, "basic", versions, 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 apply -json was added in 0.15.3")
+
+		err = tf.ApplyJSON(context.Background(), io.Discard)
+		if err != nil && !re.MatchString(err.Error()) {
+			t.Fatalf("error running Apply: %s", err)
+		}
+	})
+}
+
+func TestApplyJSON_TF015AndLater(t *testing.T) {
+	versions := []string{testutil.Latest015, testutil.Latest_v1, testutil.Latest_v1_1}
+
+	runTestWithVersions(t, "basic", versions, 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.ApplyJSON(context.Background(), io.Discard)
+		if err != nil {
+			t.Fatalf("error running Apply: %s", err)
+		}
+	})
+}
diff --git a/tfexec/internal/e2etest/destroy_test.go b/tfexec/internal/e2etest/destroy_test.go
index da0c856..9d157fd 100644
--- a/tfexec/internal/e2etest/destroy_test.go
+++ b/tfexec/internal/e2etest/destroy_test.go
@@ -2,11 +2,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 TestDestroy(t *testing.T) {
@@ -27,3 +30,37 @@
 		}
 	})
 }
+
+func TestDestroyJSON_TF014AndEarlier(t *testing.T) {
+	versions := []string{testutil.Latest011, testutil.Latest012, testutil.Latest013, testutil.Latest014}
+
+	runTestWithVersions(t, "basic", versions, 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 destroy -json was added in 0.15.3")
+
+		err = tf.DestroyJSON(context.Background(), io.Discard)
+		if err != nil && !re.MatchString(err.Error()) {
+			t.Fatalf("error running Apply: %s", err)
+		}
+	})
+}
+
+func TestDestroyJSON_TF015AndLater(t *testing.T) {
+	versions := []string{testutil.Latest015, testutil.Latest_v1, testutil.Latest_v1_1}
+
+	runTestWithVersions(t, "basic", versions, 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.DestroyJSON(context.Background(), io.Discard)
+		if err != nil {
+			t.Fatalf("error running Apply: %s", err)
+		}
+	})
+}
diff --git a/tfexec/internal/e2etest/plan_test.go b/tfexec/internal/e2etest/plan_test.go
index a5282b7..83364fd 100644
--- a/tfexec/internal/e2etest/plan_test.go
+++ b/tfexec/internal/e2etest/plan_test.go
@@ -2,11 +2,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 TestPlan(t *testing.T) {
@@ -45,5 +48,44 @@
 			t.Fatalf("expected: false, got: %t", hasChanges)
 		}
 	})
+}
 
+func TestPlanJSON_TF014AndEarlier(t *testing.T) {
+	versions := []string{testutil.Latest011, testutil.Latest012, testutil.Latest013, testutil.Latest014}
+
+	runTestWithVersions(t, "basic", versions, 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 plan -json was added in 0.15.3")
+
+		hasChanges, err := tf.PlanJSON(context.Background(), io.Discard)
+		if err != nil && !re.MatchString(err.Error()) {
+			t.Fatalf("error running Apply: %s", err)
+		}
+		if hasChanges {
+			t.Fatalf("expected: false, got: %t", hasChanges)
+		}
+	})
+}
+
+func TestPlanJSON_TF015AndLater(t *testing.T) {
+	versions := []string{testutil.Latest015, testutil.Latest_v1, testutil.Latest_v1_1}
+
+	runTestWithVersions(t, "basic", versions, 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)
+		}
+
+		hasChanges, err := tf.PlanJSON(context.Background(), io.Discard)
+		if err != nil {
+			t.Fatalf("error running Apply: %s", err)
+		}
+		if !hasChanges {
+			t.Fatalf("expected: true, got: %t", hasChanges)
+		}
+	})
 }
diff --git a/tfexec/internal/e2etest/refresh_test.go b/tfexec/internal/e2etest/refresh_test.go
index 40dbb9d..1cea79e 100644
--- a/tfexec/internal/e2etest/refresh_test.go
+++ b/tfexec/internal/e2etest/refresh_test.go
@@ -2,11 +2,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 TestRefresh(t *testing.T) {
@@ -27,3 +30,37 @@
 		}
 	})
 }
+
+func TestRefreshJSON_TF014AndEarlier(t *testing.T) {
+	versions := []string{testutil.Latest011, testutil.Latest012, testutil.Latest013, testutil.Latest014}
+
+	runTestWithVersions(t, "basic", versions, 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 refresh -json was added in 0.15.3")
+
+		err = tf.RefreshJSON(context.Background(), io.Discard)
+		if err != nil && !re.MatchString(err.Error()) {
+			t.Fatalf("error running Apply: %s", err)
+		}
+	})
+}
+
+func TestRefreshJSON_TF015AndLater(t *testing.T) {
+	versions := []string{testutil.Latest015, testutil.Latest_v1, testutil.Latest_v1_1}
+
+	runTestWithVersions(t, "basic", versions, 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.RefreshJSON(context.Background(), io.Discard)
+		if err != nil {
+			t.Fatalf("error running Apply: %s", err)
+		}
+	})
+}
diff --git a/tfexec/internal/e2etest/util_test.go b/tfexec/internal/e2etest/util_test.go
index 54d4f33..36959ef 100644
--- a/tfexec/internal/e2etest/util_test.go
+++ b/tfexec/internal/e2etest/util_test.go
@@ -42,6 +42,12 @@
 		versions = strings.Split(override, ",")
 	}
 
+	runTestWithVersions(t, fixtureName, versions, cb)
+}
+
+func runTestWithVersions(t *testing.T, fixtureName string, versions []string, cb func(t *testing.T, tfVersion *version.Version, tf *tfexec.Terraform)) {
+	t.Helper()
+
 	// If the env var TFEXEC_E2ETEST_TERRAFORM_PATH is set to the path of a
 	// valid Terraform executable, only tests appropriate to that
 	// executable's version will be run.
diff --git a/tfexec/plan.go b/tfexec/plan.go
index bf41094..5ea3155 100644
--- a/tfexec/plan.go
+++ b/tfexec/plan.go
@@ -3,6 +3,7 @@
 import (
 	"context"
 	"fmt"
+	"io"
 	"os/exec"
 	"strconv"
 )
@@ -108,6 +109,42 @@
 	return false, err
 }
 
+// PlanJSON executes `terraform plan` with the specified options as well as the
+// `-json` flag and waits for it to complete.
+//
+// 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`.
+//
+// The returned boolean is false when the plan diff is empty (no changes) and
+// true when the plan diff is non-empty (changes present).
+//
+// The returned error is nil if `terraform plan` has been executed and exits
+// with either 0 or 2.
+//
+// PlanJSON is likely to be removed in a future major version in favour of
+// Plan returning JSON by default.
+func (tf *Terraform) PlanJSON(ctx context.Context, w io.Writer, opts ...PlanOption) (bool, error) {
+	err := tf.compatible(ctx, tf0_15_3, nil)
+	if err != nil {
+		return false, fmt.Errorf("terraform plan -json was added in 0.15.3: %w", err)
+	}
+
+	tf.SetStdout(w)
+
+	cmd, err := tf.planJSONCmd(ctx, opts...)
+	if err != nil {
+		return false, err
+	}
+
+	err = tf.runTerraformCmd(ctx, cmd)
+	if err != nil && cmd.ProcessState.ExitCode() == 2 {
+		return true, nil
+	}
+
+	return false, err
+}
+
 func (tf *Terraform) planCmd(ctx context.Context, opts ...PlanOption) (*exec.Cmd, error) {
 	c := defaultPlanOptions
 
@@ -115,6 +152,32 @@
 		o.configurePlan(&c)
 	}
 
+	args, err := tf.buildPlanArgs(ctx, c)
+	if err != nil {
+		return nil, err
+	}
+
+	return tf.buildPlanCmd(ctx, c, args)
+}
+
+func (tf *Terraform) planJSONCmd(ctx context.Context, opts ...PlanOption) (*exec.Cmd, error) {
+	c := defaultPlanOptions
+
+	for _, o := range opts {
+		o.configurePlan(&c)
+	}
+
+	args, err := tf.buildPlanArgs(ctx, c)
+	if err != nil {
+		return nil, err
+	}
+
+	args = append(args, "-json")
+
+	return tf.buildPlanCmd(ctx, c, args)
+}
+
+func (tf *Terraform) buildPlanArgs(ctx context.Context, c planConfig) ([]string, error) {
 	args := []string{"plan", "-no-color", "-input=false", "-detailed-exitcode"}
 
 	// string opts: only pass if set
@@ -162,6 +225,10 @@
 		}
 	}
 
+	return args, nil
+}
+
+func (tf *Terraform) buildPlanCmd(ctx context.Context, c planConfig, args []string) (*exec.Cmd, error) {
 	// optional positional argument
 	if c.dir != "" {
 		args = append(args, c.dir)
diff --git a/tfexec/plan_test.go b/tfexec/plan_test.go
index 7a467ac..f84190b 100644
--- a/tfexec/plan_test.go
+++ b/tfexec/plan_test.go
@@ -80,3 +80,79 @@
 		}, nil, planCmd)
 	})
 }
+
+func TestPlanJSONCmd(t *testing.T) {
+	td := t.TempDir()
+
+	tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_v1))
+	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) {
+		planCmd, err := tf.planJSONCmd(context.Background())
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		assertCmd(t, []string{
+			"plan",
+			"-no-color",
+			"-input=false",
+			"-detailed-exitcode",
+			"-lock-timeout=0s",
+			"-lock=true",
+			"-parallelism=10",
+			"-refresh=true",
+			"-json",
+		}, nil, planCmd)
+	})
+
+	t.Run("override all defaults", func(t *testing.T) {
+		planCmd, err := tf.planJSONCmd(context.Background(),
+			Destroy(true),
+			Lock(false),
+			LockTimeout("22s"),
+			Out("whale"),
+			Parallelism(42),
+			Refresh(false),
+			Replace("ford.prefect"),
+			Replace("arthur.dent"),
+			State("marvin"),
+			Target("zaphod"),
+			Target("beeblebrox"),
+			Var("android=paranoid"),
+			Var("brain_size=planet"),
+			VarFile("trillian"),
+			Dir("earth"))
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		assertCmd(t, []string{
+			"plan",
+			"-no-color",
+			"-input=false",
+			"-detailed-exitcode",
+			"-lock-timeout=22s",
+			"-out=whale",
+			"-state=marvin",
+			"-var-file=trillian",
+			"-lock=false",
+			"-parallelism=42",
+			"-refresh=false",
+			"-replace=ford.prefect",
+			"-replace=arthur.dent",
+			"-destroy",
+			"-target=zaphod",
+			"-target=beeblebrox",
+			"-var", "android=paranoid",
+			"-var", "brain_size=planet",
+			"-json",
+			"earth",
+		}, nil, planCmd)
+	})
+}
diff --git a/tfexec/refresh.go b/tfexec/refresh.go
index 78f6b4b..4bdd896 100644
--- a/tfexec/refresh.go
+++ b/tfexec/refresh.go
@@ -2,6 +2,8 @@
 
 import (
 	"context"
+	"fmt"
+	"io"
 	"os/exec"
 	"strconv"
 )
@@ -78,6 +80,27 @@
 	return tf.runTerraformCmd(ctx, cmd)
 }
 
+// RefreshJSON represents the terraform refresh 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`. RefreshJSON is likely to be
+// removed in a future major version in favour of Refresh returning JSON by default.
+func (tf *Terraform) RefreshJSON(ctx context.Context, w io.Writer, opts ...RefreshCmdOption) error {
+	err := tf.compatible(ctx, tf0_15_3, nil)
+	if err != nil {
+		return fmt.Errorf("terraform refresh -json was added in 0.15.3: %w", err)
+	}
+
+	tf.SetStdout(w)
+
+	cmd, err := tf.refreshJSONCmd(ctx, opts...)
+	if err != nil {
+		return err
+	}
+
+	return tf.runTerraformCmd(ctx, cmd)
+}
+
 func (tf *Terraform) refreshCmd(ctx context.Context, opts ...RefreshCmdOption) (*exec.Cmd, error) {
 	c := defaultRefreshOptions
 
@@ -85,6 +108,26 @@
 		o.configureRefresh(&c)
 	}
 
+	args := tf.buildRefreshArgs(c)
+
+	return tf.buildRefreshCmd(ctx, c, args)
+
+}
+
+func (tf *Terraform) refreshJSONCmd(ctx context.Context, opts ...RefreshCmdOption) (*exec.Cmd, error) {
+	c := defaultRefreshOptions
+
+	for _, o := range opts {
+		o.configureRefresh(&c)
+	}
+
+	args := tf.buildRefreshArgs(c)
+	args = append(args, "-json")
+
+	return tf.buildRefreshCmd(ctx, c, args)
+}
+
+func (tf *Terraform) buildRefreshArgs(c refreshConfig) []string {
 	args := []string{"refresh", "-no-color", "-input=false"}
 
 	// string opts: only pass if set
@@ -119,6 +162,10 @@
 		}
 	}
 
+	return args
+}
+
+func (tf *Terraform) buildRefreshCmd(ctx context.Context, c refreshConfig, args []string) (*exec.Cmd, error) {
 	// optional positional argument
 	if c.dir != "" {
 		args = append(args, c.dir)
diff --git a/tfexec/refresh_test.go b/tfexec/refresh_test.go
index bd4a94c..0b94a1e 100644
--- a/tfexec/refresh_test.go
+++ b/tfexec/refresh_test.go
@@ -57,3 +57,56 @@
 		}, nil, refreshCmd)
 	})
 }
+
+func TestRefreshJSONCmd(t *testing.T) {
+	td := t.TempDir()
+
+	tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_v1))
+	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) {
+		refreshCmd, err := tf.refreshJSONCmd(context.Background())
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		assertCmd(t, []string{
+			"refresh",
+			"-no-color",
+			"-input=false",
+			"-lock-timeout=0s",
+			"-lock=true",
+			"-json",
+		}, nil, refreshCmd)
+	})
+
+	t.Run("override all defaults", func(t *testing.T) {
+		refreshCmd, err := tf.refreshJSONCmd(context.Background(), Backup("testbackup"), LockTimeout("200s"), State("teststate"), StateOut("teststateout"), VarFile("testvarfile"), Lock(false), Target("target1"), Target("target2"), Var("var1=foo"), Var("var2=bar"), Dir("refreshdir"))
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		assertCmd(t, []string{
+			"refresh",
+			"-no-color",
+			"-input=false",
+			"-backup=testbackup",
+			"-lock-timeout=200s",
+			"-state=teststate",
+			"-state-out=teststateout",
+			"-var-file=testvarfile",
+			"-lock=false",
+			"-target=target1",
+			"-target=target2",
+			"-var", "var1=foo",
+			"-var", "var2=bar",
+			"-json",
+			"refreshdir",
+		}, nil, refreshCmd)
+	})
+}
diff --git a/tfexec/version.go b/tfexec/version.go
index 9978ae2..37825b5 100644
--- a/tfexec/version.go
+++ b/tfexec/version.go
@@ -25,6 +25,7 @@
 	tf0_14_0 = version.Must(version.NewVersion("0.14.0"))
 	tf0_15_0 = version.Must(version.NewVersion("0.15.0"))
 	tf0_15_2 = version.Must(version.NewVersion("0.15.2"))
+	tf0_15_3 = version.Must(version.NewVersion("0.15.3"))
 	tf1_1_0  = version.Must(version.NewVersion("1.1.0"))
 )