Compare commits

...

7 Commits
v0.1.1 ... main

Author SHA1 Message Date
09d7c98069 install tools on devcontainer creation
this will stop vscode's Go extension of installing `golangci-lint@latest`
2026-04-09 07:03:21 +00:00
b46a998aac GOPRIVATE=gitea.djmil.dev 2026-04-08 21:02:51 +00:00
dbd513f7b4 pkg/result: Expectf() 2026-04-08 20:20:39 +00:00
7e2b50faf0 update .vscode settings 2026-04-08 19:31:36 +00:00
9ea29d3ba4 rename.sh: keep gitea.djmil.dev/go/template/pkg/result 2026-04-08 19:14:41 +00:00
bc637b3a77 enforce result oriented coding style for Claude 2026-04-08 18:37:44 +00:00
4b8a092201 add go doc as tools 2026-04-08 18:28:06 +00:00
9 changed files with 79 additions and 24 deletions

View File

@ -12,7 +12,7 @@
"remoteUser": "vscode", "remoteUser": "vscode",
// Run once after the container is created. // Run once after the container is created.
"postCreateCommand": "make init", "postCreateCommand": "make init && make tools",
// Fix ownership of the mounted ~/.claude so the vscode user can read host auth. // Fix ownership of the mounted ~/.claude so the vscode user can read host auth.
"postStartCommand": "sudo chown -R vscode:vscode /home/vscode/.claude 2>/dev/null || true", "postStartCommand": "sudo chown -R vscode:vscode /home/vscode/.claude 2>/dev/null || true",
@ -35,6 +35,7 @@
"github.copilot.inlineSuggest.enable": false, "github.copilot.inlineSuggest.enable": false,
"go.useLanguageServer": true, "go.useLanguageServer": true,
"go.toolsManagement.autoUpdate": false,
"go.lintTool": "golangci-lint", "go.lintTool": "golangci-lint",
"go.lintFlags": ["--fast"], "go.lintFlags": ["--fast"],
"go.lintOnSave": "workspace", "go.lintOnSave": "workspace",
@ -54,11 +55,12 @@
"mounts": [ "mounts": [
"source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,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. // Forward the default HTTP port so `make run` is reachable from the host.
"forwardPorts": [8080], "forwardPorts": [8080],
"remoteEnv": { "remoteEnv": {
"CONFIG_PATH": "${containerWorkspaceFolder}/config/dev.yaml" "CONFIG_PATH": "${containerWorkspaceFolder}/config/dev.yaml",
"GOPRIVATE": "gitea.djmil.dev"
} }
} }

View File

@ -1,12 +1,10 @@
{ {
// Go // Go
"go.useLanguageServer": true,
"go.lintTool": "golangci-lint", "go.lintTool": "golangci-lint",
"go.lintFlags": ["--fast"], "go.lintFlags": ["--fast"],
"go.lintOnSave": "workspace", "go.lintOnSave": "workspace",
"go.testFlags": ["-race"], "go.testFlags": ["-race"],
"go.coverOnSave": false, "go.coverOnSave": false,
"go.generateOnSave": false,
// Editor // Editor
"[go]": { "[go]": {
@ -16,9 +14,6 @@
"source.organizeImports": "explicit" "source.organizeImports": "explicit"
} }
}, },
"[yaml]": {
"editor.defaultFormatter": "redhat.vscode-yaml"
},
// Files // Files
"files.exclude": { "files.exclude": {
@ -27,16 +22,16 @@
}, },
"search.exclude": { "search.exclude": {
"**/bin": true, "**/bin": true,
"**/mocks": true,
"**/vendor": true "**/vendor": true
}, },
// Test explorer // Test explorer
"go.testExplorer.enable": true, "go.testExplorer.enable": true,
"makefile.configureOnOpen": false,
"cSpell.words": [ "cSpell.words": [
"djmil", "djmil",
"Expectf",
"gitea", "gitea",
"golangci",
"testutil" "testutil"
] ]
} }

7
.vscode/tasks.json vendored
View File

