// Copyright IBM Corp. 2014, 2026 // SPDX-License-Identifier: BUSL-1.1 package tfdiags import ( "errors" "fmt" "reflect" "strings" "testing" "github.com/davecgh/go-spew/spew" "github.com/hashicorp/hcl/v2" ) func TestBuild(t *testing.T) { type diagFlat struct { Severity Severity Summary string Detail string Subject *SourceRange Context *SourceRange } tests := map[string]struct { Cons func(Diagnostics) Diagnostics Want []diagFlat }{ "nil": { func(diags Diagnostics) Diagnostics { return diags }, nil, }, "fmt.Errorf": { func(diags Diagnostics) Diagnostics { diags = diags.Append(fmt.Errorf("oh no bad")) return diags }, []diagFlat{ { Severity: Error, Summary: "oh no bad", }, }, }, "errors.New": { func(diags Diagnostics) Diagnostics { diags = diags.Append(errors.New("oh no bad")) return diags }, []diagFlat{ { Severity: Error, Summary: "oh no bad", }, }, }, "hcl.Diagnostic": { func(diags Diagnostics) Diagnostics { diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "Something bad happened", Detail: "It was really, really bad.", Subject: &hcl.Range{ Filename: "foo.tf", Start: hcl.Pos{Line: 1, Column: 10, Byte: 9}, End: hcl.Pos{Line: 2, Column: 3, Byte: 25}, }, Context: &hcl.Range{ Filename: "foo.tf", Start: hcl.Pos{Line: 1, Column: 1, Byte: 0}, End: hcl.Pos{Line: 3, Column: 1, Byte: 30}, }, }) return diags }, []diagFlat{ { Severity: Error, Summary: "Something bad happened", Detail: "It was really, really bad.", Subject: &SourceRange{ Filename: "foo.tf", Start: SourcePos{Line: 1, Column: 10, Byte: 9}, End: SourcePos{Line: 2, Column: 3, Byte: 25}, }, Context: &SourceRange{ Filename: "foo.tf", Start: SourcePos{Line: 1, Column: 1, Byte: 0}, End: SourcePos{Line: 3, Column: 1, Byte: 30}, }, }, }, }, "hcl.Diagnostics": { func(diags Diagnostics) Diagnostics { diags = diags.Append(hcl.Diagnostics{ { Severity: hcl.DiagError, Summary: "Something bad happened", Detail: "It was really, really bad.", }, { Severity: hcl.DiagWarning, Summary: "Also, somebody sneezed", Detail: "How rude!", }, }) return diags }, []diagFlat{ { Severity: Error, Summary: "Something bad happened", Detail: "It was really, really bad.", }, { Severity: Warning, Summary: "Also, somebody sneezed", Detail: "How rude!", }, }, }, "errors.Join": { func(diags Diagnostics) Diagnostics { err := errors.Join(nil, errors.New("bad thing A")) err = errors.Join(err, errors.New("bad thing B")) diags = diags.Append(err) return diags }, []diagFlat{ { Severity: Error, Summary: "bad thing A", }, { Severity: Error, Summary: "bad thing B", }, }, }, "concat Diagnostics": { func(diags Diagnostics) Diagnostics { var moreDiags Diagnostics moreDiags = moreDiags.Append(errors.New("bad thing A")) moreDiags = moreDiags.Append(errors.New("bad thing B")) return diags.Append(moreDiags) }, []diagFlat{ { Severity: Error, Summary: "bad thing A", }, { Severity: Error, Summary: "bad thing B", }, }, }, "Diagnostics.Err": { func(diags Diagnostics) Diagnostics { var moreDiags Diagnostics moreDiags = moreDiags.Append(errors.New("bad thing A")) moreDiags = moreDiags.Append(errors.New("bad thing B")) return diags.Append(moreDiags.Err()) }, []diagFlat{ { Severity: Error, Summary: "bad thing A", }, { Severity: Error, Summary: "bad thing B", }, }, }, "Diagnostics.ErrWithWarnings": { func(diags Diagnostics) Diagnostics { var moreDiags Diagnostics moreDiags = moreDiags.Append(SimpleWarning("Don't forget your toothbrush!")) moreDiags = moreDiags.Append(SimpleWarning("Always make sure you know where your towel is")) return diags.Append(moreDiags.ErrWithWarnings()) }, []diagFlat{ { Severity: Warning, Summary: "Don't forget your toothbrush!", }, { Severity: Warning, Summary: "Always make sure you know where your towel is", }, }, }, "single Diagnostic": { func(diags Diagnostics) Diagnostics { return diags.Append(SimpleWarning("Don't forget your toothbrush!")) }, []diagFlat{ { Severity: Warning, Summary: "Don't forget your toothbrush!", }, }, }, "multiple appends": { func(diags Diagnostics) Diagnostics { diags = diags.Append(SimpleWarning("Don't forget your toothbrush!")) diags = diags.Append(fmt.Errorf("exploded")) return diags }, []diagFlat{ { Severity: Warning, Summary: "Don't forget your toothbrush!", }, { Severity: Error, Summary: "exploded", }, }, }, } for name, test := range tests { t.Run(name, func(t *testing.T) { gotDiags := test.Cons(nil) var got []diagFlat for _, item := range gotDiags { desc := item.Description() source := item.Source() got = append(got, diagFlat{ Severity: item.Severity(), Summary: desc.Summary, Detail: desc.Detail, Subject: source.Subject, Context: source.Context, }) } if !reflect.DeepEqual(got, test.Want) { t.Errorf("wrong result\ngot: %swant: %s", spew.Sdump(got), spew.Sdump(test.Want)) } }) } } func TestDiagnosticsErr(t *testing.T) { t.Run("empty", func(t *testing.T) { var diags Diagnostics err := diags.Err() if err != nil { t.Errorf("got non-nil error %#v; want nil", err) } }) t.Run("warning only", func(t *testing.T) { var diags Diagnostics diags = diags.Append(SimpleWarning("bad")) err := diags.Err() if err != nil { t.Errorf("got non-nil error %#v; want nil", err) } }) t.Run("one error", func(t *testing.T) { var diags Diagnostics diags = diags.Append(errors.New("didn't work")) err := diags.Err() if err == nil { t.Fatalf("got nil error %#v; want non-nil", err) } if got, want := err.Error(), "didn't work"; got != want { t.Errorf("wrong error message\ngot: %s\nwant: %s", got, want) } }) t.Run("two errors", func(t *testing.T) { var diags Diagnostics diags = diags.Append(errors.New("didn't work")) diags = diags.Append(errors.New("didn't work either")) err := diags.Err() if err == nil { t.Fatalf("got nil error %#v; want non-nil", err) } want := strings.TrimSpace(` 2 problems: - didn't work - didn't work either `) if got := err.Error(); got != want { t.Errorf("wrong error message\ngot: %s\nwant: %s", got, want) } }) t.Run("error and warning", func(t *testing.T) { var diags Diagnostics diags = diags.Append(errors.New("didn't work")) diags = diags.Append(SimpleWarning("didn't work either")) err := diags.Err() if err == nil { t.Fatalf("got nil error %#v; want non-nil", err) } // Since this "as error" mode is just a fallback for // non-diagnostics-aware situations like tests, we don't actually // distinguish warnings and errors here since the point is to just // get the messages rendered. User-facing code should be printing // each diagnostic separately, so won't enter this codepath, want := strings.TrimSpace(` 2 problems: - didn't work - didn't work either `) if got := err.Error(); got != want { t.Errorf("wrong error message\ngot: %s\nwant: %s", got, want) } }) } func TestDiagnosticsErrWithWarnings(t *testing.T) { t.Run("empty", func(t *testing.T) { var diags Diagnostics err := diags.ErrWithWarnings() if err != nil { t.Errorf("got non-nil error %#v; want nil", err) } }) t.Run("warning only", func(t *testing.T) { var diags Diagnostics diags = diags.Append(SimpleWarning("bad")) err := diags.ErrWithWarnings() if err == nil { t.Errorf("got nil error; want NonFatalError") return } if _, ok := err.(NonFatalError); !ok { t.Errorf("got %T; want NonFatalError", err) } }) t.Run("one error", func(t *testing.T) { var diags Diagnostics diags = diags.Append(errors.New("didn't work")) err := diags.ErrWithWarnings() if err == nil { t.Fatalf("got nil error %#v; want non-nil", err) } if got, want := err.Error(), "didn't work"; got != want { t.Errorf("wrong error message\ngot: %s\nwant: %s", got, want) } }) t.Run("two errors", func(t *testing.T) { var diags Diagnostics diags = diags.Append(errors.New("didn't work")) diags = diags.Append(errors.New("didn't work either")) err := diags.ErrWithWarnings() if err == nil { t.Fatalf("got nil error %#v; want non-nil", err) } want := strings.TrimSpace(` 2 problems: - didn't work - didn't work either `) if got := err.Error(); got != want { t.Errorf("wrong error message\ngot: %s\nwant: %s", got, want) } }) t.Run("error and warning", func(t *testing.T) { var diags Diagnostics diags = diags.Append(errors.New("didn't work")) diags = diags.Append(SimpleWarning("didn't work either")) err := diags.ErrWithWarnings() if err == nil { t.Fatalf("got nil error %#v; want non-nil", err) } // Since this "as error" mode is just a fallback for // non-diagnostics-aware situations like tests, we don't actually // distinguish warnings and errors here since the point is to just // get the messages rendered. User-facing code should be printing // each diagnostic separately, so won't enter this codepath, want := strings.TrimSpace(` 2 problems: - didn't work - didn't work either `) if got := err.Error(); got != want { t.Errorf("wrong error message\ngot: %s\nwant: %s", got, want) } }) } func TestDiagnosticsNonFatalErr(t *testing.T) { t.Run("empty", func(t *testing.T) { var diags Diagnostics err := diags.NonFatalErr() if err != nil { t.Errorf("got non-nil error %#v; want nil", err) } }) t.Run("warning only", func(t *testing.T) { var diags Diagnostics diags = diags.Append(SimpleWarning("bad")) err := diags.NonFatalErr() if err == nil { t.Errorf("got nil error; want NonFatalError") return } if _, ok := err.(NonFatalError); !ok { t.Errorf("got %T; want NonFatalError", err) } }) t.Run("one error", func(t *testing.T) { var diags Diagnostics diags = diags.Append(errors.New("didn't work")) err := diags.NonFatalErr() if err == nil { t.Fatalf("got nil error %#v; want non-nil", err) } if got, want := err.Error(), "didn't work"; got != want { t.Errorf("wrong error message\ngot: %s\nwant: %s", got, want) } if _, ok := err.(NonFatalError); !ok { t.Errorf("got %T; want NonFatalError", err) } }) t.Run("two errors", func(t *testing.T) { var diags Diagnostics diags = diags.Append(errors.New("didn't work")) diags = diags.Append(errors.New("didn't work either")) err := diags.NonFatalErr() if err == nil { t.Fatalf("got nil error %#v; want non-nil", err) } want := strings.TrimSpace(` 2 problems: - didn't work - didn't work either `) if got := err.Error(); got != want { t.Errorf("wrong error message\ngot: %s\nwant: %s", got, want) } if _, ok := err.(NonFatalError); !ok { t.Errorf("got %T; want NonFatalError", err) } }) t.Run("error and warning", func(t *testing.T) { var diags Diagnostics diags = diags.Append(errors.New("didn't work")) diags = diags.Append(SimpleWarning("didn't work either")) err := diags.NonFatalErr() if err == nil { t.Fatalf("got nil error %#v; want non-nil", err) } // Since this "as error" mode is just a fallback for // non-diagnostics-aware situations like tests, we don't actually // distinguish warnings and errors here since the point is to just // get the messages rendered. User-facing code should be printing // each diagnostic separately, so won't enter this codepath, want := strings.TrimSpace(` 2 problems: - didn't work - didn't work either `) if got := err.Error(); got != want { t.Errorf("wrong error message\ngot: %s\nwant: %s", got, want) } if _, ok := err.(NonFatalError); !ok { t.Errorf("got %T; want NonFatalError", err) } }) } func TestWarnings(t *testing.T) { errorDiag := &hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "something bad happened", Detail: "details of the error", } warnDiag := &hcl.Diagnostic{ Severity: hcl.DiagWarning, Summary: "something bad happened", Detail: "details of the warning", } cases := map[string]struct { diags Diagnostics expected Diagnostics }{ "empty diags": { diags: Diagnostics{}, expected: Diagnostics{}, }, "nil diags": { diags: nil, expected: Diagnostics{}, }, "all error diags": { diags: func() Diagnostics { var d Diagnostics d = d.Append(errorDiag, errorDiag, errorDiag) return d }(), expected: Diagnostics{}, }, "mixture of error and warning diags": { diags: func() Diagnostics { var d Diagnostics d = d.Append(errorDiag, errorDiag, warnDiag) return d }(), expected: func() Diagnostics { var d Diagnostics d = d.Append(warnDiag) return d }(), }, "empty error diags": { diags: func() Diagnostics { var d Diagnostics d = d.Append(warnDiag, warnDiag) return d }(), expected: func() Diagnostics { var d Diagnostics d = d.Append(warnDiag, warnDiag) return d }(), }, } for tn, tc := range cases { t.Run(tn, func(t *testing.T) { warnings := tc.diags.Warnings() AssertDiagnosticsMatch(t, tc.expected, warnings) }) } } func TestAppendWithoutDuplicates(t *testing.T) { type diagFlat struct { Severity Severity Summary string Detail string Subject *SourceRange Context *SourceRange } tests := map[string]struct { Cons func(Diagnostics) Diagnostics Want []diagFlat }{ "nil": { func(diags Diagnostics) Diagnostics { return nil }, nil, }, "errors.New": { // these could be from different locations, so we can't dedupe them func(diags Diagnostics) Diagnostics { return diags.Append( errors.New("oh no bad"), errors.New("oh no bad"), ) }, []diagFlat{ { Severity: Error, Summary: "oh no bad", }, { Severity: Error, Summary: "oh no bad", }, }, }, "hcl.Diagnostic": { func(diags Diagnostics) Diagnostics { diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "Something bad happened", Detail: "It was really, really bad.", Subject: &hcl.Range{ Filename: "foo.tf", Start: hcl.Pos{Line: 1, Column: 10, Byte: 9}, End: hcl.Pos{Line: 2, Column: 3, Byte: 25}, }, Context: &hcl.Range{ Filename: "foo.tf", Start: hcl.Pos{Line: 1, Column: 1, Byte: 0}, End: hcl.Pos{Line: 3, Column: 1, Byte: 30}, }, }) // exact same diag diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "Something bad happened", Detail: "It was really, really bad.", Subject: &hcl.Range{ Filename: "foo.tf", Start: hcl.Pos{Line: 1, Column: 10, Byte: 9}, End: hcl.Pos{Line: 2, Column: 3, Byte: 25}, }, Context: &hcl.Range{ Filename: "foo.tf", Start: hcl.Pos{Line: 1, Column: 1, Byte: 0}, End: hcl.Pos{Line: 3, Column: 1, Byte: 30}, }, }) // same diag as prev, different location diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "Something bad happened", Detail: "It was really, really bad.", Subject: &hcl.Range{ Filename: "foo.tf", Start: hcl.Pos{Line: 4, Column: 10, Byte: 40}, End: hcl.Pos{Line: 5, Column: 3, Byte: 55}, }, Context: &hcl.Range{ Filename: "foo.tf", Start: hcl.Pos{Line: 4, Column: 1, Byte: 40}, End: hcl.Pos{Line: 6, Column: 1, Byte: 60}, }, }) return diags }, []diagFlat{ { Severity: Error, Summary: "Something bad happened", Detail: "It was really, really bad.", Subject: &SourceRange{ Filename: "foo.tf", Start: SourcePos{Line: 1, Column: 10, Byte: 9}, End: SourcePos{Line: 2, Column: 3, Byte: 25}, }, Context: &SourceRange{ Filename: "foo.tf", Start: SourcePos{Line: 1, Column: 1, Byte: 0}, End: SourcePos{Line: 3, Column: 1, Byte: 30}, }, }, { Severity: Error, Summary: "Something bad happened", Detail: "It was really, really bad.", Subject: &SourceRange{ Filename: "foo.tf", Start: SourcePos{Line: 4, Column: 10, Byte: 40}, End: SourcePos{Line: 5, Column: 3, Byte: 55}, }, Context: &SourceRange{ Filename: "foo.tf", Start: SourcePos{Line: 4, Column: 1, Byte: 40}, End: SourcePos{Line: 6, Column: 1, Byte: 60}, }, }, }, }, "simple warning": { func(diags Diagnostics) Diagnostics { diags = diags.Append(SimpleWarning("Don't forget your toothbrush!")) diags = diags.Append(SimpleWarning("Don't forget your toothbrush!")) return diags }, []diagFlat{ { Severity: Warning, Summary: "Don't forget your toothbrush!", }, { Severity: Warning, Summary: "Don't forget your toothbrush!", }, }, }, "hcl.Diagnostic extra": { // Extra can contain anything, and we don't know how to compare // those values, so we can't dedupe them func(diags Diagnostics) Diagnostics { diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "Something bad happened", Detail: "It was really, really bad.", Extra: 42, Subject: &hcl.Range{ Filename: "foo.tf", Start: hcl.Pos{Line: 1, Column: 10, Byte: 9}, End: hcl.Pos{Line: 2, Column: 3, Byte: 25}, }, }) diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "Something bad happened", Detail: "It was really, really bad.", Extra: 38, Subject: &hcl.Range{ Filename: "foo.tf", Start: hcl.Pos{Line: 1, Column: 10, Byte: 9}, End: hcl.Pos{Line: 2, Column: 3, Byte: 25}, }, }) return diags }, []diagFlat{ { Severity: Error, Summary: "Something bad happened", Detail: "It was really, really bad.", Subject: &SourceRange{ Filename: "foo.tf", Start: SourcePos{Line: 1, Column: 10, Byte: 9}, End: SourcePos{Line: 2, Column: 3, Byte: 25}, }, }, { Severity: Error, Summary: "Something bad happened", Detail: "It was really, really bad.", Subject: &SourceRange{ Filename: "foo.tf", Start: SourcePos{Line: 1, Column: 10, Byte: 9}, End: SourcePos{Line: 2, Column: 3, Byte: 25}, }, }, }, }, "hcl.Diagnostic no-location": { // Extra can contain anything, and we don't know how to compare // those values, so we can't dedupe them func(diags Diagnostics) Diagnostics { diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "Something bad happened", Detail: "It was really, really bad.", }) diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "Something bad happened", Detail: "It was really, really bad.", }) return diags }, []diagFlat{ { Severity: Error, Summary: "Something bad happened", Detail: "It was really, really bad.", }, { Severity: Error, Summary: "Something bad happened", Detail: "It was really, really bad.", }, }, }, } for name, test := range tests { t.Run(name, func(t *testing.T) { var deduped Diagnostics diags := test.Cons(nil) deduped = deduped.AppendWithoutDuplicates(diags...) var got []diagFlat for _, item := range deduped { desc := item.Description() source := item.Source() got = append(got, diagFlat{ Severity: item.Severity(), Summary: desc.Summary, Detail: desc.Detail, Subject: source.Subject, Context: source.Context, }) } if !reflect.DeepEqual(got, test.Want) { t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.Want) } }) } }