Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: dineshba/tf-summarize
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: v0.3.11
Choose a base ref
...
head repository: dineshba/tf-summarize
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: v0.3.12
Choose a head ref
  • 4 commits
  • 22 files changed
  • 3 contributors

Commits on Oct 3, 2024

  1. Fix release pipeline which is caused by breaking goreleaser change (#77)

    * Update goreleaser github action
    
    * Fix gorelease yaml file
    dineshba authored Oct 3, 2024

    Verified

    This commit was signed with the committer’s verified signature.
    Copy the full SHA
    789555f View commit details

Commits on Oct 14, 2024

  1. Add test coverage (#78)

    * add test cases
    
    * add test cases
    
    * remove fmt stmts
    
    * fix flaky tests and add more test cases
    kumdeepakgit authored Oct 14, 2024
    Copy the full SHA
    3483af4 View commit details

Commits on Oct 19, 2024

  1. print change summary as json (#22)

    * print change summary as json
    
    * update test
    cgroschupp authored Oct 19, 2024
    Copy the full SHA
    4e3f1ce View commit details
  2. Copy the full SHA
    6c9fdfb View commit details
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -46,10 +46,10 @@ jobs:

-
name: Run GoReleaser
uses: goreleaser/goreleaser-action@v2
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: latest
version: "~> v2"
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -7,3 +7,7 @@ tf-summarize
*.swp

example/tfplan

cover.out
cover.html
*.cov
2 changes: 1 addition & 1 deletion .goreleaser.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Visit https://goreleaser.com for documentation on how to customize this
# behavior.
version: 2
before:
hooks:
# this is just an example and not a requirement for provider building/publishing
@@ -84,5 +85,4 @@ release:
# If you want to manually examine the release before its live, uncomment this line:
draft: false
changelog:
skip: false
use: github
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -114,6 +114,8 @@ Usage of tf-summarize [args] [tf-plan.json|tfplan]
[Optional] print changes in html format
-json
[Optional] print changes in json format
-json-sum
[Optional] print summary in json format
-md
[Optional, used only with table view] output table as markdown
-out string
@@ -136,6 +138,7 @@ tf-summarize tfplan # summary in table format
tf-summarize -tree tfplan # summary in tree format
tf-summarize -tree -draw tfplan # summary in 2D tree format
tf-summarize -json tfplan # summary in json format
tf-summarize -json-sum tfplan # summary of changes in json format
tf-summarize -separate-tree tfplan # summary in separate tree format
tf-summarize -separate-tree -draw tfplan # summary in separate 2D tree format
tf-summarize -out=summary.md tfplan # summary in output file instead of stdout
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -14,6 +14,7 @@ require (
require (
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/golang/mock v1.6.0 // indirect
github.com/hashicorp/go-version v1.6.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
25 changes: 25 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -5,6 +5,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek=
github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
@@ -28,13 +30,36 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/zclconf/go-cty v1.14.1 h1:t9fyA35fwjjUMcmL5hLER+e/rEPqrbCK1/OSE4SI9KA=
github.com/zclconf/go-cty v1.14.1/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4=
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
11 changes: 6 additions & 5 deletions main.go
Original file line number Diff line number Diff line change
@@ -19,6 +19,7 @@ func main() {
tree := flag.Bool("tree", false, "[Optional] print changes in tree format")
json := flag.Bool("json", false, "[Optional] print changes in json format")
html := flag.Bool("html", false, "[Optional] print changes in html format")
jsonSum := flag.Bool("json-sum", false, "[Optional] print summary in json format")
separateTree := flag.Bool("separate-tree", false, "[Optional] print changes in tree format for add/delete/change/recreate changes")
drawable := flag.Bool("draw", false, "[Optional, used only with -tree or -separate-tree] draw trees instead of plain tree")
md := flag.Bool("md", false, "[Optional, used only with table view] output table as markdown")
@@ -36,7 +37,7 @@ func main() {
}

args := flag.Args()
err := validateFlags(*tree, *separateTree, *drawable, *md, *json, *html, args)
err := validateFlags(*tree, *separateTree, *drawable, *md, *json, *jsonSum, *html, args)
logIfErrorAndExit("invalid input flags: %s\n", err, flag.Usage)

newReader, err := reader.CreateReader(args)
@@ -53,7 +54,7 @@ func main() {

terraformstate.FilterNoOpResources(&terraformState)

newWriter := writer.CreateWriter(*tree, *separateTree, *drawable, *md, *json, *html, terraformState)
newWriter := writer.CreateWriter(*tree, *separateTree, *drawable, *md, *json, *html, *jsonSum, terraformState)

var outputFile io.Writer = os.Stdout

@@ -84,7 +85,7 @@ func logIfErrorAndExit(format string, err error, callback func()) {
}
}

func validateFlags(tree, separateTree, drawable bool, md bool, json bool, html bool, args []string) error {
func validateFlags(tree, separateTree, drawable bool, md bool, json bool, jsonSum bool, html bool, args []string) error {
if tree && md {
return fmt.Errorf("both -tree and -md should not be provided")
}
@@ -97,8 +98,8 @@ func validateFlags(tree, separateTree, drawable bool, md bool, json bool, html b
if !tree && !separateTree && drawable {
return fmt.Errorf("drawable should be provided with -tree or -seperate-tree")
}
if multipleTrueVals(md, json, html) {
return fmt.Errorf("only one of -md, -json, or -html should be provided")
if multipleTrueVals(md, json, html, jsonSum) {
return fmt.Errorf("only one of -md, -json, -json-sum, or -html should be provided")
}
if len(args) > 1 {
return fmt.Errorf("only one argument is allowed which is filename, but got %v", args)
47 changes: 47 additions & 0 deletions parser/json-parser_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package parser

import (
"encoding/json"
"testing"

tfjson "github.com/hashicorp/terraform-json"
"github.com/stretchr/testify/assert"
)

func TestJSONParser_Parse_Success(t *testing.T) {
mockPlan := tfjson.Plan{
FormatVersion: "0.1",
TerraformVersion: "1.0.0",
Variables: map[string]*tfjson.PlanVariable{"example": {}},
}
data, err := json.Marshal(mockPlan)
assert.NoError(t, err)

parser := NewJSONParser(data)
parsedPlan, err := parser.Parse()

assert.NoError(t, err)
assert.Equal(t, mockPlan.FormatVersion, parsedPlan.FormatVersion)
assert.Equal(t, mockPlan.TerraformVersion, parsedPlan.TerraformVersion)
assert.Equal(t, mockPlan.Variables, parsedPlan.Variables)
}

func TestJSONParser_Parse_InvalidJSON(t *testing.T) {
// invalid JSON data
invalidData := []byte(`{"invalid-json"}`)

parser := NewJSONParser(invalidData)
_, err := parser.Parse()

assert.Error(t, err)
assert.Contains(t, err.Error(), "error when parsing input")
}

func TestNewJSONParser(t *testing.T) {
data := []byte(`{"plan": "mock"}`)

parser := NewJSONParser(data)
jp, ok := parser.(JSONParser)
assert.True(t, ok, "expected a JSONParser instance")
assert.Equal(t, data, jp.data, "expected data to match the input")
}
60 changes: 60 additions & 0 deletions parser/parser_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package parser

import (
"testing"

"github.com/dineshba/tf-summarize/reader"
"github.com/stretchr/testify/assert"
)

// Test for JSON file input
func TestCreateParser_JSONFile(t *testing.T) {
data := []byte(`{"plan": "mock"}`)
fileName := "example.json"

p, err := CreateParser(data, fileName)

assert.NoError(t, err)

_, ok := p.(JSONParser)
assert.True(t, ok, "expected a JSON parser to be returned")
}

// Test for stdin input
func TestCreateParser_Stdin(t *testing.T) {
data := []byte(`{"plan": "mock"}`)
fileName := reader.StdinFileName

p, err := CreateParser(data, fileName)

assert.NoError(t, err)

_, ok := p.(JSONParser)
assert.True(t, ok, "expected a JSON parser for stdin input")
}

// Test for binary file input
func TestCreateParser_BinaryFile(t *testing.T) {
data := []byte{0x00, 0x01, 0x02} // Mock binary data
fileName := "example.binary"

p, err := CreateParser(data, fileName)

assert.NoError(t, err)

_, ok := p.(BinaryParser)
assert.True(t, ok, "expected a Binary parser to be returned")
}

// Test for non-JSON file name (like .txt or other extensions)
func TestCreateParser_InvalidFileName(t *testing.T) {
data := []byte(`irrelevant data`)
fileName := "example.txt"

p, err := CreateParser(data, fileName)

assert.NoError(t, err)

_, ok := p.(BinaryParser)
assert.True(t, ok, "expected a Binary parser for non-JSON file extension")
}
49 changes: 49 additions & 0 deletions testdata/mocks/mock_io_writer.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion testdata/multiple_format_flags_error.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
invalid input flags: only one of -md, -json, or -html should be provided
invalid input flags: only one of -md, -json, -json-sum, or -html should be provided

Usage of ./tf-summarize-test [args] [tf-plan.json|tfplan]

@@ -8,6 +8,8 @@ Usage of ./tf-summarize-test [args] [tf-plan.json|tfplan]
[Optional] print changes in html format
-json
[Optional] print changes in json format
-json-sum
[Optional] print summary in json format
-md
[Optional, used only with table view] output table as markdown
-out string
6 changes: 4 additions & 2 deletions writer/html.go
Original file line number Diff line number Diff line change
@@ -14,11 +14,13 @@ type HTMLWriter struct {
OutputChanges map[string][]string
}

var cfs = getFS()

// Write outputs the HTML summary to the io.Writer it's passed.
func (t HTMLWriter) Write(writer io.Writer) error {
templatesDir := "templates"
rcTmpl := "resourceChanges.html"
tmpl, err := template.New(rcTmpl).ParseFS(templates, path.Join(templatesDir, rcTmpl))
tmpl, err := template.New(rcTmpl).ParseFS(cfs, path.Join(templatesDir, rcTmpl))
if err != nil {
return err
}
@@ -33,7 +35,7 @@ func (t HTMLWriter) Write(writer io.Writer) error {
}

ocTmpl := "outputChanges.html"
outputTmpl, err := template.New(ocTmpl).ParseFS(templates, path.Join(templatesDir, ocTmpl))
outputTmpl, err := template.New(ocTmpl).ParseFS(cfs, path.Join(templatesDir, ocTmpl))
if err != nil {
return err
}
112 changes: 112 additions & 0 deletions writer/html_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package writer

import (
"bytes"
"testing"
"testing/fstest"

"github.com/dineshba/tf-summarize/terraformstate"
. "github.com/hashicorp/terraform-json"
)

func TestHTMLWriterWithMockFileSystem(t *testing.T) {
origFS := cfs
cfs = fstest.MapFS{
"templates/resourceChanges.html": &fstest.MapFile{
Data: []byte(`<table>
<tr>
<th>CHANGE</th>
<th>RESOURCE</th>
</tr>{{ range $change, $resources := .ResourceChanges }}{{ $length := len $resources }}{{ if gt $length 0 }}
<tr>
<td>{{ $change }}</td>
<td>
<ul>{{ range $i, $r := $resources }}
<li><code>{{ $r.Address }}</code></li>{{ end }}
</ul>
</td>
</tr>{{ end }}{{ end }}
</table>
`),
},
"templates/outputChanges.html": &fstest.MapFile{
Data: []byte(`<table>
<tr>
<th>CHANGE</th>
<th>OUTPUT</th>
</tr>{{ range $change, $outputs := .OutputChanges }}{{ $length := len $outputs }}{{ if gt $length 0 }}
<tr>
<td>{{ $change }}</td>
<td>
<ul>{{ range $i, $o := $outputs }}
<li><code>{{ $o }}</code></li>{{ end }}
</ul>
</td>
</tr>{{ end }}{{ end }}
</table>
`),
},
}
t.Cleanup(func() {
cfs = origFS
})

resourceChanges := map[string]terraformstate.ResourceChanges{
"module.test": {
{
Address: "aws_instance.example",
Name: "example",
Change: &Change{
Before: map[string]interface{}{"name": "old_instance"},
After: map[string]interface{}{"name": "new_instance"},
Actions: Actions{ActionCreate},
},
},
},
}
outputChanges := map[string][]string{
"output_key": {"output_value"},
}

htmlWriter := NewHTMLWriter(resourceChanges, outputChanges)
var buf bytes.Buffer

err := htmlWriter.Write(&buf)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}

expectedOutput := `<table>
<tr>
<th>CHANGE</th>
<th>RESOURCE</th>
</tr>
<tr>
<td>module.test</td>
<td>
<ul>
<li><code>aws_instance.example</code></li>
</ul>
</td>
</tr>
</table>
<table>
<tr>
<th>CHANGE</th>
<th>OUTPUT</th>
</tr>
<tr>
<td>output_key</td>
<td>
<ul>
<li><code>output_value</code></li>
</ul>
</td>
</tr>
</table>
`
if buf.String() != expectedOutput {
t.Errorf("expected %q, got %q", expectedOutput, buf.String())
}

}
26 changes: 26 additions & 0 deletions writer/json-sum.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package writer

import (
"fmt"
"io"

"github.com/dineshba/tf-summarize/terraformstate"
)

type JsonSumWriter struct {
changes map[string]terraformstate.ResourceChanges
}

func (t JsonSumWriter) Write(writer io.Writer) error {
result := make(map[string]int, len(t.changes))
for k, v := range t.changes {
result[k] = len(v)
}
s, _ := Marshal(map[string]map[string]int{"changes": result})
_, err := fmt.Fprint(writer, string(s))
return err
}

func NewJsonSumWriter(changes map[string]terraformstate.ResourceChanges) Writer {
return JsonSumWriter{changes: changes}
}
129 changes: 129 additions & 0 deletions writer/json_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package writer

import (
"bytes"
"encoding/json"
"testing"

"github.com/dineshba/tf-summarize/terraformstate"
. "github.com/hashicorp/terraform-json"
"github.com/nsf/jsondiff"
)

// Struct to hold arguments and expected output for each test case
type testCase struct {
args terraformstate.ResourceChanges
expectedOutput map[string]interface{}
}

func TestJSONWriter(t *testing.T) {

// Define test cases
testCases := []testCase{
{
args: terraformstate.ResourceChanges{
{
Address: "module.test.azapi_resource.logical_network",
Type: "aws_instance",
Name: "example",
Change: &Change{
Before: map[string]interface{}{"name": "old_instance"},
After: map[string]interface{}{"name": "new_instance"},
Actions: Actions{ActionCreate},
},
},
},
expectedOutput: map[string]interface{}{
"module": map[string]interface{}{
"test": map[string]interface{}{
"azapi_resource": map[string]interface{}{
"logical_network": map[string]interface{}{
"(+)": map[string]interface{}{
"name": "new_instance",
},
},
},
},
},
},
},
{
args: terraformstate.ResourceChanges{
{
Address: "module.test.aws_s3_bucket.example",
Type: "aws_s3_bucket",
Name: "example",
Change: &Change{
Before: map[string]interface{}{"name": "old_bucket"},
After: map[string]interface{}{"name": "new_bucket"},
Actions: Actions{ActionUpdate},
},
},
},
expectedOutput: map[string]interface{}{
"module": map[string]interface{}{
"test": map[string]interface{}{
"aws_s3_bucket": map[string]interface{}{
"example": map[string]interface{}{
"(~)": map[string]interface{}{
"name": map[string]interface{}{
"changed": []string{
"old_bucket",
"new_bucket",
},
},
},
},
},
},
},
},
},
{
args: terraformstate.ResourceChanges{
{
Address: "module.test.aws_security_group.example",
Type: "aws_security_group",
Name: "example",
Change: &Change{
Before: map[string]interface{}{"name": "old_sg"},
After: map[string]interface{}{"name": "new_sg"},
Actions: Actions{ActionDelete},
},
},
},
expectedOutput: map[string]interface{}{
"module": map[string]interface{}{
"test": map[string]interface{}{
"aws_security_group": map[string]interface{}{
"example": map[string]interface{}{
"(-)": map[string]interface{}{
"name": "old_sg",
},
},
},
},
},
},
},
}

// Iterate through test cases
for i, tc := range testCases {
jsonWriter := NewJSONWriter(tc.args)
var buf bytes.Buffer
err := jsonWriter.Write(&buf)
if err != nil {
t.Fatalf("Unexpected error in test case %d: %v", i+1, err)
}
expectedJSON, err := json.Marshal(tc.expectedOutput)
if err != nil {
t.Fatalf("Error marshalling expected output in test case %d: %v", i+1, err)
}
opts := jsondiff.DefaultJSONOptions()
diff, str := jsondiff.Compare(expectedJSON, buf.Bytes(), &opts)
if diff != jsondiff.FullMatch {
t.Errorf("Output mismatch in test case %d: %s", i+1, str)
}
}
}
4 changes: 3 additions & 1 deletion writer/separate_tree.go
Original file line number Diff line number Diff line change
@@ -15,6 +15,8 @@ type SeparateTree struct {
drawable bool
}

var NewTreeWriterFunc = NewTreeWriter

func (s SeparateTree) Write(writer io.Writer) error {
var err error
for k, v := range s.changes {
@@ -23,7 +25,7 @@ func (s SeparateTree) Write(writer io.Writer) error {
if err != nil {
return fmt.Errorf("error writing to %s: %s", writer, err)
}
treeWriter := NewTreeWriter(v, s.drawable)
treeWriter := NewTreeWriterFunc(v, s.drawable)
err = treeWriter.Write(writer)
if err != nil {
return fmt.Errorf("error writing to %s: %s", writer, err)
168 changes: 168 additions & 0 deletions writer/separate_tree_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
package writer

import (
"bytes"
"errors"
"io"
"testing"

"github.com/dineshba/tf-summarize/terraformstate"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"

"github.com/dineshba/tf-summarize/testdata/mocks"

. "github.com/hashicorp/terraform-json"
tfjson "github.com/hashicorp/terraform-json"
)

// Mock tree writer
type mockTreeWriter struct{}

func (m *mockTreeWriter) Write(writer io.Writer) error {
return nil
}

func NewMockTreeWriter(changes []*tfjson.ResourceChange, drawable bool) Writer {
return &mockTreeWriter{}
}

// Mock tree writer to simulate an error during treeWriter.Write
type mockTreeWriterWithError struct{}

func (m *mockTreeWriterWithError) Write(writer io.Writer) error {
return errors.New("tree writer error")
}

func NewMockTreeWriterWithError(changes []*tfjson.ResourceChange, drawable bool) Writer {
return &mockTreeWriterWithError{}
}

// Helper function to create changes
func createMockChanges() map[string]terraformstate.ResourceChanges {
return map[string]terraformstate.ResourceChanges{
"add": {
{
Address: "aws_instance.example1",
Change: &Change{Actions: Actions{ActionCreate}},
},
},
"delete": {
{
Address: "aws_instance.example2",
Change: &Change{Actions: Actions{ActionDelete}},
},
},
}
}

func TestSeparateTree_Write(t *testing.T) {
mockChanges := createMockChanges()

t.Run("Drawable True", func(t *testing.T) {
tw := NewSeparateTree(mockChanges, true)
var buf bytes.Buffer
err := tw.Write(&buf)

assert.NoError(t, err)

expectedAdd := `################### ADD ###################
╭─╮
│.│
╰┬╯
╭──────┴─────╮
│aws_instance│
╰──────┬─────╯
╭─────┴─────╮
│example1(+)│
╰───────────╯
`
expectedDelete := `################### DELETE ###################
╭─╮
│.│
╰┬╯
╭──────┴─────╮
│aws_instance│
╰──────┬─────╯
╭─────┴─────╮
│example2(-)│
╰───────────╯
`

actualOutput := removeANSI(buf.String())
assert.Contains(t, actualOutput, expectedAdd)
assert.Contains(t, actualOutput, expectedDelete)

})

t.Run("Drawable False", func(t *testing.T) {
tw := NewSeparateTree(mockChanges, false)
var buf bytes.Buffer
err := tw.Write(&buf)

assert.NoError(t, err)

expectedAdd := `################### ADD ###################
|---aws_instance
| |---example1(+)
`
expectedDelete := `################### DELETE ###################
|---aws_instance
| |---example2(-)
`

actualOutput := removeANSI(buf.String())
assert.Contains(t, actualOutput, expectedAdd)
assert.Contains(t, actualOutput, expectedDelete)
})

t.Run("Error Handling", func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()

s := NewSeparateTree(mockChanges, false)

t.Run("Write Error", func(t *testing.T) {
mockWriter := mocks.NewMockWriter(ctrl)
mockWriter.EXPECT().Write(gomock.Any()).Return(0, errors.New("write error")).Times(1)

err := s.Write(mockWriter)
assert.Error(t, err)
assert.Contains(t, err.Error(), "write error")
})

t.Run("Tree Writer Error", func(t *testing.T) {
originalFunc := NewTreeWriterFunc
defer func() { NewTreeWriterFunc = originalFunc }()

// Replace NewTreeWriter with the mock that returns an error
NewTreeWriterFunc = NewMockTreeWriterWithError

mockWriter := mocks.NewMockWriter(ctrl)
mockWriter.EXPECT().Write(gomock.Any()).Return(0, nil).AnyTimes()

err := s.Write(mockWriter)
assert.Error(t, err)
assert.Contains(t, err.Error(), "tree writer error")
})

t.Run("Final Newline Write Error", func(t *testing.T) {
originalFunc := NewTreeWriterFunc
defer func() { NewTreeWriterFunc = originalFunc }()

NewTreeWriterFunc = NewMockTreeWriter

mockWriter := mocks.NewMockWriter(ctrl)
mockWriter.EXPECT().Write(gomock.Any()).Return(0, nil).Times(1)
mockWriter.EXPECT().Write(gomock.Any()).Return(0, errors.New("write error")).Times(1)

err := s.Write(mockWriter)
assert.Error(t, err)
assert.Contains(t, err.Error(), "write error")
})
})
}
84 changes: 84 additions & 0 deletions writer/table_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package writer

import (
"bytes"
"testing"

"github.com/dineshba/tf-summarize/terraformstate"
"github.com/stretchr/testify/assert"
)

func TestTableWriter_Write_NoMarkdown(t *testing.T) {
changes := createMockChanges()

outputChanges := map[string][]string{
"update": {
"output.example",
},
}

tw := NewTableWriter(changes, outputChanges, false)
var output bytes.Buffer
err := tw.Write(&output)
assert.NoError(t, err)

expectedOutput := `+--------+-----------------------+
| CHANGE | RESOURCE |
+--------+-----------------------+
| add | aws_instance.example1 |
+--------+-----------------------+
| delete | aws_instance.example2 |
+--------+-----------------------+
+--------+----------------+
| CHANGE | OUTPUT |
+--------+----------------+
| update | output.example |
+--------+----------------+
`

assert.Equal(t, expectedOutput, output.String())
}

func TestTableWriter_Write_WithMarkdown(t *testing.T) {
changes := createMockChanges()

outputChanges := map[string][]string{
"update": {
"output.example",
},
}

tw := NewTableWriter(changes, outputChanges, true)
var output bytes.Buffer
err := tw.Write(&output)
assert.NoError(t, err)

expectedOutput := `| CHANGE | RESOURCE |
|--------|-------------------------|
| add | ` + "`aws_instance.example1`" + ` |
| delete | ` + "`aws_instance.example2`" + ` |
| CHANGE | OUTPUT |
|--------|------------------|
| update | ` + "`output.example`" + ` |
`

assert.Equal(t, expectedOutput, output.String())
}

func TestTableWriter_NoChanges(t *testing.T) {
changes := map[string]terraformstate.ResourceChanges{}
outputChanges := map[string][]string{}

tw := NewTableWriter(changes, outputChanges, false)
var output bytes.Buffer
err := tw.Write(&output)
assert.NoError(t, err)

expectedOutput := `+--------+----------+
| CHANGE | RESOURCE |
+--------+----------+
+--------+----------+
`
assert.Equal(t, expectedOutput, output.String())
}
115 changes: 115 additions & 0 deletions writer/tree_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package writer

import (
"bytes"
"errors"
"testing"

"github.com/dineshba/tf-summarize/terraformstate"
. "github.com/hashicorp/terraform-json"
tfjson "github.com/hashicorp/terraform-json"
"github.com/stretchr/testify/assert"
)

func TestTreeWriter_Write_DrawableTrue(t *testing.T) {

changes := terraformstate.ResourceChanges{
&tfjson.ResourceChange{Address: "module.test.azapi_resource.logical_network", Change: &Change{Actions: Actions{ActionNoop}}},
}

tw := NewTreeWriter(changes, true)

var buf bytes.Buffer
err := tw.Write(&buf)

assert.NoError(t, err)

expectedOutput := ` ╭─╮
│.│
╰┬╯
╭───┴──╮
│module│
╰───┬──╯
╭──┴─╮
│test│
╰──┬─╯
╭───────┴──────╮
│azapi_resource│
╰───────┬──────╯
╭───────┴───────╮
│logical_network│
╰───────────────╯
`

assert.Equal(t, expectedOutput, removeANSI(buf.String()))
}

func TestTreeWriter_Write_NonDrawable(t *testing.T) {

changes := terraformstate.ResourceChanges{
&tfjson.ResourceChange{Address: "module.test.azapi_resource.logical_network", Change: &Change{Actions: Actions{ActionNoop}}},
}

tw := NewTreeWriter(changes, false)

var buf bytes.Buffer
err := tw.Write(&buf)

assert.NoError(t, err)
assert.Contains(t, buf.String(), "|---module")
assert.Contains(t, buf.String(), "|---test")
assert.Contains(t, buf.String(), "|---azapi_resource")
assert.Contains(t, buf.String(), "|---logical_network")

expectedOutput := `|---module
| |---test
| | |---azapi_resource
| | | |---logical_network
`

assert.Equal(t, expectedOutput, removeANSI(buf.String()))
}

func TestTreeWriter_Write_NonDrawable_PrintTreeError(t *testing.T) {

changes := terraformstate.ResourceChanges{
&tfjson.ResourceChange{Address: "module.test.azapi_resource.logical_network", Change: &Change{Actions: Actions{ActionNoop}}},
}
tw := NewTreeWriter(changes, false)
faultyWriter := &errorWriter{}
err := tw.Write(faultyWriter)
assert.Error(t, err)
}

func TestTreeWriter_Write_PrintTreeError(t *testing.T) {
changes := terraformstate.ResourceChanges{
&tfjson.ResourceChange{Address: "module.test.azapi_resource.logical_network", Change: &Change{Actions: Actions{ActionNoop}}},
}
tw := NewTreeWriter(changes, true)
faultyWriter := &errorWriter{}
err := tw.Write(faultyWriter)
assert.Error(t, err)
assert.Contains(t, err.Error(), "simulated write error")
}

func TestTreeWriter_Write_EmptyChanges(t *testing.T) {
changes := terraformstate.ResourceChanges{} // Empty changes
tw := NewTreeWriter(changes, false)
var buf bytes.Buffer
err := tw.Write(&buf)

// Verify output and no errors (it should handle empty cases gracefully)
assert.NoError(t, err)
assert.Equal(t, "", buf.String())
}

// Custom faulty writer to simulate write errors
type errorWriter struct{}

func (ew *errorWriter) Write(p []byte) (n int, err error) {
return 0, errors.New("simulated write error")
}
16 changes: 15 additions & 1 deletion writer/util.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
package writer

import "embed"
import (
"embed"
"io/fs"
"regexp"
)

// Embed the templates directory in the compiled binary.
//
//go:embed templates
var templates embed.FS

var getFS = func() fs.FS {
return templates
}

func hasOutputChanges(opChanges map[string][]string) bool {
hasChanges := false

@@ -19,3 +27,9 @@ func hasOutputChanges(opChanges map[string][]string) bool {

return hasChanges
}

// Function to remove ANSI escape sequences
func removeANSI(input string) string {
re := regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`)
return re.ReplaceAllString(input, "")
}
88 changes: 88 additions & 0 deletions writer/util_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package writer

import (
"testing"
)

// Test for hasOutputChanges function
func TestHasOutputChanges(t *testing.T) {
tests := []struct {
name string
input map[string][]string
expected bool
}{
{
name: "No changes",
input: map[string][]string{
"change1": {},
"change2": {},
},
expected: false,
},
{
name: "Has changes",
input: map[string][]string{
"change1": {"added"},
"change2": {},
},
expected: true,
},
{
name: "All empty",
input: map[string][]string{
"change1": {},
"change2": {},
"change3": {},
},
expected: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := hasOutputChanges(tt.input)
if result != tt.expected {
t.Errorf("expected %v, got %v", tt.expected, result)
}
})
}
}

// Test for removeANSI function
func TestRemoveANSI(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "String without ANSI escape",
input: "This is a normal string",
expected: "This is a normal string",
},
{
name: "String with ANSI escape",
input: "\x1b[31mThis is red\x1b[0m",
expected: "This is red",
},
{
name: "String with multiple ANSI escapes",
input: "\x1b[31mThis is red\x1b[0m and \x1b[32mthis is green\x1b[0m",
expected: "This is red and this is green",
},
{
name: "Empty string",
input: "",
expected: "",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := removeANSI(tt.input)
if result != tt.expected {
t.Errorf("expected %v, got %v", tt.expected, result)
}
})
}
}
5 changes: 4 additions & 1 deletion writer/writer.go
Original file line number Diff line number Diff line change
@@ -11,7 +11,7 @@ type Writer interface {
Write(writer io.Writer) error
}

func CreateWriter(tree, separateTree, drawable, mdEnabled, json, html bool, plan tfjson.Plan) Writer {
func CreateWriter(tree, separateTree, drawable, mdEnabled, json, html bool, jsonSum bool, plan tfjson.Plan) Writer {
if tree {
return NewTreeWriter(plan.ResourceChanges, drawable)
}
@@ -24,6 +24,9 @@ func CreateWriter(tree, separateTree, drawable, mdEnabled, json, html bool, plan
if html {
return NewHTMLWriter(terraformstate.GetAllResourceChanges(plan), terraformstate.GetAllOutputChanges(plan))
}
if jsonSum {
return NewJsonSumWriter(terraformstate.GetAllResourceChanges(plan))
}

return NewTableWriter(terraformstate.GetAllResourceChanges(plan), terraformstate.GetAllOutputChanges(plan), mdEnabled)
}