diff --git a/README.md b/README.md index b25003f..7f01d36 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ - Schemaless: No dependency on specific HCL application binary or schema - Support HCL2 (not HCL1) - Available operations: - - attribute append / get / mv / rm / set + - attribute append / get / mv / replace / rm / set - block append / get / list / mv / new / rm - body get - fmt @@ -84,6 +84,7 @@ Available Commands: append Append attribute get Get attribute mv Move attribute (Rename attribute key) + replace Replace both the name and value of attribute rm Remove attribute set Set attribute @@ -133,6 +134,16 @@ resource "foo" "bar" { } ``` +``` +$ cat tmp/attr.hcl | hcledit attribute replace resource.foo.bar.nested.attr2 attr3 '"val3"' +resource "foo" "bar" { + attr1 = "val1" + nested { + attr3 = "val3" + } +} +``` + ``` $ cat tmp/attr.hcl | hcledit attribute rm resource.foo.bar.attr1 resource "foo" "bar" { diff --git a/cmd/attribute.go b/cmd/attribute.go index 3a60b73..7f95eaf 100644 --- a/cmd/attribute.go +++ b/cmd/attribute.go @@ -26,6 +26,7 @@ func newAttributeCmd() *cobra.Command { newAttributeGetCmd(), newAttributeSetCmd(), newAttributeMvCmd(), + newAttributeReplaceCmd(), newAttributeRmCmd(), newAttributeAppendCmd(), ) @@ -80,9 +81,9 @@ Arguments: ADDRESS An address of attribute to set. VALUE A new value of attribute. The value is set literally, even if references or expressions. - Thus, if you want to set a string literal "hoge", be sure to + Thus, if you want to set a string literal "foo", be sure to escape double quotes so that they are not discarded by your shell. - e.g.) hcledit attribute set aaa.bbb.ccc '"hoge"' + e.g.) hcledit attribute set aaa.bbb.ccc '"foo"' `, RunE: runAttributeSetCmd, } @@ -151,6 +152,43 @@ func runAttributeMvCmd(cmd *cobra.Command, args []string) error { return c.Edit(file, update, filter) } +func newAttributeReplaceCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "replace
", + Short: "Replace both the name and value of attribute", + Long: `Replace both the name and value of matched attribute at a given address + +Arguments: + ADDRESS An address of attribute to be replaced. + NAME A new name (key) of attribute. + VALUE A new value of attribute. + The value is set literally, even if references or expressions. + Thus, if you want to set a string literal "bar", be sure to + escape double quotes so that they are not discarded by your shell. + e.g.) hcledit attribute replace aaa.bbb.ccc foo '"bar"' +`, + RunE: runAttributeReplaceCmd, + } + + return cmd +} + +func runAttributeReplaceCmd(cmd *cobra.Command, args []string) error { + if len(args) != 3 { + return fmt.Errorf("expected 3 argument, but got %d arguments", len(args)) + } + + address := args[0] + name := args[1] + value := args[2] + file := viper.GetString("file") + update := viper.GetBool("update") + + filter := editor.NewAttributeReplaceFilter(address, name, value) + c := newDefaultClient(cmd) + return c.Edit(file, update, filter) +} + func runAttributeRmCmd(cmd *cobra.Command, args []string) error { if len(args) != 1 { return fmt.Errorf("expected 1 argument, but got %d arguments", len(args)) diff --git a/cmd/attribute_test.go b/cmd/attribute_test.go index 835c8c0..06aa4f6 100644 --- a/cmd/attribute_test.go +++ b/cmd/attribute_test.go @@ -260,6 +260,85 @@ resource "foo" "bar" { } } +func TestAttributeReplace(t *testing.T) { + src := `terraform { + backend "s3" { + region = "ap-northeast-1" + bucket = "my-s3lock-test" + key = "dir1/terraform.tfstate" + dynamodb_table = "tflock" + profile = "foo" + } +} +` + + cases := []struct { + name string + args []string + ok bool + want string + }{ + { + name: "simple", + args: []string{"terraform.backend.s3.dynamodb_table", "use_lockfile", "true"}, + ok: true, + want: `terraform { + backend "s3" { + region = "ap-northeast-1" + bucket = "my-s3lock-test" + key = "dir1/terraform.tfstate" + use_lockfile = true + profile = "foo" + } +} +`, + }, + { + name: "no match", + args: []string{"terraform.backend.s3.foo_table", "use_lockfile", "true"}, + ok: true, + want: src, + }, + { + name: "duplicated", + args: []string{"terraform.backend.s3.dynamodb_table", "profile", "true"}, + ok: false, + want: "", + }, + { + name: "no args", + args: []string{}, + ok: false, + want: "", + }, + { + name: "1 arg", + args: []string{"foo"}, + ok: false, + want: "", + }, + { + name: "2 args", + args: []string{"foo", "bar"}, + ok: false, + want: "", + }, + { + name: "too many args", + args: []string{"foo", "bar", "baz", "qux"}, + ok: false, + want: "", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cmd := newMockCmd(newAttributeReplaceCmd(), src) + assertMockCmd(t, cmd, tc.args, tc.ok, tc.want) + }) + } +} + func TestAttributeRm(t *testing.T) { src := `locals { service = "hoge" diff --git a/editor/filter_attribute_replace.go b/editor/filter_attribute_replace.go new file mode 100644 index 0000000..7f12198 --- /dev/null +++ b/editor/filter_attribute_replace.go @@ -0,0 +1,62 @@ +package editor + +import ( + "fmt" + + "github.com/hashicorp/hcl/v2/hclwrite" +) + +// AttributeReplaceFilter is a filter implementation for replacing attribute. +type AttributeReplaceFilter struct { + address string + name string + value string +} + +var _ Filter = (*AttributeReplaceFilter)(nil) + +// NewAttributeReplaceFilter creates a new instance of AttributeReplaceFilter. +func NewAttributeReplaceFilter(address string, name string, value string) Filter { + return &AttributeReplaceFilter{ + address: address, + name: name, + value: value, + } +} + +// Filter reads HCL and replaces both the name and value of matched an +// attribute at a given address. +func (f *AttributeReplaceFilter) Filter(inFile *hclwrite.File) (*hclwrite.File, error) { + attr, body, err := findAttribute(inFile.Body(), f.address) + if err != nil { + return nil, err + } + + if attr != nil { + _, fromAttributeName, err := parseAttributeAddress(f.address) + if err != nil { + return nil, err + } + toAttributeName := f.name + + // The Body.RenameAttribute() returns false if fromName does not exist or + // toName already exists. However, here, we want to return an error only + // if toName already exists, so we check it ourselves. + toAttr := body.GetAttribute(toAttributeName) + if toAttr != nil { + return nil, fmt.Errorf("attribute already exists: %s", toAttributeName) + } + + _ = body.RenameAttribute(fromAttributeName, toAttributeName) + + // To delegate expression parsing to the hclwrite parser, + // We build a new expression and set back to the attribute by tokens. + expr, err := buildExpression(toAttributeName, f.value) + if err != nil { + return nil, err + } + body.SetAttributeRaw(toAttributeName, expr.BuildTokens(nil)) + } + + return inFile, nil +} diff --git a/editor/filter_attribute_replace_test.go b/editor/filter_attribute_replace_test.go new file mode 100644 index 0000000..8791883 --- /dev/null +++ b/editor/filter_attribute_replace_test.go @@ -0,0 +1,151 @@ +package editor + +import ( + "testing" +) + +func TestAttributeReplaceFilter(t *testing.T) { + cases := []struct { + name string + src string + address string + toName string + toValue string + ok bool + want string + }{ + { + name: "simple", + src: ` +a0 = v0 +a1 = v1 +`, + address: "a0", + toName: "a2", + toValue: "v2", + ok: true, + want: ` +a2 = v2 +a1 = v1 +`, + }, + { + name: "with comments", + src: ` +# before attr +a0 = "v0" # inline +a1 = "v1" +`, + address: "a0", + toName: "a2", + toValue: `"v2"`, + ok: true, + want: ` +# before attr +a2 = "v2" # inline +a1 = "v1" +`, + }, + { + name: "attribute in block", + src: ` +a0 = v0 +b1 "l1" { + a1 = v1 +} +`, + address: "b1.l1.a1", + toName: "a2", + toValue: "v2", + ok: true, + want: ` +a0 = v0 +b1 "l1" { + a2 = v2 +} +`, + }, + { + name: "not found", + src: ` +a0 = v0 +`, + address: "a1", + toName: "a2", + toValue: "v2", + ok: true, + want: ` +a0 = v0 +`, + }, + { + name: "attribute not found in block", + src: ` +a0 = v0 +b1 "l1" { + a1 = v1 +} +`, + address: "b1.l1.a2", + toName: "a3", + toValue: "v3", + ok: true, + want: ` +a0 = v0 +b1 "l1" { + a1 = v1 +} +`, + }, + { + name: "block not found", + src: ` +a0 = v0 +b1 "l1" { + a1 = v1 +} +`, + address: "b2.l1.a1", + toName: "a2", + toValue: "v2", + ok: true, + want: ` +a0 = v0 +b1 "l1" { + a1 = v1 +} +`, + }, + { + name: "attribute already exists", + src: ` +a0 = v0 +a1 = v1 +`, + address: "a0", + toName: "a1", + toValue: "v2", + ok: false, + want: "", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + o := NewEditOperator(NewAttributeReplaceFilter(tc.address, tc.toName, tc.toValue)) + output, err := o.Apply([]byte(tc.src), "test") + if tc.ok && err != nil { + t.Fatalf("unexpected err = %s", err) + } + + got := string(output) + if !tc.ok && err == nil { + t.Fatalf("expected to return an error, but no error, outStream: \n%s", got) + } + + if got != tc.want { + t.Fatalf("got:\n%s\nwant:\n%s", got, tc.want) + } + }) + } +}