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)
+}