Skip to content

Commit caed015

Browse files
committed
feat: add typed error system for executor
Introduce mdl/errors/ package with 6 structured error types: NotConnectedError, NotFoundError, AlreadyExistsError, UnsupportedError, ValidationError, BackendError. Migrate all ~1,084 fmt.Errorf calls across 79 executor files to typed errors. Enables programmatic error classification via errors.Is/errors.As for future dispatch registry, LSP integration, and API backend support. All existing consumers (CLI, TUI, LSP, diaglog) use .Error() only and remain fully compatible. ErrExit sentinel re-exported from executor package for backward compatibility.
1 parent 99cf0eb commit caed015

84 files changed

Lines changed: 1493 additions & 1113 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

mdl/errors/errors.go

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
// Package mdlerrors provides structured error types for the MDL executor.
4+
//
5+
// All error types support errors.Is and errors.As for programmatic classification.
6+
// Every error preserves the original message via Error() for backward-compatible
7+
// string output — callers that only use %v or .Error() see no change.
8+
//
9+
// Only BackendError supports Unwrap — it wraps an underlying storage/IO error.
10+
// All other error types are leaf errors with no wrapped cause.
11+
package mdlerrors
12+
13+
import (
14+
"errors"
15+
"fmt"
16+
)
17+
18+
// ErrExit is a sentinel error indicating clean script/session termination.
19+
// Use errors.Is(err, ErrExit) to detect exit requests.
20+
var ErrExit = errors.New("exit")
21+
22+
// NotConnectedError indicates an operation was attempted without an active project connection.
23+
type NotConnectedError struct {
24+
// WriteMode is true when write access was required but not available.
25+
WriteMode bool
26+
msg string
27+
}
28+
29+
// NewNotConnected creates a NotConnectedError for read access.
30+
func NewNotConnected() *NotConnectedError {
31+
return &NotConnectedError{msg: "not connected to a project"}
32+
}
33+
34+
// NewNotConnectedWrite creates a NotConnectedError for write access.
35+
func NewNotConnectedWrite() *NotConnectedError {
36+
return &NotConnectedError{WriteMode: true, msg: "not connected to a project in write mode"}
37+
}
38+
39+
func (e *NotConnectedError) Error() string { return e.msg }
40+
41+
// NotFoundError indicates a named element was not found.
42+
type NotFoundError struct {
43+
// Kind is the element type (e.g. "entity", "module", "microflow").
44+
Kind string
45+
// Name is the qualified or simple name of the element.
46+
Name string
47+
msg string
48+
}
49+
50+
// NewNotFound creates a NotFoundError.
51+
func NewNotFound(kind, name string) *NotFoundError {
52+
return &NotFoundError{
53+
Kind: kind,
54+
Name: name,
55+
msg: fmt.Sprintf("%s not found: %s", kind, name),
56+
}
57+
}
58+
59+
// NewNotFoundMsg creates a NotFoundError with a custom message.
60+
func NewNotFoundMsg(kind, name, msg string) *NotFoundError {
61+
return &NotFoundError{Kind: kind, Name: name, msg: msg}
62+
}
63+
64+
func (e *NotFoundError) Error() string { return e.msg }
65+
66+
// AlreadyExistsError indicates an element already exists when creating.
67+
type AlreadyExistsError struct {
68+
// Kind is the element type.
69+
Kind string
70+
// Name is the qualified or simple name.
71+
Name string
72+
msg string
73+
}
74+
75+
// NewAlreadyExists creates an AlreadyExistsError.
76+
func NewAlreadyExists(kind, name string) *AlreadyExistsError {
77+
return &AlreadyExistsError{
78+
Kind: kind,
79+
Name: name,
80+
msg: fmt.Sprintf("%s already exists: %s", kind, name),
81+
}
82+
}
83+
84+
// NewAlreadyExistsMsg creates an AlreadyExistsError with a custom message.
85+
func NewAlreadyExistsMsg(kind, name, msg string) *AlreadyExistsError {
86+
return &AlreadyExistsError{Kind: kind, Name: name, msg: msg}
87+
}
88+
89+
func (e *AlreadyExistsError) Error() string { return e.msg }
90+
91+
// UnsupportedError indicates an unsupported operation, feature, or property.
92+
type UnsupportedError struct {
93+
// What holds the full error message describing what is unsupported
94+
// (e.g. "unsupported attribute type: Binary").
95+
What string
96+
msg string
97+
}
98+
99+
// NewUnsupported creates an UnsupportedError.
100+
func NewUnsupported(msg string) *UnsupportedError {
101+
return &UnsupportedError{What: msg, msg: msg}
102+
}
103+
104+
func (e *UnsupportedError) Error() string { return e.msg }
105+
106+
// ValidationError indicates invalid input or configuration.
107+
type ValidationError struct {
108+
msg string
109+
}
110+
111+
// NewValidation creates a ValidationError.
112+
func NewValidation(msg string) *ValidationError {
113+
return &ValidationError{msg: msg}
114+
}
115+
116+
// NewValidationf creates a ValidationError with formatted message.
117+
func NewValidationf(format string, args ...any) *ValidationError {
118+
return &ValidationError{msg: fmt.Sprintf(format, args...)}
119+
}
120+
121+
func (e *ValidationError) Error() string { return e.msg }
122+
123+
// BackendError wraps an error from the underlying storage layer (mpr/SDK).
124+
type BackendError struct {
125+
// Op describes the operation that failed (e.g. "get domain model", "write entity").
126+
Op string
127+
Err error
128+
}
129+
130+
// NewBackend creates a BackendError wrapping a cause.
131+
func NewBackend(op string, err error) *BackendError {
132+
return &BackendError{Op: op, Err: err}
133+
}
134+
135+
func (e *BackendError) Error() string {
136+
if e.Err == nil {
137+
return fmt.Sprintf("failed to %s", e.Op)
138+
}
139+
return fmt.Sprintf("failed to %s: %v", e.Op, e.Err)
140+
}
141+
142+
func (e *BackendError) Unwrap() error { return e.Err }

