diff --git a/internal/ast/ast.go b/internal/ast/ast.go index f1908e53d6..1e5d388319 100644 --- a/internal/ast/ast.go +++ b/internal/ast/ast.go @@ -42,6 +42,11 @@ func ParseFile(src []byte, filename string, schemas *providers.ProviderSchema) ( }, nil } +// Filename returns the filename this file was parsed from. +func (f *File) Filename() string { + return f.filename +} + // Bytes returns the current content of the file, reflecting any // mutations that have been applied. func (f *File) Bytes() []byte { diff --git a/internal/ast/config.go b/internal/ast/config.go index e72bfa4681..12ed14f56d 100644 --- a/internal/ast/config.go +++ b/internal/ast/config.go @@ -50,6 +50,11 @@ func (m *Module) Editable() bool { return m.editable } +// Files returns all files in this module. +func (m *Module) Files() []*File { + return m.files +} + // FindBlocks searches all files in this module for blocks matching // blockType and firstLabel. If firstLabel is empty, matches blocks // with no labels (e.g., terraform {}). diff --git a/internal/ast/migrations_future_test.go b/internal/ast/migrations_future_test.go index 4c8699895e..c884aa43c4 100644 --- a/internal/ast/migrations_future_test.go +++ b/internal/ast/migrations_future_test.go @@ -3,50 +3,481 @@ package ast -import "testing" +import ( + "testing" -// These tests document future AST operations needed for full migration coverage. -// Each is skipped with a description of the required capability. + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclwrite" +) -func TestFuture_ExtractToResource(t *testing.T) { - t.Skip("TODO: extract nested block/attribute from one resource into a new standalone resource block. " + - "Example: aws_s3_bucket.versioning {} → new aws_s3_bucket_versioning resource wired via bucket = aws_s3_bucket.X.id") +// TestMigrationPatterns_ExtractToResource exercises extraction of a nested block +// from one resource into a new standalone resource, wired back to the parent. +// +// Real-world examples: +// - AWS v3→v4: aws_s3_bucket.versioning {} → new aws_s3_bucket_versioning resource +// - AWS v3→v4: aws_s3_bucket.logging {} → new aws_s3_bucket_logging resource +func TestMigrationPatterns_ExtractToResource(t *testing.T) { + tests := []struct { + name string + files map[string]string + mutate func(t *testing.T, mod *Module) + want map[string]string + }{ + { + name: "extract_nested_block_to_new_resource", + files: map[string]string{ + "main.tf": `# S3 bucket with versioning enabled +resource "test_bucket" "main" { + name = "my-bucket" # the bucket name - // When implemented, this test should: - // 1. Parse an aws_s3_bucket with a versioning {} block - // 2. Remove the versioning block from aws_s3_bucket - // 3. Create a new aws_s3_bucket_versioning resource - // 4. Wire it to the bucket via bucket attribute - // 5. Verify the output has both resources with correct references + versioning { + enabled = true + } } -func TestFuture_MoveAttributeToBlock(t *testing.T) { - t.Skip("TODO: move a top-level attribute into a nested block. " + - "Example: aws_instance.cpu_core_count → aws_instance.cpu_options { core_count }") +/* Output to prove surrounding code is preserved */ +output "bucket_id" { + value = test_bucket.main.id +} +`, + }, + mutate: func(t *testing.T, mod *Module) { + for _, r := range mod.FindBlocks("resource", "test_bucket") { + nested := r.Block.NestedBlocks("versioning") + if len(nested) == 0 { + continue + } + // Read attributes from nested block before removing it + attrs := nested[0].Attributes() + + // Remove nested block from parent + r.Block.RemoveBlock("versioning") + + // Create new standalone resource + labels := r.Block.Labels() + newBlock := r.File.AddBlock("resource", []string{"test_bucket_versioning", labels[1]}) + + // Wire to parent via traversal + newBlock.SetAttributeTraversal("bucket", hcl.Traversal{ + hcl.TraverseRoot{Name: "test_bucket"}, + hcl.TraverseAttr{Name: labels[1]}, + hcl.TraverseAttr{Name: "id"}, + }) + + // Copy attributes from the extracted nested block + for name, expr := range attrs { + newBlock.SetAttributeRaw(name, expr.BuildTokens(nil)) + } + } + }, + want: map[string]string{ + "main.tf": `# S3 bucket with versioning enabled +resource "test_bucket" "main" { + name = "my-bucket" # the bucket name + +} + +/* Output to prove surrounding code is preserved */ +output "bucket_id" { + value = test_bucket.main.id +} +resource "test_bucket_versioning" "main" { + bucket = test_bucket.main.id + enabled = true +} +`, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var files []*File + for name, content := range tc.files { + f, err := ParseFile([]byte(content), name, nil) + if err != nil { + t.Fatalf("parsing %s: %s", name, err) + } + files = append(files, f) + } + mod := NewModule(files, "", true, nil) + + tc.mutate(t, mod) + + got := mod.Bytes() + for name, wantContent := range tc.want { + gotContent, ok := got[name] + if !ok { + t.Errorf("missing output file %s", name) + continue + } + if string(gotContent) != wantContent { + t.Errorf("file %s mismatch\n--- want ---\n%s\n--- got ---\n%s", name, wantContent, string(gotContent)) + } + } + }) + } +} + +// TestMigrationPatterns_MoveAttributeToBlock exercises moving a top-level attribute +// into a nested block. +// +// Real-world examples: +// - AWS v6: aws_instance.cpu_core_count → aws_instance.cpu_options { core_count } +// - AWS v6: aws_instance.cpu_threads_per_core → aws_instance.cpu_options { threads_per_core } +func TestMigrationPatterns_MoveAttributeToBlock(t *testing.T) { + tests := []struct { + name string + files map[string]string + mutate func(t *testing.T, mod *Module) + want map[string]string + }{ + { + name: "move_attribute_into_nested_block", + files: map[string]string{ + "main.tf": `# Instance with top-level CPU attributes +resource "test_instance" "web" { + ami = "abc-123" # the base image + instance_type = "c5.xlarge" + cpu_core_count = 2 + cpu_threads_per_core = 1 +} +`, + }, + mutate: func(t *testing.T, mod *Module) { + for _, r := range mod.FindBlocks("resource", "test_instance") { + // Read expressions before removing + coreExpr := r.Block.GetAttributeExpression("cpu_core_count") + threadsExpr := r.Block.GetAttributeExpression("cpu_threads_per_core") + if coreExpr == nil && threadsExpr == nil { + continue + } + + // Create nested block + cpuOpts := r.Block.AddBlock("cpu_options") + + // Move attributes + if coreExpr != nil { + cpuOpts.SetAttributeRaw("core_count", coreExpr.BuildTokens(nil)) + r.Block.RemoveAttribute("cpu_core_count") + } + if threadsExpr != nil { + cpuOpts.SetAttributeRaw("threads_per_core", threadsExpr.BuildTokens(nil)) + r.Block.RemoveAttribute("cpu_threads_per_core") + } + } + }, + want: map[string]string{ + "main.tf": `# Instance with top-level CPU attributes +resource "test_instance" "web" { + ami = "abc-123" # the base image + instance_type = "c5.xlarge" + cpu_options { + core_count = 2 + threads_per_core = 1 + } +} +`, + }, + }, + { + name: "move_attribute_with_expression_value", + files: map[string]string{ + "main.tf": `resource "test_instance" "dynamic" { + ami = var.ami_id + instance_type = var.instance_type + cpu_core_count = var.cpu_cores + cpu_threads_per_core = var.env == "prod" ? 2 : 1 +} +`, + }, + mutate: func(t *testing.T, mod *Module) { + for _, r := range mod.FindBlocks("resource", "test_instance") { + coreExpr := r.Block.GetAttributeExpression("cpu_core_count") + threadsExpr := r.Block.GetAttributeExpression("cpu_threads_per_core") + if coreExpr == nil && threadsExpr == nil { + continue + } + cpuOpts := r.Block.AddBlock("cpu_options") + if coreExpr != nil { + cpuOpts.SetAttributeRaw("core_count", coreExpr.BuildTokens(nil)) + r.Block.RemoveAttribute("cpu_core_count") + } + if threadsExpr != nil { + cpuOpts.SetAttributeRaw("threads_per_core", threadsExpr.BuildTokens(nil)) + r.Block.RemoveAttribute("cpu_threads_per_core") + } + } + }, + want: map[string]string{ + "main.tf": `resource "test_instance" "dynamic" { + ami = var.ami_id + instance_type = var.instance_type + cpu_options { + core_count = var.cpu_cores + threads_per_core = var.env == "prod" ? 2 : 1 + } +} +`, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var files []*File + for name, content := range tc.files { + f, err := ParseFile([]byte(content), name, nil) + if err != nil { + t.Fatalf("parsing %s: %s", name, err) + } + files = append(files, f) + } + mod := NewModule(files, "", true, nil) + + tc.mutate(t, mod) + + got := mod.Bytes() + for name, wantContent := range tc.want { + gotContent, ok := got[name] + if !ok { + t.Errorf("missing output file %s", name) + continue + } + if string(gotContent) != wantContent { + t.Errorf("file %s mismatch\n--- want ---\n%s\n--- got ---\n%s", name, wantContent, string(gotContent)) + } + } + }) + } +} - // When implemented, this test should: - // 1. Parse an aws_instance with cpu_core_count = 2 - // 2. Move it into cpu_options { core_count = 2 } - // 3. Verify the attribute is removed from top level and present in nested block +// TestMigrationPatterns_FlattenBlock exercises inlining nested block attributes +// into the parent block. +// +// Real-world examples: +// - AWS v5: aws_elasticache_replication_group.cluster_mode { num_node_groups, replicas_per_node_group } +// → top-level num_node_groups, replicas_per_node_group +func TestMigrationPatterns_FlattenBlock(t *testing.T) { + tests := []struct { + name string + files map[string]string + mutate func(t *testing.T, mod *Module) + want map[string]string + }{ + { + name: "flatten_nested_block_to_parent", + files: map[string]string{ + "main.tf": `# Replication group with cluster_mode block +resource "test_replication_group" "main" { + description = "my cluster" # the description + + cluster_mode { + num_node_groups = 3 + replicas_per_node_group = 2 + } } +`, + }, + mutate: func(t *testing.T, mod *Module) { + for _, r := range mod.FindBlocks("resource", "test_replication_group") { + nested := r.Block.NestedBlocks("cluster_mode") + if len(nested) == 0 { + continue + } + // Read specific attributes by name for deterministic order + attrNames := []string{"num_node_groups", "replicas_per_node_group"} + exprs := make(map[string]*hclwrite.Expression) + for _, name := range attrNames { + if expr := nested[0].GetAttributeExpression(name); expr != nil { + exprs[name] = expr + } + } + + // Remove the nested block + r.Block.RemoveBlock("cluster_mode") -func TestFuture_FlattenBlock(t *testing.T) { - t.Skip("TODO: inline nested block attributes into parent. " + - "Example: aws_elasticache_replication_group.cluster_mode { num_node_groups } → top-level num_node_groups") + // Add attributes to parent in deterministic order + for _, name := range attrNames { + if expr, ok := exprs[name]; ok { + r.Block.SetAttributeRaw(name, expr.BuildTokens(nil)) + } + } + } + }, + want: map[string]string{ + "main.tf": `# Replication group with cluster_mode block +resource "test_replication_group" "main" { + description = "my cluster" # the description - // When implemented, this test should: - // 1. Parse a block with cluster_mode { num_node_groups = 3, replicas_per_node_group = 2 } - // 2. Flatten into top-level num_node_groups = 3, replicas_per_node_group = 2 - // 3. Verify cluster_mode block is removed and attributes are at top level + num_node_groups = 3 + replicas_per_node_group = 2 } +`, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var files []*File + for name, content := range tc.files { + f, err := ParseFile([]byte(content), name, nil) + if err != nil { + t.Fatalf("parsing %s: %s", name, err) + } + files = append(files, f) + } + mod := NewModule(files, "", true, nil) + + tc.mutate(t, mod) -func TestFuture_RemoveResourceWithRefWarnings(t *testing.T) { - t.Skip("TODO: remove a resource block and add FIXME comments at all reference sites across files. " + - "Example: remove aws_opsworks_stack and add FIXME wherever aws_opsworks_stack.X is referenced") + got := mod.Bytes() + for name, wantContent := range tc.want { + gotContent, ok := got[name] + if !ok { + t.Errorf("missing output file %s", name) + continue + } + if string(gotContent) != wantContent { + t.Errorf("file %s mismatch\n--- want ---\n%s\n--- got ---\n%s", name, wantContent, string(gotContent)) + } + } + }) + } +} + +// TestMigrationPatterns_RemoveResourceWithRefWarnings exercises removing a resource +// block and adding FIXME comments to all files that reference it. +// +// Real-world examples: +// - AWS v6: aws_opsworks_* resources removed (17 resources) +// - AWS v5: aws_db_security_group, aws_redshift_security_group removed (EC2-Classic) +func TestMigrationPatterns_RemoveResourceWithRefWarnings(t *testing.T) { + tests := []struct { + name string + files map[string]string + mutate func(t *testing.T, mod *Module) + want map[string]string + }{ + { + name: "remove_resource_and_warn_at_reference_sites", + files: map[string]string{ + "main.tf": `# OpsWorks stack definition +resource "test_opsworks_stack" "main" { + name = "my-stack" # the stack name + region = "us-east-1" +} - // When implemented, this test should: - // 1. Parse multiple files with an aws_opsworks_stack and references to it - // 2. Remove the resource block - // 3. Add FIXME comments at each reference site in other files - // 4. Verify the resource is gone and all reference sites have comments +// A non-related resource in the same file +resource "test_instance" "web" { + ami = "abc-123" + instance_type = "t2.micro" } +`, + "references.tf": `# Layer referencing the stack +resource "test_opsworks_layer" "app" { + stack_id = test_opsworks_stack.main.id + name = "app-layer" +} + +output "stack_id" { + value = test_opsworks_stack.main.id +} +`, + "unrelated.tf": `# File with no references to the removed resource +resource "test_bucket" "logs" { + name = "logs-bucket" +} +`, + }, + mutate: func(t *testing.T, mod *Module) { + results := mod.FindBlocks("resource", "test_opsworks_stack") + if len(results) == 0 { + return + } + + // Collect which files reference this resource + prefix := hcl.Traversal{hcl.TraverseRoot{Name: "test_opsworks_stack"}} + + // Track files that have references (deduplicate) + warned := make(map[string]bool) + for _, f := range mod.Files() { + if f.ReferencesPrefix(prefix) { + if !warned[f.Filename()] { + f.AppendComment("FIXME: test_opsworks_stack has been removed. Update references manually.") + warned[f.Filename()] = true + } + } + } + + // Remove the resource blocks + for _, r := range results { + labels := r.Block.Labels() + r.File.RemoveBlock("resource", append([]string{"test_opsworks_stack"}, labels[1:]...)) + } + }, + want: map[string]string{ + "main.tf": ` +// A non-related resource in the same file +resource "test_instance" "web" { + ami = "abc-123" + instance_type = "t2.micro" +} +`, + "references.tf": `# Layer referencing the stack +resource "test_opsworks_layer" "app" { + stack_id = test_opsworks_stack.main.id + name = "app-layer" +} + +output "stack_id" { + value = test_opsworks_stack.main.id +} + +# FIXME: test_opsworks_stack has been removed. Update references manually. +`, + "unrelated.tf": `# File with no references to the removed resource +resource "test_bucket" "logs" { + name = "logs-bucket" +} +`, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var files []*File + for name, content := range tc.files { + f, err := ParseFile([]byte(content), name, nil) + if err != nil { + t.Fatalf("parsing %s: %s", name, err) + } + files = append(files, f) + } + mod := NewModule(files, "", true, nil) + + tc.mutate(t, mod) + + got := mod.Bytes() + for name, wantContent := range tc.want { + gotContent, ok := got[name] + if !ok { + t.Errorf("missing output file %s", name) + continue + } + if string(gotContent) != wantContent { + t.Errorf("file %s mismatch\n--- want ---\n%s\n--- got ---\n%s", name, wantContent, string(gotContent)) + } + } + // Verify no extra files appeared + for name := range got { + if _, ok := tc.want[name]; !ok { + t.Errorf("unexpected output file %s", name) + } + } + }) + } +} +