result package
- usage tests as examples - test utils
This commit is contained in:
parent
0f75b279c3
commit
3403a8e168
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
@ -8,7 +8,7 @@
|
|||||||
"request": "launch",
|
"request": "launch",
|
||||||
"mode": "auto",
|
"mode": "auto",
|
||||||
"program": "${workspaceFolder}/cmd/app",
|
"program": "${workspaceFolder}/cmd/app",
|
||||||
"args": ["-env", "dev", "-log-level", "debug"]
|
"args": ["-env", "dev", "-log-level", "debug", "-name", "Tester"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// ── Debug: same as above but with delve attached ───────────────────────
|
// ── Debug: same as above but with delve attached ───────────────────────
|
||||||
|
|||||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@ -36,6 +36,7 @@
|
|||||||
"makefile.configureOnOpen": false,
|
"makefile.configureOnOpen": false,
|
||||||
"cSpell.words": [
|
"cSpell.words": [
|
||||||
"djmil",
|
"djmil",
|
||||||
"gitea"
|
"gitea",
|
||||||
|
"testutil"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
15
CLAUDE.md
15
CLAUDE.md
@ -8,8 +8,8 @@ Keep it concise — the agent needs signal, not essays.
|
|||||||
## Project overview
|
## Project overview
|
||||||
|
|
||||||
Go 1.25 template / PoC starter. Demonstrates: structured logging (slog),
|
Go 1.25 template / PoC starter. Demonstrates: structured logging (slog),
|
||||||
config (flag), interfaces + manual fakes, linting (golangci-lint),
|
config (flag), consumer-defined interfaces + manual fakes, result type (happy-path error handling),
|
||||||
security scanning (gosec, govulncheck), git hooks, devcontainer, VSCode tasks.
|
linting (golangci-lint), security scanning (gosec, govulncheck), git hooks, devcontainer, VSCode tasks.
|
||||||
|
|
||||||
Module: `gitea.djmil.dev/djmil/go-template` — update this when you fork.
|
Module: `gitea.djmil.dev/djmil/go-template` — update this when you fork.
|
||||||
|
|
||||||
@ -33,7 +33,7 @@ tools.go Tool version pinning (build tag: tools)
|
|||||||
|
|
||||||
- **Module imports** — always use the full module path `gitea.djmil.dev/djmil/go-template/...`
|
- **Module imports** — always use the full module path `gitea.djmil.dev/djmil/go-template/...`
|
||||||
- **Packages** — keep `cmd/` thin (wiring only); business logic belongs in `internal/`
|
- **Packages** — keep `cmd/` thin (wiring only); business logic belongs in `internal/`
|
||||||
- **Interfaces** — define interfaces where they are *used*, not where they are *implemented*
|
- **Types** — expose concrete types from constructors (`New(...) *Type`); never wrap in an interface at the implementation site. Consumers define their own interfaces if they need one (Go's implicit satisfaction makes this free)
|
||||||
- **Errors** — wrap with `fmt.Errorf("context: %w", err)`; never swallow errors silently
|
- **Errors** — wrap with `fmt.Errorf("context: %w", err)`; never swallow errors silently
|
||||||
- **Logging** — use `log.WithField("key", val)` for structured context; never `fmt.Sprintf` in log messages; `log/slog` is the backend
|
- **Logging** — use `log.WithField("key", val)` for structured context; never `fmt.Sprintf` in log messages; `log/slog` is the backend
|
||||||
- **Config** — all configuration through `internal/config` (flag-parsed); no hard-coded values in logic packages
|
- **Config** — all configuration through `internal/config` (flag-parsed); no hard-coded values in logic packages
|
||||||
@ -85,10 +85,11 @@ Debug: use launch config "Debug: app" (F5).
|
|||||||
|
|
||||||
## Adding new features (checklist)
|
## Adding new features (checklist)
|
||||||
|
|
||||||
1. Define the interface in `internal/<domain>/`
|
1. Write the implementation in `internal/<domain>/` — return a concrete `*Type`, no interface at the implementation site
|
||||||
2. Write the implementation and its unit tests (using a manual fake for the interface)
|
2. In the *consumer* package (or `_test.go`), declare a minimal interface covering only the methods you call
|
||||||
3. Wire it in `cmd/app/main.go`
|
3. Write unit tests using a manual fake that satisfies that interface
|
||||||
4. Run `make lint test` before committing
|
4. Wire the concrete type in `cmd/app/main.go`
|
||||||
|
5. Run `make lint test` before committing
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
20
README.md
20
README.md
@ -134,22 +134,28 @@ In development, `logger.NewDevelopment()` uses the human-friendly text handler.
|
|||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Tests use the standard `testing` package. For dependencies on interfaces, write
|
Tests use the standard `testing` package. Concrete types are returned from
|
||||||
a manual fake inline in the test file — no code generation required:
|
constructors — consumers (including tests) define their own minimal interfaces
|
||||||
|
and satisfy them with manual fakes. No code generation required.
|
||||||
|
|
||||||
```go
|
```go
|
||||||
type fakeGreeter struct {
|
// Interface declared in the consumer (or _test.go), not in greeter package.
|
||||||
greetFn func(name string) (string, error)
|
type greeter interface {
|
||||||
|
Greet(name string) result.Expect[string]
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeGreeter) Greet(name string) (string, error) {
|
type fakeGreeter struct {
|
||||||
|
greetFn func(name string) result.Expect[string]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeGreeter) Greet(name string) result.Expect[string] {
|
||||||
return f.greetFn(name)
|
return f.greetFn(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSomething(t *testing.T) {
|
func TestSomething(t *testing.T) {
|
||||||
fake := &fakeGreeter{
|
fake := &fakeGreeter{
|
||||||
greetFn: func(name string) (string, error) {
|
greetFn: func(name string) result.Expect[string] {
|
||||||
return "Hello, " + name + "!", nil
|
return result.Ok("Hello, " + name + "!")
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
// pass fake to the system under test
|
// pass fake to the system under test
|
||||||
|
|||||||
@ -10,31 +10,31 @@ import (
|
|||||||
"gitea.djmil.dev/djmil/go-template/internal/config"
|
"gitea.djmil.dev/djmil/go-template/internal/config"
|
||||||
"gitea.djmil.dev/djmil/go-template/internal/greeter"
|
"gitea.djmil.dev/djmil/go-template/internal/greeter"
|
||||||
"gitea.djmil.dev/djmil/go-template/internal/logger"
|
"gitea.djmil.dev/djmil/go-template/internal/logger"
|
||||||
|
"gitea.djmil.dev/djmil/go-template/pkg/result"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if err := run(); err != nil {
|
if err := run(); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "fatal: %v\n", err)
|
fmt.Fprintf(os.Stderr, "fatal: %v\n", err)
|
||||||
|
if stack := result.StackTrace(err); stack != "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s\n", stack)
|
||||||
|
}
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func run() error {
|
func run() (err error) {
|
||||||
|
defer result.Catch(&err)
|
||||||
|
|
||||||
// ── Config ────────────────────────────────────────────────────────────────
|
// ── Config ────────────────────────────────────────────────────────────────
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
|
|
||||||
// ── Logger ────────────────────────────────────────────────────────────────
|
// ── Logger ────────────────────────────────────────────────────────────────
|
||||||
var (
|
var log *logger.Logger
|
||||||
log *logger.Logger
|
|
||||||
err error
|
|
||||||
)
|
|
||||||
if cfg.App.Env == "dev" {
|
if cfg.App.Env == "dev" {
|
||||||
log = logger.NewDevelopment()
|
log = logger.NewDevelopment()
|
||||||
} else {
|
} else {
|
||||||
log, err = logger.New(cfg.Logger.Level)
|
log = result.Of(logger.New(cfg.Logger.Level)).Expect("create logger")
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("creating logger: %w", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.WithFields(map[string]any{
|
log.WithFields(map[string]any{
|
||||||
@ -43,13 +43,10 @@ func run() error {
|
|||||||
}).Info("starting up")
|
}).Info("starting up")
|
||||||
|
|
||||||
// ── Services ──────────────────────────────────────────────────────────────
|
// ── Services ──────────────────────────────────────────────────────────────
|
||||||
greetSvc := greeter.New(log)
|
var greetSvc greeter.Greeter = greeter.New(log)
|
||||||
|
|
||||||
// ── Example usage ─────────────────────────────────────────────────────────
|
// ── Example usage ─────────────────────────────────────────────────────────
|
||||||
msg, err := greetSvc.Greet("Gopher")
|
msg := greetSvc.Greet("").Expect("greeting")
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("greeter: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.WithField("message", msg).Info("greeting complete")
|
log.WithField("message", msg).Info("greeting complete")
|
||||||
|
|
||||||
|
|||||||
118
examples/result/usage_test.go
Normal file
118
examples/result/usage_test.go
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
// Package result_example demonstrates typical usage patterns for pkg/result.
|
||||||
|
package result_example
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"gitea.djmil.dev/djmil/go-template/pkg/result"
|
||||||
|
)
|
||||||
|
|
||||||
|
// parsePort wraps strconv.Atoi so callers can use the happy-path style.
|
||||||
|
func parsePort(s string) result.Expect[int] {
|
||||||
|
n, err := strconv.Atoi(s)
|
||||||
|
if err != nil {
|
||||||
|
return result.Fail[int](fmt.Errorf("parsePort: %w", err))
|
||||||
|
}
|
||||||
|
if n < 1 || n > 65535 {
|
||||||
|
return result.Fail[int](fmt.Errorf("parsePort: %d out of range", n))
|
||||||
|
}
|
||||||
|
return result.Ok(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example_happyPath shows the basic happy-path pattern: call Expect at each
|
||||||
|
// step; if anything fails the panic unwinds to the nearest Catch.
|
||||||
|
func Example_happyPath() {
|
||||||
|
port := parsePort("8080").Expect("read port")
|
||||||
|
fmt.Println(port)
|
||||||
|
// Output:
|
||||||
|
// 8080
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example_errCheck shows checking the error without panicking — useful at the
|
||||||
|
// outermost boundary where you want a normal error return.
|
||||||
|
func Example_errCheck() {
|
||||||
|
r := parsePort("not-a-number")
|
||||||
|
if r.Err() != nil {
|
||||||
|
fmt.Println("failed:", r.Err())
|
||||||
|
}
|
||||||
|
// Output:
|
||||||
|
// failed: parsePort: strconv.Atoi: parsing "not-a-number": invalid syntax
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example_of shows wrapping an existing (value, error) function with result.Of.
|
||||||
|
func Example_of() {
|
||||||
|
port := result.Of(strconv.Atoi("9090")).Expect("parse port")
|
||||||
|
fmt.Println(port)
|
||||||
|
// Output:
|
||||||
|
// 9090
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example_catch shows the boundary pattern: defer Catch at the entry point so
|
||||||
|
// any Expect/Must panic anywhere in the call stack is captured as a normal
|
||||||
|
// error return.
|
||||||
|
func Example_catch() {
|
||||||
|
run := func() (err error) {
|
||||||
|
defer result.Catch(&err)
|
||||||
|
// Simulate a deep call stack: this panics because the port is invalid.
|
||||||
|
_ = parsePort("99999").Expect("load config port")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := run(); err != nil {
|
||||||
|
fmt.Println("caught:", err)
|
||||||
|
}
|
||||||
|
// Output:
|
||||||
|
// caught: load config port: parsePort: 99999 out of range
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example_unwrap shows re-joining the normal Go (value, error) world at a
|
||||||
|
// boundary where both values are needed separately.
|
||||||
|
func Example_unwrap() {
|
||||||
|
port, err := parsePort("443").Unwrap()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("error:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println(port)
|
||||||
|
// Output:
|
||||||
|
// 443
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example_nonErrorPanic shows that Catch does NOT swallow genuine runtime
|
||||||
|
// panics — only error panics produced by Must/Expect are captured.
|
||||||
|
func Example_nonErrorPanic() {
|
||||||
|
safeRun := func() (err error) {
|
||||||
|
defer func() {
|
||||||
|
// Outer recover catches the re-panic from Catch.
|
||||||
|
if v := recover(); v != nil {
|
||||||
|
err = fmt.Errorf("non-error panic: %v", v)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
defer result.Catch(&err)
|
||||||
|
panic("unexpected runtime problem") // not an error — Catch re-panics
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := safeRun(); err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
}
|
||||||
|
// Output:
|
||||||
|
// non-error panic: unexpected runtime problem
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example_fail shows constructing a failed Expect explicitly, e.g. when a
|
||||||
|
// function detects an error condition before calling any fallible op.
|
||||||
|
func Example_fail() {
|
||||||
|
validate := func(name string) result.Expect[string] {
|
||||||
|
if name == "" {
|
||||||
|
return result.Fail[string](errors.New("name must not be empty"))
|
||||||
|
}
|
||||||
|
return result.Ok(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := validate("")
|
||||||
|
fmt.Println(r.Err())
|
||||||
|
// Output:
|
||||||
|
// name must not be empty
|
||||||
|
}
|
||||||
@ -8,15 +8,17 @@
|
|||||||
package greeter
|
package greeter
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.djmil.dev/djmil/go-template/internal/logger"
|
"gitea.djmil.dev/djmil/go-template/internal/logger"
|
||||||
|
"gitea.djmil.dev/djmil/go-template/pkg/result"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Greeter produces a greeting for a given name.
|
// Greeter produces a greeting for a given name.
|
||||||
// The interface is what other packages should depend on — never the concrete type.
|
// The interface is what other packages should depend on — never the concrete type.
|
||||||
type Greeter interface {
|
type Greeter interface {
|
||||||
Greet(name string) (string, error)
|
Greet(name string) result.Expect[string]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Service is the concrete implementation.
|
// Service is the concrete implementation.
|
||||||
@ -29,10 +31,10 @@ func New(log *logger.Logger) *Service {
|
|||||||
return &Service{log: log}
|
return &Service{log: log}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Greet returns a personalised greeting and logs the interaction.
|
// Greet returns a personalized greeting and logs the interaction.
|
||||||
func (s *Service) Greet(name string) (string, error) {
|
func (s *Service) Greet(name string) result.Expect[string] {
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return "", fmt.Errorf("greeter: name must not be empty")
|
return result.Fail[string](errors.New("Greet: name must not be empty"))
|
||||||
}
|
}
|
||||||
|
|
||||||
msg := fmt.Sprintf("Hello, %s!", name)
|
msg := fmt.Sprintf("Hello, %s!", name)
|
||||||
@ -42,5 +44,5 @@ func (s *Service) Greet(name string) (string, error) {
|
|||||||
WithField("name", name).
|
WithField("name", name).
|
||||||
Info("greeting generated")
|
Info("greeting generated")
|
||||||
|
|
||||||
return msg, nil
|
return result.Ok(msg)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
package greeter_test
|
package greeter_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.djmil.dev/djmil/go-template/internal/greeter"
|
"gitea.djmil.dev/djmil/go-template/internal/greeter"
|
||||||
"gitea.djmil.dev/djmil/go-template/internal/logger"
|
"gitea.djmil.dev/djmil/go-template/internal/logger"
|
||||||
|
"gitea.djmil.dev/djmil/go-template/internal/testutil"
|
||||||
|
"gitea.djmil.dev/djmil/go-template/pkg/result"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Service (unit tests) ──────────────────────────────────────────────────────
|
// ── Service (unit tests) ──────────────────────────────────────────────────────
|
||||||
@ -14,23 +15,13 @@ func TestGreet(t *testing.T) {
|
|||||||
svc := greeter.New(logger.NewNop())
|
svc := greeter.New(logger.NewNop())
|
||||||
|
|
||||||
t.Run("returns personalized greeting", func(t *testing.T) {
|
t.Run("returns personalized greeting", func(t *testing.T) {
|
||||||
msg, err := svc.Greet("World")
|
msg, err := svc.Greet("World").Unwrap()
|
||||||
if err != nil {
|
testutil.NoError(t, err)
|
||||||
t.Fatalf("unexpected error: %v", err)
|
testutil.Equal(t, msg, "Hello, World!")
|
||||||
}
|
|
||||||
if msg != "Hello, World!" {
|
|
||||||
t.Errorf("got %q, want %q", msg, "Hello, World!")
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("rejects empty name", func(t *testing.T) {
|
t.Run("rejects empty name", func(t *testing.T) {
|
||||||
_, err := svc.Greet("")
|
testutil.ErrorContains(t, svc.Greet("").Err(), "name must not be empty")
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error, got nil")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "name must not be empty") {
|
|
||||||
t.Errorf("error %q does not contain %q", err.Error(), "name must not be empty")
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -39,25 +30,21 @@ func TestGreet(t *testing.T) {
|
|||||||
// No code generation required — just implement the interface directly.
|
// No code generation required — just implement the interface directly.
|
||||||
|
|
||||||
type fakeGreeter struct {
|
type fakeGreeter struct {
|
||||||
greetFn func(name string) (string, error)
|
greetFn func(name string) result.Expect[string]
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeGreeter) Greet(name string) (string, error) {
|
func (f *fakeGreeter) Greet(name string) result.Expect[string] {
|
||||||
return f.greetFn(name)
|
return f.greetFn(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFakeUsageExample(t *testing.T) {
|
func TestFakeUsageExample(t *testing.T) {
|
||||||
fake := &fakeGreeter{
|
fake := &fakeGreeter{
|
||||||
greetFn: func(name string) (string, error) {
|
greetFn: func(name string) result.Expect[string] {
|
||||||
return "Hello, " + name + "!", nil
|
return result.Ok("Hello, " + name + "!")
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
msg, err := fake.Greet("Alice")
|
msg, err := fake.Greet("Alice").Unwrap()
|
||||||
if err != nil {
|
testutil.NoError(t, err)
|
||||||
t.Fatalf("unexpected error: %v", err)
|
testutil.Equal(t, msg, "Hello, Alice!")
|
||||||
}
|
|
||||||
if msg != "Hello, Alice!" {
|
|
||||||
t.Errorf("got %q, want %q", msg, "Hello, Alice!")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
44
internal/testutil/testutil.go
Normal file
44
internal/testutil/testutil.go
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
// Package testutil provides lightweight test helpers to reduce boilerplate in
|
||||||
|
// table-driven tests. Import it from any _test.go file in this module.
|
||||||
|
//
|
||||||
|
// Every helper calls t.Helper() so failures are reported at the call site, not
|
||||||
|
// inside this package.
|
||||||
|
package testutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NoError fails the test immediately if err is not nil.
|
||||||
|
func NoError(t *testing.T, err error) {
|
||||||
|
t.Helper()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error fails the test if err is nil.
|
||||||
|
func Error(t *testing.T, err error) {
|
||||||
|
t.Helper()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorContains fails the test if err is nil or its message does not contain substr.
|
||||||
|
func ErrorContains(t *testing.T, err error, substr string) {
|
||||||
|
t.Helper()
|
||||||
|
Error(t, err)
|
||||||
|
if !strings.Contains(err.Error(), substr) {
|
||||||
|
t.Errorf("error %q does not contain %q", err.Error(), substr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Equal fails the test if got != want.
|
||||||
|
func Equal[T comparable](t *testing.T, got, want T) {
|
||||||
|
t.Helper()
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("got %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
123
pkg/result/result.go
Normal file
123
pkg/result/result.go
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
// Package result provides a generic Expect[T] type for happy-path-oriented code.
|
||||||
|
//
|
||||||
|
// The intended pattern is:
|
||||||
|
//
|
||||||
|
// 1. Deep call stacks write for the happy path, using Must/Expect to panic on
|
||||||
|
// unexpected errors rather than threading error returns through every frame.
|
||||||
|
// 2. The function that initiates a complex operation defers Catch, which
|
||||||
|
// recovers any Expect panic and returns it as a normal error.
|
||||||
|
//
|
||||||
|
// Stack traces are captured at the panic site (inside Expect/Must) and can be
|
||||||
|
// retrieved from the caught error via StackTrace.
|
||||||
|
//
|
||||||
|
// See the examples/ directory for complete usage patterns.
|
||||||
|
package result
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"runtime/debug"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Expect holds either a value of type T or an error.
|
||||||
|
type Expect[T any] struct {
|
||||||
|
value T
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ok wraps a successful value in an Expect.
|
||||||
|
func Ok[T any](v T) Expect[T] {
|
||||||
|
return Expect[T]{value: v}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fail wraps an error in an Expect.
|
||||||
|
func Fail[T any](err error) Expect[T] {
|
||||||
|
return Expect[T]{err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Of is a convenience constructor that bridges standard Go (value, error)
|
||||||
|
// return signatures:
|
||||||
|
//
|
||||||
|
// result.Of(os.Open("file.txt")).Expect("open config")
|
||||||
|
func Of[T any](v T, err error) Expect[T] {
|
||||||
|
return Expect[T]{value: v, err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Err returns the wrapped error, or nil on success.
|
||||||
|
func (r Expect[T]) Err() error {
|
||||||
|
return r.err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must returns the value or panics with the wrapped error and a stack trace.
|
||||||
|
// Prefer [Expect.Expect] — it adds a message that makes the panic site easy to
|
||||||
|
// locate in logs.
|
||||||
|
func (r Expect[T]) Must() T {
|
||||||
|
if r.err != nil {
|
||||||
|
panic(&stackError{err: r.err, stack: debug.Stack()})
|
||||||
|
}
|
||||||
|
return r.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expect returns the value or panics with the error annotated by msg and a
|
||||||
|
// stack trace captured at this call site.
|
||||||
|
//
|
||||||
|
// data := Parse(raw).Expect("parse user input")
|
||||||
|
func (r Expect[T]) Expect(msg string) T {
|
||||||
|
if r.err != nil {
|
||||||
|
panic(&stackError{
|
||||||
|
err: fmt.Errorf("%s: %w", msg, r.err),
|
||||||
|
stack: debug.Stack(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return r.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unwrap returns the value and error in the standard Go (value, error) form.
|
||||||
|
// Useful at the boundary where you want to re-join normal error-return code.
|
||||||
|
func (r Expect[T]) Unwrap() (T, error) {
|
||||||
|
return r.value, r.err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Catch recovers a panic produced by [Expect.Must] or [Expect.Expect] and
|
||||||
|
// stores it in *errp. Call it via defer at the entry point of any function
|
||||||
|
// that runs a happy-path call stack:
|
||||||
|
//
|
||||||
|
// func run() (err error) {
|
||||||
|
// defer result.Catch(&err)
|
||||||
|
// ...
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// The stored error retains its stack trace; retrieve it with [StackTrace].
|
||||||
|
// Panics that are not errors (e.g. nil-pointer dereferences) are re-panicked
|
||||||
|
// so genuine bugs are not silently swallowed.
|
||||||
|
func Catch(errp *error) {
|
||||||
|
v := recover()
|
||||||
|
if v == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err, ok := v.(error); ok {
|
||||||
|
*errp = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
panic(v) // not an error — let it propagate
|
||||||
|
}
|
||||||
|
|
||||||
|
// StackTrace returns the stack trace captured when [Expect.Expect] or
|
||||||
|
// [Expect.Must] panicked. Returns an empty string if err was not produced by
|
||||||
|
// this package.
|
||||||
|
func StackTrace(err error) string {
|
||||||
|
var s *stackError
|
||||||
|
if errors.As(err, &s) {
|
||||||
|
return string(s.stack)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// stackError wraps an error with a stack trace captured at the panic site.
|
||||||
|
type stackError struct {
|
||||||
|
err error
|
||||||
|
stack []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stackError) Error() string { return s.err.Error() }
|
||||||
|
func (s *stackError) Unwrap() error { return s.err }
|
||||||
Loading…
Reference in New Issue
Block a user