Compare commits
No commits in common. "main" and "v0.4.0" have entirely different histories.
@ -62,8 +62,6 @@
|
|||||||
"forwardPorts": [8080],
|
"forwardPorts": [8080],
|
||||||
|
|
||||||
"remoteEnv": {
|
"remoteEnv": {
|
||||||
"GOPRIVATE": "gitea.djmil.dev",
|
"GOPRIVATE": "gitea.djmil.dev"
|
||||||
// Adds ./bin/ to PATH so built binaries are runnable by name after `make build`.
|
|
||||||
"PATH": "${containerWorkspaceFolder}/bin:${localEnv:PATH}"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
8
.vscode/tasks.json
vendored
8
.vscode/tasks.json
vendored
@ -9,6 +9,14 @@
|
|||||||
"presentation": { "reveal": "always", "panel": "shared" },
|
"presentation": { "reveal": "always", "panel": "shared" },
|
||||||
"problemMatcher": "$go"
|
"problemMatcher": "$go"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"label": "run",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "make run",
|
||||||
|
"group": "none",
|
||||||
|
"presentation": { "reveal": "always", "panel": "dedicated" },
|
||||||
|
"isBackground": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "test",
|
"label": "test",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
|
|||||||
@ -66,12 +66,8 @@ 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
|
- 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
|
- **`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")`
|
- 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
|
- 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`
|
- still use `fmt.Errorf("context: %w", err)` when wrapping errors *before* constructing a `result.Fail`
|
||||||
- **Logging** — logs go to `stderr` per 12-factor XI; human output goes to `stdout` via `fmt.Print*`.
|
- **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,
|
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.
|
JSON when piped. Use `logger.New(level)` for headless services that always want JSON.
|
||||||
@ -157,5 +153,3 @@ make clean # remove bin/
|
|||||||
- 2026-04-01 — Added make release: lists tags with no args; validates semver, runs test-race+lint+security, then tags+pushes.
|
- 2026-04-01 — Added make release: lists tags with no args; validates semver, runs test-race+lint+security, then tags+pushes.
|
||||||
- 2026-04-23 — Documented result layering rule: pkg/ libraries only return Expect[T]; .Expect()/.Must() calls belong in application-layer code.
|
- 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-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/<name> 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.
|
|
||||||
|
|||||||
27
Makefile
27
Makefile
@ -1,19 +1,11 @@
|
|||||||
.PHONY: help init setup build test test-race test-verbose lint lint-fix security docs release clean
|
.PHONY: help init setup build run test test-race test-verbose lint lint-fix security docs release clean
|
||||||
|
|
||||||
include tools.versions
|
include tools.versions
|
||||||
|
|
||||||
# ── Variables ──────────────────────────────────────────────────────────────────
|
# ── Variables ──────────────────────────────────────────────────────────────────
|
||||||
MODULE := $(shell go list -m)
|
BINARY_NAME := app
|
||||||
VERSION ?= $(shell git describe --tags 2>/dev/null || echo dev)
|
BINARY_PATH := ./bin/$(BINARY_NAME)
|
||||||
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
CMD_PATH := ./cmd/app
|
||||||
BUILT_AT := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
||||||
LDFLAGS := -ldflags "\
|
|
||||||
-X $(MODULE)/internal/buildinfo.Version=$(VERSION) \
|
|
||||||
-X $(MODULE)/internal/buildinfo.Commit=$(COMMIT) \
|
|
||||||
-X $(MODULE)/internal/buildinfo.BuildTime=$(BUILT_AT)"
|
|
||||||
|
|
||||||
CMDS := $(shell find cmd -mindepth 1 -maxdepth 1 -type d 2>/dev/null)
|
|
||||||
BINS := $(patsubst cmd/%,bin/%,$(CMDS))
|
|
||||||
|
|
||||||
# ── Default target ─────────────────────────────────────────────────────────────
|
# ── Default target ─────────────────────────────────────────────────────────────
|
||||||
help: ## Show this help message
|
help: ## Show this help message
|
||||||
@ -42,11 +34,12 @@ tools: ## Install tool binaries to GOPATH/bin (versions from tools.versions)
|
|||||||
go install golang.org/x/pkgsite/cmd/pkgsite@$(PKGSITE_VERSION)
|
go install golang.org/x/pkgsite/cmd/pkgsite@$(PKGSITE_VERSION)
|
||||||
|
|
||||||
# ── Build ──────────────────────────────────────────────────────────────────────
|
# ── Build ──────────────────────────────────────────────────────────────────────
|
||||||
build: $(BINS) ## Compile all cmd/* binaries to ./bin/ (stamped with version, commit, build time)
|
build: ## Compile the binary to ./bin/
|
||||||
|
go build -o $(BINARY_PATH) $(CMD_PATH)
|
||||||
|
|
||||||
bin/%: cmd/%
|
# ── Run ────────────────────────────────────────────────────────────────────────
|
||||||
@mkdir -p bin
|
run: ## Run the application with default flags
|
||||||
go build $(LDFLAGS) -o $@ ./$<
|
go run $(CMD_PATH)/main.go
|
||||||
|
|
||||||
# ── Test ───────────────────────────────────────────────────────────────────────
|
# ── Test ───────────────────────────────────────────────────────────────────────
|
||||||
test: ## Run all tests
|
test: ## Run all tests
|
||||||
@ -79,7 +72,7 @@ docs: ## Serve package documentation locally via pkgsite (http://localhost:8080)
|
|||||||
|
|
||||||
# ── Release ────────────────────────────────────────────────────────────────────
|
# ── Release ────────────────────────────────────────────────────────────────────
|
||||||
release: ## List releases, or tag+push a new one (usage: make release VERSION=v0.1.0)
|
release: ## List releases, or tag+push a new one (usage: make release VERSION=v0.1.0)
|
||||||
ifeq ($(origin VERSION), command line)
|
ifdef VERSION
|
||||||
@echo "$(VERSION)" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$$' || \
|
@echo "$(VERSION)" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$$' || \
|
||||||
(echo "VERSION must be semver: v0.1.0"; exit 1)
|
(echo "VERSION must be semver: v0.1.0"; exit 1)
|
||||||
@git diff --quiet && git diff --cached --quiet || \
|
@git diff --quiet && git diff --cached --quiet || \
|
||||||
|
|||||||
19
README.md
19
README.md
@ -75,13 +75,10 @@ make tools # (optional) install tool binaries to GOPATH/bin for IDE inte
|
|||||||
### 3. Build and run
|
### 3. Build and run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make build # compiles every cmd/* binary to ./bin/, stamped with git commit + build time
|
make build # compiles to ./bin/app
|
||||||
|
make run # go run with default flags
|
||||||
```
|
```
|
||||||
|
|
||||||
Every subdirectory under `cmd/` becomes a named binary in `./bin/`. In the devcontainer,
|
|
||||||
`./bin/` is on `PATH` automatically, so after `make build` you can run `app` (or any other
|
|
||||||
binary) directly — with any flags — from any directory in the terminal.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Daily workflow
|
## Daily workflow
|
||||||
@ -92,8 +89,7 @@ make test-race # … with race detector
|
|||||||
make lint # go vet + golangci-lint
|
make lint # go vet + golangci-lint
|
||||||
make lint-fix # go fix + golangci-lint auto-fix
|
make lint-fix # go fix + golangci-lint auto-fix
|
||||||
make security # gosec + govulncheck
|
make security # gosec + govulncheck
|
||||||
make release # list tags
|
make release # list tags, or run full checks then tag+push (make release VERSION=v0.1.0)
|
||||||
make release VERSION=v0.1.0 # run full checks then tag+push
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Keyboard shortcut (VSCode):** `Ctrl+Shift+B` → build, `Ctrl+Shift+T` → test.
|
> **Keyboard shortcut (VSCode):** `Ctrl+Shift+B` → build, `Ctrl+Shift+T` → test.
|
||||||
@ -134,6 +130,15 @@ Commands live in `.claude/commands/` and are available to anyone who clones the
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Releasing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make release # list all existing tags
|
||||||
|
make release VERSION=v0.1.0 # run full checks, then tag and push
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Devcontainer
|
## Devcontainer
|
||||||
|
|
||||||
Open this repo in VSCode and choose **"Reopen in Container"**. Run `make init` to
|
Open this repo in VSCode and choose **"Reopen in Container"**. Run `make init` to
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import (
|
|||||||
|
|
||||||
// Config is the root configuration object. Add sub-structs as the app grows.
|
// Config is the root configuration object. Add sub-structs as the app grows.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Version bool
|
|
||||||
App AppConfig
|
App AppConfig
|
||||||
Logger LoggerConfig
|
Logger LoggerConfig
|
||||||
Greeter GreeterConfig
|
Greeter GreeterConfig
|
||||||
@ -39,7 +38,6 @@ type GreeterConfig struct {
|
|||||||
// cfg := config.parseArgs()
|
// cfg := config.parseArgs()
|
||||||
// fmt.Println(cfg.App.Port)
|
// fmt.Println(cfg.App.Port)
|
||||||
func parseArgs() *Config {
|
func parseArgs() *Config {
|
||||||
version := flag.Bool("version", false, "print version information and exit")
|
|
||||||
name := flag.String("name", "Gopher", "application name")
|
name := flag.String("name", "Gopher", "application name")
|
||||||
port := flag.Int("port", 8080, "listen port")
|
port := flag.Int("port", 8080, "listen port")
|
||||||
env := flag.String("env", "dev", "environment: dev | staging | prod")
|
env := flag.String("env", "dev", "environment: dev | staging | prod")
|
||||||
@ -49,7 +47,6 @@ func parseArgs() *Config {
|
|||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
return &Config{
|
return &Config{
|
||||||
Version: *version,
|
|
||||||
App: AppConfig{
|
App: AppConfig{
|
||||||
Port: *port,
|
Port: *port,
|
||||||
Env: *env,
|
Env: *env,
|
||||||
|
|||||||
@ -10,7 +10,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"gitea.djmil.dev/go/template/internal/buildinfo"
|
|
||||||
"gitea.djmil.dev/go/template/internal/greeter"
|
"gitea.djmil.dev/go/template/internal/greeter"
|
||||||
"gitea.djmil.dev/go/template/pkg/logger"
|
"gitea.djmil.dev/go/template/pkg/logger"
|
||||||
"gitea.djmil.dev/go/template/pkg/result"
|
"gitea.djmil.dev/go/template/pkg/result"
|
||||||
@ -45,11 +44,6 @@ func newApp(cfg *Config) *app {
|
|||||||
func main() {
|
func main() {
|
||||||
conf := parseArgs()
|
conf := parseArgs()
|
||||||
|
|
||||||
if conf.Version {
|
|
||||||
fmt.Println(buildinfo.String())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
app := newApp(conf)
|
app := newApp(conf)
|
||||||
app.log.WithFields(map[string]any{
|
app.log.WithFields(map[string]any{
|
||||||
"app": filepath.Base(os.Args[0]),
|
"app": filepath.Base(os.Args[0]),
|
||||||
|
|||||||
@ -1,25 +0,0 @@
|
|||||||
// Package buildinfo exposes build-time metadata injected via -ldflags.
|
|
||||||
// All vars default to safe fallbacks so the binary runs without stamping.
|
|
||||||
//
|
|
||||||
// Wire into the binary by passing LDFLAGS from the Makefile:
|
|
||||||
//
|
|
||||||
// go build -ldflags "-X .../internal/buildinfo.Commit=$(git rev-parse --short HEAD) \
|
|
||||||
// -X .../internal/buildinfo.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
|
|
||||||
// -X .../internal/buildinfo.Version=$(git describe --tags)"
|
|
||||||
package buildinfo
|
|
||||||
|
|
||||||
import "fmt"
|
|
||||||
|
|
||||||
// Version is the semver release tag (e.g. "v1.2.3") or "dev" for untagged builds.
|
|
||||||
var Version = "dev"
|
|
||||||
|
|
||||||
// Commit is the short git SHA of the build (e.g. "a1b2c3d").
|
|
||||||
var Commit = "unknown"
|
|
||||||
|
|
||||||
// BuildTime is the UTC timestamp when the binary was compiled (RFC 3339).
|
|
||||||
var BuildTime = "unknown"
|
|
||||||
|
|
||||||
// String returns a one-line summary suitable for a --version flag or startup log line.
|
|
||||||
func String() string {
|
|
||||||
return fmt.Sprintf("version=%s commit=%s built=%s", Version, Commit, BuildTime)
|
|
||||||
}
|
|
||||||
@ -26,7 +26,7 @@ func New(log *logger.Logger) *Service {
|
|||||||
// Greet returns a personalized greeting and logs the interaction.
|
// Greet returns a personalized greeting and logs the interaction.
|
||||||
func (s *Service) Greet(name string) result.Expect[string] {
|
func (s *Service) Greet(name string) result.Expect[string] {
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return result.Failf[string]("name must not be empty")
|
return result.Errf[string]("name must not be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
msg := fmt.Sprintf("Hello, %s!", name)
|
msg := fmt.Sprintf("Hello, %s!", name)
|
||||||
|
|||||||
@ -4,9 +4,9 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.djmil.dev/go/template/internal/greeter"
|
"gitea.djmil.dev/go/template/internal/greeter"
|
||||||
"gitea.djmil.dev/go/template/pkg/check"
|
|
||||||
"gitea.djmil.dev/go/template/pkg/logger"
|
"gitea.djmil.dev/go/template/pkg/logger"
|
||||||
"gitea.djmil.dev/go/template/pkg/result"
|
"gitea.djmil.dev/go/template/pkg/result"
|
||||||
|
"gitea.djmil.dev/go/template/pkg/testutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Service (unit tests) ──────────────────────────────────────────────────────
|
// ── Service (unit tests) ──────────────────────────────────────────────────────
|
||||||
@ -16,12 +16,12 @@ func TestGreet(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("returns personalized greeting", func(t *testing.T) {
|
t.Run("returns personalized greeting", func(t *testing.T) {
|
||||||
msg, err := svc.Greet("World").Unwrap()
|
msg, err := svc.Greet("World").Unwrap()
|
||||||
check.NoError(t, err)
|
testutil.NoError(t, err)
|
||||||
check.Equal(t, msg, "Hello, World!")
|
testutil.Equal(t, msg, "Hello, World!")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("rejects empty name", func(t *testing.T) {
|
t.Run("rejects empty name", func(t *testing.T) {
|
||||||
check.ErrorContains(t, svc.Greet(""), "name must not be empty")
|
testutil.ErrorContains(t, svc.Greet("").Err(), "name must not be empty")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -45,6 +45,6 @@ func TestFakeUsageExample(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
msg, err := fake.Greet("Alice").Unwrap()
|
msg, err := fake.Greet("Alice").Unwrap()
|
||||||
check.NoError(t, err)
|
testutil.NoError(t, err)
|
||||||
check.Equal(t, msg, "Hello, Alice!")
|
testutil.Equal(t, msg, "Hello, Alice!")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,132 +0,0 @@
|
|||||||
// Package check provides lightweight test helpers to reduce boilerplate in
|
|
||||||
// table-driven tests.
|
|
||||||
//
|
|
||||||
// Error-family functions (NoError, Error, ErrorContains) accept either a plain
|
|
||||||
// error or a result.Expect[T] value — the package extracts the error from
|
|
||||||
// whichever type it receives.
|
|
||||||
//
|
|
||||||
// Every helper calls t.Helper() so failures are reported at the call site.
|
|
||||||
package check
|
|
||||||
|
|
||||||
import (
|
|
||||||
"reflect"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"gitea.djmil.dev/go/template/pkg/result"
|
|
||||||
)
|
|
||||||
|
|
||||||
// extractErr pulls an error out of v, which must be error or result.Expect[T].
|
|
||||||
// Fatally fails the test for any other type.
|
|
||||||
func extractErr(t *testing.T, v any) error {
|
|
||||||
t.Helper()
|
|
||||||
if v == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
switch x := v.(type) {
|
|
||||||
case error:
|
|
||||||
return x
|
|
||||||
case interface{ Err() error }:
|
|
||||||
return x.Err()
|
|
||||||
default:
|
|
||||||
t.Fatalf("check: unsupported type %T (want error or result.Expect[T])", v)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NoError fails the test if v contains a non-nil error.
|
|
||||||
func NoError(t *testing.T, v any) {
|
|
||||||
t.Helper()
|
|
||||||
if err := extractErr(t, v); err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error fails the test if v contains no error.
|
|
||||||
func Error(t *testing.T, v any) {
|
|
||||||
t.Helper()
|
|
||||||
if extractErr(t, v) == nil {
|
|
||||||
t.Fatal("expected error, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ErrorContains fails the test if v contains no error or its message does not
|
|
||||||
// contain substr.
|
|
||||||
func ErrorContains(t *testing.T, v any, substr string) {
|
|
||||||
t.Helper()
|
|
||||||
err := extractErr(t, v)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error, got nil")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NotEqual fails the test if got == want.
|
|
||||||
func NotEqual[T comparable](t *testing.T, got, want T) {
|
|
||||||
t.Helper()
|
|
||||||
if got == want {
|
|
||||||
t.Errorf("expected values to differ, got %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeepEqual fails the test if got and want are not deeply equal.
|
|
||||||
// Use instead of Equal for maps, slices, and structs with slice fields.
|
|
||||||
func DeepEqual(t *testing.T, got, want any) {
|
|
||||||
t.Helper()
|
|
||||||
if !reflect.DeepEqual(got, want) {
|
|
||||||
t.Errorf("got %v, want %v", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ElementsMatch fails the test if got and want do not contain the same
|
|
||||||
// elements regardless of order, including duplicates. T must be comparable —
|
|
||||||
// for slices or maps as elements, use DeepEqual after sorting manually.
|
|
||||||
func ElementsMatch[T comparable](t *testing.T, got, want []T) {
|
|
||||||
t.Helper()
|
|
||||||
if len(got) != len(want) {
|
|
||||||
t.Errorf("length mismatch: got %d elements, want %d", len(got), len(want))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
freq := make(map[T]int, len(want))
|
|
||||||
for _, v := range want {
|
|
||||||
freq[v]++
|
|
||||||
}
|
|
||||||
for _, v := range got {
|
|
||||||
if freq[v]--; freq[v] < 0 {
|
|
||||||
t.Errorf("unexpected element: %v", v)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ok fails the test if r holds an error, then returns the value.
|
|
||||||
func Ok[T any](t *testing.T, r result.Expect[T]) T {
|
|
||||||
t.Helper()
|
|
||||||
if r.Err() != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", r.Err())
|
|
||||||
}
|
|
||||||
return r.Value()
|
|
||||||
}
|
|
||||||
|
|
||||||
// OkNotNil fails the test if r holds an error or its value is the zero value.
|
|
||||||
// T must be a pointer or comparable type where zero means absent.
|
|
||||||
func OkNotNil[T comparable](t *testing.T, r result.Expect[T]) T {
|
|
||||||
t.Helper()
|
|
||||||
v := Ok(t, r)
|
|
||||||
var zero T
|
|
||||||
if v == zero {
|
|
||||||
t.Fatal("expected non-nil value, got zero")
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
@ -47,10 +47,6 @@ type writeState struct {
|
|||||||
// INFO: message: k=v, … full fields — debug mode dumps everything
|
// INFO: message: k=v, … full fields — debug mode dumps everything
|
||||||
// WARN: warning: msg: k=v, …
|
// WARN: warning: msg: k=v, …
|
||||||
// ERROR: error: msg: k=v, …
|
// ERROR: error: msg: k=v, …
|
||||||
//
|
|
||||||
// INFO fields are intentionally hidden in normal mode: they are context for a
|
|
||||||
// debug session, not alerts for the operator. Put fields on WARN or ERROR when
|
|
||||||
// the reader needs them immediately to understand a problem.
|
|
||||||
type humanHandler struct {
|
type humanHandler struct {
|
||||||
w io.Writer
|
w io.Writer
|
||||||
level slog.Level
|
level slog.Level
|
||||||
|
|||||||
@ -54,7 +54,7 @@ type Logger struct {
|
|||||||
func New(level string) result.Expect[*Logger] {
|
func New(level string) result.Expect[*Logger] {
|
||||||
lvl := parseLevel(level)
|
lvl := parseLevel(level)
|
||||||
if lvl.Err() != nil {
|
if lvl.Err() != nil {
|
||||||
return result.Wrap[*Logger](lvl, "parse log level")
|
return result.Errw[*Logger](lvl.Err(), "parse log level")
|
||||||
}
|
}
|
||||||
|
|
||||||
h := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: lvl.Value()})
|
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] {
|
func NewCLI(level string, debugOut io.Writer) result.Expect[*Logger] {
|
||||||
lvl := parseLevel(level)
|
lvl := parseLevel(level)
|
||||||
if lvl.Err() != nil {
|
if lvl.Err() != nil {
|
||||||
return result.Wrap[*Logger](lvl, "parse log level")
|
return result.Errw[*Logger](lvl.Err(), "parse log level")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isTerminal(os.Stderr) {
|
if !isTerminal(os.Stderr) {
|
||||||
@ -112,7 +112,7 @@ func IsInteractive() bool {
|
|||||||
func NewWriter(w io.Writer, level string) result.Expect[*Logger] {
|
func NewWriter(w io.Writer, level string) result.Expect[*Logger] {
|
||||||
lvl := parseLevel(level)
|
lvl := parseLevel(level)
|
||||||
if lvl.Err() != nil {
|
if lvl.Err() != nil {
|
||||||
return result.Wrap[*Logger](lvl, "parse log level")
|
return result.Errw[*Logger](lvl.Err(), "parse log level")
|
||||||
}
|
}
|
||||||
|
|
||||||
h := slog.NewJSONHandler(w, &slog.HandlerOptions{Level: lvl.Value()})
|
h := slog.NewJSONHandler(w, &slog.HandlerOptions{Level: lvl.Value()})
|
||||||
@ -154,6 +154,8 @@ func (l *Logger) WithFields(fields map[string]any) *Logger {
|
|||||||
|
|
||||||
func parseLevel(level string) result.Expect[slog.Level] {
|
func parseLevel(level string) result.Expect[slog.Level] {
|
||||||
var lvl slog.Level
|
var lvl slog.Level
|
||||||
err := lvl.UnmarshalText([]byte(level))
|
if err := lvl.UnmarshalText([]byte(level)); err != nil {
|
||||||
return result.Of(lvl, err)
|
return result.Errw[slog.Level](err, "unknown level (use debug|info|warn|error)")
|
||||||
|
}
|
||||||
|
return result.Ok(lvl)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,8 +3,8 @@ package logger_test
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.djmil.dev/go/template/pkg/check"
|
|
||||||
"gitea.djmil.dev/go/template/pkg/logger"
|
"gitea.djmil.dev/go/template/pkg/logger"
|
||||||
|
"gitea.djmil.dev/go/template/pkg/testutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewCLI(t *testing.T) {
|
func TestNewCLI(t *testing.T) {
|
||||||
@ -24,10 +24,10 @@ func TestNewCLI(t *testing.T) {
|
|||||||
// In tests stderr is not a terminal — NewCLI uses JSON path.
|
// In tests stderr is not a terminal — NewCLI uses JSON path.
|
||||||
r := logger.NewCLI(tc.level, nil)
|
r := logger.NewCLI(tc.level, nil)
|
||||||
if tc.wantErr {
|
if tc.wantErr {
|
||||||
check.Error(t, r)
|
testutil.ResultErr(t, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
check.OkNotNil(t, r)
|
testutil.ResultOkNotNil(t, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -49,10 +49,10 @@ func TestNew(t *testing.T) {
|
|||||||
t.Run(tc.level, func(t *testing.T) {
|
t.Run(tc.level, func(t *testing.T) {
|
||||||
r := logger.New(tc.level)
|
r := logger.New(tc.level)
|
||||||
if tc.wantErr {
|
if tc.wantErr {
|
||||||
check.Error(t, r)
|
testutil.ResultErr(t, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
check.OkNotNil(t, r)
|
testutil.ResultOkNotNil(t, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -252,7 +252,7 @@ func r_parseHeader3(raw string) result.Expect[bHeader] {
|
|||||||
}
|
}
|
||||||
parts := strings.SplitN(raw, "|", 3)
|
parts := strings.SplitN(raw, "|", 3)
|
||||||
if len(parts) != 3 {
|
if len(parts) != 3 {
|
||||||
return result.Failf[bHeader]("malformed record: %q", raw)
|
return result.Errf[bHeader]("malformed record: %q", raw)
|
||||||
}
|
}
|
||||||
return result.Ok(bHeader{raw: raw, id: parts[0], name: parts[1], val: parts[2]})
|
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] {
|
func r_validate5(h bHeader) result.Expect[bFields] {
|
||||||
id, err := strconv.Atoi(h.id)
|
id, err := strconv.Atoi(h.id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result.Failf[bFields]("parse id %q: %w", h.id, err)
|
return result.Errf[bFields]("parse id %q: %w", h.id, err)
|
||||||
}
|
}
|
||||||
if id <= 0 {
|
if id <= 0 {
|
||||||
return result.Failf[bFields]("id %d: must be > 0", id)
|
return result.Errf[bFields]("id %d: must be > 0", id)
|
||||||
}
|
}
|
||||||
return result.Ok(bFields{id: id, name: h.name, val: h.val})
|
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] {
|
func r_transform5(f bFields) result.Expect[bRecord] {
|
||||||
v, err := strconv.ParseFloat(f.val, 64)
|
v, err := strconv.ParseFloat(f.val, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result.Failf[bRecord]("parse value %q: %w", f.val, err)
|
return result.Errf[bRecord]("parse value %q: %w", f.val, err)
|
||||||
}
|
}
|
||||||
return result.Ok(bRecord{id: f.id, name: f.name, score: v * 1.5})
|
return result.Ok(bRecord{id: f.id, name: f.name, score: v * 1.5})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
//
|
//
|
||||||
// func parseHost(s string) result.Expect[string] {
|
// func parseHost(s string) result.Expect[string] {
|
||||||
// if s == "" {
|
// if s == "" {
|
||||||
// return result.Failf[string]("host must not be empty")
|
// return result.Errf[string]("host must not be empty")
|
||||||
// }
|
// }
|
||||||
// return result.Ok(s)
|
// return result.Ok(s)
|
||||||
// }
|
// }
|
||||||
@ -54,10 +54,8 @@
|
|||||||
//
|
//
|
||||||
// # Constructors
|
// # Constructors
|
||||||
//
|
//
|
||||||
// [Ok] and [Err] are the two field constructors (a success value or a bare
|
// Use [Ok] to wrap a success value, [Err] / [Errf] / [Errw] to wrap errors,
|
||||||
// error). On top of them, [Failf] originates a failure from a message (embed a
|
// and [Of] to bridge existing (value, error) return signatures:
|
||||||
// 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")
|
// data := result.Of(os.ReadFile("cfg.json")).Expect("read config")
|
||||||
//
|
//
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import (
|
|||||||
// parseHost is an example of a simple utility function, that validates a hostname.
|
// parseHost is an example of a simple utility function, that validates a hostname.
|
||||||
func parseHost(s string) result.Expect[string] {
|
func parseHost(s string) result.Expect[string] {
|
||||||
if s == "" {
|
if s == "" {
|
||||||
return result.Failf[string]("host must not be empty")
|
return result.Errf[string]("host must not be empty")
|
||||||
}
|
}
|
||||||
return result.Ok(s)
|
return result.Ok(s)
|
||||||
}
|
}
|
||||||
@ -19,10 +19,10 @@ func parseHost(s string) result.Expect[string] {
|
|||||||
func parsePort(s string) result.Expect[int] {
|
func parsePort(s string) result.Expect[int] {
|
||||||
port := result.Of(strconv.Atoi(s))
|
port := result.Of(strconv.Atoi(s))
|
||||||
if port.Err() != nil {
|
if port.Err() != nil {
|
||||||
return result.Wrap[int](port, "result.Of")
|
return result.Errw[int](port.Err(), "result.Of")
|
||||||
}
|
}
|
||||||
if port.Value() < 1 || port.Value() > 65535 {
|
if port.Value() < 1 || port.Value() > 65535 {
|
||||||
return result.Failf[int]("%d out of range", port.Value())
|
return result.Errf[int]("%d out of range", port.Value())
|
||||||
}
|
}
|
||||||
return port
|
return port
|
||||||
}
|
}
|
||||||
@ -148,25 +148,3 @@ func Example_unwrap() {
|
|||||||
// Output:
|
// Output:
|
||||||
// 443
|
// 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
|
|
||||||
}
|
|
||||||
|
|||||||
@ -16,7 +16,7 @@ import (
|
|||||||
// without a subprocess, so it is only documented here.
|
// without a subprocess, so it is only documented here.
|
||||||
func TestMustCollected(t *testing.T) {
|
func TestMustCollected(t *testing.T) {
|
||||||
err := result.Run(func() {
|
err := result.Run(func() {
|
||||||
result.Failf[int]("unrecoverable").Must()
|
result.Errf[int]("unrecoverable").Must()
|
||||||
})
|
})
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@ -49,63 +49,32 @@ func Ok[T any](v T) Expect[T] {
|
|||||||
return Expect[T]{value: v}
|
return Expect[T]{value: v}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Err wraps an error in an Expect — the failure-side counterpart to [Ok],
|
// Err wraps an error in an Expect.
|
||||||
// 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] {
|
func Err[T any](err error) Expect[T] {
|
||||||
return Expect[T]{err: err}
|
return Expect[T]{err: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Failf originates a failure from a formatted message. Where [Err] boxes an
|
// Errf wraps a formatted error in an Expect. It is a convenience shorthand
|
||||||
// existing error verbatim, Failf builds a new one — optionally embedding a
|
// for [Err][fmt.Errorf(format, args...)]. The caller's file and line are
|
||||||
// cause with %w to preserve the errors.Is/As chain:
|
// prepended to the error message automatically.
|
||||||
//
|
func Errf[T any](format string, args ...any) Expect[T] {
|
||||||
// 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)
|
_, file, line, _ := runtime.Caller(1)
|
||||||
loc := fmt.Sprintf("%s:%d", filepath.Base(file), line)
|
loc := fmt.Sprintf("%s:%d", filepath.Base(file), line)
|
||||||
return Expect[T]{err: fmt.Errorf(loc+": "+format, args...)}
|
return Expect[T]{err: fmt.Errorf(loc+": "+format, args...)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wrap propagates a failed Expect[T] as an Expect[U], optionally annotating it
|
// Errw wraps an existing error with a context message, following the standard
|
||||||
// with a context message. It carries a failure across the type boundary between
|
// Go error-propagation convention (errors.Is/As chain is preserved). Each
|
||||||
// two result-returning functions without the unwrap-then-rebuild dance of
|
// wrapping level is placed on its own line so the full error reads as a
|
||||||
// reaching into [Expect.Err] by hand.
|
// top-down trace: outermost context first, root cause last. The caller's file
|
||||||
|
// and line are prepended automatically.
|
||||||
//
|
//
|
||||||
// Wrap is meaningful only on a failed result — a successful r holds a T whose
|
// main.go:42: load config
|
||||||
// value cannot be retyped to U — so guard it with the usual error check:
|
// logger.go:35: parse log level
|
||||||
//
|
// strconv.Atoi: parsing "x": invalid syntax
|
||||||
// lvl := parseLevel(level)
|
func Errw[T any](err error, format string, args ...any) Expect[T] {
|
||||||
// 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)
|
_, file, line, _ := runtime.Caller(1)
|
||||||
msg := fmt.Sprintf(msgArgs[0].(string), msgArgs[1:]...)
|
return Expect[T]{err: fmt.Errorf("%s:%d: %s\n%w", filepath.Base(file), line, fmt.Sprintf(format, args...), err)}
|
||||||
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)
|
// Of is a convenience constructor that bridges standard Go (value, error)
|
||||||
@ -222,7 +191,7 @@ func Async[T any](fn func() T) <-chan Expect[T] {
|
|||||||
if err := collectGoexitFailure(); err != nil {
|
if err := collectGoexitFailure(); err != nil {
|
||||||
ch <- Err[T](err)
|
ch <- Err[T](err)
|
||||||
} else {
|
} else {
|
||||||
ch <- Failf[T]("goroutine exited unexpectedly")
|
ch <- Errf[T]("goroutine exited unexpectedly")
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
val = fn()
|
val = fn()
|
||||||
|
|||||||
@ -1,82 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
75
pkg/testutil/testutil.go
Normal file
75
pkg/testutil/testutil.go
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
// Package testutil provides lightweight test helpers to reduce boilerplate in
|
||||||
|
// table-driven tests.
|
||||||
|
//
|
||||||
|
// Every helper calls t.Helper() so failures are reported at the call site, not
|
||||||
|
// inside this package.
|
||||||
|
package testutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.djmil.dev/go/template/pkg/result"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResultOk fails the test if r holds an error, then returns the value.
|
||||||
|
func ResultOk[T any](t *testing.T, r result.Expect[T]) T {
|
||||||
|
t.Helper()
|
||||||
|
if r.Err() != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", r.Err())
|
||||||
|
}
|
||||||
|
return r.Value()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResultOkNotNil fails the test if r holds an error or its value is nil.
|
||||||
|
// T must be a pointer type.
|
||||||
|
func ResultOkNotNil[T comparable](t *testing.T, r result.Expect[T]) T {
|
||||||
|
t.Helper()
|
||||||
|
v := ResultOk(t, r)
|
||||||
|
var zero T
|
||||||
|
if v == zero {
|
||||||
|
t.Fatal("expected non-nil value, got nil")
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResultErr fails the test if r does not hold an error.
|
||||||
|
func ResultErr[T any](t *testing.T, r result.Expect[T]) {
|
||||||
|
t.Helper()
|
||||||
|
if r.Err() == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user