Skip to content

Commit d09e510

Browse files
authored
Merge pull request #1620 from gruntwork-io/feature/modularize-terragrunt
Refactor: Extract formatting utilities to internal/lib
2 parents ac5ebc1 + 0b64605 commit d09e510

File tree

5 files changed

+259
-180
lines changed

5 files changed

+259
-180
lines changed

internal/lib/formatting/format.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
// Package formatting provides internal utilities for formatting Terraform/Terragrunt CLI arguments.
2+
package formatting
3+
4+
import (
5+
"fmt"
6+
"reflect"
7+
"strconv"
8+
"strings"
9+
)
10+
11+
// FormatBackendConfigAsArgs formats backend configuration as Terraform CLI args.
12+
// Example: {"bucket": "my-bucket"} -> ["-backend-config=bucket=my-bucket"]
13+
func FormatBackendConfigAsArgs(vars map[string]interface{}) []string {
14+
return formatTerraformArgs(vars, "-backend-config", false, true)
15+
}
16+
17+
// FormatPluginDirAsArgs formats plugin directory as a Terraform CLI arg.
18+
// Returns nil if pluginDir is empty.
19+
func FormatPluginDirAsArgs(pluginDir string) []string {
20+
if pluginDir == "" {
21+
return nil
22+
}
23+
return []string{fmt.Sprintf("-plugin-dir=%v", pluginDir)}
24+
}
25+
26+
// formatTerraformArgs formats vars as CLI args with the given prefix.
27+
func formatTerraformArgs(vars map[string]interface{}, prefix string, useSpaceAsSeparator bool, omitNil bool) []string {
28+
var args []string
29+
30+
for key, value := range vars {
31+
var argValue string
32+
if omitNil && value == nil {
33+
argValue = key
34+
} else {
35+
hclString := toHclString(value, false)
36+
argValue = fmt.Sprintf("%s=%s", key, hclString)
37+
}
38+
if useSpaceAsSeparator {
39+
args = append(args, prefix, argValue)
40+
} else {
41+
args = append(args, fmt.Sprintf("%s=%s", prefix, argValue))
42+
}
43+
}
44+
45+
return args
46+
}
47+
48+
// toHclString converts Go values to HCL-formatted strings for Terraform CLI arguments.
49+
// Handles primitives, slices, and maps. Example: []int{1,2,3} -> "[1, 2, 3]"
50+
func toHclString(value interface{}, isNested bool) string {
51+
if slice, isSlice := tryToConvertToGenericSlice(value); isSlice {
52+
return sliceToHclString(slice)
53+
} else if m, isMap := tryToConvertToGenericMap(value); isMap {
54+
return mapToHclString(m)
55+
} else {
56+
return primitiveToHclString(value, isNested)
57+
}
58+
}
59+
60+
// tryToConvertToGenericSlice converts any slice type to []interface{} using reflection.
61+
func tryToConvertToGenericSlice(value interface{}) ([]interface{}, bool) {
62+
reflectValue := reflect.ValueOf(value)
63+
if reflectValue.Kind() != reflect.Slice {
64+
return []interface{}{}, false
65+
}
66+
67+
genericSlice := make([]interface{}, reflectValue.Len())
68+
69+
for i := 0; i < reflectValue.Len(); i++ {
70+
genericSlice[i] = reflectValue.Index(i).Interface()
71+
}
72+
73+
return genericSlice, true
74+
}
75+
76+
// tryToConvertToGenericMap converts any map[string]T to map[string]interface{} using reflection.
77+
func tryToConvertToGenericMap(value interface{}) (map[string]interface{}, bool) {
78+
reflectValue := reflect.ValueOf(value)
79+
if reflectValue.Kind() != reflect.Map {
80+
return map[string]interface{}{}, false
81+
}
82+
83+
reflectType := reflect.TypeOf(value)
84+
if reflectType.Key().Kind() != reflect.String {
85+
return map[string]interface{}{}, false
86+
}
87+
88+
genericMap := make(map[string]interface{}, reflectValue.Len())
89+
90+
mapKeys := reflectValue.MapKeys()
91+
for _, key := range mapKeys {
92+
genericMap[key.String()] = reflectValue.MapIndex(key).Interface()
93+
}
94+
95+
return genericMap, true
96+
}
97+
98+
func sliceToHclString(slice []interface{}) string {
99+
hclValues := []string{}
100+
101+
for _, value := range slice {
102+
hclValue := toHclString(value, true)
103+
hclValues = append(hclValues, hclValue)
104+
}
105+
106+
return fmt.Sprintf("[%s]", strings.Join(hclValues, ", "))
107+
}
108+
109+
func mapToHclString(m map[string]interface{}) string {
110+
keyValuePairs := []string{}
111+
112+
for key, value := range m {
113+
keyValuePair := fmt.Sprintf(`"%s" = %s`, key, toHclString(value, true))
114+
keyValuePairs = append(keyValuePairs, keyValuePair)
115+
}
116+
117+
return fmt.Sprintf("{%s}", strings.Join(keyValuePairs, ", "))
118+
}
119+
120+
func primitiveToHclString(value interface{}, isNested bool) string {
121+
if value == nil {
122+
return "null"
123+
}
124+
125+
switch v := value.(type) {
126+
127+
case bool:
128+
return strconv.FormatBool(v)
129+
130+
case string:
131+
// If string is nested in a larger data structure (e.g. list of string, map of string), ensure value is quoted
132+
if isNested {
133+
return fmt.Sprintf("\"%v\"", v)
134+
}
135+
136+
return fmt.Sprintf("%v", v)
137+
138+
default:
139+
return fmt.Sprintf("%v", v)
140+
}
141+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package formatting
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
func TestFormatBackendConfigAsArgs(t *testing.T) {
10+
t.Parallel()
11+
12+
tests := []struct {
13+
name string
14+
input map[string]interface{}
15+
expect []string
16+
}{
17+
{
18+
name: "empty config",
19+
input: map[string]interface{}{},
20+
expect: []string{},
21+
},
22+
{
23+
name: "string value",
24+
input: map[string]interface{}{"bucket": "my-bucket"},
25+
expect: []string{"-backend-config=bucket=my-bucket"},
26+
},
27+
{
28+
name: "nil value omitted",
29+
input: map[string]interface{}{"key": nil},
30+
expect: []string{"-backend-config=key"},
31+
},
32+
{
33+
name: "multiple values",
34+
input: map[string]interface{}{"region": "us-east-1", "bucket": "state"},
35+
expect: []string{"-backend-config=bucket=state", "-backend-config=region=us-east-1"},
36+
},
37+
}
38+
39+
for _, tt := range tests {
40+
t.Run(tt.name, func(t *testing.T) {
41+
result := FormatBackendConfigAsArgs(tt.input)
42+
assert.ElementsMatch(t, tt.expect, result)
43+
})
44+
}
45+
}
46+
47+
func TestFormatPluginDirAsArgs(t *testing.T) {
48+
t.Parallel()
49+
50+
tests := []struct {
51+
name string
52+
input string
53+
expect []string
54+
}{
55+
{
56+
name: "empty path",
57+
input: "",
58+
expect: nil,
59+
},
60+
{
61+
name: "valid path",
62+
input: "/path/to/plugins",
63+
expect: []string{"-plugin-dir=/path/to/plugins"},
64+
},
65+
}
66+
67+
for _, tt := range tests {
68+
t.Run(tt.name, func(t *testing.T) {
69+
result := FormatPluginDirAsArgs(tt.input)
70+
assert.Equal(t, tt.expect, result)
71+
})
72+
}
73+
}
74+
75+
func TestToHclString(t *testing.T) {
76+
t.Parallel()
77+
78+
tests := []struct {
79+
name string
80+
input interface{}
81+
expect string
82+
}{
83+
{"nil", nil, "null"},
84+
{"bool true", true, "true"},
85+
{"bool false", false, "false"},
86+
{"string", "hello", "hello"},
87+
{"int", 42, "42"},
88+
{"list of strings", []string{"a", "b"}, `["a", "b"]`},
89+
{"list of ints", []int{1, 2, 3}, "[1, 2, 3]"},
90+
{"map", map[string]string{"key": "value"}, `{"key" = "value"}`},
91+
{"nested list", []interface{}{[]int{1, 2}}, "[[1, 2]]"},
92+
}
93+
94+
for _, tt := range tests {
95+
t.Run(tt.name, func(t *testing.T) {
96+
result := toHclString(tt.input, false)
97+
assert.Equal(t, tt.expect, result)
98+
})
99+
}
100+
}
101+
102+
func TestToHclStringNested(t *testing.T) {
103+
t.Parallel()
104+
105+
// Nested strings should be quoted
106+
result := toHclString("nested", true)
107+
assert.Equal(t, `"nested"`, result)
108+
109+
// Non-nested strings should not be quoted
110+
result = toHclString("not-nested", false)
111+
assert.Equal(t, "not-nested", result)
112+
}

modules/terraform/format.go

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"strconv"
77
"strings"
88

9+
"github.com/gruntwork-io/terratest/internal/lib/formatting"
910
"github.com/gruntwork-io/terratest/modules/collections"
1011
)
1112

