Skip to content

Commit 57d2601

Browse files
dvdksnclaude
andcommitted
internal/hint: add structured hint mechanism for user-facing errors
Adds a small internal package that attaches actionable user guidance to errors, separated from the error message itself. The hint is exposed via the Hinter interface and is read out of the error chain by the top-level renderer (a follow-up commit wires this up). Keeping the hint out of err.Error() means substring-based test assertions stay stable as hints are added or reworded, and gives the CLI control over rendering (formatting, prefixing, future i18n) at a single point instead of having every call site embed it inline. Internal-only for now; can be promoted to a public API later if other plugins (buildx, compose) want to share the convention. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: David Karlsson <35727626+dvdksn@users.noreply.github.com>
1 parent 0b459a4 commit 57d2601

2 files changed

Lines changed: 103 additions & 0 deletions

File tree

internal/hint/hint.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// Package hint attaches actionable user guidance to errors.
2+
//
3+
// A hint describes what the user can do about a failure ("Run
4+
// 'docker rm foo' to remove it.") and is rendered separately from the
5+
// error's message by the top-level error handler. It is not part of
6+
// [error.Error], so substring matching on the error message stays
7+
// stable as hints are added or reworded.
8+
//
9+
// Use [Wrap] to attach a hint at the call site, and [Of] (or
10+
// [errors.As] against [Hinter]) to extract it for rendering.
11+
package hint
12+
13+
import "errors"
14+
15+
// Hinter is implemented by errors that carry actionable user guidance.
16+
type Hinter interface {
17+
Hint() string
18+
}
19+
20+
type errWithHint struct {
21+
error
22+
hint string
23+
}
24+
25+
func (e *errWithHint) Hint() string { return e.hint }
26+
func (e *errWithHint) Unwrap() error { return e.error }
27+
28+
// Wrap attaches actionable guidance to err. It returns nil if err is
29+
// nil. The hint does not appear in the wrapped error's [error.Error]
30+
// output; it is read out of the chain by the top-level renderer via
31+
// [Of] (or [errors.As] against [Hinter]).
32+
func Wrap(err error, hint string) error {
33+
if err == nil {
34+
return nil
35+
}
36+
return &errWithHint{error: err, hint: hint}
37+
}
38+
39+
// Of returns the first non-empty hint in the error chain, or "" if
40+
// none of the wrapped errors implement [Hinter].
41+
func Of(err error) string {
42+
for err != nil {
43+
if h, ok := err.(Hinter); ok {
44+
if msg := h.Hint(); msg != "" {
45+
return msg
46+
}
47+
}
48+
err = errors.Unwrap(err)
49+
}
50+
return ""
51+
}

internal/hint/hint_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package hint
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"testing"
7+
8+
"gotest.tools/v3/assert"
9+
)
10+
11+
func TestWrap_NilError(t *testing.T) {
12+
assert.Assert(t, Wrap(nil, "irrelevant") == nil)
13+
}
14+
15+
func TestWrap_PreservesMessage(t *testing.T) {
16+
err := Wrap(errors.New("bad input"), "Try --help.")
17+
assert.Equal(t, err.Error(), "bad input")
18+
}
19+
20+
func TestWrap_HintReadable(t *testing.T) {
21+
err := Wrap(errors.New("bad input"), "Try --help.")
22+
23+
var h Hinter
24+
assert.Assert(t, errors.As(err, &h))
25+
assert.Equal(t, h.Hint(), "Try --help.")
26+
}
27+
28+
func TestOf_FindsHint(t *testing.T) {
29+
base := Wrap(errors.New("bad input"), "Try --help.")
30+
wrapped := fmt.Errorf("context: %w", base)
31+
32+
assert.Equal(t, Of(wrapped), "Try --help.")
33+
}
34+
35+
func TestOf_NoHint(t *testing.T) {
36+
assert.Equal(t, Of(errors.New("plain")), "")
37+
assert.Equal(t, Of(nil), "")
38+
}
39+
40+
func TestOf_FirstNonEmpty(t *testing.T) {
41+
// outer wrapper has empty hint; inner has content. Of should
42+
// return the first non-empty hint.
43+
inner := Wrap(errors.New("bad input"), "Try --help.")
44+
outer := Wrap(inner, "")
45+
assert.Equal(t, Of(outer), "Try --help.")
46+
}
47+
48+
func TestUnwrap(t *testing.T) {
49+
base := errors.New("bad input")
50+
err := Wrap(base, "Try --help.")
51+
assert.Assert(t, errors.Is(err, base))
52+
}

0 commit comments

Comments
 (0)