diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c525b60 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +build/ +.task/ diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..3f33683 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,39 @@ +version: "2" + +linters: + enable: + - errcheck + - govet + - ineffassign + - staticcheck + - unused + - copyloopvar + - dupl + - err113 + - errname + - exptostd + - fatcontext + - funlen + - gocognit + - goconst + - gocritic + - gocyclo + - godot + - godox + - gosec + - perfsprint + - testifylint + settings: + testifylint: + enable-all: true + disable: + - require-error + gocognit: + min-complexity: 16 + gocyclo: + min-complexity: 15 + gocritic: + enable-all: true + settings: + hugeParam: + sizeThreshold: 255 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..33ff925 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,45 @@ +# Agent Guidelines for waiterr + +This document outlines the conventions and commands for agents operating within the `waiterr` Go project. + +## Build/Lint/Test Commands + +- **Lint:** `task lint` +- **Test All:** `task test` +- **Test Single File:** `go test -run ` (e.g., `go test -run TestNewHappy ./ezcache_test.go`) +- **Format:** `task fmt` + +## Code Style Guidelines + +- **Module**: `codeberg.org/danjones000/waiterr` +- **Go version**: 1.24.9 +- **Imports:** Group standard library imports separately from third-party imports. +- **Formatting:** Adhere to `go fmt` standards. +- **Naming Conventions:** + - Variables: `camelCase` + - Functions/Methods: `CamelCase` (exported), `camelCase` (unexported) + - Packages: `lowercase` +- **Error Handling:** Return errors explicitly. Check errors immediately after a function call that returns an error. +- **Linter Rules:** Refer to `.golangci.yaml` for detailed linting rules. +- **Testing**: + - Use `github.com/nalgeon/be` + - Tests should be in a separate package, such as waiterr_test + +## Git Commit Guidelines +- **Format**: Prepend commit messages with a gitmoji emoji (see https://gitmoji.dev) +- **Style**: Write detailed commit messages that explain what changed and why +- **Examples**: `✨ Add JSON export functionality for log entries`, `🐛 Fix date parsing for RFC3339 timestamps`, `📝 Update README with configuration examples` + +## Git Flow Workflow +- **Main branches**: `stable` (production-ready), `develop` (integration branch) +- **Development**: Always commit new features/fixes to `develop` branch or appropriate feature branches +- **Branch prefixes**: + - `feat/feature-name` - New features, merge to `develop` when complete + - `bug/bug-name` - Bug fixes (non-urgent), merge to `develop` when complete + - `rel/version` - Release preparation branches, merge to `stable` and then **also** merge `stable` back to `develop` + - `hot/version` - Hotfixes for production issues follow same merge rules as releases +- **Version tags**: Prefix all version tags with `v` (e.g., `v1.0.2`, `v0.0.6`) +- **Releases**: Update CHANGELOG.md with a summary of changes for each new version +- **Never commit directly to** `stable` branch (only merge from `rel/` or `hot/` branches) +- After merging to `stable`, always merge it back to `develop` +- **Before starting work**: Ensure you're on `develop` branch or create an appropriate feature branch from it diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..049fc9a --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,55 @@ +version: '3' + +tasks: + default: + cmds: + - task: fmt + - task: test + - task: lint + + fmt: + desc: Format go code + sources: + - '**/*.go' + cmds: + - go fmt ./... + - go mod tidy + + lint: + desc: Do static analysis + sources: + - '**/*.go' + cmds: + - golangci-lint run + + test: + desc: Run unit tests + deps: [fmt] + sources: + - '**/*.go' + generates: + - build/cover.out + cmds: + - go test -race -cover -coverprofile build/cover.out ./... + + coverage-report: + desc: Build coverage report + deps: [test] + sources: + - build/cover.out + generates: + - build/cover.html + cmds: + - go tool cover -html=build/cover.out -o build/cover.html + + serve-report: + desc: Serve the coverage report + deps: [coverage-report] + cmds: + - ip addr list | grep inet + - python3 -m http.server -d build 3434 + + serve-docs: + desc: Serve the current docs + cmds: + - godoc -http=0.0.0.0:3434 -play diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6d6bf2f --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module codeberg.org/danjones000/waiterr + +go 1.24.9 + +require github.com/nalgeon/be v0.3.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..bd32e32 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/nalgeon/be v0.3.0 h1:QsPANqEtcOD5qT2S3KAtIkDBBn8SXUf/Lb5Bi/z4UqM= +github.com/nalgeon/be v0.3.0/go.mod h1:PMwMuBLopwKJkSHnr2qHyLcZYUTqNejN7A8RAqNWO3E= diff --git a/waiterr.go b/waiterr.go new file mode 100644 index 0000000..3da73bf --- /dev/null +++ b/waiterr.go @@ -0,0 +1,58 @@ +package waiterr + +import ( + "errors" + "sync" +) + +// WaitErr wraps a sync.WaitGroup with error handling. +type WaitErr struct { + wg sync.WaitGroup + errs []error + mut sync.RWMutex +} + +// Go runs f in its own goroutine. When f returns, its error is stored, and returned +// with we.Wait() +func (we *WaitErr) Go(f func() error) { + wrap := func() { + err := f() + we.mut.Lock() + defer we.mut.Unlock() + we.errs = append(we.errs, err) + } + we.wg.Go(wrap) +} + +// WaitForError waits for the first error to be returned by one of our go routines, and immediately returns +// with that error. If all functions return successfully, a nil is returned. +func (we *WaitErr) WaitForError() error { + // Implement this + // If we already have an error, return it immediately without waiting + // If no error has yet been returned, wait for the very first error and return it + return nil +} + +// Wait for all current goroutines to finish. Return an error that combines all errors returned +// in the group so far (if any). +func (we *WaitErr) Wait() error { + we.wg.Wait() + we.mut.RLock() + defer we.mut.RUnlock() + return errors.Join(we.errs...) +} + +// Unwrap returns all non-nil errors returned by our functions. +// If we.errs is empty, or all errors are nil, just return nil. +func (we *WaitErr) Unwrap() []error { + errs := make([]error, 0, len(we.errs)) + for _, e := range we.errs { + if e != nil { + errs = append(errs, e) + } + } + if len(errs) == 0 { + return nil + } + return errs +} diff --git a/waiterr_test.go b/waiterr_test.go new file mode 100644 index 0000000..293f7dc --- /dev/null +++ b/waiterr_test.go @@ -0,0 +1,66 @@ +package waiterr_test + +import ( + "errors" + "testing" + + "github.com/nalgeon/be" + + "codeberg.org/danjones000/waiterr" +) + +func TestGo(t *testing.T) { + we := new(waiterr.WaitErr) + err := errors.New("uh-oh") + var run bool + we.Go(func() error { + run = true + return err + }) + + be.Err(t, we.Wait(), err) + be.True(t, run) +} + +func TestWait(t *testing.T) { + we := new(waiterr.WaitErr) + er1 := errors.New("uh-oh") + er2 := errors.New("oops") + we.Go(func() error { return er1 }) + we.Go(func() error { return nil }) + we.Go(func() error { return er2 }) + + err := we.Wait() + be.Err(t, err, er1, er2) + if ers, ok := err.(interface{ Unwrap() []error }); ok { + all := ers.Unwrap() + be.Equal(t, len(all), 2) + be.True(t, all[0] == er1 || all[0] == er2) + be.True(t, all[1] == er2 || all[1] == er1) + } else { + t.Fatal("Returned error should have Unwrap method") + } +} + +func TestWaitForError(t *testing.T) { + we := new(waiterr.WaitErr) + er1 := errors.New("uh-oh") + er2 := errors.New("oops") + we.Go(func() error { return nil }) + we.Go(func() error { return er1 }) + we.Go(func() error { return er2 }) + + err := we.WaitForError() + // Due to how goroutines run, it is possible that either of those return first. This is an acceptable limitation + be.True(t, err == er1 || err == er2) +} + +func TestWaitForErrorNoErr(t *testing.T) { + we := new(waiterr.WaitErr) + we.Go(func() error { return nil }) + we.Go(func() error { return nil }) + we.Go(func() error { return nil }) + + err := we.WaitForError() + be.Err(t, err, nil) +}