@ -41,13 +41,6 @@
"presentation": { "reveal": "always", "panel": "shared" }, "presentation": { "reveal": "always", "panel": "shared" },
"problemMatcher": "$go" "problemMatcher": "$go"
}, },
{
"label": "mocks",
"type": "shell",
"command": "make mocks",
"group": "none",
"presentation": { "reveal": "always", "panel": "shared" }
},
{ {
"label": "security scan", "label": "security scan",
"type": "shell", "type": "shell",

View File

@ -39,7 +39,13 @@ tools.versions Pinned tool versions (sourced by Makefile and pre-push
- **Module imports** — always use the full module path `gitea.djmil.dev/go/template/...` - **Module imports** — always use the full module path `gitea.djmil.dev/go/template/...`
- **Packages** — keep `cmd/` thin (wiring only); business logic belongs in `internal/` - **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) - **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** — wrap with `fmt.Errorf("context: %w", err)`; never swallow errors silently - **Errors**`pkg/result` is the default error-handling mechanism for all code in this repo, including public APIs:
- functions return `result.Expect[T]` instead of `(T, error)`
- callers unwrap with `.Expect("context")` (panics with annotated error + stack trace) or `.Must()` (panics with raw error)
- top-level entry points (e.g. `cmd/` functions, HTTP handlers) defer `result.Catch(&err)` to convert any result panic into a normal Go error; genuine runtime panics (nil-deref, etc.) are re-panicked
- bridge existing `(T, error)` stdlib/third-party calls with `result.Of(...)`: `result.Of(os.ReadFile("cfg.json")).Expect("read config")`
- use `result.StackTrace(err)` to retrieve the capture-site stack from a caught error
- still use `fmt.Errorf("context: %w", err)` when wrapping errors *before* constructing a `result.Fail`
- **Logging** — use `log.WithField("key", val)` for structured context; never `fmt.Sprintf` in log messages; `log/slog` is the backend - **Logging** — use `log.WithField("key", val)` for structured context; never `fmt.Sprintf` in log messages; `log/slog` is the backend
- **Config** — all configuration through `internal/config` (flag-parsed); no hard-coded values in logic packages - **Config** — all configuration through `internal/config` (flag-parsed); no hard-coded values in logic packages

View File

@ -1,4 +1,4 @@
.PHONY: help init setup build run test test-race lint lint-fix security clean .PHONY: help init setup build run test test-race lint lint-fix security docs clean
include tools.versions include tools.versions
@ -14,6 +14,7 @@ help: ## Show this help message
# ── First-time setup ─────────────────────────────────────────────────────────── # ── First-time setup ───────────────────────────────────────────────────────────
init: ## First-time project init: fetch deps, configure git hooks init: ## First-time project init: fetch deps, configure git hooks
go env -w GOPRIVATE=gitea.djmil.dev
go mod tidy go mod tidy
$(MAKE) setup $(MAKE) setup
@echo "Done! Run 'make build' to verify." @echo "Done! Run 'make build' to verify."
@ -30,6 +31,7 @@ tools: ## Install tool binaries to GOPATH/bin (versions from tools.versions)
go install github.com/golangci/golangci-lint/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) go install github.com/golangci/golangci-lint/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)
go install github.com/securego/gosec/v2/cmd/gosec@$(GOSEC_VERSION) go install github.com/securego/gosec/v2/cmd/gosec@$(GOSEC_VERSION)
go install golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) go install golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION)
go install golang.org/x/pkgsite/cmd/pkgsite@$(PKGSITE_VERSION)
# ── Build ────────────────────────────────────────────────────────────────────── # ── Build ──────────────────────────────────────────────────────────────────────
build: ## Compile the binary to ./bin/ build: ## Compile the binary to ./bin/
@ -64,6 +66,10 @@ security: ## Run gosec + govulncheck
@echo "--- govulncheck ---" @echo "--- govulncheck ---"
go run golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) ./... go run golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) ./...
# ── Docs ───────────────────────────────────────────────────────────────────────
docs: ## Serve package documentation locally via pkgsite (http://localhost:8080)
go run golang.org/x/pkgsite/cmd/pkgsite@$(PKGSITE_VERSION) -open .
# ── Release ──────────────────────────────────────────────────────────────────── # ── Release ────────────────────────────────────────────────────────────────────
release: ## List releases, or tag+push a new one (usage: make release VERSION=v0.1.0) release: ## List releases, or tag+push a new one (usage: make release VERSION=v0.1.0)
ifdef VERSION ifdef VERSION

View File