mdl/errors/errors_test.go

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
package mdlerrors
4+
5+
import (
6+
"errors"
7+
"fmt"
8+
"testing"
9+
)
10+
11+
func TestErrExit(t *testing.T) {
12+
err := ErrExit
13+
if !errors.Is(err, ErrExit) {
14+
t.Fatal("errors.Is(ErrExit, ErrExit) should be true")
15+
}
16+
wrapped := fmt.Errorf("wrapper: %w", ErrExit)
17+
if !errors.Is(wrapped, ErrExit) {
18+
t.Fatal("errors.Is(wrapped, ErrExit) should be true")
19+
}
20+
}
21+
22+
func TestNotConnectedError(t *testing.T) {
23+
t.Run("read mode", func(t *testing.T) {
24+
err := NewNotConnected()
25+
if err.Error() != "not connected to a project" {
26+
t.Fatalf("unexpected message: %s", err.Error())
27+
}
28+
if err.WriteMode {
29+
t.Fatal("WriteMode should be false")
30+
}
31+
var target *NotConnectedError
32+
if !errors.As(err, &target) {
33+
t.Fatal("errors.As should match *NotConnectedError")
34+
}
35+
})
36+
37+
t.Run("write mode", func(t *testing.T) {
38+
err := NewNotConnectedWrite()
39+
if err.Error() != "not connected to a project in write mode" {
40+
t.Fatalf("unexpected message: %s", err.Error())
41+
}
42+
if !err.WriteMode {
43+
t.Fatal("WriteMode should be true")
44+
}
45+
var target *NotConnectedError
46+
if !errors.As(err, &target) {
47+
t.Fatal("errors.As should match *NotConnectedError")
48+
}
49+
})
50+
51+
t.Run("wrapped", func(t *testing.T) {
52+
inner := NewNotConnected()
53+
wrapped := fmt.Errorf("context: %w", inner)
54+
var target *NotConnectedError
55+
if !errors.As(wrapped, &target) {
56+
t.Fatal("errors.As should match through wrapping")
57+
}
58+
})
59+
}
60+
61+
func TestNotFoundError(t *testing.T) {
62+
err := NewNotFound("entity", "MyModule.MyEntity")
63+
if err.Error() != "entity not found: MyModule.MyEntity" {
64+
t.Fatalf("unexpected message: %s", err.Error())
65+
}
66+
if err.Kind != "entity" || err.Name != "MyModule.MyEntity" {
67+
t.Fatalf("unexpected fields: Kind=%s Name=%s", err.Kind, err.Name)
68+
}
69+
var target *NotFoundError
70+
if !errors.As(err, &target) {
71+
t.Fatal("errors.As should match *NotFoundError")
72+
}
73+
74+
custom := NewNotFoundMsg("microflow", "MyModule.DoSomething", "microflow MyModule.DoSomething does not exist")
75+
if custom.Error() != "microflow MyModule.DoSomething does not exist" {
76+
t.Fatalf("unexpected message: %s", custom.Error())
77+
}
78+
}
79+
80+
func TestAlreadyExistsError(t *testing.T) {
81+
err := NewAlreadyExists("entity", "MyModule.MyEntity")
82+
if err.Error() != "entity already exists: MyModule.MyEntity" {
83+
t.Fatalf("unexpected message: %s", err.Error())
84+
}
85+
var target *AlreadyExistsError
86+
if !errors.As(err, &target) {
87+
t.Fatal("errors.As should match *AlreadyExistsError")
88+
}
89+
90+
custom := NewAlreadyExistsMsg("entity", "MyModule.MyEntity", "entity already exists: MyModule.MyEntity (use CREATE OR MODIFY to update)")
91+
if custom.Kind != "entity" {
92+
t.Fatalf("unexpected Kind: %s", custom.Kind)
93+
}
94+
}
95+
96+
func TestUnsupportedError(t *testing.T) {
97+
err := NewUnsupported("unsupported attribute type: Binary")
98+
if err.Error() != "unsupported attribute type: Binary" {
99+
t.Fatalf("unexpected message: %s", err.Error())
100+
}
101+
var target *UnsupportedError
102+
if !errors.As(err, &target) {
103+
t.Fatal("errors.As should match *UnsupportedError")
104+
}
105+
}
106+
107+
func TestValidationError(t *testing.T) {
108+
err := NewValidation("invalid entity name")
109+
if err.Error() != "invalid entity name" {
110+
t.Fatalf("unexpected message: %s", err.Error())
111+
}
112+
var target *ValidationError
113+
if !errors.As(err, &target) {
114+
t.Fatal("errors.As should match *ValidationError")
115+
}
116+
117+
errf := NewValidationf("invalid %s: %s", "entity name", "123Bad")
118+
if errf.Error() != "invalid entity name: 123Bad" {
119+
t.Fatalf("unexpected message: %s", errf.Error())
120+
}
121+
}
122+
123+
func TestBackendError(t *testing.T) {
124+
cause := fmt.Errorf("disk full")
125+
err := NewBackend("write entity", cause)
126+
if err.Error() != "failed to write entity: disk full" {
127+
t.Fatalf("unexpected message: %s", err.Error())
128+
}
129+
var target *BackendError
130+
if !errors.As(err, &target) {
131+
t.Fatal("errors.As should match *BackendError")
132+
}
133+
if target.Op != "write entity" {
134+
t.Fatalf("unexpected Op: %s", target.Op)
135+
}
136+
137+
// Unwrap
138+
if !errors.Is(err, cause) {
139+
t.Fatal("errors.Is should find the cause through Unwrap")
140+
}
141+
142+
// Double-wrapped
143+
wrapped := fmt.Errorf("outer: %w", err)
144+
if !errors.As(wrapped, &target) {
145+
t.Fatal("errors.As should match through double wrapping")
146+
}
147+
if !errors.Is(wrapped, cause) {
148+
t.Fatal("errors.Is should find cause through double wrapping")
149+
}
150+
151+
// Nil cause
152+
nilErr := NewBackend("test op", nil)
153+
if nilErr.Error() != "failed to test op" {
154+
t.Fatalf("unexpected nil-cause message: %s", nilErr.Error())
155+
}
156+
if nilErr.Unwrap() != nil {
157+
t.Fatal("Unwrap should return nil when cause is nil")
158+
}
159+
}

