logging: Introduce types for structured UI messages + `UnmarshalLogMessage()` (#167)
diff --git a/logging.go b/logging.go
new file mode 100644
index 0000000..adfcf60
--- /dev/null
+++ b/logging.go
@@ -0,0 +1,84 @@
+// Copyright (c) HashiCorp, Inc.
+// SPDX-License-Identifier: MPL-2.0
+package tfjson
+
+import (
+ "bytes"
+ "encoding/json"
+ "time"
+)
+
+// LogMessageLevel represents log level
+// See https://github.com/hashicorp/go-hclog/blob/v1.6.3/logger.go#L126-L145
+type LogMessageLevel string
+
+const (
+ // Trace is the most verbose level. Intended to be used for the tracing
+ // of actions in code, such as function enters/exits, etc.
+ Trace LogMessageLevel = "trace"
+
+ // Debug information for programmer low-level analysis.
+ Debug LogMessageLevel = "debug"
+
+ // Info information about steady state operations.
+ Info LogMessageLevel = "info"
+
+ // Warn information about rare but handled events.
+ Warn LogMessageLevel = "warn"
+
+ // Error information about unrecoverable events.
+ Error LogMessageLevel = "error"
+)
+
+// LogMessage represents a log message emitted from commands
+// which support structured log output.
+//
+// This is implemented via hashicorp/go-hclog which
+// defines the format.
+type LogMsg interface {
+ Level() LogMessageLevel
+ Message() string
+ Timestamp() time.Time
+}
+
+type baseLogMessage struct {
+ Lvl LogMessageLevel `json:"@level"`
+ Msg string `json:"@message"`
+ Time time.Time `json:"@timestamp"`
+}
+
+type msgType struct {
+ // Type represents a message type
+ // which is documented at https://developer.hashicorp.com/terraform/internals/machine-readable-ui#message-types
+ Type LogMessageType `json:"type"`
+}
+
+func (m baseLogMessage) Level() LogMessageLevel {
+ return m.Lvl
+}
+
+func (m baseLogMessage) Message() string {
+ return m.Msg
+}
+
+func (m baseLogMessage) Timestamp() time.Time {
+ return m.Time
+}
+
+// UnknownLogMessage represents a message of unknown type
+type UnknownLogMessage struct {
+ baseLogMessage
+}
+
+func UnmarshalLogMessage(b []byte) (LogMsg, error) {
+ d := json.NewDecoder(bytes.NewReader(b))
+
+ mt := msgType{}
+ err := d.Decode(&mt)
+ if err != nil {
+ return nil, err
+ }
+
+ v, err := unmarshalByType(mt.Type, b)
+ return v, err
+}
diff --git a/logging_generic.go b/logging_generic.go
new file mode 100644
index 0000000..4414abc
--- /dev/null
+++ b/logging_generic.go
@@ -0,0 +1,27 @@
+// Copyright (c) HashiCorp, Inc.
+// SPDX-License-Identifier: MPL-2.0
+package tfjson
+
+import "github.com/hashicorp/go-version"
+
+// VersionLogMessage represents information about the Terraform version
+// and the version of the schema used for the following messages.
+// This is a message of type "version".
+type VersionLogMessage struct {
+ baseLogMessage
+ Terraform *version.Version `json:"terraform"`
+ UI *version.Version `json:"ui"`
+}
+
+// LogMessage represents a generic human-readable log line
+// This is a message of type "log"
+type LogMessage struct {
+ baseLogMessage
+}
+
+// DiagnosticLogMessage represents diagnostic warning or error message.
+// This is a message of type "diagnostic"
+type DiagnosticLogMessage struct {
+ baseLogMessage
+ Diagnostic `json:"diagnostic"`
+}
diff --git a/logging_test.go b/logging_test.go
new file mode 100644
index 0000000..851cc2c
--- /dev/null
+++ b/logging_test.go
@@ -0,0 +1,99 @@
+// Copyright (c) HashiCorp, Inc.
+// SPDX-License-Identifier: MPL-2.0
+package tfjson
+
+import (
+ "testing"
+ "time"
+
+ "github.com/google/go-cmp/cmp"
+ "github.com/hashicorp/go-version"
+)
+
+var cmpOpts = cmp.AllowUnexported(allLogMessageTypes...)
+
+func TestLogging_generic(t *testing.T) {
+ testCases := []struct {
+ rawMessage string
+ expectedMessage LogMsg
+ }{
+ {
+ `{"@level":"info","@message":"Installing provider version: hashicorp/aws v6.8.0...","@module":"terraform.ui","@timestamp":"2025-08-11T15:09:18.827459+00:00","type":"log"}`,
+ LogMessage{
+ baseLogMessage: baseLogMessage{
+ Lvl: Info,
+ Msg: "Installing provider version: hashicorp/aws v6.8.0...",
+ Time: time.Date(2025, 8, 11, 15, 9, 18, 827459000, time.UTC),
+ },
+ },
+ },
+ {
+ `{"@level":"info","@message":"Terraform 1.9.0","@module":"terraform.ui","@timestamp":"2025-08-11T15:09:15.919212+00:00","terraform":"1.9.0","type":"version","ui":"1.2"}`,
+ VersionLogMessage{
+ baseLogMessage: baseLogMessage{
+ Lvl: Info,
+ Msg: "Terraform 1.9.0",
+ Time: time.Date(2025, 8, 11, 15, 9, 15, 919212000, time.UTC),
+ },
+ Terraform: version.Must(version.NewVersion("1.9.0")),
+ UI: version.Must(version.NewVersion("1.2")),
+ },
+ },
+ {
+ `{"@level":"error","@message":"Error: Unclosed configuration block","@module":"terraform.ui","@timestamp":"2025-08-13T10:40:46.749685+00:00","diagnostic":{"severity":"error","summary":"Unclosed configuration block","detail":"There is no closing brace for this block before the end of the file. This may be caused by incorrect brace nesting elsewhere in this file.","range":{"filename":"main.tf","start":{"line":11,"column":30,"byte":153},"end":{"line":11,"column":31,"byte":154}},"snippet":{"context":"resource \"random_pet\" \"name\"","code":"resource \"random_pet\" \"name\" {","start_line":11,"highlight_start_offset":29,"highlight_end_offset":30,"values":[]}},"type":"diagnostic"}`,
+ DiagnosticLogMessage{
+ baseLogMessage: baseLogMessage{
+ Lvl: Error,
+ Msg: "Error: Unclosed configuration block",
+ Time: time.Date(2025, 8, 13, 10, 40, 46, 749685000, time.UTC),
+ },
+ Diagnostic: Diagnostic{
+ Severity: DiagnosticSeverityError,
+ Summary: "Unclosed configuration block",
+ Detail: "There is no closing brace for this block before the end of the file. This may be caused by incorrect brace nesting elsewhere in this file.",
+ Range: &Range{
+ Filename: "main.tf",
+ Start: Pos{
+ Line: 11,
+ Column: 30,
+ Byte: 153,
+ },
+ End: Pos{
+ Line: 11,
+ Column: 31,
+ Byte: 154,
+ },
+ },
+ Snippet: &DiagnosticSnippet{
+ Context: ptrToString(`resource "random_pet" "name"`),
+ Code: `resource "random_pet" "name" {`,
+ StartLine: 11,
+ HighlightStartOffset: 29,
+ HighlightEndOffset: 30,
+ Values: []DiagnosticExpressionValue{},
+ },
+ },
+ },
+ },
+ {
+ `{"@level":"debug","@message":"Foobar","@module":"terraform.ui","@timestamp":"2025-08-11T15:09:18.827459+00:00","type":"FOO"}`,
+ UnknownLogMessage{
+ baseLogMessage: baseLogMessage{
+ Lvl: Debug,
+ Msg: "Foobar",
+ Time: time.Date(2025, 8, 11, 15, 9, 18, 827459000, time.UTC),
+ },
+ },
+ },
+ }
+
+ for _, tc := range testCases {
+ msg, err := UnmarshalLogMessage([]byte(tc.rawMessage))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if diff := cmp.Diff(tc.expectedMessage, msg, cmpOpts); diff != "" {
+ t.Fatalf("unexpected message: %s", diff)
+ }
+ }
+}
diff --git a/logging_types.go b/logging_types.go
new file mode 100644
index 0000000..e26522a
--- /dev/null
+++ b/logging_types.go
@@ -0,0 +1,41 @@
+// Copyright (c) HashiCorp, Inc.
+// SPDX-License-Identifier: MPL-2.0
+package tfjson
+
+import (
+ "encoding/json"
+)
+
+type LogMessageType string
+
+const (
+ MessageTypeVersion LogMessageType = "version"
+ MessageTypeLog LogMessageType = "log"
+ MessageTypeDiagnostic LogMessageType = "diagnostic"
+)
+
+// allLogMessageTypes is a slice containing all recognised message types
+// to be passed into cmp.AllowUnexported
+var allLogMessageTypes = []any{
+ VersionLogMessage{},
+ LogMessage{},
+ DiagnosticLogMessage{},
+ UnknownLogMessage{},
+}
+
+func unmarshalByType(t LogMessageType, b []byte) (LogMsg, error) {
+ switch t {
+ case MessageTypeVersion:
+ v := VersionLogMessage{}
+ return v, json.Unmarshal(b, &v)
+ case MessageTypeLog:
+ v := LogMessage{}
+ return v, json.Unmarshal(b, &v)
+ case MessageTypeDiagnostic:
+ v := DiagnosticLogMessage{}
+ return v, json.Unmarshal(b, &v)
+ }
+
+ v := UnknownLogMessage{}
+ return v, json.Unmarshal(b, &v)
+}