@@ -121,11 +122,7 @@ func FormatTerraformLockAsArgs(lockCheck bool, lockTimeout string) []string {
121122
// FormatTerraformPluginDirAsArgs formats the plugin-dir variable
122123
// -plugin-dir
123124
func FormatTerraformPluginDirAsArgs(pluginDir string) []string {
124-
pluginArgs := []string{fmt.Sprintf("-plugin-dir=%v", pluginDir)}
125-
if pluginDir == "" {
126-
return nil
127-
}
128-
return pluginArgs
125+
return formatting.FormatPluginDirAsArgs(pluginDir)
129126
}
130127

131128
// FormatTerraformArgs will format multiple args with the arg name (e.g. "-var-file", []string{"foo.tfvars", "bar.tfvars", "baz.tfvars.json"})
@@ -141,7 +138,7 @@ func FormatTerraformArgs(argName string, args []string) []string {
141138
// FormatTerraformBackendConfigAsArgs formats the given variables as backend config args for Terraform (e.g. of the
142139
// format -backend-config=key=value).
143140
func FormatTerraformBackendConfigAsArgs(vars map[string]interface{}) []string {
144-
return formatTerraformArgs(vars, "-backend-config", false, true)
141+
return formatting.FormatBackendConfigAsArgs(vars)
145142
}
146143

147144
// Format the given vars into 'Terraform' format, with each var being prefixed with the given prefix. If

0 commit comments

Comments
 (0)