mdl/executor/cmd_agenteditor_agents.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,19 @@ import (
1212
"strings"
1313

1414
"github.com/mendixlabs/mxcli/mdl/ast"
15+
mdlerrors "github.com/mendixlabs/mxcli/mdl/errors"
1516
"github.com/mendixlabs/mxcli/sdk/agenteditor"
1617
)
1718

1819
// showAgentEditorAgents handles SHOW AGENTS [IN module].
1920
func (e *Executor) showAgentEditorAgents(moduleName string) error {
2021
if e.reader == nil {
21-
return fmt.Errorf("not connected to a project")
22+
return mdlerrors.NewNotConnected()
2223
}
2324

2425
agents, err := e.reader.ListAgentEditorAgents()
2526
if err != nil {
26-
return fmt.Errorf("failed to list agents: %w", err)
27+
return mdlerrors.NewBackend("list agents", err)
2728
}
2829

2930
h, err := e.getHierarchy()
@@ -64,12 +65,12 @@ func (e *Executor) showAgentEditorAgents(moduleName string) error {
6465
// round-trippable CREATE AGENT statement reflecting the Contents JSON.
6566
func (e *Executor) describeAgentEditorAgent(name ast.QualifiedName) error {
6667
if e.reader == nil {
67-
return fmt.Errorf("not connected to a project")
68+
return mdlerrors.NewNotConnected()
6869
}
6970

7071
a := e.findAgentEditorAgent(name.Module, name.Name)
7172
if a == nil {
72-
return fmt.Errorf("agent not found: %s", name)
73+
return mdlerrors.NewNotFound("agent", name.String())
7374
}
7475

7576
h, err := e.getHierarchy()

mdl/executor/cmd_agenteditor_kbs.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,19 @@ import (
1212
"fmt"
1313

1414
"github.com/mendixlabs/mxcli/mdl/ast"
15+
mdlerrors "github.com/mendixlabs/mxcli/mdl/errors"
1516
"github.com/mendixlabs/mxcli/sdk/agenteditor"
1617
)
1718

1819
// showAgentEditorKnowledgeBases handles SHOW KNOWLEDGE BASES [IN module].
1920
func (e *Executor) showAgentEditorKnowledgeBases(moduleName string) error {
2021
if e.reader == nil {
21-
return fmt.Errorf("not connected to a project")
22+
return mdlerrors.NewNotConnected()
2223
}
2324

2425
kbs, err := e.reader.ListAgentEditorKnowledgeBases()
2526
if err != nil {
26-
return fmt.Errorf("failed to list knowledge bases: %w", err)
27+
return mdlerrors.NewBackend("list knowledge bases", err)
2728
}
2829

2930
h, err := e.getHierarchy()
@@ -62,12 +63,12 @@ func (e *Executor) showAgentEditorKnowledgeBases(moduleName string) error {
6263
// describeAgentEditorKnowledgeBase handles DESCRIBE KNOWLEDGE BASE Module.Name.
6364
func (e *Executor) describeAgentEditorKnowledgeBase(name ast.QualifiedName) error {
6465
if e.reader == nil {
65-
return fmt.Errorf("not connected to a project")
66+
return mdlerrors.NewNotConnected()
6667
}
6768

6869
k := e.findAgentEditorKnowledgeBase(name.Module, name.Name)
6970
if k == nil {
70-
return fmt.Errorf("knowledge base not found: %s", name)
71+
return mdlerrors.NewNotFound("knowledge base", name.String())
7172
}
7273

7374
h, err := e.getHierarchy()

0 commit comments

Comments
 (0)