From c9ab7854cbbb66c1a7018e965106c5f50ce2d5c3 Mon Sep 17 00:00:00 2001 From: djmil Date: Wed, 5 Aug 2026 21:18:50 +0000 Subject: [PATCH] add flow controls to the pkg/result Combinators for composing Expect-returning calls into a single expression. --- .vscode/settings.json | 1 + CLAUDE.md | 26 ++-- pkg/result/doc.go | 41 +++++- pkg/result/example_flow_test.go | 142 ++++++++++++++++++ pkg/result/flow.go | 217 ++++++++++++++++++++++++++++ pkg/result/flow_test.go | 245 ++++++++++++++++++++++++++++++++ 6 files changed, 659 insertions(+), 13 deletions(-) create mode 100644 pkg/result/example_flow_test.go create mode 100644 pkg/result/flow.go create mode 100644 pkg/result/flow_test.go diff --git a/.vscode/settings.json b/.vscode/settings.json index 3ecb4f2..a9db2fa 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -28,6 +28,7 @@ // ── Test explorer ────────────────────────────────────────────────────────── "go.testExplorer.enable": true, "cSpell.words": [ + "combinators", "djmil", "Errf", "Errw", diff --git a/CLAUDE.md b/CLAUDE.md index f262f11..591298b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,10 +1,3 @@ -# CLAUDE.md — Agent Instructions - -This file is read automatically by Claude Code at the start of every session. -Keep it concise — the agent needs signal, not essays. - ---- - ## Project overview Go 1.25 template (developed on the 1.26 toolchain) for PoC, hobby projects, and small publishable packages. @@ -72,6 +65,21 @@ tools.versions Pinned tool versions (sourced by Makefile and pre-push - `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.Err` +- **Flow control** — `pkg/result/flow.go` holds combinators over `Expect[T]`; error handling is not written, it *emerges* from the operations chosen: + - a computation that can fail at several steps reads as one expression, with no `if err != nil` between them: + `parsePort(raw).Filter(unprivileged, "privileged port").Or(fromConfigFile).UnwrapOr(8080)` + - a failure short-circuits: no later closure runs and no zero value is carried onward, until something explicitly handles it + (`Or`, `OrElse`, `UnwrapOr`, `UnwrapOrElse`) or the boundary collects it (pinned by `TestFlowShortCircuits`) + - combinators never Goexit, so unlike `.Expect()`/`.Must()` they **are** allowed inside `pkg/` library code + - **a chain holds only while `T` holds.** Go has no type parameters on methods, so `Expect.Map`/`Expect.AndThen` are the same-type + (chaining) forms and `result.Map(r, f)`/`result.AndThen(r, f)` are the type-changing ones — the chain breaks at the crossing and resumes on the result + - **name a step and declare it before the chain when it is reused, or when its body would bury the pipeline** — the name is then the + documentation and the call site reads as a sentence. But a one-shot closure with a single call site stays where it is used: hoisting a + single-use one-liner out adds a name without adding structure, and only forces the reader to jump elsewhere + - `Filter(pred, "msg")` *can* name the rejected value given a binding — `r.Filter(pred, "port %d bad", r.Value())` (the eager arg is harmless: + the message is formatted only on rejection). Mid-chain there is no binding, so use `AndThen` + `Failf` there + - there is deliberately no `Option`/`Maybe` type: a nil error means the value is valid, default-constructed or not, so a second + wrapper would add a distinction the codebase does not want - **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. @@ -103,7 +111,7 @@ tools.versions Pinned tool versions (sourced by Makefile and pre-push - Table-driven tests with `t.Run("description", ...)` for multiple cases - The race detector is enabled in CI (`make test-race`); don't introduce data races - Never use `time.Sleep` in tests; use channels or `t.Cleanup` -- Use `gitea.djmil.dev/go/template/pkg/testutil` helpers instead of manual checks — `ResultOk`, `ResultOkNotNil`, `ResultErr` for `result.Expect[T]`; `NoError`, `Error`, `ErrorContains`, `Equal` for plain values +- Use `gitea.djmil.dev/go/template/pkg/check` helpers instead of manual checks — `Ok`, `OkNotNil` for `result.Expect[T]`; `NoError`, `Error`, `ErrorContains` accept either an `error` or an `Expect[T]`; `Equal`, `NotEqual`, `DeepEqual`, `ElementsMatch` for plain values --- @@ -160,3 +168,5 @@ make clean # remove bin/ - 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. - 2026-06-28 — Bumped devcontainer image golang:1.25→1.26-bookworm to develop on the latest toolchain. Deliberately kept the go.mod `go 1.25.0` directive unchanged: it is the minimum-version floor for consumers of the published pkg/ packages, so the newer toolchain builds against the lower floor for maximum compatibility. Bump the floor only when a 1.26 language/stdlib feature is actually needed (and run `make lint-fix` after). +- 2026-08-05 — Added pkg/result/flow.go: combinators over Expect[T] (Map, AndThen, Filter, MapErr, Or, OrElse, UnwrapOr, UnwrapOrElse) so a multi-step fallible computation reads as one expression and error handling emerges from the operations rather than being written out. Map/AndThen exist twice on purpose: same-type *methods* (which chain) and type-changing *package functions* (which cannot be methods — Go has no type parameters on methods), so a chain breaks only at a type crossing and resumes on the far side. Combinators never Goexit, so they are library-safe unlike .Expect()/.Must(). + Design history worth keeping: this started as a separate pkg/option with a Maybe[T] type, and was rejected on two grounds. (1) A nil error already means the value is valid — default-constructed or not — so Maybe added a distinction the codebase does not want; and building Maybe *on* Expect was worse still, since Expect's zero value reads as Ok(zero), silently inverting absent/present. (2) Combinators can only chain as methods, and Go forbids defining methods on a type from another package — so a standalone pkg/option could only offer inside-out nested functions. Hence: one type, combinators beside it, no Option. Also fixed the stale pkg/testutil reference in the testing rules (the package is pkg/check). diff --git a/pkg/result/doc.go b/pkg/result/doc.go index 9b8c8a1..ff34b45 100644 --- a/pkg/result/doc.go +++ b/pkg/result/doc.go @@ -1,11 +1,12 @@ -// Package result provides a generic Expect[T] type that supports two error -// handling styles without forcing either one. +// Package result provides a generic Expect[T] type that supports three error +// handling styles without forcing any one of them. // -// # Two modes, one type +// # Three modes, one type // // Expect[T] is a drop-in replacement for (T, error) that also enables -// panic-based happy-path propagation when that suits the code better. Both -// styles compose freely — the same Expect[T] value works in either. +// panic-based happy-path propagation, and composition of fallible steps into a +// single expression, when either suits the code better. All three styles +// compose freely — the same Expect[T] value works in any of them. // // func parseHost(s string) result.Expect[string] { // if s == "" { @@ -36,6 +37,36 @@ // Failures are collected at the entry point by [Go] or [Run] and returned as a // normal Go error — no goroutine leaks, no silent swallowing. // +// Mode 3 — flow-control style (combinators): +// +// port := parsePort(raw). +// Filter(unprivileged, "privileged port"). +// Or(fromConfigFile). +// UnwrapOr(8080) +// +// Every combinator takes an Expect and returns one, so a computation that can +// fail at several steps reads as a single expression with no `if err != nil` +// between them. Error handling is not written; it emerges from the operations +// chosen. A failure skips every step that follows — no closure runs, no zero +// value is carried onward as if it were real — until something explicitly +// handles it ([Expect.Or], [Expect.OrElse], [Expect.UnwrapOr], +// [Expect.UnwrapOrElse]) or the boundary collects it. +// +// Readability is the entire point. Name a step and declare it before the chain +// when it is reused, or when its body would otherwise bury the pipeline in the +// middle of the call — the name is then the documentation, and the call site +// reads as a sentence. A one-shot closure with a single call site stays where it +// is used: hoisting it out only forces the reader to jump elsewhere to find out +// what the chain actually does. +// +// See flow.go for the full set. Combinators never exit the goroutine, so unlike +// [Expect.Expect] and [Expect.Must] they are safe inside pkg/ library code. +// +// Because Go has no type parameters on methods, a combinator can only be a +// method when it preserves T: [Expect.Map] and [Expect.AndThen] are the +// same-type forms, and the package-level [Map] and [AndThen] are the +// type-changing ones. A chain breaks at the crossing and resumes on the result. +// // # Layering rule // // Reusable library code (packages under pkg/) must only *return* Expect[T] — diff --git a/pkg/result/example_flow_test.go b/pkg/result/example_flow_test.go new file mode 100644 index 0000000..1c899c2 --- /dev/null +++ b/pkg/result/example_flow_test.go @@ -0,0 +1,142 @@ +package result_test + +import ( + "fmt" + "strings" + + "gitea.djmil.dev/go/template/pkg/result" +) + +// The steps declared here are the ones worth naming: each is reused across +// examples, or carries enough logic that inlining it would bury the pipeline in +// the middle of the call. At the call site the name is then the documentation, +// so the chain reads as a sentence. Steps used exactly once and small enough to +// read at a glance stay inside the example that uses them. + +// defaultPort is the last resort when no source yields a usable port. +const defaultPort = 3000 + +// unprivileged reports whether p sits outside the reserved port range. +func unprivileged(p int) bool { return p >= 1024 } + +// rejectPrivileged fails a port inside the reserved range, naming the offender. +// Filter can name it too, but only given a binding to read Value from; mid-chain +// there is none, which is why this is written as an AndThen step. +func rejectPrivileged(p int) result.Expect[int] { + if unprivileged(p) { + return result.Ok(p) + } + return result.Err[int](fmt.Errorf("port %d is privileged", p)) +} + +// listenAddr renders a port as the address the server should bind to. +func listenAddr(p int) string { return fmt.Sprintf("localhost:%d", p) } + +// isLocal reports whether addr points at the loopback host. +func isLocal(addr string) bool { return strings.HasPrefix(addr, "localhost") } + +// describeAsPortFailure says what the program was trying to achieve when a port +// could not be resolved, keeping the original cause in the errors.Is chain. +func describeAsPortFailure(err error) error { + return fmt.Errorf("resolve listen port: %w", err) +} + +// listenAddrOf hides a type crossing behind a name. parsePort yields an +// Expect[int] and an address is a string, so the crossing has to be the +// package-level result.Map — but only in here. Callers receive an +// Expect[string] they can keep chaining on, and MapErr layers on context that +// result.Map alone cannot, since it carries the error verbatim. +func listenAddrOf(raw string) result.Expect[string] { + return result.Map(parsePort(raw), listenAddr).MapErr(describeAsPortFailure) +} + +// Example_flow shows the point of the combinators: a computation that can fail +// at two different steps reads as one expression, with no `if err != nil` +// between them. Nothing inspects the error along the way — a failure simply +// skips the rest of the chain until UnwrapOr supplies a value. +func Example_flow() { + for _, raw := range []string{"8080", "80", "not-a-number"} { + port := parsePort(raw). + Filter(unprivileged, "privileged port"). + UnwrapOr(defaultPort) + + fmt.Println(raw, "->", port) + } + // Output: + // 8080 -> 8080 + // 80 -> 3000 + // not-a-number -> 3000 +} + +// Example_flowFallback shows Or expressing a precedence order over several +// config sources: the first that succeeds wins, and the errors of the sources +// it displaces are discarded. +func Example_flowFallback() { + var ( + fromFlag = result.Failf[int]("no --port given") + fromEnv = parsePort("9000") + fromFile = parsePort("8080") + ) + + port := fromFlag.Or(fromEnv).Or(fromFile).UnwrapOr(defaultPort) + + fmt.Println(port) + // Output: + // 9000 +} + +// Example_flowTypeChange shows where a chain has to break. listenAddr turns an +// int into a string, and Go cannot express that as a method, so the crossing is +// written as result.Map — and the chain resumes on its result. +func Example_flowTypeChange() { + addr := result.Map(parsePort("8080"), listenAddr). + Filter(isLocal, "not a local address") + + fmt.Println(addr.UnwrapOr("")) + // Output: + // localhost:8080 +} + +// Example_flowNamedCrossing shows the alternative to breaking a chain at a type +// change: name the crossing instead. Compare Example_flowTypeChange, which calls +// result.Map at the call site — here the call site is one unbroken chain, and +// the failure arrives carrying the context listenAddrOf added. +func Example_flowNamedCrossing() { + for _, raw := range []string{"8080", "not-a-number"} { + addr := listenAddrOf(raw). + Filter(isLocal, "not a local address"). + UnwrapOr("") + + fmt.Println(raw, "->", addr) + } + + failed := listenAddrOf("nope") + fmt.Println("context added:", strings.HasPrefix(failed.Err().Error(), "resolve listen port:")) + // Output: + // 8080 -> localhost:8080 + // not-a-number -> + // context added: true +} + +// Example_flowBoundary shows the failure path. Nothing in the chain handles the +// error: it propagates untouched from the step that produced it, collects +// context on the way past, and only surfaces where the chain ends — here at +// Expect, which result.Run turns back into an ordinary Go error. +// +// AndThen carries the rejection rather than Filter, because the message names +// the value it rejected and the chain offers no binding to read it from. +func Example_flowBoundary() { + err := result.Run(func() { + port := parsePort("80"). + AndThen(rejectPrivileged). + MapErr(describeAsPortFailure). + Expect("start server") + + fmt.Println("listening on", port) + }) + + fmt.Println("failed:", err) + // Output: + // failed: start server + // resolve listen port: port 80 is privileged +} diff --git a/pkg/result/flow.go b/pkg/result/flow.go new file mode 100644 index 0000000..e7e1483 --- /dev/null +++ b/pkg/result/flow.go @@ -0,0 +1,217 @@ +package result + +import ( + "fmt" + "path/filepath" + "runtime" +) + +// Combinators for composing Expect-returning calls into a single expression. +// +// Every combinator takes an Expect and returns one, so failure propagates +// untouched from step to step and no intermediate code inspects it. A failed +// Expect skips every transform that follows — no closure runs, no zero value is +// carried onward as if it were real — until something explicitly handles it: +// [Expect.Or], [Expect.OrElse], [Expect.UnwrapOr], [Expect.UnwrapOrElse], or +// the boundary itself via [Expect.Expect]. +// +// None of these exit the goroutine, so unlike [Expect.Expect] and [Expect.Must] +// they are safe inside pkg/ library code. +// +// Readability is the entire point. Name a step and declare it before the chain +// when it is reused, or when its body would otherwise bury the pipeline in the +// middle of the call: +// +// port := parsePort(raw).Filter(unprivileged, "privileged port").UnwrapOr(defaultPort) +// +// A one-shot closure with a single call site stays where it is used — hoisting +// it out only forces the reader to jump elsewhere. See example_flow_test.go. +// +// A combinator can only be a method when it preserves T, because Go has no type +// parameters on methods. The package-level [Map] and [AndThen] are the +// type-changing forms; a chain breaks at the crossing and resumes on the result. + +// Map applies f to a successful value and passes a failure through untouched. +// f is not called on a failed Expect. +// +// func readFile(path string) result.Expect[string] // contents, or a failure +// +// raw := readFile(path).Map(strings.TrimSpace).Map(strings.ToLower) +// +// This is the chaining form and requires f to preserve the type; when the +// transform produces a different type, use the package-level [Map]. +func (r Expect[T]) Map(f func(T) T) Expect[T] { + if r.err != nil { + return Expect[T]{err: r.err} + } + return Expect[T]{value: f(r.value)} +} + +// AndThen chains a step that is itself fallible, flattening the result so +// nested Expects never appear. f is not called on a failed Expect. +// +// port := parsePort(raw).AndThen(rejectPrivileged) +// +// The chaining counterpart to [Expect.Map] for fallible steps, with the same +// same-type restriction; the package-level [AndThen] handles a type change. +func (r Expect[T]) AndThen(f func(T) Expect[T]) Expect[T] { + if r.err != nil { + return Expect[T]{err: r.err} + } + return f(r.value) +} + +// Filter turns a successful value into a failure when pred rejects it, building +// the error from a formatted message with the caller's file and line prepended, +// exactly as [Failf] does. pred is not called on an already-failed Expect. +// +// port := parsePort(raw).Filter(unprivileged, "port must be >= 1024") +// +// The message can name the rejected value wherever the Expect has a binding to +// read it from. The arg is evaluated eagerly, which does no harm: the message is +// formatted only when pred rejects, and [Expect.Value] is exactly what pred was +// handed. +// +// r := parsePort(raw) +// port := r.Filter(unprivileged, "port %d is privileged", r.Value()) +// +// Mid-chain there is no such binding, so reach for [Expect.AndThen] and return +// a [Failf] instead: +// +// func rejectPrivileged(p int) result.Expect[int] { +// if p < 1024 { +// return result.Failf[int]("port %d is privileged", p) +// } +// return result.Ok(p) +// } +// +// port := parsePort(raw).AndThen(rejectPrivileged) +func (r Expect[T]) Filter(pred func(T) bool, format string, args ...any) Expect[T] { + if r.err != nil { + return Expect[T]{err: r.err} + } + if pred(r.value) { + return r + } + _, file, line, _ := runtime.Caller(1) + loc := fmt.Sprintf("%s:%d", filepath.Base(file), line) + return Expect[T]{err: fmt.Errorf(loc+": "+format, args...)} +} + +// MapErr replaces the error of a failed Expect with f(err), leaving a success +// untouched. Use it to add context mid-chain without breaking it; wrap with %w +// to preserve the errors.Is/As chain. +// +// func describeAsConfigFailure(err error) error { +// return fmt.Errorf("load config: %w", err) +// } +// +// cfg := readFile(path).MapErr(describeAsConfigFailure) +// +// f is expected to return a non-nil error. Returning nil produces a successful +// Expect holding the zero value, which is almost always a bug. +func (r Expect[T]) MapErr(f func(error) error) Expect[T] { + if r.err == nil { + return r + } + return Expect[T]{err: f(r.err)} +} + +// Or returns r when it succeeded, otherwise alt — discarding r's error. Chain +// it to express a precedence order over several sources: +// +// port := fromFlag.Or(fromEnv).Or(fromFile).UnwrapOr(8080) +// +// Every alternative is evaluated before the chain runs; use [Expect.OrElse] +// when producing one has a cost or a side effect. If alt is itself a failure +// the chain stays failed, carrying alt's error. +func (r Expect[T]) Or(alt Expect[T]) Expect[T] { + if r.err != nil { + return alt + } + return r +} + +// OrElse returns r when it succeeded, otherwise f(err). The lazy counterpart to +// [Expect.Or]: f runs only on failure, and receives the error so it can decide +// whether to recover from this particular one. +// +// func rereadFromBackup(error) result.Expect[string] { return readFile(backupPath) } +// +// cfg := readFile(primaryPath).OrElse(rereadFromBackup) +func (r Expect[T]) OrElse(f func(error) Expect[T]) Expect[T] { + if r.err != nil { + return f(r.err) + } + return r +} + +// UnwrapOr leaves the chain with a value in hand, returning def on failure. +// The error is discarded; when it should reach the caller instead, end the +// chain with [Expect.Expect] or [Expect.Unwrap]. +func (r Expect[T]) UnwrapOr(def T) T { + if r.err != nil { + return def + } + return r.value +} + +// UnwrapOrElse leaves the chain with a value in hand, computing the fallback +// from the error on failure. The lazy counterpart to [Expect.UnwrapOr]. +// +// func warnAndUseDefaultPort(err error) int { +// log.Warn("bad port, using default", "err", err) +// return 8080 +// } +// +// port := parsePort(raw).UnwrapOrElse(warnAndUseDefaultPort) +func (r Expect[T]) UnwrapOrElse(f func(error) T) T { + if r.err != nil { + return f(r.err) + } + return r.value +} + +// Map is the type-changing form of [Expect.Map]: it applies f to a successful +// value, producing an Expect of the new type, and passes a failure through +// untouched. f is not called on a failed Expect. +// +// It is a function rather than a method because Go has no type parameters on +// methods. A chain therefore breaks at each type change and resumes on the +// result, with the crossing written inside-out: +// +// func listenAddr(p int) string { return fmt.Sprintf("localhost:%d", p) } +// +// addr := result.Map(parsePort(raw), listenAddr).Map(strings.ToLower) +// +// When the chain continues past the crossing, give the crossing a name of its +// own. The caller's site is then an unbroken chain again, and the named step can +// layer on context that Map cannot — Map carries the error verbatim: +// +// func describeAsPortFailure(err error) error { +// return fmt.Errorf("resolve listen port: %w", err) +// } +// +// func listenAddrOf(raw string) result.Expect[string] { +// return result.Map(parsePort(raw), listenAddr).MapErr(describeAsPortFailure) +// } +// +// addr := listenAddrOf(raw).Filter(isLocal, "not a local address").UnwrapOr(defaultAddr) +func Map[T, U any](r Expect[T], f func(T) U) Expect[U] { + if r.err != nil { + return Expect[U]{err: r.err} + } + return Expect[U]{value: f(r.value)} +} + +// AndThen is the type-changing form of [Expect.AndThen]: it chains a fallible +// step that produces a different type, flattening the result so nested Expects +// never appear. f is not called on a failed Expect. +// +// port := result.AndThen(readFile(path), parsePort).Filter(unprivileged, "privileged port") +func AndThen[T, U any](r Expect[T], f func(T) Expect[U]) Expect[U] { + if r.err != nil { + return Expect[U]{err: r.err} + } + return f(r.value) +} diff --git a/pkg/result/flow_test.go b/pkg/result/flow_test.go new file mode 100644 index 0000000..5999673 --- /dev/null +++ b/pkg/result/flow_test.go @@ -0,0 +1,245 @@ +package result_test + +import ( + "errors" + "fmt" + "strings" + "testing" + + "gitea.djmil.dev/go/template/pkg/check" + "gitea.djmil.dev/go/template/pkg/result" +) + +var errFlow = errors.New("flow failure") + +// TestFlowShortCircuits is the guarantee the whole style rests on: once a chain +// fails, no later step runs at all — the value is not silently replaced by a +// zero and carried onward — and Or resumes the chain from there. +func TestFlowShortCircuits(t *testing.T) { + var ran []string + track := func(name string) func(int) int { + return func(n int) int { ran = append(ran, name); return n } + } + + got := result.Ok(5). + Filter(func(int) bool { return false }, "rejected"). // failed from here + Map(track("map")). + AndThen(func(n int) result.Expect[int] { + ran = append(ran, "andThen") + return result.Ok(n) + }). + Or(result.Ok(42)). // recovered + Map(track("afterOr")). + UnwrapOr(-1) + + check.Equal(t, got, 42) + check.DeepEqual(t, ran, []string{"afterOr"}) +} + +// TestMapMethod covers the same-type transform and pins that f is skipped on a +// failed Expect rather than being handed a zero value. +func TestMapMethod(t *testing.T) { + calls := 0 + double := func(n int) int { calls++; return n * 2 } + + check.Equal(t, result.Ok(5).Map(double).Map(double).UnwrapOr(-1), 20) + check.Equal(t, calls, 2) + + failed := result.Err[int](errFlow).Map(double) + check.Equal(t, calls, 2) + if !errors.Is(failed.Err(), errFlow) { + t.Fatalf("Map should carry the failure through: %v", failed.Err()) + } +} + +// TestAndThenMethod covers the same-type fallible step: a failure returned by f +// propagates, and f is skipped on a failed input. +func TestAndThenMethod(t *testing.T) { + calls := 0 + halve := func(n int) result.Expect[int] { + calls++ + if n%2 != 0 { + return result.Failf[int]("%d is odd", n) + } + return result.Ok(n / 2) + } + + check.Equal(t, result.Ok(8).AndThen(halve).AndThen(halve).UnwrapOr(-1), 2) + check.ErrorContains(t, result.Ok(5).AndThen(halve), "5 is odd") + check.Equal(t, calls, 3) + + check.NoError(t, result.Ok(8).AndThen(halve)) + calls = 0 + check.Error(t, result.Err[int](errFlow).AndThen(halve)) + check.Equal(t, calls, 0) +} + +// TestFilter verifies that a rejected value becomes a failure carrying the +// formatted message and the caller's file:line, and that the predicate never +// sees an already-failed Expect. +func TestFilter(t *testing.T) { + positive := func(n int) bool { return n > 0 } + + check.Equal(t, check.Ok(t, result.Ok(5).Filter(positive, "must be positive")), 5) + + rejected := result.Ok(-5).Filter(positive, "must be positive, got sign %d", -1) + msg := rejected.Err().Error() + if !strings.Contains(msg, "must be positive, got sign -1") { + t.Fatalf("formatted message missing: %q", msg) + } + if !strings.Contains(msg, "flow_test.go:") { + t.Fatalf("caller file:line not prepended: %q", msg) + } + + calls := 0 + counting := func(int) bool { calls++; return true } + check.Error(t, result.Err[int](errFlow).Filter(counting, "unused")) + check.Equal(t, calls, 0) +} + +// TestFilterMessageCanNameTheValue pins the documented workaround: given a +// binding, the message can report the value pred rejected. The arg is evaluated +// eagerly, which is harmless — on the accepting path the message is never +// formatted, and on an already-failed Expect the original error survives even +// though Value is the zero value. +func TestFilterMessageCanNameTheValue(t *testing.T) { + positive := func(n int) bool { return n > 0 } + + rejected := result.Ok(-5) + check.ErrorContains(t, rejected.Filter(positive, "%d must be positive", rejected.Value()), + "-5 must be positive") + + accepted := result.Ok(5) + check.Equal(t, check.Ok(t, accepted.Filter(positive, "%d must be positive", accepted.Value())), 5) + + failed := result.Err[int](errFlow) + check.ErrorContains(t, failed.Filter(positive, "%d must be positive", failed.Value()), "flow failure") +} + +// TestMapErr verifies that context can be layered onto a failure mid-chain +// without breaking it, and that a success passes through untouched. +func TestMapErr(t *testing.T) { + addContext := func(err error) error { return fmt.Errorf("load config: %w", err) } + + ok := result.Ok(5).MapErr(addContext) + check.Equal(t, check.Ok(t, ok), 5) + + failed := result.Err[int](errFlow).MapErr(addContext) + check.ErrorContains(t, failed, "load config") + if !errors.Is(failed.Err(), errFlow) { + t.Fatalf("%%w chain not preserved: %v", failed.Err()) + } +} + +// TestOrChain verifies left-biased precedence: the first success wins and the +// rest are ignored, including their errors. +func TestOrChain(t *testing.T) { + var ( + bad = result.Err[string](errFlow) + first = result.Ok("first") + second = result.Ok("second") + ) + + check.Equal(t, first.Or(second).UnwrapOr(""), "first") + check.Equal(t, bad.Or(second).UnwrapOr(""), "second") + check.Equal(t, bad.Or(bad).Or(first).Or(second).UnwrapOr(""), "first") + + // A failing alternative leaves the chain failed, carrying its own error. + last := result.Failf[string]("last resort") + check.ErrorContains(t, bad.Or(last), "last resort") +} + +// TestOrElseIsLazy pins the difference from Or: the alternative is produced +// only when needed, and it receives the error it is recovering from. +func TestOrElseIsLazy(t *testing.T) { + calls := 0 + var seen error + alt := func(err error) result.Expect[string] { + calls++ + seen = err + return result.Ok("alt") + } + + check.Equal(t, result.Ok("v").OrElse(alt).UnwrapOr(""), "v") + check.Equal(t, calls, 0) + + check.Equal(t, result.Err[string](errFlow).OrElse(alt).UnwrapOr(""), "alt") + check.Equal(t, calls, 1) + if !errors.Is(seen, errFlow) { + t.Fatalf("OrElse should receive the failing error, got %v", seen) + } +} + +// TestUnwrapOr covers both exits from a chain, and pins that the lazy one skips +// its closure on success and sees the error on failure. +func TestUnwrapOr(t *testing.T) { + check.Equal(t, result.Ok(1).UnwrapOr(9), 1) + check.Equal(t, result.Err[int](errFlow).UnwrapOr(9), 9) + + calls := 0 + var seen error + fallback := func(err error) int { calls++; seen = err; return 9 } + + check.Equal(t, result.Ok(1).UnwrapOrElse(fallback), 1) + check.Equal(t, calls, 0) + + check.Equal(t, result.Err[int](errFlow).UnwrapOrElse(fallback), 9) + check.Equal(t, calls, 1) + if !errors.Is(seen, errFlow) { + t.Fatalf("UnwrapOrElse should receive the failing error, got %v", seen) + } +} + +// TestMapFunction covers the type-changing transform and pins that a failure +// crosses the type boundary intact. +func TestMapFunction(t *testing.T) { + calls := 0 + length := func(s string) int { calls++; return len(s) } + + check.Equal(t, result.Map(result.Ok("hello"), length).UnwrapOr(-1), 5) + check.Equal(t, calls, 1) + + failed := result.Map(result.Err[string](errFlow), length) + check.Equal(t, calls, 1) + if !errors.Is(failed.Err(), errFlow) { + t.Fatalf("failure lost across the type change: %v", failed.Err()) + } +} + +// TestAndThenFunction covers the type-changing fallible step, including that +// the chain resumes as methods on the far side of the crossing. +func TestAndThenFunction(t *testing.T) { + firstRune := func(s string) result.Expect[rune] { + if s == "" { + return result.Failf[rune]("empty string") + } + return result.Ok([]rune(s)[0]) + } + + got := result.AndThen(result.Ok("hello"), firstRune). + Filter(func(r rune) bool { return r != 0 }, "null rune"). + UnwrapOr('?') + check.Equal(t, got, 'h') + + check.ErrorContains(t, result.AndThen(result.Ok(""), firstRune), "empty string") + + failed := result.AndThen(result.Err[string](errFlow), firstRune) + if !errors.Is(failed.Err(), errFlow) { + t.Fatalf("failure lost across the type change: %v", failed.Err()) + } +} + +// TestFlowIsLibrarySafe pins that combinators never exit the goroutine, so they +// are usable inside pkg/ library code where .Expect() and .Must() are not. +// A plain function call would not survive a Goexit; reaching the return proves +// the chain stayed on the normal control path. +func TestFlowIsLibrarySafe(t *testing.T) { + libraryFunc := func() (out result.Expect[int]) { + out = result.Err[int](errFlow). + Map(func(n int) int { return n }). + Filter(func(int) bool { return true }, "unused") + return out + } + + check.Error(t, libraryFunc()) +}