feat: add `-generate-config-out` option to `plan` (#563)

Co-authored-by: Hiromasa Kakehashi <hrmsk66@gmail.com>
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ac93c8d..b035441 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
 
 ENHANCEMENTS:
 - tfexec: Added provider reattach support to all `terraform workspace` subcommands ([#556](https://github.com/hashicorp/terraform-exec/pull/556))
+- tfexec: Add `-generate-config-out` to the `(Terraform).Plan()` method ([#563](https://github.com/hashicorp/terraform-exec/pull/563))
 
 # 0.24.0 (September 17, 2025)
 
diff --git a/tfexec/internal/e2etest/plan_test.go b/tfexec/internal/e2etest/plan_test.go
index 16b7a43..0b19dc7 100644
--- a/tfexec/internal/e2etest/plan_test.go
+++ b/tfexec/internal/e2etest/plan_test.go
@@ -6,6 +6,8 @@
 import (
 	"context"
 	"io"
+	"os"
+	"path/filepath"
 	"regexp"
 	"testing"
 
@@ -15,6 +17,10 @@
 	"github.com/hashicorp/terraform-exec/tfexec/internal/testutil"
 )
 
+var (
+	generateConfigOutMinVersion = version.Must(version.NewVersion("1.5.0"))
+)
+
 func TestPlan(t *testing.T) {
 	runTest(t, "basic", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) {
 		err := tf.Init(context.Background())
@@ -92,3 +98,25 @@
 		}
 	})
 }
+
+func TestPlanGenerateConfigOut(t *testing.T) {
+	runTest(t, "generate_config_out", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) {
+		if tfv.LessThan(generateConfigOutMinVersion) {
+			t.Skip("terraform plan -generate-config-out was added in Terraform 1.5.0, so test is not valid")
+		}
+
+		err := tf.Init(context.Background())
+		if err != nil {
+			t.Fatalf("error running Init in test directory: %s", err)
+		}
+
+		_, err = tf.Plan(context.Background(), tfexec.GenerateConfigOut("generated.tf"))
+		if err != nil {
+			t.Fatalf("error running Plan: %s", err)
+		}
+
+		if _, err = os.Stat(filepath.Join(tf.WorkingDir(), "generated.tf")); os.IsNotExist(err) {
+			t.Fatalf("expected generated.tf to be created, but it does not exist")
+		}
+	})
+}
diff --git a/tfexec/internal/e2etest/testdata/generate_config_out/main.tf b/tfexec/internal/e2etest/testdata/generate_config_out/main.tf
new file mode 100644
index 0000000..8257ac5
--- /dev/null
+++ b/tfexec/internal/e2etest/testdata/generate_config_out/main.tf
@@ -0,0 +1,4 @@
+import {
+  id = "bar"
+  to = terraform_data.foo
+}
diff --git a/tfexec/options.go b/tfexec/options.go
index 0129bd5..85d2acd 100644
--- a/tfexec/options.go
+++ b/tfexec/options.go
@@ -188,6 +188,7 @@
 	path string
 }
 
+// GenerateConfigOut represents the -generate-config-out flag.
 func GenerateConfigOut(path string) *GenerateConfigOutOption {
 	return &GenerateConfigOutOption{path}
 }
diff --git a/tfexec/plan.go b/tfexec/plan.go
index c2ec1f9..48f486d 100644
--- a/tfexec/plan.go
+++ b/tfexec/plan.go
@@ -12,21 +12,22 @@
 )
 
 type planConfig struct {
-	allowDeferral bool
-	destroy       bool
-	dir           string
-	lock          bool
-	lockTimeout   string
-	out           string
-	parallelism   int
-	reattachInfo  ReattachInfo
-	refresh       bool
-	refreshOnly   bool
-	replaceAddrs  []string
-	state         string
-	targets       []string
-	vars          []string
-	varFiles      []string
+	allowDeferral     bool
+	destroy           bool
+	dir               string
+	generateConfigOut string
+	lock              bool
+	lockTimeout       string
+	out               string
+	parallelism       int
+	reattachInfo      ReattachInfo
+	refresh           bool
+	refreshOnly       bool
+	replaceAddrs      []string
+	state             string
+	targets           []string
+	vars              []string
+	varFiles          []string
 }
 
 var defaultPlanOptions = planConfig{
@@ -102,6 +103,10 @@
 	conf.allowDeferral = opt.allowDeferral
 }
 
