diff --git a/pkg/result/example_test.go b/pkg/result/example_test.go index 22ee080..74cf444 100644 --- a/pkg/result/example_test.go +++ b/pkg/result/example_test.go @@ -8,25 +8,24 @@ import ( "gitea.djmil.dev/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)) +// parseHost is an example of a simple utility function, that validates a hostname. +func parseHost(s string) result.Expect[string] { + if s == "" { + return result.Fail[string](errors.New("host must not be empty")) } - if n < 1 || n > 65535 { - return result.Fail[int](fmt.Errorf("parsePort: %d out of range", n)) - } - return result.Ok(n) + return result.Ok(s) } -// 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 +// parsePort wraps strconv.Atoi so callers can use the happy-path style. +func parsePort(s string) result.Expect[int] { + port := result.Of(strconv.Atoi(s)) + if port.Err() != nil { + return port + } + if port.Value() < 1 || port.Value() > 65535 { + return result.Fail[int](fmt.Errorf("%d out of range", port.Value())) + } + return port } // Example_errCheck shows checking the error without panicking — useful at the @@ -34,59 +33,102 @@ func Example_happyPath() { func Example_errCheck() { r := parsePort("not-a-number") if r.Err() != nil { - fmt.Println("failed:", r.Err()) + fmt.Println("parsePort failed:", r.Err()) } // Output: - // failed: parsePort: strconv.Atoi: parsing "not-a-number": invalid syntax + // parsePort failed: 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) +// 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() { + host := parseHost("localhost").Expect("read host") + fmt.Println(host) // Output: - // 9090 + // localhost } -// Example_go shows result.Go running a closure in a goroutine and returning -// a typed Expect[T] value — the happy-path result or a collected error. -func Example_go() { - port := result.Go(func() int { - return parsePort("8080").Expect("parse port") +// Example_catch shows the failure path: when Expect exits the goroutine, +// defer Catch at the entry point converts it into a normal error return. +func Example_catch() { + loadHost := func() (err error) { + defer result.Catch(&err) + host := parseHost("").Expect("read host") + fmt.Println(host) + return + } + + if err := loadHost(); err != nil { + fmt.Println("caught:", err) + } + // Output: + // caught: read host: host must not be empty +} + +// getURL is an example of business logic implementation with emphasis on happy-path. +func getURL(host, port string) string { + mHost := parseHost(host).Expect("parse host") + mPort := parsePort(port).Expect("parse port") + return fmt.Sprintf("http://%s:%d", mHost, mPort) +} + +// Example_run shows result.Run as a lightweight synchronous boundary — a concise +// alternative to defer Catch when no named return is needed. Expect calls may be +// chained freely inside. +func Example_run() { + err := result.Run(func() { + url := getURL("localhost", "8080") + fmt.Println(url) }) - fmt.Println(port.Expect("main")) - // Output: - // 8080 -} - -// Example_goError shows result.Go collecting an error from a failed Expect -// call inside the goroutine. -func Example_goError() { - port := result.Go(func() int { - return parsePort("99999").Expect("parse port") - }) - - if err := port.Err(); err != nil { + if err != nil { fmt.Println("failed:", err) } // Output: - // failed: parse port: parsePort: 99999 out of range + // http://localhost:8080 } -// Example_catch shows the boundary pattern: result.Run collects any -// Expect/Expectf failure anywhere in the call stack as a normal error. -func Example_catch() { +// Example_runtimeError shows that Expectf annotates the error message with the +// formatted context, just like Expect does. +// +// NOTE: Genuine runtime panics (nil-deref, index out of bounds) are NOT collected, +// they propagate and crash the program, as they should. +func Example_runtimeError() { err := result.Run(func() { - // Simulate a deep call stack: this exits the goroutine because the port is invalid. - _ = parsePort("99999").Expect("load config port") + _ = parsePort("99999").Expectf("arg %d port value", 2) }) if err != nil { fmt.Println("caught:", err) } // Output: - // caught: load config port: parsePort: 99999 out of range + // caught: arg 2 port value: 99999 out of range +} + +// Example_go is like result.Run but returns a typed Expect[T] so the computed +// value can be retrieved. Any failure is collected and surfaced on that Expect. +func Example_go() { + url := result.Go(func() string { + return getURL("localhost", "8080") + }) + + fmt.Println(url.Expect("get url")) + // Output: + // http://localhost:8080 +} + +// Example_goError shows result.Go collecting an error when one of the chained +// Expect calls inside the goroutine fails. +func Example_goError() { + url := result.Go(func() string { + return getURL("localhost", "99999") + }) + + if err := url.Err(); err != nil { + fmt.Println("failed:", err) + } + // Output: + // failed: parse port: 99999 out of range } // Example_unwrap shows re-joining the normal Go (value, error) world at a @@ -101,58 +143,3 @@ func Example_unwrap() { // Output: // 443 } - -// Example_mustCollected shows that Must() panics (produced by this package) -// are collected by Go/Run as normal errors, just like Expect failures. -// Genuine runtime panics (nil-deref, index out of bounds) are NOT collected — -// they propagate and crash the program, as they should. -func Example_mustCollected() { - err := result.Run(func() { - result.Fail[int](errors.New("unrecoverable")).Must() - }) - - if err != nil { - fmt.Println("collected:", err) - } - // Output: - // collected: unrecoverable -} - -// Example_expectf shows Expectf for context messages that include runtime -// values — equivalent to Expect(fmt.Sprintf(...)) but more concise. -func Example_expectf() { - port := parsePort("3000").Expectf("read port from arg %d", 1) - fmt.Println(port) - // Output: - // 3000 -} - -// Example_expectfError shows that Expectf annotates the error message with the -// formatted context, just like Expect does. -func Example_expectfError() { - err := result.Run(func() { - _ = parsePort("99999").Expectf("arg %d port value", 2) - }) - - if err != nil { - fmt.Println("caught:", err) - } - // Output: - // caught: arg 2 port value: parsePort: 99999 out of range -} - -// 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 -} diff --git a/pkg/result/panic_test.go b/pkg/result/panic_test.go new file mode 100644 index 0000000..7d7362c --- /dev/null +++ b/pkg/result/panic_test.go @@ -0,0 +1,67 @@ +package result_test + +import ( + "errors" + "os" + "os/exec" + "strings" + "testing" + + "gitea.djmil.dev/go/template/pkg/result" +) + +// TestMustCollected verifies that Must() failures are collected by Run as +// normal errors, not left as unhandled panics. By contrast, genuine runtime +// panics (nil-deref, index out of bounds, etc.) are NOT collected — they +// propagate and crash the program. That behavior cannot be unit-tested +// without a subprocess, so it is only documented here. +func TestMustCollected(t *testing.T) { + err := result.Run(func() { + result.Fail[int](errors.New("unrecoverable")).Must() + }) + + if err == nil { + t.Fatal("expected error, got nil") + } + if err.Error() != "unrecoverable" { + t.Fatalf("unexpected error: %v", err) + } +} + +// TestGenuineRuntimePanicPropagates verifies that genuine runtime panics +// (nil-deref, index out of bounds, etc.) are NOT collected by Go/Run — they +// crash the program. Tested via subprocess so the crash doesn't kill the suite. +func TestGenuineRuntimePanicPropagates(t *testing.T) { + if os.Getenv("RESULT_TEST_CRASH") == "1" { + _ = result.Run(func() { + panic("genuine runtime panic") // not a *stackError, so Run must not collect it + }) + return + } + + cmd := exec.Command(os.Args[0], "-test.run=TestGenuineRuntimePanicPropagates") + cmd.Env = append(os.Environ(), "RESULT_TEST_CRASH=1") + if cmd.Run() == nil { + t.Fatal("expected subprocess to crash, but it exited cleanly") + } +} + +// TestStackTrace verifies that StackTrace returns a non-empty string containing +// the call site of the failing Expect call. +func TestStackTrace(t *testing.T) { + err := result.Run(func() { + parsePort("not-a-number").Expect("parse port") + }) + + if err == nil { + t.Fatal("expected error, got nil") + } + + trace := result.StackTrace(err) + if trace == "" { + t.Fatal("expected non-empty stack trace") + } + if !strings.Contains(trace, "panic_test.go") { + t.Fatalf("expected trace to reference panic_test.go, got:\n%s", trace) + } +} diff --git a/pkg/result/result.go b/pkg/result/result.go index 55b4b1e..ccdcef0 100644 --- a/pkg/result/result.go +++ b/pkg/result/result.go @@ -61,6 +61,12 @@ func (r Expect[T]) Err() error { return r.err } +// Value returns the underlying value, or the zero value of T if the Expect +// holds an error. Always check [Expect.Err] first. +func (r Expect[T]) Value() T { + return r.value +} + // Must returns the value or panics with the wrapped error and a stack trace. // Use for genuine unrecoverable conditions where an immediate crash is correct. // For normal error propagation inside [Go] or [Run], use [Expect.Expect].