From 13f6a6812a0aef2fe952e6f84094130890b2697b Mon Sep 17 00:00:00 2001 From: djmil Date: Sun, 14 Jun 2026 11:43:23 +0000 Subject: [PATCH] result.Wrap - propagate a failed Expect into a new type U - public API streamline - Failf[T]("msg") - originate a failure from a message; embed a cause with %w - Err[T](err) - sets .err verbatim (the return zero, err / sentinel case) --- CLAUDE.md | 7 +++- internal/greeter/greeter.go | 2 +- pkg/logger/logger.go | 12 +++--- pkg/result/bench_test.go | 8 ++-- pkg/result/doc.go | 8 ++-- pkg/result/example_test.go | 28 +++++++++++-- pkg/result/panic_test.go | 2 +- pkg/result/result.go | 63 ++++++++++++++++++++-------- pkg/result/wrap_test.go | 82 +++++++++++++++++++++++++++++++++++++ 9 files changed, 176 insertions(+), 36 deletions(-) create mode 100644 pkg/result/wrap_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 1884a74..fc52f53 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,8 +66,12 @@ tools.versions Pinned tool versions (sourced by Makefile and pre-push - top-level entry points defer `result.Catch(&err)` (or use `result.Run(...)`) to convert any result exit into a normal Go error; genuine runtime panics (nil-deref, etc.) are re-panicked - **`result.Catch` is incompatible with `-tags result_goexit`**: it relies on `recover()` which cannot intercept `runtime.Goexit`; prefer `result.Run`/`result.Go` which work in both builds - bridge existing `(T, error)` stdlib/third-party calls with `result.Of(...)`: `result.Of(os.ReadFile("cfg.json")).Expect("read config")` + - construct failures with these constructors, each a distinct intent — don't invent more: + - `result.Ok[T](v)` / `result.Err[T](err)` — the two field constructors: a success value, or a bare error boxed verbatim (use `Err` for `return zero, err` and sentinels) + - `result.Failf[T]("msg")` — *originate* a failure from a message; embed a cause with `%w` (`result.Failf[T]("load config: %w", err)`) + - `result.Wrap[U](r, "msg")` — *propagate* an already-failed `Expect` into a new type `U`, optionally adding context; only valid on a failed result (panics on a success), so guard with `if r.Err() != nil`. Use this instead of pulling the error out with `r.Err()` by hand - use `result.StackTrace(err)` to retrieve the capture-site stack from a caught error - - still use `fmt.Errorf("context: %w", err)` when wrapping errors *before* constructing a `result.Fail` + - still use `fmt.Errorf("context: %w", err)` when wrapping errors *before* constructing a `result.Err` - **Logging** — logs go to `stderr` per 12-factor XI; human output goes to `stdout` via `fmt.Print*`. Use `logger.NewCLI(level, debugFile)` for CLI apps: auto-detects TTY → human text on terminal, JSON when piped. Use `logger.New(level)` for headless services that always want JSON. @@ -154,3 +158,4 @@ make clean # remove bin/ - 2026-04-23 — Documented result layering rule: pkg/ libraries only return Expect[T]; .Expect()/.Must() calls belong in application-layer code. - 2026-06-03 — pkg/logger v0.4.0: replaced NewDevelopment with NewCLI(level, debugFile); two-mode model (human text on TTY / JSON when piped); debug file mode; IsInteractive() helper. Established "form over mechanism" as core design principle. - 2026-06-13 — Build stamping + multi-binary build: internal/buildinfo (Version, Commit, BuildTime injected via -ldflags); make build discovers all cmd/* via find and produces named binaries in ./bin/; make run replaced with make run/ pattern; devcontainer adds ./bin to PATH via ${containerWorkspaceFolder}. +- 2026-06-14 — pkg/result: reworked the failure surface into four intent-split constructors — Ok/Err (field constructors: value / bare error), Failf (originate from a message, %w for a cause), Wrap[U](r, "msg") (propagate a failed Expect into a new type; panics on success). Removed Errf/Errw. Wrap eliminates the r.Err() unwrap-rewrap dance; behavioral guarantees covered in pkg/result/wrap_test.go. diff --git a/internal/greeter/greeter.go b/internal/greeter/greeter.go index b630ec9..1eff1c8 100644 --- a/internal/greeter/greeter.go +++ b/internal/greeter/greeter.go @@ -26,7 +26,7 @@ func New(log *logger.Logger) *Service { // Greet returns a personalized greeting and logs the interaction. func (s *Service) Greet(name string) result.Expect[string] { if name == "" { - return result.Errf[string]("name must not be empty") + return result.Failf[string]("name must not be empty") } msg := fmt.Sprintf("Hello, %s!", name) diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go index 5bb180e..1d08d00 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -54,7 +54,7 @@ type Logger struct { func New(level string) result.Expect[*Logger] { lvl := parseLevel(level) if lvl.Err() != nil { - return result.Errw[*Logger](lvl.Err(), "parse log level") + return result.Wrap[*Logger](lvl, "parse log level") } h := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: lvl.Value()}) @@ -76,7 +76,7 @@ func New(level string) result.Expect[*Logger] { func NewCLI(level string, debugOut io.Writer) result.Expect[*Logger] { lvl := parseLevel(level) if lvl.Err() != nil { - return result.Errw[*Logger](lvl.Err(), "parse log level") + return result.Wrap[*Logger](lvl, "parse log level") } if !isTerminal(os.Stderr) { @@ -112,7 +112,7 @@ func IsInteractive() bool { func NewWriter(w io.Writer, level string) result.Expect[*Logger] { lvl := parseLevel(level) if lvl.Err() != nil { - return result.Errw[*Logger](lvl.Err(), "parse log level") + return result.Wrap[*Logger](lvl, "parse log level") } h := slog.NewJSONHandler(w, &slog.HandlerOptions{Level: lvl.Value()}) @@ -154,8 +154,6 @@ func (l *Logger) WithFields(fields map[string]any) *Logger { func parseLevel(level string) result.Expect[slog.Level] { var lvl slog.Level - if err := lvl.UnmarshalText([]byte(level)); err != nil { - return result.Errw[slog.Level](err, "unknown level (use debug|info|warn|error)") - } - return result.Ok(lvl) + err := lvl.UnmarshalText([]byte(level)) + return result.Of(lvl, err) } diff --git a/pkg/result/bench_test.go b/pkg/result/bench_test.go index 1dd42f5..7be2766 100644 --- a/pkg/result/bench_test.go +++ b/pkg/result/bench_test.go @@ -252,7 +252,7 @@ func r_parseHeader3(raw string) result.Expect[bHeader] { } parts := strings.SplitN(raw, "|", 3) if len(parts) != 3 { - return result.Errf[bHeader]("malformed record: %q", raw) + return result.Failf[bHeader]("malformed record: %q", raw) } return result.Ok(bHeader{raw: raw, id: parts[0], name: parts[1], val: parts[2]}) } @@ -273,10 +273,10 @@ func r_validate4(h bHeader) result.Expect[bFields] { return r_validate5(h) } func r_validate5(h bHeader) result.Expect[bFields] { id, err := strconv.Atoi(h.id) if err != nil { - return result.Errf[bFields]("parse id %q: %w", h.id, err) + return result.Failf[bFields]("parse id %q: %w", h.id, err) } if id <= 0 { - return result.Errf[bFields]("id %d: must be > 0", id) + return result.Failf[bFields]("id %d: must be > 0", id) } return result.Ok(bFields{id: id, name: h.name, val: h.val}) } @@ -297,7 +297,7 @@ func r_transform4(f bFields) result.Expect[bRecord] { return r_transform5(f) } func r_transform5(f bFields) result.Expect[bRecord] { v, err := strconv.ParseFloat(f.val, 64) if err != nil { - return result.Errf[bRecord]("parse value %q: %w", f.val, err) + return result.Failf[bRecord]("parse value %q: %w", f.val, err) } return result.Ok(bRecord{id: f.id, name: f.name, score: v * 1.5}) } diff --git a/pkg/result/doc.go b/pkg/result/doc.go index 27bc2fd..9b8c8a1 100644 --- a/pkg/result/doc.go +++ b/pkg/result/doc.go @@ -9,7 +9,7 @@ // // func parseHost(s string) result.Expect[string] { // if s == "" { -// return result.Errf[string]("host must not be empty") +// return result.Failf[string]("host must not be empty") // } // return result.Ok(s) // } @@ -54,8 +54,10 @@ // // # Constructors // -// Use [Ok] to wrap a success value, [Err] / [Errf] / [Errw] to wrap errors, -// and [Of] to bridge existing (value, error) return signatures: +// [Ok] and [Err] are the two field constructors (a success value or a bare +// error). On top of them, [Failf] originates a failure from a message (embed a +// cause with %w), [Wrap] propagates an already-failed result into a new type, +// and [Of] bridges existing (value, error) return signatures: // // data := result.Of(os.ReadFile("cfg.json")).Expect("read config") // diff --git a/pkg/result/example_test.go b/pkg/result/example_test.go index 84941c3..b211e67 100644 --- a/pkg/result/example_test.go +++ b/pkg/result/example_test.go @@ -10,7 +10,7 @@ import ( // parseHost is an example of a simple utility function, that validates a hostname. func parseHost(s string) result.Expect[string] { if s == "" { - return result.Errf[string]("host must not be empty") + return result.Failf[string]("host must not be empty") } return result.Ok(s) } @@ -19,10 +19,10 @@ func parseHost(s string) result.Expect[string] { func parsePort(s string) result.Expect[int] { port := result.Of(strconv.Atoi(s)) if port.Err() != nil { - return result.Errw[int](port.Err(), "result.Of") + return result.Wrap[int](port, "result.Of") } if port.Value() < 1 || port.Value() > 65535 { - return result.Errf[int]("%d out of range", port.Value()) + return result.Failf[int]("%d out of range", port.Value()) } return port } @@ -148,3 +148,25 @@ func Example_unwrap() { // Output: // 443 } + +// Example_wrap shows propagating a failure across result types. parsePort +// returns Expect[int], but buildAddr returns Expect[string]; Wrap carries the +// same failure into the new type and layers on its own context — no manual +// r.Err() unwrap-and-rebuild. The result reads as a top-down trace: outermost +// context first, root cause last. +func Example_wrap() { + buildAddr := func(portStr string) result.Expect[string] { + port := parsePort(portStr) // Expect[int] + if port.Err() != nil { + return result.Wrap[string](port, "build address") + } + return result.Ok(fmt.Sprintf(":%d", port.Value())) + } + + if r := buildAddr("99999"); r.Err() != nil { + fmt.Println("failed:", r.Err()) + } + // Output: + // failed: example_test.go:161: build address + // example_test.go:25: 99999 out of range +} diff --git a/pkg/result/panic_test.go b/pkg/result/panic_test.go index cee3fa8..6386b6c 100644 --- a/pkg/result/panic_test.go +++ b/pkg/result/panic_test.go @@ -16,7 +16,7 @@ import ( // without a subprocess, so it is only documented here. func TestMustCollected(t *testing.T) { err := result.Run(func() { - result.Errf[int]("unrecoverable").Must() + result.Failf[int]("unrecoverable").Must() }) if err == nil { diff --git a/pkg/result/result.go b/pkg/result/result.go index 716a7eb..37c7152 100644 --- a/pkg/result/result.go +++ b/pkg/result/result.go @@ -49,32 +49,63 @@ func Ok[T any](v T) Expect[T] { return Expect[T]{value: v} } -// Err wraps an error in an Expect. +// Err wraps an error in an Expect — the failure-side counterpart to [Ok], +// setting the error field verbatim with no added location or message. Use it to +// pass an error along unchanged, including a package-level sentinel: +// +// return result.Err[T](err) +// return result.Err[bHeader](ErrEmpty) +// +// To build a failure from a message (optionally wrapping a cause with %w), use +// [Failf]; to carry an already-failed result into a new type, use [Wrap]. func Err[T any](err error) Expect[T] { return Expect[T]{err: err} } -// Errf wraps a formatted error in an Expect. It is a convenience shorthand -// for [Err][fmt.Errorf(format, args...)]. The caller's file and line are -// prepended to the error message automatically. -func Errf[T any](format string, args ...any) Expect[T] { +// Failf originates a failure from a formatted message. Where [Err] boxes an +// existing error verbatim, Failf builds a new one — optionally embedding a +// cause with %w to preserve the errors.Is/As chain: +// +// return result.Failf[string]("host must not be empty") +// return result.Failf[T]("create dir %q: %w", dir, err) +// +// The caller's file and line are prepended automatically. To propagate an +// already-failed result, use [Wrap] instead of pulling its error out by hand. +func Failf[T any](format string, args ...any) Expect[T] { _, file, line, _ := runtime.Caller(1) loc := fmt.Sprintf("%s:%d", filepath.Base(file), line) return Expect[T]{err: fmt.Errorf(loc+": "+format, args...)} } -// Errw wraps an existing error with a context message, following the standard -// Go error-propagation convention (errors.Is/As chain is preserved). Each -// wrapping level is placed on its own line so the full error reads as a -// top-down trace: outermost context first, root cause last. The caller's file -// and line are prepended automatically. +// Wrap propagates a failed Expect[T] as an Expect[U], optionally annotating it +// with a context message. It carries a failure across the type boundary between +// two result-returning functions without the unwrap-then-rebuild dance of +// reaching into [Expect.Err] by hand. // -// main.go:42: load config -// logger.go:35: parse log level -// strconv.Atoi: parsing "x": invalid syntax -func Errw[T any](err error, format string, args ...any) Expect[T] { +// Wrap is meaningful only on a failed result — a successful r holds a T whose +// value cannot be retyped to U — so guard it with the usual error check: +// +// lvl := parseLevel(level) +// if lvl.Err() != nil { +// return result.Wrap[*Logger](lvl, "parse log level") +// } +// +// With no message Wrap retypes the failure verbatim. With a message (optionally +// fmt.Sprintf-formatted) it follows Go's error-propagation convention: the +// errors.Is/As chain is preserved and the caller's file and line are prepended, +// each wrapping level on its own line so the error reads as a top-down trace. +// +// Calling Wrap on a successful r is a programmer error and panics. +func Wrap[U, T any](r Expect[T], msgArgs ...any) Expect[U] { + if r.err == nil { + panic("result.Wrap called on a successful Expect") + } + if len(msgArgs) == 0 { + return Expect[U]{err: r.err} + } _, file, line, _ := runtime.Caller(1) - return Expect[T]{err: fmt.Errorf("%s:%d: %s\n%w", filepath.Base(file), line, fmt.Sprintf(format, args...), err)} + msg := fmt.Sprintf(msgArgs[0].(string), msgArgs[1:]...) + return Expect[U]{err: fmt.Errorf("%s:%d: %s\n%w", filepath.Base(file), line, msg, r.err)} } // Of is a convenience constructor that bridges standard Go (value, error) @@ -191,7 +222,7 @@ func Async[T any](fn func() T) <-chan Expect[T] { if err := collectGoexitFailure(); err != nil { ch <- Err[T](err) } else { - ch <- Errf[T]("goroutine exited unexpectedly") + ch <- Failf[T]("goroutine exited unexpectedly") } }() val = fn() diff --git a/pkg/result/wrap_test.go b/pkg/result/wrap_test.go new file mode 100644 index 0000000..d95d709 --- /dev/null +++ b/pkg/result/wrap_test.go @@ -0,0 +1,82 @@ +package result_test + +import ( + "errors" + "strings" + "testing" + + "gitea.djmil.dev/go/template/pkg/result" +) + +var errRoot = errors.New("root cause") + +// Intended usage of Wrap is demonstrated in example_test.go (parsePort). These +// tests pin the behavioral guarantees that an example's Output cannot assert: +// chain preservation, file:line stamping, stack survival, and the panic +// contract on misuse. + +// TestWrapRetypesVerbatim verifies that Wrap with no message carries a failure +// from one value type to another, preserving the error chain unchanged. +func TestWrapRetypesVerbatim(t *testing.T) { + src := result.Err[int](errRoot) // Expect[int] + + dst := result.Wrap[string](src) // Expect[string] + + if !errors.Is(dst.Err(), errRoot) { + t.Fatalf("errors.Is chain not preserved: %v", dst.Err()) + } + if dst.Err().Error() != errRoot.Error() { + t.Fatalf("verbatim wrap changed the message: got %q want %q", dst.Err(), errRoot) + } +} + +// TestWrapAnnotates verifies that a context message is prepended with the +// caller's file:line while the underlying chain stays intact and leads the +// trace (outermost context first, root cause last). +func TestWrapAnnotates(t *testing.T) { + src := result.Failf[int]("parse %d", 7) + + dst := result.Wrap[string](src, "load config id=%d", 42) + + msg := dst.Err().Error() + if !strings.Contains(msg, "load config id=42") { + t.Fatalf("formatted context missing: %q", msg) + } + if !strings.Contains(msg, "wrap_test.go:") { + t.Fatalf("caller file:line not prepended: %q", msg) + } + if !strings.Contains(msg, "parse 7") { + t.Fatalf("underlying cause lost: %q", msg) + } + if first, _, _ := strings.Cut(msg, "\n"); !strings.Contains(first, "load config id=42") { + t.Fatalf("context should head the trace, got first line %q", first) + } +} + +// TestWrapPreservesStackError verifies that a failure captured by Expect (a +// *stackError) survives the retype, so StackTrace still resolves it. +func TestWrapPreservesStackError(t *testing.T) { + captured := result.Run(func() { + result.Err[int](errRoot).Expect("inner") + }) + if captured == nil { + t.Fatal("setup: expected Run to collect an error") + } + + dst := result.Wrap[string](result.Err[int](captured), "outer") + + if result.StackTrace(dst.Err()) == "" { + t.Fatal("stack trace lost through Wrap") + } +} + +// TestWrapOnSuccessPanics locks in the documented contract: Wrap is only valid +// on a failed result; calling it on a success is a programmer error. +func TestWrapOnSuccessPanics(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("expected Wrap on a successful Expect to panic") + } + }() + _ = result.Wrap[string](result.Ok(1)) +}