+func (opt *GenerateConfigOutOption) configurePlan(conf *planConfig) {
+	conf.generateConfigOut = opt.path
+}
+
 // Plan executes `terraform plan` with the specified options and waits for it
 // to complete.
 //
@@ -194,6 +199,13 @@
 	args := []string{"plan", "-no-color", "-input=false", "-detailed-exitcode"}
 
 	// string opts: only pass if set
+	if c.generateConfigOut != "" {
+		err := tf.compatible(ctx, tf1_5_0, nil)
+		if err != nil {
+			return nil, fmt.Errorf("generate-config-out option was introduced in Terraform 1.5.0: %w", err)
+		}
+		args = append(args, "-generate-config-out="+c.generateConfigOut)
+	}
 	if c.lockTimeout != "" {
 		args = append(args, "-lock-timeout="+c.lockTimeout)
 	}
diff --git a/tfexec/plan_test.go b/tfexec/plan_test.go
index 91c13f5..ef45b12 100644
--- a/tfexec/plan_test.go
+++ b/tfexec/plan_test.go
@@ -13,7 +13,7 @@
 func TestPlanCmd(t *testing.T) {
 	td := t.TempDir()
 
-	tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_v1))
+	tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_v1_5))
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -101,12 +101,31 @@
 			"-refresh-only",
 		}, nil, planCmd)
 	})
+
+	t.Run("run a generate-config-out plan", func(t *testing.T) {
+		planCmd, err := tf.planCmd(context.Background(), GenerateConfigOut("generated.tf"))
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		assertCmd(t, []string{
+			"plan",
+			"-no-color",
+			"-input=false",
+			"-detailed-exitcode",
+			"-generate-config-out=generated.tf",
+			"-lock-timeout=0s",
+			"-lock=true",
+			"-parallelism=10",
+			"-refresh=true",
+		}, nil, planCmd)
+	})
 }
 
 func TestPlanJSONCmd(t *testing.T) {
 	td := t.TempDir()
 
-	tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_v1))
+	tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_v1_5))
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -177,6 +196,26 @@
 			"earth",
 		}, nil, planCmd)
 	})
+
+	t.Run("generate-config-out", func(t *testing.T) {
+		planCmd, err := tf.planJSONCmd(context.Background(), GenerateConfigOut("generated.tf"))
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		assertCmd(t, []string{
+			"plan",
+			"-no-color",
+			"-input=false",
+			"-detailed-exitcode",
+			"-generate-config-out=generated.tf",
+			"-lock-timeout=0s",
+			"-lock=true",
+			"-parallelism=10",
+			"-refresh=true",
+			"-json",
+		}, nil, planCmd)
+	})
 }
 
 func TestPlanCmd_AllowDeferral(t *testing.T) {
diff --git a/tfexec/version.go b/tfexec/version.go
index 880c7b3..cbf28fb 100644
--- a/tfexec/version.go
+++ b/tfexec/version.go
@@ -32,6 +32,7 @@
 	tf0_15_4 = version.Must(version.NewVersion("0.15.4"))
 	tf1_1_0  = version.Must(version.NewVersion("1.1.0"))
 	tf1_4_0  = version.Must(version.NewVersion("1.4.0"))
+	tf1_5_0  = version.Must(version.NewVersion("1.5.0"))
 	tf1_6_0  = version.Must(version.NewVersion("1.6.0"))
 	tf1_9_0  = version.Must(version.NewVersion("1.9.0"))
 	tf1_13_0 = version.Must(version.NewVersion("1.13.0"))