Compare commits
4 Commits
concurent-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e3a9f2cbd | |||
| 1479022388 | |||
| fd9621669c | |||
| 5055d69685 |
28
.claude/commands/new-package.md
Normal file
28
.claude/commands/new-package.md
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
Scaffold a new internal domain package named `$ARGUMENTS`.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Create `internal/$ARGUMENTS/$ARGUMENTS.go`:
|
||||||
|
- Package declaration `package $ARGUMENTS`
|
||||||
|
- One exported concrete type named after the package's responsibility (e.g. `Service`, `Store`, `Client`, `Parser`)
|
||||||
|
- Constructor: `func New(...) *<Type>` — only accept dependencies the type actually needs; leave the signature empty if none are obvious yet
|
||||||
|
- At least one exported method stub representing the package's primary operation; return `result.Expect[T]` if the operation can fail
|
||||||
|
- Doc comment on every exported symbol (the linter enforces this)
|
||||||
|
|
||||||
|
2. Create `internal/$ARGUMENTS/$ARGUMENTS_test.go`:
|
||||||
|
- Package: `package $ARGUMENTS_test` (black-box)
|
||||||
|
- Declare a minimal interface covering only the methods the test calls
|
||||||
|
- Write a manual fake struct that satisfies that interface (no code generation)
|
||||||
|
- One table-driven test using `t.Run` and helpers from `gitea.djmil.dev/go/template/pkg/testutil`
|
||||||
|
- Use `logger.NewNop()` from `gitea.djmil.dev/go/template/pkg/logger` if logging is needed
|
||||||
|
|
||||||
|
## Rules (do not break these)
|
||||||
|
|
||||||
|
- Never define an interface inside the package itself — consumers define interfaces
|
||||||
|
- Never call `.Expect()`, `.Must()`, or `.Expectf()` inside the package — only return `result.Expect[T]`
|
||||||
|
- No third-party imports
|
||||||
|
- No hard-coded configuration values
|
||||||
|
|
||||||
|
## After scaffolding
|
||||||
|
|
||||||
|
Run `make lint test` to verify the files compile and the stub test passes. Report what was created and suggest what the caller should wire in `cmd/app/main.go`.
|
||||||
8
.claude/settings.json
Normal file
8
.claude/settings.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(go test *)",
|
||||||
|
"Bash(go vet *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
49
CLAUDE.md
49
CLAUDE.md
@ -8,25 +8,41 @@ Keep it concise — the agent needs signal, not essays.
|
|||||||
## Project overview
|
## Project overview
|
||||||
|
|
||||||
Go 1.25 template for PoC, hobby projects, and small publishable packages.
|
Go 1.25 template for PoC, hobby projects, and small publishable packages.
|
||||||
Demonstrates: structured logging (slog), config (flag), consumer-defined interfaces + manual fakes,
|
Stack: structured logging (slog), config (flag), consumer-defined interfaces + manual fakes,
|
||||||
result type (happy-path error handling), linting (golangci-lint), security scanning (gosec, govulncheck),
|
result type (happy-path error handling), linting (golangci-lint), security scanning (gosec, govulncheck).
|
||||||
git hooks, devcontainer, VSCode tasks.
|
|
||||||
|
|
||||||
Key constraint: `go.mod` stays free of dev tool deps (tools are pinned in `tools.versions` and run via
|
Key constraint: `go.mod` stays free of dev tool deps (tools are pinned in `tools.versions` and run via
|
||||||
`go run tool@version`) so packages published from this repo have a clean module graph for consumers.
|
`go run tool@version`) so packages published from this repo have a clean module graph for consumers.
|
||||||
|
|
||||||
Module: `gitea.djmil.dev/go/template` — update this when you fork.
|
---
|
||||||
|
|
||||||
|
## Design philosophy
|
||||||
|
|
||||||
|
**[The Twelve-Factor App](https://12factor.net) is the primary reference for default implementation choices.**
|
||||||
|
Follow its recommendations unless circumstances force a deviation — and document the reason when you do.
|
||||||
|
|
||||||
|
Key factors that shape this codebase most directly:
|
||||||
|
|
||||||
|
- **[Logs (factor XI)](https://12factor.net/logs)** — the app writes log events to `stderr` as an unbuffered stream.
|
||||||
|
It never opens log files or manages rotation. The execution environment (shell, systemd, Docker, k8s)
|
||||||
|
routes and stores the stream. Human-readable program output goes to `stdout` (`fmt.Print*`).
|
||||||
|
- **[Config (factor III)](https://12factor.net/config)** — all configuration comes from the environment
|
||||||
|
(flags in this template; env vars are the 12-factor ideal). No hard-coded values in logic packages.
|
||||||
|
- **[Dependencies (factor II)](https://12factor.net/dependencies)** — explicit declaration; dev tools are
|
||||||
|
pinned in `tools.versions` and kept out of `go.mod` so published packages have a clean module graph.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Project structure
|
## Project structure
|
||||||
|
|
||||||
```
|
```
|
||||||
cmd/app/main.go composition root — wires deps, no logic here
|
cmd/app/ CLI entrypoint: parses flags, wires dependencies, and expresses
|
||||||
internal/config/ flag-based config loader (config.Load)
|
what the program does in high-level readable steps — calls internal/
|
||||||
internal/logger/ slog wrapper with WithField / WithFields
|
and pkg/ packages; no domain logic lives here
|
||||||
internal/greeter/ Example domain package (delete or repurpose)
|
internal/ domain-specific packages; logical grouping of substantial code into
|
||||||
pkg/result/ Example publishable package (Result/Expect types)
|
digestible types and interfaces; not importable outside this module
|
||||||
|
pkg/ generic, publishable packages reusable across projects; no
|
||||||
|
assumptions about the calling application
|
||||||
tools.versions Pinned tool versions (sourced by Makefile and pre-push hook)
|
tools.versions Pinned tool versions (sourced by Makefile and pre-push hook)
|
||||||
.golangci.yml Linter rules
|
.golangci.yml Linter rules
|
||||||
.githooks/pre-push Runs gofmt + go vet + golangci-lint + gosec before push
|
.githooks/pre-push Runs gofmt + go vet + golangci-lint + gosec before push
|
||||||
@ -36,8 +52,8 @@ tools.versions Pinned tool versions (sourced by Makefile and pre-push
|
|||||||
|
|
||||||
## Project rules
|
## Project rules
|
||||||
|
|
||||||
- **Module imports** — always use the full module path `gitea.djmil.dev/go/template/...`
|
- **Module imports** — always use the full module path from `go.mod` (never relative imports)
|
||||||
- **Packages** — keep `cmd/` thin (wiring only); business logic belongs in `internal/`
|
- **Packages** — `cmd/` owns CLI parsing, dependency wiring, and high-level orchestration; domain logic belongs in `internal/`
|
||||||
- **Types** — expose concrete types from constructors (`New(...) *Type`); never wrap in an interface at the implementation site. Consumers define their own interfaces if they need one (Go's implicit satisfaction makes this free)
|
- **Types** — expose concrete types from constructors (`New(...) *Type`); never wrap in an interface at the implementation site. Consumers define their own interfaces if they need one (Go's implicit satisfaction makes this free)
|
||||||
- **Errors** — `pkg/result` is a convenience tool for removing error-threading clutter from application logic; use it as follows:
|
- **Errors** — `pkg/result` is a convenience tool for removing error-threading clutter from application logic; use it as follows:
|
||||||
- `pkg/` libraries **only return** `result.Expect[T]` — never call `.Expect()`, `.Must()`, or `.Expectf()` inside library code; those methods exit the goroutine via `runtime.Goexit` and are only safe in application-layer code protected by a boundary
|
- `pkg/` libraries **only return** `result.Expect[T]` — never call `.Expect()`, `.Must()`, or `.Expectf()` inside library code; those methods exit the goroutine via `runtime.Goexit` and are only safe in application-layer code protected by a boundary
|
||||||
@ -47,8 +63,8 @@ tools.versions Pinned tool versions (sourced by Makefile and pre-push
|
|||||||
- 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")`
|
||||||
- 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.Fail`
|
- still use `fmt.Errorf("context: %w", err)` when wrapping errors *before* constructing a `result.Fail`
|
||||||
- **Logging** — use `log.WithField("key", val)` for structured context; never `fmt.Sprintf` in log messages; `log/slog` is the backend
|
- **Logging** — logs go to `stderr` (structured, machine-readable, per 12-factor XI); human output goes to `stdout` via `fmt.Print*`. Use `log.WithField("key", val)` for structured context; never `fmt.Sprintf` in log messages; `log/slog` is the backend
|
||||||
- **Config** — all configuration through `internal/config` (flag-parsed); no hard-coded values in logic packages
|
- **Config** — all configuration parsed in `cmd/app/config.go` (flags); no hard-coded values in `internal/` or `pkg/` packages
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -60,7 +76,6 @@ tools.versions Pinned tool versions (sourced by Makefile and pre-push
|
|||||||
- Constructors: `New(deps...) *Type` pattern
|
- Constructors: `New(deps...) *Type` pattern
|
||||||
- Comment every exported symbol (golangci-lint will warn if missing)
|
- Comment every exported symbol (golangci-lint will warn if missing)
|
||||||
- Max line length: 120 chars (configured in `.golangci.yml`)
|
- Max line length: 120 chars (configured in `.golangci.yml`)
|
||||||
- Prefer explicit over clever; PoC code should be readable first
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -70,10 +85,11 @@ tools.versions Pinned tool versions (sourced by Makefile and pre-push
|
|||||||
- Test files: `package foo_test` (black-box) unless white-box access is needed
|
- Test files: `package foo_test` (black-box) unless white-box access is needed
|
||||||
- Fake dependencies with **manual fakes** (implement the interface inline in `_test.go`)
|
- Fake dependencies with **manual fakes** (implement the interface inline in `_test.go`)
|
||||||
- Use `logger.NewNop()` when the test doesn't care about log output
|
- Use `logger.NewNop()` when the test doesn't care about log output
|
||||||
|
- Use `logger.NewWriter(&buf, "debug")` in acceptance / integration tests that need to assert on log content
|
||||||
- Table-driven tests with `t.Run("description", ...)` for multiple cases
|
- 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
|
- 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`
|
- Never use `time.Sleep` in tests; use channels or `t.Cleanup`
|
||||||
- Use `internal/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/testutil` helpers instead of manual checks — `ResultOk`, `ResultOkNotNil`, `ResultErr` for `result.Expect[T]`; `NoError`, `Error`, `ErrorContains`, `Equal` for plain values
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -93,9 +109,6 @@ make release # list releases, or tag+push after full checks (make release
|
|||||||
make clean # remove bin/
|
make clean # remove bin/
|
||||||
```
|
```
|
||||||
|
|
||||||
VSCode: `Ctrl+Shift+B` = build, `Ctrl+Shift+T` = test.
|
|
||||||
Debug: use launch config "Debug: app" (F5).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Adding new features (checklist)
|
## Adding new features (checklist)
|
||||||
|
|||||||
17
Makefile
17
Makefile
@ -1,4 +1,4 @@
|
|||||||
.PHONY: help init setup build run test test-race lint lint-fix security docs 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
|
||||||
|
|
||||||
@ -9,7 +9,7 @@ CMD_PATH := ./cmd/app
|
|||||||
|
|
||||||
# ── Default target ─────────────────────────────────────────────────────────────
|
# ── Default target ─────────────────────────────────────────────────────────────
|
||||||
help: ## Show this help message
|
help: ## Show this help message
|
||||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
|
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' Makefile | \
|
||||||
awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' | sort
|
awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' | sort
|
||||||
|
|
||||||
# ── First-time setup ───────────────────────────────────────────────────────────
|
# ── First-time setup ───────────────────────────────────────────────────────────
|
||||||
@ -82,7 +82,18 @@ ifdef VERSION
|
|||||||
$(MAKE) test-race
|
$(MAKE) test-race
|
||||||
$(MAKE) lint
|
$(MAKE) lint
|
||||||
$(MAKE) security
|
$(MAKE) security
|
||||||
git tag $(VERSION)
|
@MSG=$$(mktemp); \
|
||||||
|
LAST=$$(git describe --tags --abbrev=0 2>/dev/null); \
|
||||||
|
echo "$(VERSION)" > $$MSG; \
|
||||||
|
echo "" >> $$MSG; \
|
||||||
|
if [ -n "$$LAST" ]; then \
|
||||||
|
echo "Changes since $$LAST:" >> $$MSG; \
|
||||||
|
git log $$LAST..HEAD --oneline >> $$MSG; \
|
||||||
|
else \
|
||||||
|
git log --oneline >> $$MSG; \
|
||||||
|
fi; \
|
||||||
|
git tag -a -e -F $$MSG $(VERSION); \
|
||||||
|
rm -f $$MSG
|
||||||
git push origin $(VERSION)
|
git push origin $(VERSION)
|
||||||
else
|
else
|
||||||
@echo "Released versions:"
|
@echo "Released versions:"
|
||||||
|
|||||||
10
README.md
10
README.md
@ -74,6 +74,16 @@ make release # list tags, or run full checks then tag+push (make release V
|
|||||||
|
|
||||||
> **Keyboard shortcut (VSCode):** `Ctrl+Shift+B` → build, `Ctrl+Shift+T` → test.
|
> **Keyboard shortcut (VSCode):** `Ctrl+Shift+B` → build, `Ctrl+Shift+T` → test.
|
||||||
|
|
||||||
|
### Claude Code commands
|
||||||
|
|
||||||
|
If you use [Claude Code](https://claude.ai/code), the repo ships a custom slash command:
|
||||||
|
|
||||||
|
| Command | What it does |
|
||||||
|
|---|---|
|
||||||
|
| `/new-package <name>` | Scaffolds `internal/<name>/` with a concrete type, constructor, doc comments, and a black-box test file — all wired to project conventions |
|
||||||
|
|
||||||
|
Commands live in `.claude/commands/` and are available to anyone who clones the repo.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Project structure
|
## Project structure
|
||||||
|
|||||||
@ -1,13 +1,4 @@
|
|||||||
// Package config parses application configuration from command-line flags.
|
package main
|
||||||
// Defaults are defined here; override at runtime with flags:
|
|
||||||
//
|
|
||||||
// ./app -port 9090 -env prod -log-level warn
|
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// cfg := config.Load()
|
|
||||||
// fmt.Println(cfg.App.Port)
|
|
||||||
package config
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"flag"
|
"flag"
|
||||||
@ -36,9 +27,16 @@ type GreeterConfig struct {
|
|||||||
Name string
|
Name string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load parses command-line flags and returns a Config.
|
// parseArgs parses application configuration from command-line flags.
|
||||||
// Call this once at startup before any other flag parsing.
|
// Defaults are defined here; override at runtime with flags:
|
||||||
func Load() *Config {
|
//
|
||||||
|
// ./app -port 9090 -env prod -log-level warn 2 > log.log
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// cfg := config.parseArgs()
|
||||||
|
// fmt.Println(cfg.App.Port)
|
||||||
|
func parseArgs() *Config {
|
||||||
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")
|
||||||
@ -1,6 +1,7 @@
|
|||||||
// main is the composition root for the application.
|
// main is the composition root for the application.
|
||||||
// It wires together config, logger, and domain services — nothing more.
|
// It parses config, wires dependencies into an app struct, and delegates.
|
||||||
// Business logic lives in internal/; cmd/ is deliberately thin.
|
// Implementation details lives in internal/; cmd/ should be kept thin by
|
||||||
|
// deliberately operating only with high level concepts.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@ -8,14 +9,42 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"gitea.djmil.dev/go/template/internal/config"
|
|
||||||
"gitea.djmil.dev/go/template/internal/greeter"
|
"gitea.djmil.dev/go/template/internal/greeter"
|
||||||
"gitea.djmil.dev/go/template/internal/logger"
|
"gitea.djmil.dev/go/template/pkg/logger"
|
||||||
"gitea.djmil.dev/go/template/pkg/result"
|
"gitea.djmil.dev/go/template/pkg/result"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// app holds all wired dependencies for the lifetime of the process.
|
||||||
|
type app struct {
|
||||||
|
cfg *Config
|
||||||
|
log *logger.Logger
|
||||||
|
greeter *greeter.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func newApp(cfg *Config) *app {
|
||||||
|
var log *logger.Logger
|
||||||
|
if cfg.App.Env == "dev" {
|
||||||
|
log = logger.NewDevelopment()
|
||||||
|
} else {
|
||||||
|
log = logger.New(cfg.Logger.Level).Expect("create logger") // might fail dramatically
|
||||||
|
}
|
||||||
|
return &app{
|
||||||
|
cfg: cfg,
|
||||||
|
log: log,
|
||||||
|
greeter: greeter.New(log),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if err := result.Run(showGreeting); err != nil {
|
conf := parseArgs()
|
||||||
|
|
||||||
|
app := newApp(conf)
|
||||||
|
app.log.WithFields(map[string]any{
|
||||||
|
"app": filepath.Base(os.Args[0]),
|
||||||
|
"env": conf.App.Env,
|
||||||
|
}).Info("starting up")
|
||||||
|
|
||||||
|
if err := result.Run(app.run); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "[failed] %v\n", err)
|
fmt.Fprintf(os.Stderr, "[failed] %v\n", err)
|
||||||
if stack := result.StackTrace(err); stack != "" {
|
if stack := result.StackTrace(err); stack != "" {
|
||||||
fmt.Fprintf(os.Stderr, "%s\n", stack)
|
fmt.Fprintf(os.Stderr, "%s\n", stack)
|
||||||
@ -24,30 +53,17 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func showGreeting() {
|
func (a *app) run() {
|
||||||
// ── Config ────────────────────────────────────────────────────────────────
|
// High level business logic goes here
|
||||||
cfg := config.Load()
|
a.showGreeting(a.cfg.Greeter.Name)
|
||||||
|
|
||||||
// ── Logger ────────────────────────────────────────────────────────────────
|
// Human readable messages.
|
||||||
var log *logger.Logger
|
// Use logs for presenting technical data in machine friendly format.
|
||||||
if cfg.App.Env == "dev" {
|
fmt.Printf("TODO: implement listening on port %d\n", a.cfg.App.Port)
|
||||||
log = logger.NewDevelopment()
|
|
||||||
} else {
|
|
||||||
log = logger.New(cfg.Logger.Level).Expect("create logger")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.WithFields(map[string]any{
|
func (a *app) showGreeting(name string) {
|
||||||
"app": filepath.Base(os.Args[0]),
|
msg := a.greeter.Greet(name).Expect("greeting")
|
||||||
"env": cfg.App.Env,
|
a.log.WithField("message", msg).Info("greeting complete")
|
||||||
}).Info("starting up")
|
fmt.Println(msg)
|
||||||
|
|
||||||
// ── Services ──────────────────────────────────────────────────────────────
|
|
||||||
greetSvc := greeter.New(log)
|
|
||||||
|
|
||||||
// ── Example usage ─────────────────────────────────────────────────────────
|
|
||||||
msg := greetSvc.Greet(cfg.Greeter.Name).Expect("greeting")
|
|
||||||
|
|
||||||
log.WithField("message", msg).Info("greeting complete")
|
|
||||||
|
|
||||||
fmt.Printf("%s (listening on :%d)\n", msg, cfg.App.Port)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
// Package greeter is a minimal example domain package.
|
// Package greeter is a minimal example domain package.
|
||||||
// It demonstrates how to:
|
// It demonstrates how to:
|
||||||
// - define an interface (satisfied by manual fakes in tests)
|
// - define an interface (satisfied by manual fakes in tests)
|
||||||
// - inject dependencies (logger) through a constructor
|
// - inject a component-scoped logger through the constructor
|
||||||
// - use the logger.WithField pattern
|
|
||||||
//
|
//
|
||||||
// Replace this package with your own domain logic.
|
// Replace this package with your own domain logic.
|
||||||
package greeter
|
package greeter
|
||||||
@ -10,7 +9,7 @@ package greeter
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.djmil.dev/go/template/internal/logger"
|
"gitea.djmil.dev/go/template/pkg/logger"
|
||||||
"gitea.djmil.dev/go/template/pkg/result"
|
"gitea.djmil.dev/go/template/pkg/result"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -21,7 +20,7 @@ type Service struct {
|
|||||||
|
|
||||||
// New creates a Greeter service with the provided logger.
|
// New creates a Greeter service with the provided logger.
|
||||||
func New(log *logger.Logger) *Service {
|
func New(log *logger.Logger) *Service {
|
||||||
return &Service{log: log}
|
return &Service{log: log.WithField("component", "greeter")}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Greet returns a personalized greeting and logs the interaction.
|
// Greet returns a personalized greeting and logs the interaction.
|
||||||
@ -32,10 +31,7 @@ func (s *Service) Greet(name string) result.Expect[string] {
|
|||||||
|
|
||||||
msg := fmt.Sprintf("Hello, %s!", name)
|
msg := fmt.Sprintf("Hello, %s!", name)
|
||||||
|
|
||||||
s.log.
|
s.log.WithField("name", name).Debug("greeting generated")
|
||||||
WithField("component", "greeter").
|
|
||||||
WithField("name", name).
|
|
||||||
Info("greeting generated")
|
|
||||||
|
|
||||||
return result.Ok(msg)
|
return result.Ok(msg)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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/internal/logger"
|
"gitea.djmil.dev/go/template/pkg/logger"
|
||||||
"gitea.djmil.dev/go/template/internal/testutil"
|
|
||||||
"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) ──────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -1,16 +1,26 @@
|
|||||||
// Package logger wraps log/slog with a thin, ergonomic API.
|
// Package logger wraps log/slog with a thin, ergonomic API.
|
||||||
//
|
//
|
||||||
// The key addition over raw slog is the WithField / WithFields helpers that
|
// Per the Twelve-Factor App (factor XI), the application writes structured log
|
||||||
// return a *Logger (not a *slog.Logger), so callers stay in the typed world
|
// events to stderr and never manages log files or routing itself. The execution
|
||||||
// and can chain field attachments without importing slog directly.
|
// environment (shell, systemd, Docker) is responsible for capturing and storing
|
||||||
|
// the stream. Human-readable output belongs on stdout via fmt.Print*.
|
||||||
//
|
//
|
||||||
// Usage:
|
// Typical use:
|
||||||
//
|
//
|
||||||
// log := logger.New("info").Expect("create logger")
|
// log := logger.New("info").Expect("create logger")
|
||||||
// log.Info("server started")
|
// log.Info("server started", "port", 8080)
|
||||||
//
|
//
|
||||||
// req := log.WithField("request_id", rid).WithField("user_id", uid)
|
// // child logger for request-scoped fields that repeat across many lines:
|
||||||
// req.Info("handling request")
|
// req := log.WithField("request_id", rid)
|
||||||
|
// req.Info("start")
|
||||||
|
// req.Info("end")
|
||||||
|
//
|
||||||
|
// In tests that need to assert on log content, use NewWriter with a buffer:
|
||||||
|
//
|
||||||
|
// var buf bytes.Buffer
|
||||||
|
// log := logger.NewWriter(&buf, "debug").Expect("create logger")
|
||||||
|
// // ... exercise code ...
|
||||||
|
// // assert on buf.String()
|
||||||
package logger
|
package logger
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@ -41,7 +51,7 @@ func New(level string) result.Expect[*Logger] {
|
|||||||
return result.Ok(logger)
|
return result.Ok(logger)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDevelopment creates a human-friendly text logger writing to stderr.
|
// NewDevelopment creates a human-friendly text logger writing to stderr at debug level.
|
||||||
// Use this in local dev; prefer New() in any deployed environment.
|
// Use this in local dev; prefer New() in any deployed environment.
|
||||||
func NewDevelopment() *Logger {
|
func NewDevelopment() *Logger {
|
||||||
h := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})
|
h := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})
|
||||||
@ -49,6 +59,19 @@ func NewDevelopment() *Logger {
|
|||||||
return &Logger{slog.New(h)}
|
return &Logger{slog.New(h)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewWriter creates a JSON logger writing to w. Intended for tests that need to
|
||||||
|
// assert on log content — pass a *bytes.Buffer and inspect it after the fact.
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := slog.NewJSONHandler(w, &slog.HandlerOptions{Level: lvl.Value()})
|
||||||
|
|
||||||
|
return result.Ok(&Logger{slog.New(handler)})
|
||||||
|
}
|
||||||
|
|
||||||
// NewNop returns a no-op logger. Useful in tests that don't care about logs.
|
// NewNop returns a no-op logger. Useful in tests that don't care about logs.
|
||||||
func NewNop() *Logger {
|
func NewNop() *Logger {
|
||||||
return &Logger{slog.New(slog.NewTextHandler(io.Discard, nil))}
|
return &Logger{slog.New(slog.NewTextHandler(io.Discard, nil))}
|
||||||
@ -3,8 +3,8 @@ package logger_test
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.djmil.dev/go/template/internal/logger"
|
"gitea.djmil.dev/go/template/pkg/logger"
|
||||||
"gitea.djmil.dev/go/template/internal/testutil"
|
"gitea.djmil.dev/go/template/pkg/testutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNew(t *testing.T) {
|
func TestNew(t *testing.T) {
|
||||||
@ -1,87 +0,0 @@
|
|||||||
# result: Concurrency API — open design problem
|
|
||||||
|
|
||||||
## Current API
|
|
||||||
|
|
||||||
```go
|
|
||||||
Async[T](fn func() T) <-chan Expect[T]
|
|
||||||
AsyncOf[T](fn func() Expect[T]) <-chan Expect[T]
|
|
||||||
Bind[T, A](fn func(A) Expect[T], arg A) func() Expect[T]
|
|
||||||
```
|
|
||||||
|
|
||||||
Typical call site:
|
|
||||||
|
|
||||||
```go
|
|
||||||
hostCh := result.AsyncOf(result.Bind(parseHost, "localhost"))
|
|
||||||
portCh := result.AsyncOf(func() result.Expect[int] { return parsePort("8080") })
|
|
||||||
```
|
|
||||||
|
|
||||||
## The problem
|
|
||||||
|
|
||||||
Every async call requires either an explicit closure or a `Bind` call. For
|
|
||||||
single-arg library functions `Bind` works cleanly; for zero-arg and multi-arg
|
|
||||||
functions the caller must write a closure, introducing visual noise that does
|
|
||||||
not add information.
|
|
||||||
|
|
||||||
### Why `result.Async(parsePort("8080"))` does not work
|
|
||||||
|
|
||||||
`parsePort("8080")` is an eager call — it evaluates immediately on the calling
|
|
||||||
goroutine and returns an `int`. `Async` expects `func() int`. The compiler
|
|
||||||
rejects it. There is no way in Go to pass a call expression as a deferred
|
|
||||||
computation without wrapping it in a closure.
|
|
||||||
|
|
||||||
## Options explored
|
|
||||||
|
|
||||||
### 1. `AsyncBind` / `AsyncBind2` — collapse `AsyncOf` + `Bind`
|
|
||||||
|
|
||||||
```go
|
|
||||||
func AsyncBind[T, A any](fn func(A) Expect[T], a A) <-chan Expect[T]
|
|
||||||
func AsyncBind2[T, A, B any](fn func(A, B) Expect[T], a A, b B) <-chan Expect[T]
|
|
||||||
```
|
|
||||||
|
|
||||||
Reduces call site to `result.AsyncBind(parsePort, "8080")`.
|
|
||||||
**Downside:** still need `AsyncBind3`, etc. for higher arities; `Bind` itself
|
|
||||||
becomes redundant.
|
|
||||||
|
|
||||||
### 2. Higher-level combinator — `Join2`, `Join3`, …
|
|
||||||
|
|
||||||
Hides channels entirely. Runs N computations concurrently, collects results,
|
|
||||||
calls a combiner only on success:
|
|
||||||
|
|
||||||
```go
|
|
||||||
url, err := result.Join2(
|
|
||||||
result.Bind(parseHost, "localhost"),
|
|
||||||
result.Bind(parsePort, "8080"),
|
|
||||||
func(host string, port int) string {
|
|
||||||
return fmt.Sprintf("http://%s:%d", host, port)
|
|
||||||
},
|
|
||||||
).Unwrap()
|
|
||||||
```
|
|
||||||
|
|
||||||
**Upside:** no channels, no closures in application code; most declarative.
|
|
||||||
**Downside:** loses the "start goroutines, do other work, collect later"
|
|
||||||
pattern; still needs `Join2`/`Join3`/… per arity; heterogeneous type
|
|
||||||
combinations explode the generic signature.
|
|
||||||
|
|
||||||
### 3. Reflection / `any` variadic
|
|
||||||
|
|
||||||
A single `AsyncCall(fn any, args ...any)` using `reflect.Call`.
|
|
||||||
**Downside:** no type safety, runtime panics on arity/type mismatch, poor IDE
|
|
||||||
support. Not worth it.
|
|
||||||
|
|
||||||
## Root constraint
|
|
||||||
|
|
||||||
Go generics have no variadic type parameters. There is no way to write a
|
|
||||||
single type-safe function that accepts an arbitrary function and its arguments.
|
|
||||||
Every approach above hits this wall and works around it by duplicating the
|
|
||||||
function for each arity (N = 1, 2, 3, …).
|
|
||||||
|
|
||||||
## Recommended direction when revisiting
|
|
||||||
|
|
||||||
Decide first which usage pattern dominates:
|
|
||||||
|
|
||||||
- **"Fire and forget, collect later"** — keep channels visible; `AsyncBind` /
|
|
||||||
`AsyncBind2` are the minimal improvement.
|
|
||||||
- **"Run N things, combine result"** — adopt `Join2`/`Join3`; channels
|
|
||||||
disappear from application code entirely.
|
|
||||||
|
|
||||||
A mixed API (both families) is possible but adds surface area. Keep it small.
|
|
||||||
@ -38,16 +38,16 @@
|
|||||||
//
|
//
|
||||||
// # Layering rule
|
// # Layering rule
|
||||||
//
|
//
|
||||||
// The rule is simple: .Expect() is safe anywhere a boundary ([Go] or [Run])
|
// Reusable library code (packages under pkg/) must only *return* Expect[T] —
|
||||||
// owns the goroutine. In practice:
|
// it must never call .Expect(), .Must(), or .Expectf() itself. Those methods
|
||||||
|
// exit the current goroutine and are only safe inside a goroutine controlled
|
||||||
|
// by [Go] or [Run].
|
||||||
//
|
//
|
||||||
// - pkg/ functions that just compute and return: return Expect[T], let the
|
// The right split:
|
||||||
// caller decide how to handle it.
|
//
|
||||||
// - pkg/ functions that internally spawn goroutines via [Go] or [Run]: they
|
// - pkg/ functions: return Expect[T] — let the caller decide how to handle it.
|
||||||
// own those goroutines and may freely chain .Expect() inside them. From
|
// - Application code (cmd/, HTTP handlers, …): chain .Expect() calls freely,
|
||||||
// the outside they still look like normal functions returning Expect[T].
|
// protected by a defer result.Catch(&err) or a result.Run wrapper.
|
||||||
// - Application code (cmd/, HTTP handlers, …): chain .Expect() freely,
|
|
||||||
// protected by a [Run] wrapper or defer [Catch].
|
|
||||||
//
|
//
|
||||||
// Stack traces are captured at the failure site and can be retrieved from the
|
// Stack traces are captured at the failure site and can be retrieved from the
|
||||||
// collected error via [StackTrace].
|
// collected error via [StackTrace].
|
||||||
@ -85,33 +85,6 @@
|
|||||||
// runtime.Goexit which recover() cannot intercept — use [Run] or [Go] instead,
|
// runtime.Goexit which recover() cannot intercept — use [Run] or [Go] instead,
|
||||||
// as they work correctly in both builds.
|
// as they work correctly in both builds.
|
||||||
//
|
//
|
||||||
// # Concurrent pattern
|
|
||||||
//
|
|
||||||
// Combining [Async] with the boundary pattern makes concurrent code almost as
|
|
||||||
// readable as sequential code. Fire goroutines with [Async], then collect with
|
|
||||||
// [All] or by reading channels individually — failures surface as normal errors
|
|
||||||
// at the boundary, with no manual WaitGroups, mutex guards, or error channels:
|
|
||||||
//
|
|
||||||
// func fetchAll(urls []string) ([]string, error) {
|
|
||||||
// return result.Map(urls, func(url string) string {
|
|
||||||
// return fetch(url).Expect("fetch") // happy path inside the goroutine
|
|
||||||
// }).Unwrap()
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// For heterogeneous concurrent work use [AsyncOf] — it accepts functions that
|
|
||||||
// already return Expect[T] (as library functions should), so only one .Expect()
|
|
||||||
// per goroutine is needed at the collection site:
|
|
||||||
//
|
|
||||||
// func loadConfig() (Config, error) {
|
|
||||||
// hostCh := result.AsyncOf(resolveHost) // resolveHost() Expect[string]
|
|
||||||
// portCh := result.AsyncOf(resolvePort) // resolvePort() Expect[int]
|
|
||||||
// return result.Go(func() Config {
|
|
||||||
// host := (<-hostCh).Expect("resolve host")
|
|
||||||
// port := (<-portCh).Expect("resolve port")
|
|
||||||
// return Config{Host: host, Port: port}
|
|
||||||
// }).Unwrap()
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// Genuine runtime panics (nil-pointer dereferences, index out of bounds, etc.)
|
// Genuine runtime panics (nil-pointer dereferences, index out of bounds, etc.)
|
||||||
// are not recovered — they still crash the program, as they should.
|
// are not recovered — they still crash the program, as they should.
|
||||||
package result
|
package result
|
||||||
|
|||||||
@ -148,106 +148,3 @@ func Example_unwrap() {
|
|||||||
// Output:
|
// Output:
|
||||||
// 443
|
// 443
|
||||||
}
|
}
|
||||||
|
|
||||||
// Example_all shows result.All collecting values from concurrently running
|
|
||||||
// goroutines. All goroutines are started before any result is read.
|
|
||||||
func Example_all() {
|
|
||||||
aCh := result.Async(func() int { return parsePort("80").Must() })
|
|
||||||
bCh := result.Async(func() int { return parsePort("443").Must() })
|
|
||||||
cCh := result.Async(func() int { return parsePort("8080").Must() })
|
|
||||||
|
|
||||||
ports := result.All(aCh, bCh, cCh).Must()
|
|
||||||
fmt.Println(ports)
|
|
||||||
// Output:
|
|
||||||
// [80 443 8080]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Example_allError shows result.All returning on the first error (in channel
|
|
||||||
// order). Here bCh fails, so its error is returned and cCh is never read.
|
|
||||||
func Example_allError() {
|
|
||||||
aCh := result.Async(func() int { return parsePort("80").Must() })
|
|
||||||
bCh := result.Async(func() int { return parsePort("99999").Must() })
|
|
||||||
cCh := result.Async(func() int { return parsePort("8080").Must() })
|
|
||||||
|
|
||||||
if err := result.All(aCh, bCh, cCh).Err(); err != nil {
|
|
||||||
fmt.Println("failed:", err)
|
|
||||||
}
|
|
||||||
// Output:
|
|
||||||
// failed: example_test.go:25: 99999 out of range
|
|
||||||
}
|
|
||||||
|
|
||||||
// Example_map shows result.Map running a function concurrently over a slice.
|
|
||||||
// All goroutines complete and all errors are collected.
|
|
||||||
func Example_map() {
|
|
||||||
ports, err := result.Map(
|
|
||||||
[]string{"80", "443", "8080"},
|
|
||||||
func(s string) int { return parsePort(s).Must() },
|
|
||||||
).Unwrap()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println("failed:", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Println(ports)
|
|
||||||
// Output:
|
|
||||||
// [80 443 8080]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Example_mapError shows result.Map collecting all errors when multiple inputs
|
|
||||||
// fail, rather than stopping at the first.
|
|
||||||
func Example_mapError() {
|
|
||||||
_, err := result.Map(
|
|
||||||
[]string{"80", "bad", "99999"},
|
|
||||||
func(s string) int { return parsePort(s).Must() },
|
|
||||||
).Unwrap()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println("failed:", err)
|
|
||||||
}
|
|
||||||
// Output:
|
|
||||||
// failed: example_test.go:22: result.Of
|
|
||||||
// strconv.Atoi: parsing "bad": invalid syntax
|
|
||||||
// example_test.go:25: 99999 out of range
|
|
||||||
}
|
|
||||||
|
|
||||||
// Example_asyncOf shows result.AsyncOf running library functions (which return
|
|
||||||
// Expect[T]) concurrently. Only one .Expect() per goroutine is needed — at the
|
|
||||||
// collection site inside the boundary.
|
|
||||||
func Example_asyncOf() {
|
|
||||||
hostCh := result.AsyncOf(result.Bind(parseHost, "localhost"))
|
|
||||||
portCh := result.AsyncOf(func() result.Expect[int] { return parsePort("8080") })
|
|
||||||
|
|
||||||
url, err := result.Go(func() string {
|
|
||||||
host := (<-hostCh).Expect("parse host")
|
|
||||||
port := (<-portCh).Expect("parse port")
|
|
||||||
return fmt.Sprintf("http://%s:%d", host, port)
|
|
||||||
}).Unwrap()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println("failed:", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Println(url)
|
|
||||||
// Output:
|
|
||||||
// http://localhost:8080
|
|
||||||
}
|
|
||||||
|
|
||||||
// Example_asyncOfError shows result.AsyncOf propagating a failure through the
|
|
||||||
// channel — the error surfaces at the .Expect() call inside the boundary.
|
|
||||||
func Example_asyncOfError() {
|
|
||||||
hostCh := result.AsyncOf(func() result.Expect[string] { return parseHost("") })
|
|
||||||
portCh := result.AsyncOf(func() result.Expect[int] { return parsePort("8080") })
|
|
||||||
|
|
||||||
_, err := result.Go(func() string {
|
|
||||||
host := (<-hostCh).Expect("parse host")
|
|
||||||
port := (<-portCh).Expect("parse port")
|
|
||||||
return fmt.Sprintf("http://%s:%d", host, port)
|
|
||||||
}).Unwrap()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println("failed:", err)
|
|
||||||
}
|
|
||||||
// Output:
|
|
||||||
// failed: parse host
|
|
||||||
// example_test.go:13: host must not be empty
|
|
||||||
}
|
|
||||||
|
|||||||
@ -189,36 +189,6 @@ func Async[T any](fn func() T) <-chan Expect[T] {
|
|||||||
return ch
|
return ch
|
||||||
}
|
}
|
||||||
|
|
||||||
// AsyncOf is like [Async] but for functions that already return Expect[T] —
|
|
||||||
// typically library functions that follow the layering rule. This avoids the
|
|
||||||
// double .Expect() that would otherwise be needed: one inside the goroutine to
|
|
||||||
// unwrap, and one on the channel read to re-propagate.
|
|
||||||
//
|
|
||||||
// hostCh := result.AsyncOf(resolveHost) // resolveHost() Expect[string]
|
|
||||||
// portCh := result.AsyncOf(resolvePort) // resolvePort() Expect[int]
|
|
||||||
// return result.Go(func() Config {
|
|
||||||
// host := (<-hostCh).Expect("resolve host")
|
|
||||||
// port := (<-portCh).Expect("resolve port")
|
|
||||||
// return Config{Host: host, Port: port}
|
|
||||||
// }).Unwrap()
|
|
||||||
func AsyncOf[T any](fn func() Expect[T]) <-chan Expect[T] {
|
|
||||||
ch := make(chan Expect[T], 1)
|
|
||||||
go func() {
|
|
||||||
ch <- fn()
|
|
||||||
}()
|
|
||||||
return ch
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bind partially applies a single-argument function, returning a zero-argument
|
|
||||||
// closure suitable for [AsyncOf]. This hides the closure syntax when the
|
|
||||||
// library function takes one argument:
|
|
||||||
//
|
|
||||||
// hostCh := result.AsyncOf(result.Bind(parseHost, "localhost"))
|
|
||||||
// portCh := result.AsyncOf(result.Bind(parsePort, "8080"))
|
|
||||||
func Bind[T, A any](fn func(A) Expect[T], arg A) func() Expect[T] {
|
|
||||||
return func() Expect[T] { return fn(arg) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Go runs fn in a new goroutine and blocks until it completes, returning the
|
// Go runs fn in a new goroutine and blocks until it completes, returning the
|
||||||
// result as Expect[T]. It is a convenience wrapper around [Async] for the
|
// result as Expect[T]. It is a convenience wrapper around [Async] for the
|
||||||
// common single-goroutine case.
|
// common single-goroutine case.
|
||||||
@ -248,51 +218,6 @@ func Run(fn func()) error {
|
|||||||
}).Err()
|
}).Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// All waits for each channel in order and returns the collected values, or the
|
|
||||||
// first error encountered. Remaining goroutines finish normally — their
|
|
||||||
// channels are buffered so no goroutines are leaked.
|
|
||||||
//
|
|
||||||
// Failure detection is channel-order, not wall-clock: if ch[2] fails before
|
|
||||||
// ch[0] finishes, you still wait for ch[0] first. In practice this rarely
|
|
||||||
// matters — arrange channels so the ones most likely to fail come first and
|
|
||||||
// the two orderings are equivalent.
|
|
||||||
func All[T any](chs ...<-chan Expect[T]) Expect[[]T] {
|
|
||||||
out := make([]T, len(chs))
|
|
||||||
for i, ch := range chs {
|
|
||||||
r := <-ch
|
|
||||||
if r.err != nil {
|
|
||||||
return Err[[]T](r.err)
|
|
||||||
}
|
|
||||||
out[i] = r.value
|
|
||||||
}
|
|
||||||
return Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map runs fn on each input in its own goroutine, waits for all to finish,
|
|
||||||
// and returns the results in input order. Unlike [All], every goroutine runs
|
|
||||||
// to completion — all errors are collected and returned together via
|
|
||||||
// errors.Join so callers see the full failure set.
|
|
||||||
func Map[In, Out any](inputs []In, fn func(In) Out) Expect[[]Out] {
|
|
||||||
chs := make([]<-chan Expect[Out], len(inputs))
|
|
||||||
for i, in := range inputs {
|
|
||||||
chs[i] = Async(func() Out { return fn(in) })
|
|
||||||
}
|
|
||||||
out := make([]Out, len(inputs))
|
|
||||||
var errs []error
|
|
||||||
for i, ch := range chs {
|
|
||||||
r := <-ch
|
|
||||||
if r.err != nil {
|
|
||||||
errs = append(errs, r.err)
|
|
||||||
} else {
|
|
||||||
out[i] = r.value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(errs) > 0 {
|
|
||||||
return Err[[]Out](errors.Join(errs...))
|
|
||||||
}
|
|
||||||
return Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Catch recovers a panic produced by [Expect.Must] and stores it in *errp.
|
// Catch recovers a panic produced by [Expect.Must] and stores it in *errp.
|
||||||
// For normal error propagation use [Go] or [Run] instead — they collect
|
// For normal error propagation use [Go] or [Run] instead — they collect
|
||||||
// failures from [Expect.Expect] and [Expect.Expectf] regardless of build tag.
|
// failures from [Expect.Expect] and [Expect.Expectf] regardless of build tag.
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
// Package testutil provides lightweight test helpers to reduce boilerplate in
|
// Package testutil provides lightweight test helpers to reduce boilerplate in
|
||||||
// table-driven tests. Import it from any _test.go file in this module.
|
// table-driven tests.
|
||||||
//
|
//
|
||||||
// Every helper calls t.Helper() so failures are reported at the call site, not
|
// Every helper calls t.Helper() so failures are reported at the call site, not
|
||||||
// inside this package.
|
// inside this package.
|
||||||
16
rename.sh
16
rename.sh
@ -10,7 +10,7 @@
|
|||||||
# **/*.go import paths
|
# **/*.go import paths
|
||||||
# .devcontainer/devcontainer.json name field
|
# .devcontainer/devcontainer.json name field
|
||||||
# README.md heading + module path references
|
# README.md heading + module path references
|
||||||
# CLAUDE.md Module line
|
# CLAUDE.md internal import references (pkg/* preserved)
|
||||||
# .golangci.yml goimports local-prefixes
|
# .golangci.yml goimports local-prefixes
|
||||||
# git tags all template tags deleted
|
# git tags all template tags deleted
|
||||||
# git history squashed into one INIT commit
|
# git history squashed into one INIT commit
|
||||||
@ -126,17 +126,17 @@ sedi() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Helper: rename module path in a file, preserving pkg/result imports ───────
|
# ── Helper: rename module path in a file, preserving all pkg/* imports ────────
|
||||||
# pkg/result is a standalone publishable package; its import path must not
|
# pkg/ packages are standalone publishable packages from this template repo;
|
||||||
# change when the consuming project is renamed.
|
# their import paths must not change when a consuming project is renamed.
|
||||||
RESULT_PKG="${OLD_MODULE}/pkg/result"
|
PKG_BASE="${OLD_MODULE}/pkg/"
|
||||||
PLACEHOLDER="__RESULT_PKG_PLACEHOLDER__"
|
PLACEHOLDER="__TEMPLATE_PKG_BASE__"
|
||||||
|
|
||||||
rename_module_in() {
|
rename_module_in() {
|
||||||
local file="$1"
|
local file="$1"
|
||||||
sedi "s|${RESULT_PKG}|${PLACEHOLDER}|g" "$file"
|
sedi "s|${PKG_BASE}|${PLACEHOLDER}|g" "$file"
|
||||||
sedi "s|${OLD_MODULE}|${NEW_MODULE}|g" "$file"
|
sedi "s|${OLD_MODULE}|${NEW_MODULE}|g" "$file"
|
||||||
sedi "s|${PLACEHOLDER}|${RESULT_PKG}|g" "$file"
|
sedi "s|${PLACEHOLDER}|${PKG_BASE}|g" "$file"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Apply substitutions ───────────────────────────────────────────────────────
|
# ── Apply substitutions ───────────────────────────────────────────────────────
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user