@ -100,6 +100,31 @@ func Example_nonErrorPanic() {
// non-error panic: unexpected runtime problem // non-error panic: unexpected runtime problem
} }
// Example_expectf shows Expectf for context messages that include runtime
// values — equivalent to Expect(fmt.Sprintf(...)) but more concise.
func Example_expectf() {
port := parsePort("3000").Expectf("read port from arg %d", 1)
fmt.Println(port)
// Output:
// 3000
}
// Example_expectfError shows that Expectf annotates the error message with the
// formatted context, just like Expect does.
func Example_expectfError() {
run := func() (err error) {
defer result.Catch(&err)
_ = parsePort("99999").Expectf("arg %d port value", 2)
return nil
}
if err := run(); err != nil {
fmt.Println("caught:", err)
}
// Output:
// caught: arg 2 port value: parsePort: 99999 out of range
}
// Example_fail shows constructing a failed Expect explicitly, e.g. when a // Example_fail shows constructing a failed Expect explicitly, e.g. when a
// function detects an error condition before calling any fallible op. // function detects an error condition before calling any fallible op.
func Example_fail() { func Example_fail() {

View File

@ -59,6 +59,20 @@ func (r Expect[T]) Expect(msg string) T {
return r.value return r.value
} }
// Expectf is like [Expect.Expect] but accepts a fmt.Sprintf-style format string
// for the context message. The wrapped error is always appended as ": <err>".
//
// data := Parse(raw).Expectf("parse user input id=%d", id)
func (r Expect[T]) Expectf(format string, args ...any) T {
if r.err != nil {
panic(&stackError{
err: fmt.Errorf("%s: %w", fmt.Sprintf(format, args...), r.err),
stack: debug.Stack(),
})
}
return r.value
}
// Unwrap returns the value and error in the standard Go (value, error) form. // Unwrap returns the value and error in the standard Go (value, error) form.
// Useful at the boundary where you want to re-join normal error-return code. // Useful at the boundary where you want to re-join normal error-return code.
func (r Expect[T]) Unwrap() (T, error) { func (r Expect[T]) Unwrap() (T, error) {

View File

@ -126,11 +126,24 @@ sedi() {
fi fi
} }
# ── 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|${RESULT_PKG}|${PLACEHOLDER}|g" "$file"
sedi "s|${OLD_MODULE}|${NEW_MODULE}|g" "$file"
sedi "s|${PLACEHOLDER}|${RESULT_PKG}|g" "$file"
}
# ── Apply substitutions ─────────────────────────────────────────────────────── # ── Apply substitutions ───────────────────────────────────────────────────────
heading "Applying changes" heading "Applying changes"
# 1. go.mod — module declaration # 1. go.mod — module declaration
sedi "s|${OLD_MODULE}|${NEW_MODULE}|g" go.mod rename_module_in go.mod
info "go.mod" info "go.mod"
# 2. All Go source files — import paths # 2. All Go source files — import paths
@ -143,7 +156,7 @@ GO_FILES=$(find . \
CHANGED_GO=0 CHANGED_GO=0
for f in $GO_FILES; do for f in $GO_FILES; do
if grep -q "$OLD_MODULE" "$f" 2>/dev/null; then if grep -q "$OLD_MODULE" "$f" 2>/dev/null; then
sedi "s|${OLD_MODULE}|${NEW_MODULE}|g" "$f" rename_module_in "$f"
CHANGED_GO=$((CHANGED_GO + 1)) CHANGED_GO=$((CHANGED_GO + 1))
fi fi
done done
@ -159,19 +172,19 @@ fi
# 4. README.md — heading + all module path occurrences # 4. README.md — heading + all module path occurrences
if [[ -f README.md ]]; then if [[ -f README.md ]]; then
sedi "s|^# ${OLD_PROJECT}$|# ${NEW_PROJECT}|g" README.md sedi "s|^# ${OLD_PROJECT}$|# ${NEW_PROJECT}|g" README.md
sedi "s|${OLD_MODULE}|${NEW_MODULE}|g" README.md rename_module_in README.md
info "README.md" info "README.md"
fi fi
# 5. CLAUDE.md — Module line # 5. CLAUDE.md — Module line
if [[ -f CLAUDE.md ]]; then if [[ -f CLAUDE.md ]]; then
sedi "s|${OLD_MODULE}|${NEW_MODULE}|g" CLAUDE.md rename_module_in CLAUDE.md
info "CLAUDE.md" info "CLAUDE.md"
fi fi
# 6. .golangci.yml — goimports local-prefixes # 6. .golangci.yml — goimports local-prefixes
if [[ -f .golangci.yml ]]; then if [[ -f .golangci.yml ]]; then
sedi "s|${OLD_MODULE}|${NEW_MODULE}|g" .golangci.yml rename_module_in .golangci.yml
info ".golangci.yml" info ".golangci.yml"
fi fi

View File

@ -2,3 +2,4 @@ DELVE_VERSION=v1.26.1
GOLANGCI_LINT_VERSION=v1.64.8 GOLANGCI_LINT_VERSION=v1.64.8
GOSEC_VERSION=v2.24.7 GOSEC_VERSION=v2.24.7
GOVULNCHECK_VERSION=v1.1.4 GOVULNCHECK_VERSION=v1.1.4
PKGSITE_VERSION=latest