Compare commits
1 Commits
main
...
concurent-
| Author | SHA1 | Date | |
|---|---|---|---|
| 386d6b548b |
@ -1,28 +0,0 @@
|
||||
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`.
|
||||
@ -1,8 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(go test *)",
|
||||
"Bash(go vet *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -52,18 +52,14 @@
|
||||
},
|
||||
|
||||
// Reuse host Claude config (auth, settings) — no re-login needed inside container.
|
||||
// Reuse host git identity so git never prompts for user.name / user.email.
|
||||
"mounts": [
|
||||
"source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind,consistency=cached",
|
||||
"source=${localEnv:HOME}/.gitconfig,target=/home/vscode/.gitconfig,type=bind,consistency=cached"
|
||||
"source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind,consistency=cached"
|
||||
],
|
||||
|
||||
// Forward the default HTTP port so `make run` is reachable from the host.
|
||||
"forwardPorts": [8080],
|
||||
|
||||
"remoteEnv": {
|
||||
"GOPRIVATE": "gitea.djmil.dev",
|
||||
// Adds ./bin/ to PATH so built binaries are runnable by name after `make build`.
|
||||
"PATH": "${containerWorkspaceFolder}/bin:${localEnv:PATH}"
|
||||
"GOPRIVATE": "gitea.djmil.dev"
|
||||
}
|
||||
}
|
||||
|
||||
8
.vscode/tasks.json
vendored
8
.vscode/tasks.json
vendored
@ -9,6 +9,14 @@
|
||||
"presentation": { "reveal": "always", "panel": "shared" },
|
||||
"problemMatcher": "$go"
|
||||
},
|
||||
{
|
||||
"label": "run",
|
||||
"type": "shell",
|
||||
"command": "make run",
|
||||
"group": "none",
|
||||
"presentation": { "reveal": "always", "panel": "dedicated" },
|
||||
"isBackground": false
|
||||
},
|
||||
{
|
||||
"label": "test",
|
||||
"type": "shell",
|
||||
|
||||
68
CLAUDE.md
68
CLAUDE.md
@ -8,46 +8,25 @@ Keep it concise — the agent needs signal, not essays.
|
||||
## Project overview
|
||||
|
||||
Go 1.25 template for PoC, hobby projects, and small publishable packages.
|
||||
Stack: structured logging (slog), config (flag), consumer-defined interfaces + manual fakes,
|
||||
result type (happy-path error handling), linting (golangci-lint), security scanning (gosec, govulncheck).
|
||||
Demonstrates: structured logging (slog), config (flag), consumer-defined interfaces + manual fakes,
|
||||
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
|
||||
`go run tool@version`) so packages published from this repo have a clean module graph for consumers.
|
||||
|
||||
---
|
||||
|
||||
## Design philosophy
|
||||
|
||||
**Form over mechanism** — call sites express *what* the code is doing, not *how* it achieves it.
|
||||
Mechanism belongs in constructors and infrastructure; intent belongs at the call site.
|
||||
When you see `fmt.Fprintln(os.Stderr, ...)` or `if err != nil { return }` scattered through
|
||||
business logic, that is mechanism leaking into intent — move it to the edges.
|
||||
|
||||
**[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.
|
||||
Module: `gitea.djmil.dev/go/template` — update this when you fork.
|
||||
|
||||
---
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
cmd/app/ CLI entrypoint: parses flags, wires dependencies, and expresses
|
||||
what the program does in high-level readable steps — calls internal/
|
||||
and pkg/ packages; no domain logic lives here
|
||||
internal/ domain-specific packages; logical grouping of substantial code into
|
||||
digestible types and interfaces; not importable outside this module
|
||||
pkg/ generic, publishable packages reusable across projects; no
|
||||
assumptions about the calling application
|
||||
cmd/app/main.go composition root — wires deps, no logic here
|
||||
internal/config/ flag-based config loader (config.Load)
|
||||
internal/logger/ slog wrapper with WithField / WithFields
|
||||
internal/greeter/ Example domain package (delete or repurpose)
|
||||
pkg/result/ Example publishable package (Result/Expect types)
|
||||
tools.versions Pinned tool versions (sourced by Makefile and pre-push hook)
|
||||
.golangci.yml Linter rules
|
||||
.githooks/pre-push Runs gofmt + go vet + golangci-lint + gosec before push
|
||||
@ -57,8 +36,8 @@ tools.versions Pinned tool versions (sourced by Makefile and pre-push
|
||||
|
||||
## Project rules
|
||||
|
||||
- **Module imports** — always use the full module path from `go.mod` (never relative imports)
|
||||
- **Packages** — `cmd/` owns CLI parsing, dependency wiring, and high-level orchestration; domain logic belongs in `internal/`
|
||||
- **Module imports** — always use the full module path `gitea.djmil.dev/go/template/...`
|
||||
- **Packages** — keep `cmd/` thin (wiring only); business 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)
|
||||
- **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
|
||||
@ -66,19 +45,10 @@ tools.versions Pinned tool versions (sourced by Makefile and pre-push
|
||||
- top-level entry points defer `result.Catch(&err)` (or use `result.Run(...)`) to convert any result exit into a normal Go error; genuine runtime panics (nil-deref, etc.) are re-panicked
|
||||
- **`result.Catch` is incompatible with `-tags result_goexit`**: it relies on `recover()` which cannot intercept `runtime.Goexit`; prefer `result.Run`/`result.Go` which work in both builds
|
||||
- bridge existing `(T, error)` stdlib/third-party calls with `result.Of(...)`: `result.Of(os.ReadFile("cfg.json")).Expect("read config")`
|
||||
- construct failures with these constructors, each a distinct intent — don't invent more:
|
||||
- `result.Ok[T](v)` / `result.Err[T](err)` — the two field constructors: a success value, or a bare error boxed verbatim (use `Err` for `return zero, err` and sentinels)
|
||||
- `result.Failf[T]("msg")` — *originate* a failure from a message; embed a cause with `%w` (`result.Failf[T]("load config: %w", err)`)
|
||||
- `result.Wrap[U](r, "msg")` — *propagate* an already-failed `Expect` into a new type `U`, optionally adding context; only valid on a failed result (panics on a success), so guard with `if r.Err() != nil`. Use this instead of pulling the error out with `r.Err()` by hand
|
||||
- use `result.StackTrace(err)` to retrieve the capture-site stack from a caught error
|
||||
- still use `fmt.Errorf("context: %w", err)` when wrapping errors *before* constructing a `result.Err`
|
||||
- **Logging** — logs go to `stderr` per 12-factor XI; human output goes to `stdout` via `fmt.Print*`.
|
||||
Use `logger.NewCLI(level, debugFile)` for CLI apps: auto-detects TTY → human text on terminal,
|
||||
JSON when piped. Use `logger.New(level)` for headless services that always want JSON.
|
||||
Passing a non-empty `debugFile` enables debug mode: INFO/WARN/ERROR on screen plus a full
|
||||
JSON DEBUG trace to file for post-run investigation.
|
||||
Use `log.WithField("key", val)` for structured context; never `fmt.Sprintf` in log messages
|
||||
- **Config** — all configuration parsed in `cmd/app/config.go` (flags); no hard-coded values in `internal/` or `pkg/` packages
|
||||
- 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
|
||||
- **Config** — all configuration through `internal/config` (flag-parsed); no hard-coded values in logic packages
|
||||
|
||||
---
|
||||
|
||||
@ -90,6 +60,7 @@ tools.versions Pinned tool versions (sourced by Makefile and pre-push
|
||||
- Constructors: `New(deps...) *Type` pattern
|
||||
- Comment every exported symbol (golangci-lint will warn if missing)
|
||||
- Max line length: 120 chars (configured in `.golangci.yml`)
|
||||
- Prefer explicit over clever; PoC code should be readable first
|
||||
|
||||
---
|
||||
|
||||
@ -99,11 +70,10 @@ tools.versions Pinned tool versions (sourced by Makefile and pre-push
|
||||
- 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`)
|
||||
- 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
|
||||
- The race detector is enabled in CI (`make test-race`); don't introduce data races
|
||||
- Never use `time.Sleep` in tests; use channels or `t.Cleanup`
|
||||
- Use `gitea.djmil.dev/go/template/pkg/testutil` helpers instead of manual checks — `ResultOk`, `ResultOkNotNil`, `ResultErr` for `result.Expect[T]`; `NoError`, `Error`, `ErrorContains`, `Equal` for plain values
|
||||
- Use `internal/testutil` helpers instead of manual checks — `ResultOk`, `ResultOkNotNil`, `ResultErr` for `result.Expect[T]`; `NoError`, `Error`, `ErrorContains`, `Equal` for plain values
|
||||
|
||||
---
|
||||
|
||||
@ -123,6 +93,9 @@ make release # list releases, or tag+push after full checks (make release
|
||||
make clean # remove bin/
|
||||
```
|
||||
|
||||
VSCode: `Ctrl+Shift+B` = build, `Ctrl+Shift+T` = test.
|
||||
Debug: use launch config "Debug: app" (F5).
|
||||
|
||||
---
|
||||
|
||||
## Adding new features (checklist)
|
||||
@ -156,6 +129,3 @@ make clean # remove bin/
|
||||
- 2026-04-01 — Replaced tools.go/go.mod pinning with tools.versions + go run tool@version; go.mod is now free of dev tool deps.
|
||||
- 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-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.
|
||||
|
||||
42
Makefile
42
Makefile
@ -1,23 +1,15 @@
|
||||
.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 lint lint-fix security docs clean
|
||||
|
||||
include tools.versions
|
||||
|
||||
# ── Variables ──────────────────────────────────────────────────────────────────
|
||||
MODULE := $(shell go list -m)
|
||||
VERSION ?= $(shell git describe --tags 2>/dev/null || echo dev)
|
||||
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||
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))
|
||||
BINARY_NAME := app
|
||||
BINARY_PATH := ./bin/$(BINARY_NAME)
|
||||
CMD_PATH := ./cmd/app
|
||||
|
||||
# ── Default target ─────────────────────────────────────────────────────────────
|
||||
help: ## Show this help message
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' Makefile | \
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
|
||||
awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' | sort
|
||||
|
||||
# ── First-time setup ───────────────────────────────────────────────────────────
|
||||
@ -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)
|
||||
|
||||
# ── 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/%
|
||||
@mkdir -p bin
|
||||
go build $(LDFLAGS) -o $@ ./$<
|
||||
# ── Run ────────────────────────────────────────────────────────────────────────
|
||||
run: ## Run the application with default flags
|
||||
go run $(CMD_PATH)/main.go
|
||||
|
||||
# ── Test ───────────────────────────────────────────────────────────────────────
|
||||
test: ## Run all tests
|
||||
@ -79,7 +72,7 @@ docs: ## Serve package documentation locally via pkgsite (http://localhost:8080)
|
||||
|
||||
# ── Release ────────────────────────────────────────────────────────────────────
|
||||
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 must be semver: v0.1.0"; exit 1)
|
||||
@git diff --quiet && git diff --cached --quiet || \
|
||||
@ -89,18 +82,7 @@ ifeq ($(origin VERSION), command line)
|
||||
$(MAKE) test-race
|
||||
$(MAKE) lint
|
||||
$(MAKE) security
|
||||
@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 tag $(VERSION)
|
||||
git push origin $(VERSION)
|
||||
else
|
||||
@echo "Released versions:"
|
||||
|
||||
255
README.md
255
README.md
@ -9,32 +9,12 @@ so packages extracted from this template can be published without module graph p
|
||||
|
||||
---
|
||||
|
||||
## Design forces
|
||||
|
||||
Two libraries in this template emerged from a recurring friction in Go:
|
||||
**mechanism leaking into intent**.
|
||||
|
||||
**`pkg/result`** exists because `if err != nil { return nil, err }` repeated on every line
|
||||
obscures what the code is actually doing. The happy path drowns in error-routing boilerplate.
|
||||
`result.Expect` moves the exit logic to the edges — constructors and entry points — so the
|
||||
body of a function reads as intent, not plumbing.
|
||||
|
||||
**`pkg/logger`** exists because `fmt.Fprintln(os.Stderr, "error: "+msg)` leaks *how* (write
|
||||
to stderr, format a string) into code that should only express *what* (something went wrong).
|
||||
The same friction appeared in format selection: choosing between human text and JSON based on
|
||||
the execution environment is mechanism, not business logic. `logger.NewCLI` hides that choice —
|
||||
the call site just says `log.Warn(...)` and gets the right output for the context.
|
||||
|
||||
The pattern: **constructors own the mechanism; call sites own the intent.**
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
| Area | Tool | Purpose |
|
||||
|---|---|---|
|
||||
| Language | Go 1.25 | Modules, toolchain directive |
|
||||
| Logging | standard `log/slog` | Auto-detects terminal vs pipe: human text or JSON; optional debug file dump |
|
||||
| Logging | standard `log/slog` | Structured JSON/text logging + `WithField` extension |
|
||||
| Config | standard `flag` | CLI flags with defaults, no config files |
|
||||
| Linting | [golangci-lint](https://golangci-lint.run) | Aggregated linters, one config file |
|
||||
| Security | [gosec](https://github.com/securego/gosec) + [govulncheck](https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck) | SAST + dependency vulnerability scan |
|
||||
@ -75,13 +55,10 @@ make tools # (optional) install tool binaries to GOPATH/bin for IDE inte
|
||||
### 3. Build and run
|
||||
|
||||
```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
|
||||
@ -92,22 +69,11 @@ make test-race # … with race detector
|
||||
make lint # go vet + golangci-lint
|
||||
make lint-fix # go fix + golangci-lint auto-fix
|
||||
make security # gosec + govulncheck
|
||||
make release # list tags
|
||||
make release VERSION=v0.1.0 # run full checks then tag+push
|
||||
make release # list tags, or run full checks then tag+push (make release VERSION=v0.1.0)
|
||||
```
|
||||
|
||||
> **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
|
||||
@ -116,17 +82,19 @@ Commands live in `.claude/commands/` and are available to anyone who clones the
|
||||
.
|
||||
├── cmd/
|
||||
│ └── app/
|
||||
│ ├── config.go # Flag parsing and Config struct (all flags live here)
|
||||
│ └── main.go # Composition root (thin — just wiring)
|
||||
├── internal/
|
||||
│ ├── config/ # flag-based config (config.Load)
|
||||
│ ├── logger/ # slog wrapper with WithField / WithFields
|
||||
│ └── greeter/ # Example domain package (replace with yours)
|
||||
├── pkg/ # Publishable packages — no app-specific assumptions
|
||||
│ ├── logger/ # slog wrapper: NewCLI, New, NewWriter, WithField/WithFields
|
||||
│ ├── result/ # Happy-path error handling (Expect[T])
|
||||
│ └── testutil/ # Test helpers (ResultOk, Equal, …)
|
||||
├── .devcontainer/ # VSCode / Codespaces container definition
|
||||
├── .githooks/ # pre-push hook (gofmt + vet + lint + gosec)
|
||||
├── .vscode/ # Launch configs, tasks, editor settings
|
||||
├── .githooks/ # pre-push hook (installed by `make setup`)
|
||||
├── .vscode/ # launch, tasks, editor settings
|
||||
├── pkg/
|
||||
│ └── result/ # Example publishable package (Result/Expect types)
|
||||
│ ├── doc.go # package-level godoc
|
||||
│ ├── result.go # implementation
|
||||
│ └── example_test.go # runnable examples (shown on pkg.go.dev)
|
||||
├── tools.versions # Pinned tool versions (Makefile + hook source this)
|
||||
├── .golangci.yml # Linter configuration
|
||||
└── Makefile # All development commands
|
||||
@ -134,10 +102,201 @@ Commands live in `.claude/commands/` and are available to anyone who clones the
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration is via CLI flags with sensible defaults:
|
||||
|
||||
```bash
|
||||
./bin/app -help
|
||||
-env string environment: dev | staging | prod (default "dev")
|
||||
-log-level string log level: debug | info | warn | error (default "info")
|
||||
-name string greeter name (default "Gopher")
|
||||
-port int listen port (default 8080)
|
||||
```
|
||||
|
||||
Override at runtime:
|
||||
|
||||
```bash
|
||||
./bin/app -port 9090 -env prod -log-level warn
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Logging
|
||||
|
||||
The logger wrapper adds `WithField` and `WithFields` for ergonomic context chaining
|
||||
on top of the standard `log/slog` package:
|
||||
|
||||
```go
|
||||
log.WithField("request_id", rid).
|
||||
WithField("user", uid).
|
||||
Info("handling request")
|
||||
|
||||
log.WithFields(map[string]any{
|
||||
"component": "greeter",
|
||||
"name": name,
|
||||
}).Debug("generating greeting")
|
||||
```
|
||||
|
||||
In production (`app.env != dev`) the output is JSON (`slog.NewJSONHandler`).
|
||||
In development, `logger.NewDevelopment()` uses the human-friendly text handler.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Tests use the standard `testing` package. Concrete types are returned from
|
||||
constructors — consumers (including tests) define their own minimal interfaces
|
||||
and satisfy them with manual fakes. No code generation required.
|
||||
|
||||
```go
|
||||
// Interface declared in the consumer (or _test.go), not in greeter package.
|
||||
type greeter interface {
|
||||
Greet(name string) result.Expect[string]
|
||||
}
|
||||
|
||||
type fakeGreeter struct {
|
||||
greetFn func(name string) result.Expect[string]
|
||||
}
|
||||
|
||||
func (f *fakeGreeter) Greet(name string) result.Expect[string] {
|
||||
return f.greetFn(name)
|
||||
}
|
||||
|
||||
func TestSomething(t *testing.T) {
|
||||
fake := &fakeGreeter{
|
||||
greetFn: func(name string) result.Expect[string] {
|
||||
return result.Ok("Hello, " + name + "!")
|
||||
},
|
||||
}
|
||||
// pass fake to the system under test
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## pkg/result — happy-path error handling
|
||||
|
||||
`pkg/result` is a small publishable package that demonstrates the pattern of
|
||||
keeping `go.mod` clean. It provides `Expect[T]`, a generic type that holds
|
||||
either a success value or an error.
|
||||
|
||||
The idea: deep call stacks write for the **happy path**, unwrapping results with
|
||||
`.Expect()` or `.Must()` (which exit the goroutine on failure). A single
|
||||
`defer result.Catch(&err)` at the entry point collects that exit as a normal
|
||||
Go error, with the stack trace captured at the original failure site.
|
||||
|
||||
### Layering rule
|
||||
|
||||
`pkg/` libraries must only **return** `Expect[T]` — never call `.Expect()` or
|
||||
`.Must()` themselves. Those methods exit the current goroutine via
|
||||
`runtime.Goexit` and are only safe inside application-layer code (`cmd/`,
|
||||
HTTP handlers) that is protected by `defer result.Catch(&err)` or
|
||||
`result.Run(...)`. Calling them inside a reusable package takes control away
|
||||
from the caller and makes the package non-composable.
|
||||
|
||||
```go
|
||||
// Wrap any (value, error) function:
|
||||
data := result.Of(os.ReadFile("config.json")).Expect("read config")
|
||||
|
||||
// Or construct explicitly:
|
||||
func parsePort(s string) result.Expect[int] {
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return result.Fail[int](fmt.Errorf("parsePort: %w", err))
|
||||
}
|
||||
return result.Ok(n)
|
||||
}
|
||||
|
||||
// Entry point catches panics from the whole call stack:
|
||||
func run() (err error) {
|
||||
defer result.Catch(&err)
|
||||
port := parsePort(cfg.Port).Expect("load config")
|
||||
_ = port // happy path continues …
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
`Catch` only intercepts error panics produced by this package. Real runtime
|
||||
panics (nil-pointer dereferences, etc.) are re-panicked so bugs are never
|
||||
silently swallowed.
|
||||
|
||||
At boundaries where you need to re-join normal Go code:
|
||||
|
||||
```go
|
||||
port, err := parsePort("443").Unwrap() // back to (T, error)
|
||||
```
|
||||
|
||||
See [`pkg/result/example_test.go`](pkg/result/example_test.go) for the
|
||||
full set of runnable examples.
|
||||
|
||||
---
|
||||
|
||||
## Git hooks
|
||||
|
||||
The pre-push hook runs **gofmt + go vet + golangci-lint + gosec** against the
|
||||
full codebase before every `git push`. Running on push (not commit) keeps the
|
||||
inner commit loop fast while still blocking bad code from reaching the remote.
|
||||
|
||||
`govulncheck` is intentionally excluded (it makes network calls and can be
|
||||
slow). Run it manually with `make security`.
|
||||
|
||||
To skip the hook in an emergency:
|
||||
|
||||
```bash
|
||||
git push --no-verify
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tool version management
|
||||
|
||||
Tool versions are pinned in `tools.versions` — a plain `KEY=value` file that
|
||||
both the `Makefile` and the pre-push hook source directly.
|
||||
|
||||
**Why not `tools.go` + `go.mod`?**
|
||||
|
||||
The conventional `//go:build tools` approach adds development tools (linters,
|
||||
scanners, debuggers) as entries in `go.mod`. This is fine for applications, but
|
||||
it's a problem when the repo is intended to publish reusable packages: consumers
|
||||
who `go get` your package see those tool dependencies in the module graph, even
|
||||
though they are never compiled into the binary. Keeping `go.mod` clean makes the
|
||||
module honest — it declares only what the package code actually needs.
|
||||
|
||||
Instead, `go run tool@version` is used in Makefile targets and the pre-push
|
||||
hook. Go caches downloaded tools in the module cache, so after the first run
|
||||
there is no extra download cost.
|
||||
|
||||
To update a tool, edit the version in `tools.versions`:
|
||||
|
||||
```
|
||||
GOLANGCI_LINT_VERSION=v1.64.8 ← change this
|
||||
```
|
||||
|
||||
Both `make lint` and the pre-push hook pick up the new version automatically.
|
||||
Run `make tools` if you also want the updated binary installed to `GOPATH/bin`
|
||||
(e.g. for IDE integration or direct CLI use).
|
||||
|
||||
---
|
||||
|
||||
## Releasing
|
||||
|
||||
```bash
|
||||
make release # list all existing tags
|
||||
make release VERSION=v0.1.0 # run full checks, then tag and push
|
||||
```
|
||||
|
||||
The release target validates the version format (semver `vX.Y.Z`), ensures the
|
||||
working tree is clean, and runs `make test-race lint security` before tagging.
|
||||
The tag is only created and pushed if every check passes.
|
||||
|
||||
---
|
||||
|
||||
## Devcontainer
|
||||
|
||||
Open this repo in VSCode and choose **"Reopen in Container"**. Run `make init` to
|
||||
configure git hooks, then `make tools` for IDE-integrated tool binaries.
|
||||
Open this repo in VSCode and choose **"Reopen in Container"** — the Go
|
||||
toolchain is installed automatically. Run `make init` to configure git hooks,
|
||||
then `make tools` to install linter/scanner binaries to `GOPATH/bin`.
|
||||
|
||||
Works with GitHub Codespaces out of the box.
|
||||
|
||||
@ -148,5 +307,5 @@ Works with GitHub Codespaces out of the box.
|
||||
- **HTTP server** — add [chi](https://github.com/go-chi/chi) or [gin](https://github.com/gin-gonic/gin)
|
||||
- **Database** — add [sqlx](https://github.com/jmoiron/sqlx) or [ent](https://entgo.io/)
|
||||
- **CI** — add a Gitea Actions / CI pipeline running `make lint test security`
|
||||
- **Docker** — add a multi-stage `Dockerfile` to publish the binary as a container image
|
||||
- **Docker** — add a multi-stage `Dockerfile` to publish the binary as a container image (separate from the devcontainer)
|
||||
- **OpenTelemetry** — add tracing with `go.opentelemetry.io/otel`
|
||||
|
||||
@ -1,62 +1,21 @@
|
||||
// main is the composition root for the application.
|
||||
// It parses config, wires dependencies into an app struct, and delegates.
|
||||
// Implementation details lives in internal/; cmd/ should be kept thin by
|
||||
// deliberately operating only with high level concepts.
|
||||
// It wires together config, logger, and domain services — nothing more.
|
||||
// Business logic lives in internal/; cmd/ is deliberately thin.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gitea.djmil.dev/go/template/internal/buildinfo"
|
||||
"gitea.djmil.dev/go/template/internal/config"
|
||||
"gitea.djmil.dev/go/template/internal/greeter"
|
||||
"gitea.djmil.dev/go/template/pkg/logger"
|
||||
"gitea.djmil.dev/go/template/internal/logger"
|
||||
"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 {
|
||||
// Open the debug writer if requested. The OS closes it on exit.
|
||||
var debugOut io.Writer
|
||||
if cfg.Logger.LogDump != "" {
|
||||
debugOut = result.Of(os.Create(cfg.Logger.LogDump)).Expect("enable logs dump") // #nosec G304 — CLI flag
|
||||
}
|
||||
|
||||
log := logger.NewCLI(cfg.Logger.Level, debugOut).Expect("create logger")
|
||||
log.Debug("config", "port", cfg.App.Port, "level", cfg.Logger.Level, "env", cfg.App.Env)
|
||||
if cfg.App.Env == "dev" {
|
||||
log.Warn("dev mode — not for production")
|
||||
}
|
||||
return &app{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
greeter: greeter.New(log),
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
conf := parseArgs()
|
||||
|
||||
if conf.Version {
|
||||
fmt.Println(buildinfo.String())
|
||||
return
|
||||
}
|
||||
|
||||
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 {
|
||||
if err := result.Run(showGreeting); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[failed] %v\n", err)
|
||||
if stack := result.StackTrace(err); stack != "" {
|
||||
fmt.Fprintf(os.Stderr, "%s\n", stack)
|
||||
@ -65,21 +24,30 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
func (a *app) run() {
|
||||
// Warm up the greeter with a few names before the real call.
|
||||
// In debug mode this produces repeated identical debug lines from the greeter,
|
||||
// demonstrating how the deduplication counter collapses them in-place.
|
||||
for _, name := range []string{"Alice", "Bob", "Carol", "Dave"} {
|
||||
a.greeter.Greet(name)
|
||||
func showGreeting() {
|
||||
// ── Config ────────────────────────────────────────────────────────────────
|
||||
cfg := config.Load()
|
||||
|
||||
// ── Logger ────────────────────────────────────────────────────────────────
|
||||
var log *logger.Logger
|
||||
if cfg.App.Env == "dev" {
|
||||
log = logger.NewDevelopment()
|
||||
} else {
|
||||
log = logger.New(cfg.Logger.Level).Expect("create logger")
|
||||
}
|
||||
|
||||
a.showGreeting(a.cfg.Greeter.Name)
|
||||
log.WithFields(map[string]any{
|
||||
"app": filepath.Base(os.Args[0]),
|
||||
"env": cfg.App.Env,
|
||||
}).Info("starting up")
|
||||
|
||||
fmt.Printf("TODO: implement listening on port %d\n", a.cfg.App.Port)
|
||||
}
|
||||
// ── Services ──────────────────────────────────────────────────────────────
|
||||
greetSvc := greeter.New(log)
|
||||
|
||||
func (a *app) showGreeting(name string) {
|
||||
msg := a.greeter.Greet(name).Expect("greeting")
|
||||
a.log.WithField("message", msg).Info("greeting complete")
|
||||
fmt.Println(msg)
|
||||
// ── 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,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)
|
||||
}
|
||||
@ -1,4 +1,13 @@
|
||||
package main
|
||||
// Package config parses application configuration from command-line flags.
|
||||
// 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 (
|
||||
"flag"
|
||||
@ -6,7 +15,6 @@ import (
|
||||
|
||||
// Config is the root configuration object. Add sub-structs as the app grows.
|
||||
type Config struct {
|
||||
Version bool
|
||||
App AppConfig
|
||||
Logger LoggerConfig
|
||||
Greeter GreeterConfig
|
||||
@ -20,8 +28,7 @@ type AppConfig struct {
|
||||
|
||||
// LoggerConfig controls logging behavior.
|
||||
type LoggerConfig struct {
|
||||
Level string // debug | info | warn | error
|
||||
LogDump string // non-empty enables debug mode: writes full JSON trace to this path
|
||||
Level string // debug | info | warn | error
|
||||
}
|
||||
|
||||
// Greeter config for internal/greeter/Service.
|
||||
@ -29,34 +36,23 @@ type GreeterConfig struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
// parseArgs parses application configuration from command-line flags.
|
||||
// Defaults are defined here; override at runtime with flags:
|
||||
//
|
||||
// ./app -port 9090 -env prod -log-level warn 2 > log.log
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// cfg := config.parseArgs()
|
||||
// fmt.Println(cfg.App.Port)
|
||||
func parseArgs() *Config {
|
||||
version := flag.Bool("version", false, "print version information and exit")
|
||||
// Load parses command-line flags and returns a Config.
|
||||
// Call this once at startup before any other flag parsing.
|
||||
func Load() *Config {
|
||||
name := flag.String("name", "Gopher", "application name")
|
||||
port := flag.Int("port", 8080, "listen port")
|
||||
env := flag.String("env", "dev", "environment: dev | staging | prod")
|
||||
level := flag.String("log-level", "info", "log level: debug | info | warn | error")
|
||||
debugLog := flag.String("log-dump", "", "write full debug trace to file (enables debug mode)")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
return &Config{
|
||||
Version: *version,
|
||||
App: AppConfig{
|
||||
Port: *port,
|
||||
Env: *env,
|
||||
},
|
||||
Logger: LoggerConfig{
|
||||
Level: *level,
|
||||
LogDump: *debugLog,
|
||||
Level: *level,
|
||||
},
|
||||
Greeter: GreeterConfig{
|
||||
Name: *name,
|
||||
@ -1,7 +1,8 @@
|
||||
// Package greeter is a minimal example domain package.
|
||||
// It demonstrates how to:
|
||||
// - define an interface (satisfied by manual fakes in tests)
|
||||
// - inject a component-scoped logger through the constructor
|
||||
// - inject dependencies (logger) through a constructor
|
||||
// - use the logger.WithField pattern
|
||||
//
|
||||
// Replace this package with your own domain logic.
|
||||
package greeter
|
||||
@ -9,7 +10,7 @@ package greeter
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.djmil.dev/go/template/pkg/logger"
|
||||
"gitea.djmil.dev/go/template/internal/logger"
|
||||
"gitea.djmil.dev/go/template/pkg/result"
|
||||
)
|
||||
|
||||
@ -20,18 +21,21 @@ type Service struct {
|
||||
|
||||
// New creates a Greeter service with the provided logger.
|
||||
func New(log *logger.Logger) *Service {
|
||||
return &Service{log: log.WithField("component", "greeter")}
|
||||
return &Service{log: log}
|
||||
}
|
||||
|
||||
// Greet returns a personalized greeting and logs the interaction.
|
||||
func (s *Service) Greet(name string) result.Expect[string] {
|
||||
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)
|
||||
|
||||
s.log.WithField("name", name).Debug("greeting generated")
|
||||
s.log.
|
||||
WithField("component", "greeter").
|
||||
WithField("name", name).
|
||||
Info("greeting generated")
|
||||
|
||||
return result.Ok(msg)
|
||||
}
|
||||
|
||||
@ -4,8 +4,8 @@ import (
|
||||
"testing"
|
||||
|
||||
"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/internal/logger"
|
||||
"gitea.djmil.dev/go/template/internal/testutil"
|
||||
"gitea.djmil.dev/go/template/pkg/result"
|
||||
)
|
||||
|
||||
@ -16,12 +16,12 @@ func TestGreet(t *testing.T) {
|
||||
|
||||
t.Run("returns personalized greeting", func(t *testing.T) {
|
||||
msg, err := svc.Greet("World").Unwrap()
|
||||
check.NoError(t, err)
|
||||
check.Equal(t, msg, "Hello, World!")
|
||||
testutil.NoError(t, err)
|
||||
testutil.Equal(t, msg, "Hello, World!")
|
||||
})
|
||||
|
||||
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()
|
||||
check.NoError(t, err)
|
||||
check.Equal(t, msg, "Hello, Alice!")
|
||||
testutil.NoError(t, err)
|
||||
testutil.Equal(t, msg, "Hello, Alice!")
|
||||
}
|
||||
|
||||
83
internal/logger/logger.go
Normal file
83
internal/logger/logger.go
Normal file
@ -0,0 +1,83 @@
|
||||
// Package logger wraps log/slog with a thin, ergonomic API.
|
||||
//
|
||||
// The key addition over raw slog is the WithField / WithFields helpers that
|
||||
// return a *Logger (not a *slog.Logger), so callers stay in the typed world
|
||||
// and can chain field attachments without importing slog directly.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// log := logger.New("info").Expect("create logger")
|
||||
// log.Info("server started")
|
||||
//
|
||||
// req := log.WithField("request_id", rid).WithField("user_id", uid)
|
||||
// req.Info("handling request")
|
||||
package logger
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"gitea.djmil.dev/go/template/pkg/result"
|
||||
)
|
||||
|
||||
// Logger is a thin wrapper around *slog.Logger.
|
||||
// All slog methods (Info, Error, Debug, Warn, …) are available via embedding.
|
||||
type Logger struct {
|
||||
*slog.Logger
|
||||
}
|
||||
|
||||
// New creates a JSON logger writing to stderr for the given level string.
|
||||
// Valid levels: debug, info, warn, error.
|
||||
func New(level string) result.Expect[*Logger] {
|
||||
lvl := parseLevel(level)
|
||||
if lvl.Err() != nil {
|
||||
return result.Errw[*Logger](lvl.Err(), "parse log level")
|
||||
}
|
||||
|
||||
handler := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: lvl.Value()})
|
||||
logger := &Logger{slog.New(handler)}
|
||||
|
||||
return result.Ok(logger)
|
||||
}
|
||||
|
||||
// NewDevelopment creates a human-friendly text logger writing to stderr.
|
||||
// Use this in local dev; prefer New() in any deployed environment.
|
||||
func NewDevelopment() *Logger {
|
||||
h := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})
|
||||
|
||||
return &Logger{slog.New(h)}
|
||||
}
|
||||
|
||||
// NewNop returns a no-op logger. Useful in tests that don't care about logs.
|
||||
func NewNop() *Logger {
|
||||
return &Logger{slog.New(slog.NewTextHandler(io.Discard, nil))}
|
||||
}
|
||||
|
||||
// WithField returns a child logger that always includes key=value in every log line.
|
||||
func (l *Logger) WithField(key string, value any) *Logger {
|
||||
return &Logger{l.Logger.With(key, value)}
|
||||
}
|
||||
|
||||
// WithFields returns a child logger enriched with every key/value in fields.
|
||||
// Prefer WithField for one or two fields; use WithFields for structured context
|
||||
// objects (e.g. attaching a request span).
|
||||
func (l *Logger) WithFields(fields map[string]any) *Logger {
|
||||
args := make([]any, 0, len(fields)*2)
|
||||
for k, v := range fields {
|
||||
args = append(args, k, v)
|
||||
}
|
||||
|
||||
return &Logger{l.Logger.With(args...)}
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func parseLevel(level string) result.Expect[slog.Level] {
|
||||
var lvl slog.Level
|
||||
if err := lvl.UnmarshalText([]byte(level)); err != nil {
|
||||
return result.Errw[slog.Level](err, "unknown level (use debug|info|warn|error)")
|
||||
}
|
||||
|
||||
return result.Ok(lvl)
|
||||
}
|
||||
33
internal/logger/logger_test.go
Normal file
33
internal/logger/logger_test.go
Normal file
@ -0,0 +1,33 @@
|
||||
package logger_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.djmil.dev/go/template/internal/logger"
|
||||
"gitea.djmil.dev/go/template/internal/testutil"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
tests := []struct {
|
||||
level string
|
||||
wantErr bool
|
||||
}{
|
||||
{level: "debug"},
|
||||
{level: "info"},
|
||||
{level: "warn"},
|
||||
{level: "error"},
|
||||
{level: "invalid", wantErr: true},
|
||||
{level: "", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.level, func(t *testing.T) {
|
||||
r := logger.New(tc.level)
|
||||
if tc.wantErr {
|
||||
testutil.ResultErr(t, r)
|
||||
return
|
||||
}
|
||||
testutil.ResultOkNotNil(t, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
75
internal/testutil/testutil.go
Normal file
75
internal/testutil/testutil.go
Normal file
@ -0,0 +1,75 @@
|
||||
// Package testutil provides lightweight test helpers to reduce boilerplate in
|
||||
// table-driven tests. Import it from any _test.go file in this module.
|
||||
//
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -1,316 +0,0 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// isTerminal reports whether f is connected to a character device (terminal).
|
||||
func isTerminal(f *os.File) bool {
|
||||
info, err := f.Stat()
|
||||
return err == nil && info.Mode()&os.ModeCharDevice != 0
|
||||
}
|
||||
|
||||
// writeState holds shared mutable display state for a humanHandler family.
|
||||
// Shared via pointer across parent and all derived handlers (WithAttrs, WithGroup)
|
||||
// so that a child-logger INFO write correctly commits a parent-started debug line.
|
||||
type writeState struct {
|
||||
mu sync.Mutex
|
||||
lastDebug string // original message text used for deduplication comparison
|
||||
lastDisplay string // display form stored for counter rewrites (msg or msg+...)
|
||||
debugN int // consecutive repeat count (0 = no pending debug line)
|
||||
pendingNL bool // true when last write was a debug line without trailing newline
|
||||
}
|
||||
|
||||
// humanHandler writes human-readable log lines to w with no timestamp.
|
||||
//
|
||||
// Two operating modes, selected by handler level at construction:
|
||||
//
|
||||
// Normal mode (level > DEBUG):
|
||||
//
|
||||
// INFO: message... "..." signals hidden structured fields (see debug file)
|
||||
// INFO: message (no suffix when no fields)
|
||||
// WARN: warning: msg: k=v, … always full fields — needs immediate visibility
|
||||
// ERROR: error: msg: k=v, … always full fields — needs immediate visibility
|
||||
//
|
||||
// Debug mode (level <= DEBUG):
|
||||
//
|
||||
// DEBUG: debug: message... fields hidden; identical consecutive messages collapse:
|
||||
// debug: message... ×N live counter updated in-place with \r
|
||||
// INFO: message: k=v, … full fields — debug mode dumps everything
|
||||
// WARN: warning: 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 {
|
||||
w io.Writer
|
||||
level slog.Level
|
||||
state *writeState // shared with all derived handlers
|
||||
attrs []slog.Attr
|
||||
prefix string
|
||||
}
|
||||
|
||||
func newHumanHandler(w io.Writer, level slog.Level) *humanHandler {
|
||||
return &humanHandler{w: w, level: level, state: &writeState{}}
|
||||
}
|
||||
|
||||
func (h *humanHandler) debugMode() bool { return h.level <= slog.LevelDebug }
|
||||
|
||||
func (h *humanHandler) Enabled(_ context.Context, level slog.Level) bool {
|
||||
return level >= h.level
|
||||
}
|
||||
|
||||
func (h *humanHandler) Handle(_ context.Context, r slog.Record) error {
|
||||
h.state.mu.Lock()
|
||||
defer h.state.mu.Unlock()
|
||||
switch {
|
||||
case r.Level < slog.LevelInfo:
|
||||
return h.handleDebug(r)
|
||||
case r.Level < slog.LevelWarn:
|
||||
return h.handleInfo(r)
|
||||
default:
|
||||
return h.handleImportant(r)
|
||||
}
|
||||
}
|
||||
|
||||
// handleDebug shows message + "..." if fields, with live deduplication counter.
|
||||
// Fields are suppressed on screen even in debug mode — they change per iteration
|
||||
// and would make the counter rewrite misleading; full values are in the debug file.
|
||||
// Assumes state.mu is held.
|
||||
func (h *humanHandler) handleDebug(r slog.Record) error {
|
||||
display := h.ellipsis(r)
|
||||
|
||||
if r.Message == h.state.lastDebug {
|
||||
h.state.debugN++
|
||||
_, err := fmt.Fprintf(h.w, "\r%s ×%d", h.state.lastDisplay, h.state.debugN)
|
||||
return err
|
||||
}
|
||||
if err := h.commitPending(); err != nil {
|
||||
return err
|
||||
}
|
||||
h.state.lastDebug = r.Message
|
||||
h.state.lastDisplay = display
|
||||
h.state.debugN = 1
|
||||
h.state.pendingNL = true
|
||||
_, err := fmt.Fprintf(h.w, "%s", display)
|
||||
return err
|
||||
}
|
||||
|
||||
// handleInfo shows the full line in debug mode; hides fields with "..." in normal mode.
|
||||
// Assumes state.mu is held.
|
||||
func (h *humanHandler) handleInfo(r slog.Record) error {
|
||||
if err := h.commitPending(); err != nil {
|
||||
return err
|
||||
}
|
||||
var line string
|
||||
if h.debugMode() {
|
||||
line = h.fullLine(r)
|
||||
} else {
|
||||
line = h.ellipsis(r)
|
||||
}
|
||||
_, err := fmt.Fprintf(h.w, "%s\n", line)
|
||||
return err
|
||||
}
|
||||
|
||||
// handleImportant always shows the full line — WARN and ERROR need immediate full visibility.
|
||||
// Assumes state.mu is held.
|
||||
func (h *humanHandler) handleImportant(r slog.Record) error {
|
||||
if err := h.commitPending(); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := fmt.Fprintf(h.w, "%s\n", h.fullLine(r))
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *humanHandler) commitPending() error {
|
||||
if h.state.pendingNL {
|
||||
h.state.pendingNL = false
|
||||
h.state.lastDebug = ""
|
||||
h.state.lastDisplay = ""
|
||||
h.state.debugN = 0
|
||||
_, err := io.WriteString(h.w, "\n")
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fullLine builds "prefix message: k=v, k=v" with all structured fields.
|
||||
func (h *humanHandler) fullLine(r slog.Record) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(humanLevel(r.Level))
|
||||
b.WriteString(r.Message)
|
||||
var parts []string
|
||||
for _, a := range h.attrs {
|
||||
collectAttr(&parts, h.prefix, a)
|
||||
}
|
||||
r.Attrs(func(a slog.Attr) bool {
|
||||
collectAttr(&parts, h.prefix, a)
|
||||
return true
|
||||
})
|
||||
if len(parts) > 0 {
|
||||
b.WriteString(": ")
|
||||
b.WriteString(strings.Join(parts, ", "))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ellipsis builds "prefix message" with "..." appended when structured fields exist.
|
||||
func (h *humanHandler) ellipsis(r slog.Record) string {
|
||||
hasFields := len(h.attrs) > 0
|
||||
if !hasFields {
|
||||
r.Attrs(func(_ slog.Attr) bool {
|
||||
hasFields = true
|
||||
return false
|
||||
})
|
||||
}
|
||||
s := humanLevel(r.Level) + r.Message
|
||||
if hasFields {
|
||||
s += "..."
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// flush commits any pending debug line by writing the trailing newline.
|
||||
// Called via Logger.Close() before process exit.
|
||||
func (h *humanHandler) flush() {
|
||||
h.state.mu.Lock()
|
||||
defer h.state.mu.Unlock()
|
||||
if h.state.pendingNL {
|
||||
h.state.pendingNL = false
|
||||
_, _ = io.WriteString(h.w, "\n")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *humanHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
merged := make([]slog.Attr, len(h.attrs)+len(attrs))
|
||||
copy(merged, h.attrs)
|
||||
copy(merged[len(h.attrs):], attrs)
|
||||
return &humanHandler{w: h.w, level: h.level, state: h.state, attrs: merged, prefix: h.prefix}
|
||||
}
|
||||
|
||||
func (h *humanHandler) WithGroup(name string) slog.Handler {
|
||||
if name == "" {
|
||||
return h
|
||||
}
|
||||
prefix := h.prefix
|
||||
if prefix != "" {
|
||||
prefix += "."
|
||||
}
|
||||
return &humanHandler{w: h.w, level: h.level, state: h.state, attrs: h.attrs, prefix: prefix + name}
|
||||
}
|
||||
|
||||
// multiHandler fans a single log record out to multiple handlers.
|
||||
// Each sub-handler's own Enabled filter is respected independently.
|
||||
type multiHandler []slog.Handler
|
||||
|
||||
func (m multiHandler) Enabled(ctx context.Context, level slog.Level) bool {
|
||||
for _, h := range m {
|
||||
if h.Enabled(ctx, level) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m multiHandler) Handle(ctx context.Context, r slog.Record) error {
|
||||
var firstErr error
|
||||
for _, h := range m {
|
||||
if h.Enabled(ctx, r.Level) {
|
||||
if err := h.Handle(ctx, r); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
func (m multiHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
handlers := make(multiHandler, len(m))
|
||||
for i, h := range m {
|
||||
handlers[i] = h.WithAttrs(attrs)
|
||||
}
|
||||
return handlers
|
||||
}
|
||||
|
||||
func (m multiHandler) WithGroup(name string) slog.Handler {
|
||||
handlers := make(multiHandler, len(m))
|
||||
for i, h := range m {
|
||||
handlers[i] = h.WithGroup(name)
|
||||
}
|
||||
return handlers
|
||||
}
|
||||
|
||||
func (m multiHandler) flush() {
|
||||
for _, h := range m {
|
||||
if f, ok := h.(interface{ flush() error }); ok {
|
||||
_ = f.flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── formatting helpers ────────────────────────────────────────────────────────
|
||||
|
||||
func humanLevel(level slog.Level) string {
|
||||
switch {
|
||||
case level >= slog.LevelError:
|
||||
return "error: "
|
||||
case level >= slog.LevelWarn:
|
||||
return "warning: "
|
||||
case level >= slog.LevelInfo:
|
||||
return "" // INFO needs no label — it is the program's normal voice
|
||||
default:
|
||||
return "debug: "
|
||||
}
|
||||
}
|
||||
|
||||
func collectAttr(out *[]string, prefix string, a slog.Attr) {
|
||||
a.Value = a.Value.Resolve()
|
||||
if a.Value.Kind() == slog.KindGroup {
|
||||
sub := prefix
|
||||
if a.Key != "" {
|
||||
if sub != "" {
|
||||
sub += "."
|
||||
}
|
||||
sub += a.Key
|
||||
}
|
||||
for _, ga := range a.Value.Group() {
|
||||
collectAttr(out, sub, ga)
|
||||
}
|
||||
return
|
||||
}
|
||||
if a.Key == "" {
|
||||
return
|
||||
}
|
||||
key := a.Key
|
||||
if prefix != "" {
|
||||
key = prefix + "." + key
|
||||
}
|
||||
var vb strings.Builder
|
||||
appendValue(&vb, a.Value)
|
||||
*out = append(*out, key+"="+vb.String())
|
||||
}
|
||||
|
||||
func appendValue(b *strings.Builder, v slog.Value) {
|
||||
switch v.Kind() {
|
||||
case slog.KindString:
|
||||
s := v.String()
|
||||
if strings.ContainsAny(s, " \t\n\"=") {
|
||||
b.WriteString(strconv.Quote(s))
|
||||
} else {
|
||||
b.WriteString(s)
|
||||
}
|
||||
case slog.KindTime:
|
||||
b.WriteString(v.Time().Format(time.RFC3339))
|
||||
default:
|
||||
fmt.Fprint(b, v.Any())
|
||||
}
|
||||
}
|
||||
@ -1,243 +0,0 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ── humanLevel ────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestHumanLevel(t *testing.T) {
|
||||
tests := []struct {
|
||||
level slog.Level
|
||||
want string
|
||||
}{
|
||||
{slog.LevelDebug, "debug: "},
|
||||
{slog.LevelInfo, ""},
|
||||
{slog.LevelWarn, "warning: "},
|
||||
{slog.LevelError, "error: "},
|
||||
{slog.LevelError + 4, "error: "},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := humanLevel(tc.level); got != tc.want {
|
||||
t.Errorf("humanLevel(%v) = %q, want %q", tc.level, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── INFO normal mode ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestInfoNoFieldsNormalMode(t *testing.T) {
|
||||
var buf strings.Builder
|
||||
slog.New(newHumanHandler(&buf, slog.LevelInfo)).Info("hello world")
|
||||
|
||||
if got := strings.TrimRight(buf.String(), "\n"); got != "hello world" {
|
||||
t.Errorf("got %q, want %q", got, "hello world")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInfoFieldsHiddenNormalMode(t *testing.T) {
|
||||
var buf strings.Builder
|
||||
slog.New(newHumanHandler(&buf, slog.LevelInfo)).Info("starting", "port", 8080)
|
||||
|
||||
got := strings.TrimRight(buf.String(), "\n")
|
||||
if got != "starting..." {
|
||||
t.Errorf("got %q, want %q", got, "starting...")
|
||||
}
|
||||
}
|
||||
|
||||
// ── INFO debug mode ───────────────────────────────────────────────────────────
|
||||
|
||||
func TestInfoFieldsVisibleDebugMode(t *testing.T) {
|
||||
var buf strings.Builder
|
||||
slog.New(newHumanHandler(&buf, slog.LevelDebug)).Info("started", "port", 8080)
|
||||
|
||||
got := strings.TrimRight(buf.String(), "\n")
|
||||
if got != "started: port=8080" {
|
||||
t.Errorf("got %q, want %q", got, "started: port=8080")
|
||||
}
|
||||
}
|
||||
|
||||
// ── WARN / ERROR always full fields ──────────────────────────────────────────
|
||||
|
||||
func TestWarnFullFieldsNormalMode(t *testing.T) {
|
||||
var buf strings.Builder
|
||||
slog.New(newHumanHandler(&buf, slog.LevelInfo)).Warn("check", "key", "val")
|
||||
|
||||
if !strings.HasPrefix(buf.String(), "warning: ") {
|
||||
t.Errorf("missing prefix: %q", buf.String())
|
||||
}
|
||||
if !strings.Contains(buf.String(), "key=val") {
|
||||
t.Errorf("fields missing from WARN: %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorFullFieldsNormalMode(t *testing.T) {
|
||||
var buf strings.Builder
|
||||
slog.New(newHumanHandler(&buf, slog.LevelInfo)).Error("failed", "host", "db:5432")
|
||||
|
||||
if !strings.HasPrefix(buf.String(), "error: ") {
|
||||
t.Errorf("missing prefix: %q", buf.String())
|
||||
}
|
||||
if !strings.Contains(buf.String(), "host=db:5432") {
|
||||
t.Errorf("fields missing from ERROR: %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── DEBUG deduplication ───────────────────────────────────────────────────────
|
||||
|
||||
func TestDebugNoFields(t *testing.T) {
|
||||
var buf strings.Builder
|
||||
h := newHumanHandler(&buf, slog.LevelDebug)
|
||||
slog.New(h).Debug("step")
|
||||
h.flush()
|
||||
|
||||
if got := strings.TrimRight(buf.String(), "\n"); got != "debug: step" {
|
||||
t.Errorf("got %q, want %q", got, "debug: step")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebugFieldsHiddenWithEllipsis(t *testing.T) {
|
||||
var buf strings.Builder
|
||||
h := newHumanHandler(&buf, slog.LevelDebug)
|
||||
slog.New(h).Debug("step", "n", 1)
|
||||
h.flush()
|
||||
|
||||
if got := strings.TrimRight(buf.String(), "\n"); got != "debug: step..." {
|
||||
t.Errorf("got %q, want %q", got, "debug: step...")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebugDedupCounter(t *testing.T) {
|
||||
var buf strings.Builder
|
||||
h := newHumanHandler(&buf, slog.LevelDebug)
|
||||
log := slog.New(h)
|
||||
|
||||
log.Debug("processing", "n", 1)
|
||||
log.Debug("processing", "n", 2)
|
||||
log.Debug("processing", "n", 3)
|
||||
log.Info("done") // commits the debug line
|
||||
|
||||
got := buf.String()
|
||||
if !strings.Contains(got, "×3") {
|
||||
t.Errorf("missing counter ×3: %q", got)
|
||||
}
|
||||
if !strings.HasSuffix(got, "done\n") {
|
||||
t.Errorf("should end with done\\n: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebugDedupCounterNotShownForSingle(t *testing.T) {
|
||||
var buf strings.Builder
|
||||
h := newHumanHandler(&buf, slog.LevelDebug)
|
||||
slog.New(h).Debug("step")
|
||||
h.flush()
|
||||
|
||||
if strings.Contains(buf.String(), "×") {
|
||||
t.Errorf("counter should not appear for single occurrence: %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebugCommittedByInfo(t *testing.T) {
|
||||
var buf strings.Builder
|
||||
h := newHumanHandler(&buf, slog.LevelDebug)
|
||||
log := slog.New(h)
|
||||
|
||||
log.Debug("step")
|
||||
log.Info("phase complete")
|
||||
|
||||
got := buf.String()
|
||||
// debug line committed (newline written) before INFO
|
||||
if !strings.Contains(got, "debug: step\n") {
|
||||
t.Errorf("debug line should be followed by newline: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "phase complete\n") {
|
||||
t.Errorf("info line should appear: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebugDifferentMessageResetsCounter(t *testing.T) {
|
||||
var buf strings.Builder
|
||||
h := newHumanHandler(&buf, slog.LevelDebug)
|
||||
log := slog.New(h)
|
||||
|
||||
log.Debug("alpha")
|
||||
log.Debug("beta") // different message — commits alpha, starts beta
|
||||
h.flush()
|
||||
|
||||
got := buf.String()
|
||||
if !strings.Contains(got, "alpha\n") {
|
||||
t.Errorf("alpha should be committed with newline: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "beta") {
|
||||
t.Errorf("beta should appear: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── WithAttrs ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestWithAttrsVisibleOnWarn(t *testing.T) {
|
||||
var buf strings.Builder
|
||||
h := newHumanHandler(&buf, slog.LevelInfo).WithAttrs([]slog.Attr{slog.String("rid", "abc")})
|
||||
slog.New(h).Warn("check")
|
||||
|
||||
if !strings.Contains(buf.String(), "rid=abc") {
|
||||
t.Errorf("pre-set attr missing from WARN: %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithAttrsHiddenOnInfoNormalMode(t *testing.T) {
|
||||
var buf strings.Builder
|
||||
h := newHumanHandler(&buf, slog.LevelInfo).WithAttrs([]slog.Attr{slog.String("rid", "abc")})
|
||||
slog.New(h).Info("request")
|
||||
|
||||
if strings.Contains(buf.String(), "rid") {
|
||||
t.Errorf("pre-set attrs should be hidden on INFO in normal mode: %q", buf.String())
|
||||
}
|
||||
if !strings.Contains(buf.String(), "...") {
|
||||
t.Errorf("ellipsis should signal hidden attrs: %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Level filtering ───────────────────────────────────────────────────────────
|
||||
|
||||
func TestLevelFilteringAtWarn(t *testing.T) {
|
||||
var buf strings.Builder
|
||||
log := slog.New(newHumanHandler(&buf, slog.LevelWarn))
|
||||
log.Info("dropped")
|
||||
log.Warn("kept")
|
||||
|
||||
if strings.Contains(buf.String(), "dropped") {
|
||||
t.Error("INFO should be filtered at WARN level")
|
||||
}
|
||||
if !strings.Contains(buf.String(), "kept") {
|
||||
t.Error("WARN should appear")
|
||||
}
|
||||
}
|
||||
|
||||
// ── multiHandler fan-out ──────────────────────────────────────────────────────
|
||||
|
||||
func TestMultiHandlerFanOut(t *testing.T) {
|
||||
var screen, file strings.Builder
|
||||
m := multiHandler{
|
||||
newHumanHandler(&screen, slog.LevelInfo),
|
||||
slog.NewJSONHandler(&file, &slog.HandlerOptions{Level: slog.LevelDebug}),
|
||||
}
|
||||
log := slog.New(m)
|
||||
log.Debug("trace", "k", "v")
|
||||
log.Info("event", "k", "v")
|
||||
|
||||
if strings.Contains(screen.String(), "trace") {
|
||||
t.Error("DEBUG should not appear on screen (handler level is INFO)")
|
||||
}
|
||||
if !strings.Contains(screen.String(), "event") {
|
||||
t.Error("INFO should appear on screen")
|
||||
}
|
||||
if !strings.Contains(file.String(), "trace") {
|
||||
t.Error("DEBUG should appear in file")
|
||||
}
|
||||
if !strings.Contains(file.String(), "event") {
|
||||
t.Error("INFO should appear in file")
|
||||
}
|
||||
}
|
||||
@ -1,159 +0,0 @@
|
||||
// Package logger wraps log/slog with a thin, ergonomic API.
|
||||
//
|
||||
// Design principle: call sites express what is logged, not how it reaches the
|
||||
// reader. The constructor picks the right format for the execution environment;
|
||||
// application code never changes.
|
||||
//
|
||||
// Per the Twelve-Factor App (factor XI), logs are written to stderr as an
|
||||
// unbuffered stream. The execution environment (shell, systemd, Docker, k8s)
|
||||
// routes and stores the stream.
|
||||
//
|
||||
// Two constructors write to stderr:
|
||||
//
|
||||
// - New(level) — JSON, 12-factor compatible; use for headless services.
|
||||
// - NewCLI(level, debugOut) — auto-detects: human text on a terminal, JSON when piped.
|
||||
// Passing a non-nil debugOut enables debug mode: screen shows debug messages
|
||||
// and the writer receives a full JSON trace. Any io.Writer is accepted —
|
||||
// a file, a bytes.Buffer, os.Stdout, a network connection. The caller owns
|
||||
// the writer and is responsible for closing it.
|
||||
//
|
||||
// Typical CLI use:
|
||||
//
|
||||
// // Normal mode:
|
||||
// log := logger.NewCLI("info", nil).Expect("create logger")
|
||||
//
|
||||
// // Debug mode — full trace to a file:
|
||||
// f, err := os.Create(path) // caller controls the file; #nosec G304 if path is a CLI flag
|
||||
// if err != nil { ... }
|
||||
// defer f.Close()
|
||||
// log := logger.NewCLI("info", f).Expect("create logger")
|
||||
//
|
||||
// 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")
|
||||
package logger
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"gitea.djmil.dev/go/template/pkg/result"
|
||||
)
|
||||
|
||||
// Logger is a thin wrapper around *slog.Logger.
|
||||
// All slog methods (Info, Error, Debug, Warn, …) are available via embedding.
|
||||
type Logger struct {
|
||||
*slog.Logger
|
||||
}
|
||||
|
||||
// New creates a JSON logger writing to stderr for the given level string.
|
||||
// Use for headless services; prefer NewCLI for programs invoked by a human.
|
||||
// Valid levels: debug, info, warn, error.
|
||||
func New(level string) result.Expect[*Logger] {
|
||||
lvl := parseLevel(level)
|
||||
if lvl.Err() != nil {
|
||||
return result.Wrap[*Logger](lvl, "parse log level")
|
||||
}
|
||||
|
||||
h := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: lvl.Value()})
|
||||
return result.Ok(&Logger{slog.New(h)})
|
||||
}
|
||||
|
||||
// NewCLI creates a logger for programs invoked by a human operator.
|
||||
//
|
||||
// When stderr is a terminal it operates in one of two modes:
|
||||
// - Normal (debugOut == nil): human-readable text, INFO and above on screen.
|
||||
// - Debug (debugOut != nil): same screen output, plus full JSON trace written
|
||||
// to debugOut. level="debug" also activates debug mode without a writer.
|
||||
//
|
||||
// When stderr is not a terminal (piped or redirected), NewCLI behaves like
|
||||
// New: JSON at the given level to stderr; debugOut is ignored.
|
||||
//
|
||||
// The caller owns debugOut and is responsible for closing it when done.
|
||||
// Valid levels: debug, info, warn, error.
|
||||
func NewCLI(level string, debugOut io.Writer) result.Expect[*Logger] {
|
||||
lvl := parseLevel(level)
|
||||
if lvl.Err() != nil {
|
||||
return result.Wrap[*Logger](lvl, "parse log level")
|
||||
}
|
||||
|
||||
if !isTerminal(os.Stderr) {
|
||||
h := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: lvl.Value()})
|
||||
return result.Ok(&Logger{slog.New(h)})
|
||||
}
|
||||
|
||||
// debugOut or level="debug" activates debug mode.
|
||||
screenLevel := slog.LevelInfo
|
||||
if debugOut != nil || lvl.Value() <= slog.LevelDebug {
|
||||
screenLevel = slog.LevelDebug
|
||||
}
|
||||
screen := newHumanHandler(os.Stderr, screenLevel)
|
||||
|
||||
var h slog.Handler = screen
|
||||
if debugOut != nil {
|
||||
dump := slog.NewJSONHandler(debugOut, &slog.HandlerOptions{Level: slog.LevelDebug})
|
||||
h = multiHandler{screen, dump}
|
||||
}
|
||||
|
||||
return result.Ok(&Logger{slog.New(h)})
|
||||
}
|
||||
|
||||
// IsInteractive reports whether stderr is attached to a terminal.
|
||||
// Use this when the application itself needs to adjust behavior based on
|
||||
// whether a human operator is watching (e.g. progress bars, prompts).
|
||||
func IsInteractive() bool {
|
||||
return isTerminal(os.Stderr)
|
||||
}
|
||||
|
||||
// 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.Wrap[*Logger](lvl, "parse log level")
|
||||
}
|
||||
|
||||
h := slog.NewJSONHandler(w, &slog.HandlerOptions{Level: lvl.Value()})
|
||||
return result.Ok(&Logger{slog.New(h)})
|
||||
}
|
||||
|
||||
// Flush writes any pending debug output. Only needed if a debug loop is the
|
||||
// very last operation before exit with no subsequent INFO/WARN/ERROR record.
|
||||
// Most programs never need this — an INFO at the end commits the line naturally.
|
||||
func (l *Logger) Flush() {
|
||||
type flusher interface{ flush() }
|
||||
if f, ok := l.Handler().(flusher); ok {
|
||||
f.flush()
|
||||
}
|
||||
}
|
||||
|
||||
// NewNop returns a no-op logger. Useful in tests that don't care about logs.
|
||||
func NewNop() *Logger {
|
||||
return &Logger{slog.New(slog.NewTextHandler(io.Discard, nil))}
|
||||
}
|
||||
|
||||
// WithField returns a child logger that always includes key=value in every log line.
|
||||
func (l *Logger) WithField(key string, value any) *Logger {
|
||||
return &Logger{l.Logger.With(key, value)}
|
||||
}
|
||||
|
||||
// WithFields returns a child logger enriched with every key/value in fields.
|
||||
// Prefer WithField for one or two fields; use WithFields for structured context
|
||||
// objects (e.g. attaching a request span).
|
||||
func (l *Logger) WithFields(fields map[string]any) *Logger {
|
||||
args := make([]any, 0, len(fields)*2)
|
||||
for k, v := range fields {
|
||||
args = append(args, k, v)
|
||||
}
|
||||
return &Logger{l.Logger.With(args...)}
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func parseLevel(level string) result.Expect[slog.Level] {
|
||||
var lvl slog.Level
|
||||
err := lvl.UnmarshalText([]byte(level))
|
||||
return result.Of(lvl, err)
|
||||
}
|
||||
@ -1,58 +0,0 @@
|
||||
package logger_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.djmil.dev/go/template/pkg/check"
|
||||
"gitea.djmil.dev/go/template/pkg/logger"
|
||||
)
|
||||
|
||||
func TestNewCLI(t *testing.T) {
|
||||
tests := []struct {
|
||||
level string
|
||||
wantErr bool
|
||||
}{
|
||||
{level: "debug"},
|
||||
{level: "info"},
|
||||
{level: "warn"},
|
||||
{level: "error"},
|
||||
{level: "invalid", wantErr: true},
|
||||
{level: "", wantErr: true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.level, func(t *testing.T) {
|
||||
// In tests stderr is not a terminal — NewCLI uses JSON path.
|
||||
r := logger.NewCLI(tc.level, nil)
|
||||
if tc.wantErr {
|
||||
check.Error(t, r)
|
||||
return
|
||||
}
|
||||
check.OkNotNil(t, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
tests := []struct {
|
||||
level string
|
||||
wantErr bool
|
||||
}{
|
||||
{level: "debug"},
|
||||
{level: "info"},
|
||||
{level: "warn"},
|
||||
{level: "error"},
|
||||
{level: "invalid", wantErr: true},
|
||||
{level: "", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.level, func(t *testing.T) {
|
||||
r := logger.New(tc.level)
|
||||
if tc.wantErr {
|
||||
check.Error(t, r)
|
||||
return
|
||||
}
|
||||
check.OkNotNil(t, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
87
pkg/result/TODO.md
Normal file
87
pkg/result/TODO.md
Normal file
@ -0,0 +1,87 @@
|
||||
# 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.
|
||||
@ -252,7 +252,7 @@ func r_parseHeader3(raw string) result.Expect[bHeader] {
|
||||
}
|
||||
parts := strings.SplitN(raw, "|", 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]})
|
||||
}
|
||||
@ -273,10 +273,10 @@ func r_validate4(h bHeader) result.Expect[bFields] { return r_validate5(h) }
|
||||
func r_validate5(h bHeader) result.Expect[bFields] {
|
||||
id, err := strconv.Atoi(h.id)
|
||||
if err != nil {
|
||||
return result.Failf[bFields]("parse id %q: %w", h.id, err)
|
||||
return result.Errf[bFields]("parse id %q: %w", h.id, err)
|
||||
}
|
||||
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})
|
||||
}
|
||||
@ -297,7 +297,7 @@ func r_transform4(f bFields) result.Expect[bRecord] { return r_transform5(f) }
|
||||
func r_transform5(f bFields) result.Expect[bRecord] {
|
||||
v, err := strconv.ParseFloat(f.val, 64)
|
||||
if err != nil {
|
||||
return result.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})
|
||||
}
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
//
|
||||
// func parseHost(s string) result.Expect[string] {
|
||||
// if s == "" {
|
||||
// return result.Failf[string]("host must not be empty")
|
||||
// return result.Errf[string]("host must not be empty")
|
||||
// }
|
||||
// return result.Ok(s)
|
||||
// }
|
||||
@ -38,26 +38,24 @@
|
||||
//
|
||||
// # Layering rule
|
||||
//
|
||||
// Reusable library code (packages under pkg/) must only *return* Expect[T] —
|
||||
// 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].
|
||||
// The rule is simple: .Expect() is safe anywhere a boundary ([Go] or [Run])
|
||||
// owns the goroutine. In practice:
|
||||
//
|
||||
// The right split:
|
||||
//
|
||||
// - pkg/ functions: return Expect[T] — let the caller decide how to handle it.
|
||||
// - Application code (cmd/, HTTP handlers, …): chain .Expect() calls freely,
|
||||
// protected by a defer result.Catch(&err) or a result.Run wrapper.
|
||||
// - pkg/ functions that just compute and return: return Expect[T], let the
|
||||
// caller decide how to handle it.
|
||||
// - pkg/ functions that internally spawn goroutines via [Go] or [Run]: they
|
||||
// own those goroutines and may freely chain .Expect() inside them. From
|
||||
// the outside they still look like normal functions returning Expect[T].
|
||||
// - 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
|
||||
// collected error via [StackTrace].
|
||||
//
|
||||
// # Constructors
|
||||
//
|
||||
// [Ok] and [Err] are the two field constructors (a success value or a bare
|
||||
// error). On top of them, [Failf] originates a failure from a message (embed a
|
||||
// cause with %w), [Wrap] propagates an already-failed result into a new type,
|
||||
// and [Of] bridges existing (value, error) return signatures:
|
||||
// Use [Ok] to wrap a success value, [Err] / [Errf] / [Errw] to wrap errors,
|
||||
// and [Of] to bridge existing (value, error) return signatures:
|
||||
//
|
||||
// data := result.Of(os.ReadFile("cfg.json")).Expect("read config")
|
||||
//
|
||||
@ -87,6 +85,33 @@
|
||||
// runtime.Goexit which recover() cannot intercept — use [Run] or [Go] instead,
|
||||
// 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.)
|
||||
// are not recovered — they still crash the program, as they should.
|
||||
package result
|
||||
|
||||
@ -10,7 +10,7 @@ import (
|
||||
// parseHost is an example of a simple utility function, that validates a hostname.
|
||||
func parseHost(s string) result.Expect[string] {
|
||||
if s == "" {
|
||||
return result.Failf[string]("host must not be empty")
|
||||
return result.Errf[string]("host must not be empty")
|
||||
}
|
||||
return result.Ok(s)
|
||||
}
|
||||
@ -19,10 +19,10 @@ func parseHost(s string) result.Expect[string] {
|
||||
func parsePort(s string) result.Expect[int] {
|
||||
port := result.Of(strconv.Atoi(s))
|
||||
if port.Err() != nil {
|
||||
return result.Wrap[int](port, "result.Of")
|
||||
return result.Errw[int](port.Err(), "result.Of")
|
||||
}
|
||||
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
|
||||
}
|
||||
@ -149,24 +149,105 @@ func Example_unwrap() {
|
||||
// 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()))
|
||||
}
|
||||
// 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() })
|
||||
|
||||
if r := buildAddr("99999"); r.Err() != nil {
|
||||
fmt.Println("failed:", r.Err())
|
||||
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:161: build address
|
||||
// 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
|
||||
}
|
||||
|
||||
@ -16,7 +16,7 @@ import (
|
||||
// without a subprocess, so it is only documented here.
|
||||
func TestMustCollected(t *testing.T) {
|
||||
err := result.Run(func() {
|
||||
result.Failf[int]("unrecoverable").Must()
|
||||
result.Errf[int]("unrecoverable").Must()
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
|
||||
@ -49,63 +49,32 @@ func Ok[T any](v T) Expect[T] {
|
||||
return Expect[T]{value: v}
|
||||
}
|
||||
|
||||
// Err wraps an error in an Expect — the failure-side counterpart to [Ok],
|
||||
// setting the error field verbatim with no added location or message. Use it to
|
||||
// pass an error along unchanged, including a package-level sentinel:
|
||||
//
|
||||
// return result.Err[T](err)
|
||||
// return result.Err[bHeader](ErrEmpty)
|
||||
//
|
||||
// To build a failure from a message (optionally wrapping a cause with %w), use
|
||||
// [Failf]; to carry an already-failed result into a new type, use [Wrap].
|
||||
// Err wraps an error in an Expect.
|
||||
func Err[T any](err error) Expect[T] {
|
||||
return Expect[T]{err: err}
|
||||
}
|
||||
|
||||
// Failf originates a failure from a formatted message. Where [Err] boxes an
|
||||
// existing error verbatim, Failf builds a new one — optionally embedding a
|
||||
// cause with %w to preserve the errors.Is/As chain:
|
||||
//
|
||||
// return result.Failf[string]("host must not be empty")
|
||||
// return result.Failf[T]("create dir %q: %w", dir, err)
|
||||
//
|
||||
// The caller's file and line are prepended automatically. To propagate an
|
||||
// already-failed result, use [Wrap] instead of pulling its error out by hand.
|
||||
func Failf[T any](format string, args ...any) Expect[T] {
|
||||
// Errf wraps a formatted error in an Expect. It is a convenience shorthand
|
||||
// for [Err][fmt.Errorf(format, args...)]. The caller's file and line are
|
||||
// prepended to the error message automatically.
|
||||
func Errf[T any](format string, args ...any) Expect[T] {
|
||||
_, file, line, _ := runtime.Caller(1)
|
||||
loc := fmt.Sprintf("%s:%d", filepath.Base(file), line)
|
||||
return Expect[T]{err: fmt.Errorf(loc+": "+format, args...)}
|
||||
}
|
||||
|
||||
// Wrap propagates a failed Expect[T] as an Expect[U], optionally annotating it
|
||||
// with a context message. It carries a failure across the type boundary between
|
||||
// two result-returning functions without the unwrap-then-rebuild dance of
|
||||
// reaching into [Expect.Err] by hand.
|
||||
// Errw wraps an existing error with a context message, following the standard
|
||||
// Go error-propagation convention (errors.Is/As chain is preserved). Each
|
||||
// wrapping level is placed on its own line so the full error reads as a
|
||||
// top-down trace: outermost context first, root cause last. The caller's file
|
||||
// and line are prepended automatically.
|
||||
//
|
||||
// Wrap is meaningful only on a failed result — a successful r holds a T whose
|
||||
// value cannot be retyped to U — so guard it with the usual error check:
|
||||
//
|
||||
// lvl := parseLevel(level)
|
||||
// if lvl.Err() != nil {
|
||||
// return result.Wrap[*Logger](lvl, "parse log level")
|
||||
// }
|
||||
//
|
||||
// With no message Wrap retypes the failure verbatim. With a message (optionally
|
||||
// fmt.Sprintf-formatted) it follows Go's error-propagation convention: the
|
||||
// errors.Is/As chain is preserved and the caller's file and line are prepended,
|
||||
// each wrapping level on its own line so the error reads as a top-down trace.
|
||||
//
|
||||
// Calling Wrap on a successful r is a programmer error and panics.
|
||||
func Wrap[U, T any](r Expect[T], msgArgs ...any) Expect[U] {
|
||||
if r.err == nil {
|
||||
panic("result.Wrap called on a successful Expect")
|
||||
}
|
||||
if len(msgArgs) == 0 {
|
||||
return Expect[U]{err: r.err}
|
||||
}
|
||||
// main.go:42: load config
|
||||
// logger.go:35: parse log level
|
||||
// strconv.Atoi: parsing "x": invalid syntax
|
||||
func Errw[T any](err error, format string, args ...any) Expect[T] {
|
||||
_, file, line, _ := runtime.Caller(1)
|
||||
msg := fmt.Sprintf(msgArgs[0].(string), msgArgs[1:]...)
|
||||
return Expect[U]{err: fmt.Errorf("%s:%d: %s\n%w", filepath.Base(file), line, msg, r.err)}
|
||||
return Expect[T]{err: fmt.Errorf("%s:%d: %s\n%w", filepath.Base(file), line, fmt.Sprintf(format, args...), err)}
|
||||
}
|
||||
|
||||
// Of is a convenience constructor that bridges standard Go (value, error)
|
||||
@ -150,18 +119,7 @@ func (r Expect[T]) Unwrap() (T, error) {
|
||||
// [Run] call as a normal Go error. A stack trace is captured at this call site
|
||||
// when [CaptureStack] is true.
|
||||
//
|
||||
// msg should express intent — what the code was trying to accomplish — not
|
||||
// the mechanism. This produces error messages that read as "intent: cause"
|
||||
// rather than "operation: cause", keeping failure context meaningful to the
|
||||
// reader without leaking implementation details.
|
||||
//
|
||||
// // Good — expresses intent:
|
||||
// data := Parse(raw).Expect("parse user input")
|
||||
// log := logger.NewCLI(level, out).Expect("create logger")
|
||||
//
|
||||
// // Avoid — describes the mechanism, not the goal:
|
||||
// data := Parse(raw).Expect("call Parse()")
|
||||
// log := logger.NewCLI(level, out).Expect("call NewCLI")
|
||||
func (r Expect[T]) Expect(msg string) T {
|
||||
if r.err != nil {
|
||||
exitGoroutine(&stackError{
|
||||
@ -222,7 +180,7 @@ func Async[T any](fn func() T) <-chan Expect[T] {
|
||||
if err := collectGoexitFailure(); err != nil {
|
||||
ch <- Err[T](err)
|
||||
} else {
|
||||
ch <- Failf[T]("goroutine exited unexpectedly")
|
||||
ch <- Errf[T]("goroutine exited unexpectedly")
|
||||
}
|
||||
}()
|
||||
val = fn()
|
||||
@ -231,6 +189,36 @@ func Async[T any](fn func() T) <-chan Expect[T] {
|
||||
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
|
||||
// result as Expect[T]. It is a convenience wrapper around [Async] for the
|
||||
// common single-goroutine case.
|
||||
@ -260,6 +248,51 @@ func Run(fn func()) error {
|
||||
}).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.
|
||||
// For normal error propagation use [Go] or [Run] instead — they collect
|
||||
// failures from [Expect.Expect] and [Expect.Expectf] regardless of build tag.
|
||||
|
||||
@ -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))
|
||||
}
|
||||
16
rename.sh
16
rename.sh
@ -10,7 +10,7 @@
|
||||
# **/*.go import paths
|
||||
# .devcontainer/devcontainer.json name field
|
||||
# README.md heading + module path references
|
||||
# CLAUDE.md internal import references (pkg/* preserved)
|
||||
# CLAUDE.md Module line
|
||||
# .golangci.yml goimports local-prefixes
|
||||
# git tags all template tags deleted
|
||||
# git history squashed into one INIT commit
|
||||
@ -126,17 +126,17 @@ sedi() {
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Helper: rename module path in a file, preserving all pkg/* imports ────────
|
||||
# pkg/ packages are standalone publishable packages from this template repo;
|
||||
# their import paths must not change when a consuming project is renamed.
|
||||
PKG_BASE="${OLD_MODULE}/pkg/"
|
||||
PLACEHOLDER="__TEMPLATE_PKG_BASE__"
|
||||
# ── Helper: rename module path in a file, preserving pkg/result imports ───────
|
||||
# pkg/result is a standalone publishable package; its import path must not
|
||||
# change when the consuming project is renamed.
|
||||
RESULT_PKG="${OLD_MODULE}/pkg/result"
|
||||
PLACEHOLDER="__RESULT_PKG_PLACEHOLDER__"
|
||||
|
||||
rename_module_in() {
|
||||
local file="$1"
|
||||
sedi "s|${PKG_BASE}|${PLACEHOLDER}|g" "$file"
|
||||
sedi "s|${RESULT_PKG}|${PLACEHOLDER}|g" "$file"
|
||||
sedi "s|${OLD_MODULE}|${NEW_MODULE}|g" "$file"
|
||||
sedi "s|${PLACEHOLDER}|${PKG_BASE}|g" "$file"
|
||||
sedi "s|${PLACEHOLDER}|${RESULT_PKG}|g" "$file"
|
||||
}
|
||||
|
||||
# ── Apply substitutions ───────────────────────────────────────────────────────
|
||||
|
||||
Loading…
Reference in New Issue
Block a user