From 0d53670ab7f5e4052ed175156e6b5069737c28a4 Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Sat, 11 Oct 2025 19:53:28 +0400 Subject: [PATCH] first commit --- .github/workflows/lint.yaml | 23 ++ .github/workflows/test.yaml | 21 ++ .gitignore | 1 + .golangci.yaml | 102 +++++++ LICENSE | 21 ++ README.md | 147 ++++++++++ Taskfile.yaml | 41 +++ common/convert.go | 5 + errors/handler.go | 146 ++++++++++ errors/handler_test.go | 384 ++++++++++++++++++++++++++ go.mod | 14 + go.sum | 23 ++ maps/base.go | 38 +++ maps/base_test.go | 317 ++++++++++++++++++++++ parser/string.go | 186 +++++++++++++ parser/string_test.go | 528 ++++++++++++++++++++++++++++++++++++ slice/slice.go | 92 +++++++ slice/slice_test.go | 324 ++++++++++++++++++++++ 18 files changed, 2413 insertions(+) create mode 100644 .github/workflows/lint.yaml create mode 100644 .github/workflows/test.yaml create mode 100644 .gitignore create mode 100644 .golangci.yaml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 Taskfile.yaml create mode 100644 common/convert.go create mode 100644 errors/handler.go create mode 100644 errors/handler_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 maps/base.go create mode 100644 maps/base_test.go create mode 100644 parser/string.go create mode 100644 parser/string_test.go create mode 100644 slice/slice.go create mode 100644 slice/slice_test.go diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml new file mode 100644 index 0000000..cc0bd1d --- /dev/null +++ b/.github/workflows/lint.yaml @@ -0,0 +1,23 @@ +name: golangci-lint +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + +jobs: + golangci: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-go@v6 + with: + go-version: stable + - name: golangci-lint + uses: golangci/golangci-lint-action@v8 + with: + version: v2.5.0 diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..6139102 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,21 @@ +name: Tests +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + +jobs: + test: + name: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + - name: Run tests + run: go test -v -race ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..efa6632 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +bin/* \ No newline at end of file diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..fb629e3 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,102 @@ +version: "2" + +run: + go: "1.25" + concurrency: 12 + +linters: + default: none + enable: + - asciicheck + - errcheck + - govet + - ineffassign + - misspell + - nakedret + - nolintlint + - prealloc + - reassign + - staticcheck + - unconvert + - unused + - whitespace + - bidichk + - bodyclose + - containedctx + - contextcheck + - copyloopvar + - embeddedstructfieldcheck + - errchkjson + - errorlint + - exptostd + - fatcontext + - forcetypeassert + - funcorder + - gocheckcompilerdirectives + - gocritic + - gomoddirectives + - gosec + - gosmopolitan + - grouper + - importas + - inamedparam + - intrange + - loggercheck + - mirror + - musttag + - perfsprint + - predeclared + - tagalign + - tagliatelle + - testifylint + - thelper + - tparallel + - unparam + - usestdlibvars + - usetesting + - varnamelen + - wastedassign + + settings: + staticcheck: + checks: + - "all" + - "-S1002" + - "-ST1000" + varnamelen: + ignore-decls: + - w http.ResponseWriter + + exclusions: + rules: + - path: _test\.go$ + linters: + - errorlint + - forcetypeassert + - perfsprint + - errcheck + - gosec + - varnamelen + + - path: _test\.go$ + linters: + - staticcheck + text: "SA5011" + +formatters: + enable: + - gofmt + + settings: + gofmt: + # Simplify code: gofmt with `-s` option. + # Default: true + simplify: true + # Apply the rewrite rules to the source before reformatting. + # https://pkg.go.dev/cmd/gofmt + # Default: [] + rewrite-rules: + - pattern: "interface{}" + replacement: "any" + - pattern: "a[b:len(a)]" + replacement: "a[b:]" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..89ae0f2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Aykhan Shahsuvarov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c055aa8 --- /dev/null +++ b/README.md @@ -0,0 +1,147 @@ +# go-utils + +A collection of generic utility functions for Go projects. + +## Installation + +```bash +go get github.com/aykhans/go-utils +``` + +## Packages + +### common + +Generic type conversion utilities. + +**ToPtr** - Convert any value to a pointer +```go +import "github.com/aykhans/go-utils/common" + +num := 42 +ptr := common.ToPtr(num) // *int +``` + +### parser + +String parsing utilities with generic type support. + +**ParseString** - Parse string to various types +```go +import "github.com/aykhans/go-utils/parser" + +num, err := parser.ParseString[int]("42") +duration, err := parser.ParseString[time.Duration]("5s") +isValid, err := parser.ParseString[bool]("true") +``` + +**ParseStringOrZero** - Parse string or return zero value on error +```go +num := parser.ParseStringOrZero[int]("invalid") // returns 0 +``` + +**ParseStringWithDefault** - Parse string with default value fallback +```go +num, err := parser.ParseStringWithDefault("invalid", 10) // returns 10, error +``` + +**ParseStringOrDefault** - Parse string or return default value without error +```go +num := parser.ParseStringOrDefault("invalid", 10) // returns 10 +``` + +Supported types: `string`, `int`, `int8`, `int16`, `int32`, `int64`, `uint`, `uint8`, `uint16`, `uint32`, `uint64`, `float64`, `bool`, `time.Duration`, `url.URL` + +### slice + +Slice manipulation utilities. + +**Cycle** - Create an infinite cycler through items +```go +import "github.com/aykhans/go-utils/slice" + +next := slice.Cycle(1, 2, 3) +fmt.Println(next()) // 1 +fmt.Println(next()) // 2 +fmt.Println(next()) // 3 +fmt.Println(next()) // 1 +``` + +**RandomCycle** - Cycle through items with randomization +```go +next := slice.RandomCycle(nil, "a", "b", "c") +// Cycles through all items, then starts from a random position +``` + +### maps + +Map utility functions. + +**InitMap** - Initialize a map pointer if nil +```go +import "github.com/aykhans/go-utils/maps" + +var m map[string]int +maps.InitMap(&m) +m["key"] = 42 // safe to use +``` + +**UpdateMap** - Merge entries from one map into another +```go +old := map[string]int{"a": 1, "b": 2} +new := map[string]int{"b": 3, "c": 4} +maps.UpdateMap(&old, new) +// old is now: {"a": 1, "b": 3, "c": 4} +``` + +### errors + +Advanced error handling utilities. + +**HandleError** - Process errors with custom matchers +```go +import "github.com/aykhans/go-utils/errors" + +handled, result := errors.HandleError(err, + errors.OnSentinelError(io.EOF, func(e error) error { + return nil // EOF is expected, ignore it + }), + errors.OnCustomError(func(e *CustomError) error { + return fmt.Errorf("custom error: %w", e) + }), +) +``` + +**HandleErrorOrDie** - Handle errors or panic if unhandled +```go +result := errors.HandleErrorOrDie(err, + errors.OnSentinelError(context.Canceled, func(e error) error { + return fmt.Errorf("operation canceled") + }), +) // Panics if err doesn't match any handler +``` + +**OnSentinelError** - Create matcher for sentinel errors (like `io.EOF`) +```go +matcher := errors.OnSentinelError(io.EOF, func(e error) error { + log.Println("reached end of file") + return nil +}) +``` + +**OnCustomError** - Create matcher for custom error types +```go +type ValidationError struct { + Field string + Msg string +} + +matcher := errors.OnCustomError(func(e *ValidationError) error { + log.Printf("validation failed on field %s", e.Field) + return fmt.Errorf("invalid input: %w", e) +}) +``` + +## Requirements + +- Go 1.25.0 or higher diff --git a/Taskfile.yaml b/Taskfile.yaml new file mode 100644 index 0000000..a358183 --- /dev/null +++ b/Taskfile.yaml @@ -0,0 +1,41 @@ +# https://taskfile.dev +version: "3" + +vars: + BIN_DIR: ./bin + GOLANGCI_LINT_VERSION: v2.5.0 + +tasks: + ftl: + cmds: + - task: fmt + - task: tidy + - task: lint + + tidy: go mod tidy {{.CLI_ARGS}} + + test: go test ./... {{.CLI_ARGS}} + + fmt: + desc: Run linters + deps: + - install-golangci-lint + cmds: + - "{{.BIN_DIR}}/golangci-lint-{{.GOLANGCI_LINT_VERSION}} fmt" + + lint: + desc: Run linters + deps: + - install-golangci-lint + cmds: + - "{{.BIN_DIR}}/golangci-lint-{{.GOLANGCI_LINT_VERSION}} run" + + install-golangci-lint: + desc: Install golangci-lint + status: + - test -f {{.BIN_DIR}}/golangci-lint-{{.GOLANGCI_LINT_VERSION}} + cmds: + - mkdir -p {{.BIN_DIR}} + - rm -f {{.BIN_DIR}}/golangci-lint-{{.GOLANGCI_LINT_VERSION}} + - curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b {{.BIN_DIR}} {{.GOLANGCI_LINT_VERSION}} + - mv {{.BIN_DIR}}/golangci-lint {{.BIN_DIR}}/golangci-lint-{{.GOLANGCI_LINT_VERSION}} diff --git a/common/convert.go b/common/convert.go new file mode 100644 index 0000000..5a44bc3 --- /dev/null +++ b/common/convert.go @@ -0,0 +1,5 @@ +package common + +func ToPtr[T any](value T) *T { + return &value +} diff --git a/errors/handler.go b/errors/handler.go new file mode 100644 index 0000000..640b939 --- /dev/null +++ b/errors/handler.go @@ -0,0 +1,146 @@ +package errors + +import ( + "errors" + "fmt" + "reflect" +) + +// ErrorHandler represents a function that handles a specific error type +type ErrorHandler func(error) error + +// ErrorMatcher holds the error type/value and its handler +type ErrorMatcher struct { + ErrorType any // Can be error value (sentinel) or error type + Handler ErrorHandler + IsSentinel bool // true for sentinel errors, false for custom types +} + +// HandleError processes an error against a list of matchers and executes the appropriate handler. +// It returns (true, handlerResult) if a matching handler is found and executed, +// or (false, nil) if no matcher matches the error. +// If err is nil, returns (true, nil). +// +// Example: +// +// handled, result := HandleError(err, +// OnSentinelError(io.EOF, func(e error) error { +// return nil // EOF is expected, ignore it +// }), +// OnCustomError(func(e *CustomError) error { +// return fmt.Errorf("custom error: %w", e) +// }), +// ) +func HandleError(err error, matchers ...ErrorMatcher) (bool, error) { + if err == nil { + return true, nil + } + + for _, matcher := range matchers { + if matcher.IsSentinel { + // Handle sentinel errors with errors.Is + if sentinelErr, ok := matcher.ErrorType.(error); ok { + if errors.Is(err, sentinelErr) { + return true, matcher.Handler(err) + } + } + } else { + // Handle custom error types with errors.As + errorType := reflect.TypeOf(matcher.ErrorType) + errorValue := reflect.New(errorType).Interface() + + if errors.As(err, errorValue) { + return true, matcher.Handler(err) + } + } + } + + return false, err // No matcher found +} + +// HandleErrorOrDie processes an error against a list of matchers and executes the appropriate handler. +// If a matching handler is found, it returns the handler's result. +// If no matcher matches the error, it panics with a descriptive message. +// This function is useful when all expected error types must be handled explicitly. +// +// Example: +// +// result := HandleErrorOrDie(err, +// OnSentinelError(context.Canceled, func(e error) error { +// return fmt.Errorf("operation canceled") +// }), +// OnCustomError(func(e *ValidationError) error { +// return fmt.Errorf("validation failed: %w", e) +// }), +// ) // Panics if err doesn't match any handler +func HandleErrorOrDie(err error, matchers ...ErrorMatcher) error { + ok, err := HandleError(err, matchers...) + if !ok { + panic(fmt.Sprintf("Unhandled error of type %T: %v", err, err)) + } + return err +} + +// OnSentinelError creates an ErrorMatcher for sentinel errors. +// Sentinel errors are predefined error values that are compared using errors.Is. +// +// This is used with HandleError or HandleErrorOrDie to match specific error +// values like io.EOF, context.Canceled, or custom sentinel errors defined with +// errors.New or fmt.Errorf. +// +// The handler function receives the original error and can return a new error +// or nil to suppress it. +// +// Example: +// +// matcher := OnSentinelError(io.EOF, func(e error) error { +// log.Println("reached end of file") +// return nil // suppress EOF error +// }) +func OnSentinelError(sentinelErr error, handler ErrorHandler) ErrorMatcher { + return ErrorMatcher{ + ErrorType: sentinelErr, + Handler: handler, + IsSentinel: true, + } +} + +// OnCustomError creates an ErrorMatcher for custom error types. +// Custom error types are struct types that implement the error interface, +// and are matched using errors.As to unwrap error chains. +// +// The type parameter T specifies the error type to match. The handler function +// receives the unwrapped typed error, allowing you to access type-specific fields +// and methods. +// +// This is particularly useful for handling errors with additional context or data, +// such as validation errors, network errors, or domain-specific errors. +// +// Example: +// +// type ValidationError struct { +// Field string +// Msg string +// } +// func (e *ValidationError) Error() string { +// return fmt.Sprintf("%s: %s", e.Field, e.Msg) +// } +// +// matcher := OnCustomError(func(e *ValidationError) error { +// log.Printf("validation failed on field %s: %s", e.Field, e.Msg) +// return fmt.Errorf("invalid input: %w", e) +// }) +func OnCustomError[T error](handler func(T) error) ErrorMatcher { + var zero T + return ErrorMatcher{ + ErrorType: zero, + Handler: func(err error) error { + var typedErr T + if errors.As(err, &typedErr) { + return handler(typedErr) + } + return nil + }, + IsSentinel: false, + } +} diff --git a/errors/handler_test.go b/errors/handler_test.go new file mode 100644 index 0000000..d9e212f --- /dev/null +++ b/errors/handler_test.go @@ -0,0 +1,384 @@ +package errors + +import ( + "context" + "errors" + "fmt" + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Custom error types for testing +type CustomError struct { + Code int + Message string +} + +func (e CustomError) Error() string { + return fmt.Sprintf("custom error %d: %s", e.Code, e.Message) +} + +type ValidationError struct { + Field string + Value string +} + +func (e *ValidationError) Error() string { + return fmt.Sprintf("validation failed for field %s with value %s", e.Field, e.Value) +} + +// Sentinel errors for testing +var ( + ErrSentinel1 = errors.New("sentinel error 1") + ErrSentinel2 = errors.New("sentinel error 2") +) + +func TestHandleError(t *testing.T) { + t.Run("HandleError with nil error", func(t *testing.T) { + handled, result := HandleError(nil) + assert.True(t, handled) + assert.NoError(t, result) + }) + + t.Run("HandleError with sentinel error match", func(t *testing.T) { + err := io.EOF + handled, result := HandleError(err, + OnSentinelError(io.EOF, func(e error) error { + return errors.New("handled EOF") + }), + ) + assert.True(t, handled) + assert.EqualError(t, result, "handled EOF") + }) + + t.Run("HandleError with wrapped sentinel error", func(t *testing.T) { + wrappedErr := fmt.Errorf("wrapped: %w", io.EOF) + handled, result := HandleError(wrappedErr, + OnSentinelError(io.EOF, func(e error) error { + return errors.New("handled wrapped EOF") + }), + ) + assert.True(t, handled) + assert.EqualError(t, result, "handled wrapped EOF") + }) + + t.Run("HandleError with custom error type match", func(t *testing.T) { + err := &CustomError{Code: 404, Message: "not found"} + handled, result := HandleError(err, + OnCustomError(func(e *CustomError) error { + return fmt.Errorf("handled custom error with code %d", e.Code) + }), + ) + assert.True(t, handled) + assert.EqualError(t, result, "handled custom error with code 404") + }) + + t.Run("HandleError with wrapped custom error", func(t *testing.T) { + customErr := &CustomError{Code: 500, Message: "internal error"} + wrappedErr := fmt.Errorf("wrapped: %w", customErr) + + handled, result := HandleError(wrappedErr, + OnCustomError(func(e *CustomError) error { + return fmt.Errorf("handled wrapped custom error: %s", e.Message) + }), + ) + assert.True(t, handled) + assert.EqualError(t, result, "handled wrapped custom error: internal error") + }) + + t.Run("HandleError with no matching handler", func(t *testing.T) { + err := errors.New("unhandled error") + handled, _ := HandleError(err, + OnSentinelError(io.EOF, func(e error) error { + return nil + }), + OnCustomError(func(e CustomError) error { + return nil + }), + ) + assert.False(t, handled) + }) + + t.Run("HandleError with multiple matchers first match wins", func(t *testing.T) { + err := io.EOF + handled, result := HandleError(err, + OnSentinelError(io.EOF, func(e error) error { + return errors.New("first handler") + }), + OnSentinelError(io.EOF, func(e error) error { + return errors.New("second handler") + }), + ) + assert.True(t, handled) + assert.EqualError(t, result, "first handler") + }) + + t.Run("HandleError with handler returning nil", func(t *testing.T) { + err := io.EOF + handled, result := HandleError(err, + OnSentinelError(io.EOF, func(e error) error { + return nil + }), + ) + assert.True(t, handled) + assert.NoError(t, result) + }) + + t.Run("HandleError with multiple error types", func(t *testing.T) { + customErr := &CustomError{Code: 400, Message: "bad request"} + validationErr := &ValidationError{Field: "email", Value: "invalid"} + + // Test CustomError handling + handled1, result1 := HandleError(customErr, + OnCustomError(func(e *CustomError) error { + return fmt.Errorf("custom: %d", e.Code) + }), + OnCustomError(func(e *ValidationError) error { + return fmt.Errorf("validation: %s", e.Field) + }), + ) + assert.True(t, handled1) + require.EqualError(t, result1, "custom: 400") + + // Test ValidationError handling + handled2, result2 := HandleError(validationErr, + OnCustomError(func(e *CustomError) error { + return fmt.Errorf("custom: %d", e.Code) + }), + OnCustomError(func(e *ValidationError) error { + return fmt.Errorf("validation: %s", e.Field) + }), + ) + assert.True(t, handled2) + assert.EqualError(t, result2, "validation: email") + }) + + t.Run("HandleError with context errors", func(t *testing.T) { + // Test context.Canceled + handled1, result1 := HandleError(context.Canceled, + OnSentinelError(context.Canceled, func(e error) error { + return errors.New("operation canceled") + }), + ) + assert.True(t, handled1) + require.EqualError(t, result1, "operation canceled") + + // Test context.DeadlineExceeded + handled2, result2 := HandleError(context.DeadlineExceeded, + OnSentinelError(context.DeadlineExceeded, func(e error) error { + return errors.New("deadline exceeded") + }), + ) + assert.True(t, handled2) + assert.EqualError(t, result2, "deadline exceeded") + }) + + t.Run("HandleError preserves original error in handler", func(t *testing.T) { + originalErr := &CustomError{Code: 403, Message: "forbidden"} + var capturedErr error + + handled, _ := HandleError(originalErr, + OnCustomError(func(e *CustomError) error { + capturedErr = e + return nil + }), + ) + + assert.True(t, handled) + assert.Equal(t, originalErr, capturedErr) + }) +} + +func TestHandleErrorOrDie(t *testing.T) { + t.Run("HandleErrorOrDie with nil error", func(t *testing.T) { + result := HandleErrorOrDie(nil) + assert.NoError(t, result) + }) + + t.Run("HandleErrorOrDie with matched error", func(t *testing.T) { + err := io.EOF + result := HandleErrorOrDie(err, + OnSentinelError(io.EOF, func(e error) error { + return errors.New("handled EOF in die") + }), + ) + assert.EqualError(t, result, "handled EOF in die") + }) + + t.Run("HandleErrorOrDie panics on unmatched error", func(t *testing.T) { + err := errors.New("unmatched error") + + assert.Panics(t, func() { + HandleErrorOrDie(err, + OnSentinelError(io.EOF, func(e error) error { + return nil + }), + ) + }) + }) + + t.Run("HandleErrorOrDie with custom error panic", func(t *testing.T) { + customErr := &CustomError{Code: 500, Message: "server error"} + + assert.Panics(t, func() { + HandleErrorOrDie(customErr, + OnCustomError(func(e *ValidationError) error { + return nil + }), + ) + }) + }) + + t.Run("HandleErrorOrDie with multiple matchers", func(t *testing.T) { + validationErr := &ValidationError{Field: "username", Value: ""} + + result := HandleErrorOrDie(validationErr, + OnSentinelError(io.EOF, func(e error) error { + return errors.New("EOF handler") + }), + OnCustomError(func(e *CustomError) error { + return errors.New("custom handler") + }), + OnCustomError(func(e *ValidationError) error { + return fmt.Errorf("validation handler: field=%s", e.Field) + }), + ) + + assert.EqualError(t, result, "validation handler: field=username") + }) +} + +func TestOnSentinelError(t *testing.T) { + t.Run("OnSentinelError creates proper matcher", func(t *testing.T) { + handler := func(e error) error { return e } + matcher := OnSentinelError(io.EOF, handler) + + assert.Equal(t, io.EOF, matcher.ErrorType) + assert.True(t, matcher.IsSentinel) + assert.NotNil(t, matcher.Handler) + }) + + t.Run("OnSentinelError with custom sentinel", func(t *testing.T) { + customSentinel := errors.New("custom sentinel") + callCount := 0 + + matcher := OnSentinelError(customSentinel, func(e error) error { + callCount++ + return errors.New("handled custom sentinel") + }) + + // Test that it matches the sentinel + handled, result := HandleError(customSentinel, matcher) + assert.True(t, handled) + require.EqualError(t, result, "handled custom sentinel") + assert.Equal(t, 1, callCount) + + // Test that it matches wrapped sentinel + wrappedErr := fmt.Errorf("wrapped: %w", customSentinel) + handled, result = HandleError(wrappedErr, matcher) + assert.True(t, handled) + require.EqualError(t, result, "handled custom sentinel") + assert.Equal(t, 2, callCount) + }) +} + +func TestOnCustomError(t *testing.T) { + t.Run("OnCustomError creates proper matcher", func(t *testing.T) { + matcher := OnCustomError(func(e *CustomError) error { + return fmt.Errorf("handled: %d", e.Code) + }) + + assert.False(t, matcher.IsSentinel) + assert.NotNil(t, matcher.Handler) + + // Test the handler works + err := &CustomError{Code: 200, Message: "ok"} + result := matcher.Handler(err) + assert.EqualError(t, result, "handled: 200") + }) + + t.Run("OnCustomError with different error types", func(t *testing.T) { + // Create matchers for different types + customMatcher := OnCustomError(func(e *CustomError) error { + return fmt.Errorf("custom error: code=%d", e.Code) + }) + + validationMatcher := OnCustomError(func(e *ValidationError) error { + return fmt.Errorf("validation error: field=%s", e.Field) + }) + + // Test with CustomError + customErr := &CustomError{Code: 404, Message: "not found"} + handled, result := HandleError(customErr, customMatcher, validationMatcher) + assert.True(t, handled) + require.EqualError(t, result, "custom error: code=404") + + // Test with ValidationError + validationErr := &ValidationError{Field: "age", Value: "-1"} + handled, result = HandleError(validationErr, customMatcher, validationMatcher) + assert.True(t, handled) + assert.EqualError(t, result, "validation error: field=age") + }) + + t.Run("OnCustomError handler receives correct type", func(t *testing.T) { + var receivedErr *CustomError + + matcher := OnCustomError(func(e *CustomError) error { + receivedErr = e + return nil + }) + + originalErr := &CustomError{Code: 301, Message: "redirect"} + handled, _ := HandleError(originalErr, matcher) + + assert.True(t, handled) + require.NotNil(t, receivedErr) + assert.Equal(t, 301, receivedErr.Code) + assert.Equal(t, "redirect", receivedErr.Message) + }) +} + +func TestErrorMatcherEdgeCases(t *testing.T) { + t.Run("Invalid sentinel error type in matcher", func(t *testing.T) { + // Create a matcher with invalid ErrorType for sentinel + matcher := ErrorMatcher{ + ErrorType: "not an error", // Invalid type + Handler: func(e error) error { return e }, + IsSentinel: true, + } + + err := errors.New("test error") + handled, _ := HandleError(err, matcher) + assert.False(t, handled) + }) + + t.Run("Handler that panics", func(t *testing.T) { + matcher := OnSentinelError(io.EOF, func(e error) error { + panic("handler panic") + }) + + assert.Panics(t, func() { + HandleError(io.EOF, matcher) + }) + }) + + t.Run("Complex error chain", func(t *testing.T) { + // Create a complex error chain + baseErr := &CustomError{Code: 500, Message: "base"} + wrapped1 := fmt.Errorf("layer1: %w", baseErr) + wrapped2 := fmt.Errorf("layer2: %w", wrapped1) + wrapped3 := fmt.Errorf("layer3: %w", wrapped2) + + handled, result := HandleError(wrapped3, + OnCustomError(func(e *CustomError) error { + return fmt.Errorf("found custom error at code %d", e.Code) + }), + ) + + assert.True(t, handled) + assert.EqualError(t, result, "found custom error at code 500") + }) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f3a899c --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module github.com/aykhans/go-utils + +go 1.25.0 + +require github.com/stretchr/testify v1.11.1 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..acd7c9b --- /dev/null +++ b/go.sum @@ -0,0 +1,23 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/maps/base.go b/maps/base.go new file mode 100644 index 0000000..3fd0512 --- /dev/null +++ b/maps/base.go @@ -0,0 +1,38 @@ +package maps + +import "maps" + +// InitMap initializes a map pointer if it is nil. +// If the map is already initialized, this function does nothing. +// +// This is useful for ensuring a map is ready to use before adding entries, +// preventing nil pointer dereferences. +// +// Example: +// +// var m map[string]int +// InitMap(&m) +// m["key"] = 42 // safe to use +func InitMap[K comparable, V any, T ~map[K]V](m *T) { + if *m == nil { + *m = make(T) + } +} + +// UpdateMap merges entries from the new map into the old map. +// If the old map is nil, it will be initialized first. +// Existing keys in the old map will be overwritten with values from the new map. +// +// This function modifies the old map in place by copying all key-value pairs +// from the new map into it. +// +// Example: +// +// old := map[string]int{"a": 1, "b": 2} +// new := map[string]int{"b": 3, "c": 4} +// UpdateMap(&old, new) +// // old is now: {"a": 1, "b": 3, "c": 4} +func UpdateMap[K comparable, V any, T ~map[K]V](oldMap *T, newMap T) { + InitMap(oldMap) + maps.Copy(*oldMap, newMap) +} diff --git a/maps/base_test.go b/maps/base_test.go new file mode 100644 index 0000000..9ba97c1 --- /dev/null +++ b/maps/base_test.go @@ -0,0 +1,317 @@ +package maps + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInitMap(t *testing.T) { + t.Run("initializes nil map", func(t *testing.T) { + var m map[string]int + require.Nil(t, m) + + InitMap(&m) + + assert.NotNil(t, m) + assert.Empty(t, m) + assert.Empty(t, m) + }) + + t.Run("does not reinitialize existing map", func(t *testing.T) { + m := map[string]int{"key": 42} + originalLen := len(m) + + InitMap(&m) + + assert.Len(t, m, originalLen) + assert.Equal(t, 42, m["key"]) + }) + + t.Run("allows adding entries after initialization", func(t *testing.T) { + var m map[string]int + InitMap(&m) + + m["test"] = 100 + assert.Equal(t, 100, m["test"]) + assert.Len(t, m, 1) + }) + + t.Run("works with empty initialized map", func(t *testing.T) { + m := make(map[string]int) + InitMap(&m) + + assert.NotNil(t, m) + assert.Empty(t, m) + }) + + t.Run("works with different key types", func(t *testing.T) { + t.Run("int keys", func(t *testing.T) { + var m map[int]string + InitMap(&m) + + assert.NotNil(t, m) + m[1] = "one" + assert.Equal(t, "one", m[1]) + }) + + t.Run("struct keys", func(t *testing.T) { + type Key struct { + ID int + Name string + } + var m map[Key]bool + InitMap(&m) + + assert.NotNil(t, m) + k := Key{ID: 1, Name: "test"} + m[k] = true + assert.True(t, m[k]) + }) + }) + + t.Run("works with different value types", func(t *testing.T) { + t.Run("string values", func(t *testing.T) { + var m map[string]string + InitMap(&m) + + assert.NotNil(t, m) + m["key"] = "value" + assert.Equal(t, "value", m["key"]) + }) + + t.Run("struct values", func(t *testing.T) { + type Value struct { + Count int + Name string + } + var m map[string]Value + InitMap(&m) + + assert.NotNil(t, m) + m["test"] = Value{Count: 5, Name: "foo"} + assert.Equal(t, 5, m["test"].Count) + }) + + t.Run("slice values", func(t *testing.T) { + var m map[string][]int + InitMap(&m) + + assert.NotNil(t, m) + m["numbers"] = []int{1, 2, 3} + assert.Equal(t, []int{1, 2, 3}, m["numbers"]) + }) + + t.Run("pointer values", func(t *testing.T) { + var m map[string]*int + InitMap(&m) + + assert.NotNil(t, m) + val := 42 + m["ptr"] = &val + assert.Equal(t, 42, *m["ptr"]) + }) + }) + + t.Run("works with custom map types", func(t *testing.T) { + type CustomMap map[string]int + var m CustomMap + InitMap(&m) + + assert.NotNil(t, m) + m["custom"] = 99 + assert.Equal(t, 99, m["custom"]) + }) +} + +func TestUpdateMap(t *testing.T) { + t.Run("merges new entries into existing map", func(t *testing.T) { + oldMap := map[string]int{"a": 1, "b": 2} + newMap := map[string]int{"c": 3, "d": 4} + + UpdateMap(&oldMap, newMap) + + assert.Len(t, oldMap, 4) + assert.Equal(t, 1, oldMap["a"]) + assert.Equal(t, 2, oldMap["b"]) + assert.Equal(t, 3, oldMap["c"]) + assert.Equal(t, 4, oldMap["d"]) + }) + + t.Run("overwrites existing keys", func(t *testing.T) { + oldMap := map[string]int{"a": 1, "b": 2} + newMap := map[string]int{"b": 3, "c": 4} + + UpdateMap(&oldMap, newMap) + + assert.Len(t, oldMap, 3) + assert.Equal(t, 1, oldMap["a"]) + assert.Equal(t, 3, oldMap["b"], "existing key 'b' should be overwritten") + assert.Equal(t, 4, oldMap["c"]) + }) + + t.Run("initializes nil map before merging", func(t *testing.T) { + var oldMap map[string]int + newMap := map[string]int{"a": 1, "b": 2} + + require.Nil(t, oldMap) + UpdateMap(&oldMap, newMap) + + assert.NotNil(t, oldMap) + assert.Len(t, oldMap, 2) + assert.Equal(t, 1, oldMap["a"]) + assert.Equal(t, 2, oldMap["b"]) + }) + + t.Run("handles empty new map", func(t *testing.T) { + oldMap := map[string]int{"a": 1} + newMap := map[string]int{} + + UpdateMap(&oldMap, newMap) + + assert.Len(t, oldMap, 1) + assert.Equal(t, 1, oldMap["a"]) + }) + + t.Run("handles empty old map", func(t *testing.T) { + oldMap := map[string]int{} + newMap := map[string]int{"a": 1, "b": 2} + + UpdateMap(&oldMap, newMap) + + assert.Len(t, oldMap, 2) + assert.Equal(t, 1, oldMap["a"]) + assert.Equal(t, 2, oldMap["b"]) + }) + + t.Run("handles both empty maps", func(t *testing.T) { + oldMap := map[string]int{} + newMap := map[string]int{} + + UpdateMap(&oldMap, newMap) + + assert.NotNil(t, oldMap) + assert.Empty(t, oldMap) + }) + + t.Run("works with different types", func(t *testing.T) { + t.Run("string values", func(t *testing.T) { + oldMap := map[string]string{"key1": "value1"} + newMap := map[string]string{"key2": "value2"} + + UpdateMap(&oldMap, newMap) + + assert.Len(t, oldMap, 2) + assert.Equal(t, "value1", oldMap["key1"]) + assert.Equal(t, "value2", oldMap["key2"]) + }) + + t.Run("struct values", func(t *testing.T) { + type Person struct { + Name string + Age int + } + oldMap := map[string]Person{ + "john": {Name: "John", Age: 30}, + } + newMap := map[string]Person{ + "jane": {Name: "Jane", Age: 25}, + } + + UpdateMap(&oldMap, newMap) + + assert.Len(t, oldMap, 2) + assert.Equal(t, "John", oldMap["john"].Name) + assert.Equal(t, "Jane", oldMap["jane"].Name) + }) + + t.Run("int keys", func(t *testing.T) { + oldMap := map[int]string{1: "one"} + newMap := map[int]string{2: "two"} + + UpdateMap(&oldMap, newMap) + + assert.Len(t, oldMap, 2) + assert.Equal(t, "one", oldMap[1]) + assert.Equal(t, "two", oldMap[2]) + }) + }) + + t.Run("modifies map in place", func(t *testing.T) { + oldMap := map[string]int{"a": 1} + originalMap := oldMap + newMap := map[string]int{"b": 2} + + UpdateMap(&oldMap, newMap) + + // Verify it's the same map (modified in place) + assert.Equal(t, 2, oldMap["b"]) + // Note: originalMap is pointing to the same underlying map + // so it should also have the new value + assert.Equal(t, 2, originalMap["b"]) + }) + + t.Run("does not modify new map", func(t *testing.T) { + oldMap := map[string]int{"a": 1} + newMap := map[string]int{"b": 2, "c": 3} + newLen := len(newMap) + + UpdateMap(&oldMap, newMap) + + assert.Len(t, newMap, newLen, "new map should not be modified") + assert.Equal(t, 2, newMap["b"]) + assert.Equal(t, 3, newMap["c"]) + _, hasA := newMap["a"] + assert.False(t, hasA, "new map should not contain keys from old map") + }) + + t.Run("works with custom map types", func(t *testing.T) { + type CustomMap map[string]int + oldMap := CustomMap{"a": 1} + newMap := CustomMap{"b": 2} + + UpdateMap(&oldMap, newMap) + + assert.Len(t, oldMap, 2) + assert.Equal(t, 1, oldMap["a"]) + assert.Equal(t, 2, oldMap["b"]) + }) + + t.Run("overwrites with zero values", func(t *testing.T) { + oldMap := map[string]int{"a": 10, "b": 20} + newMap := map[string]int{"a": 0} + + UpdateMap(&oldMap, newMap) + + assert.Len(t, oldMap, 2) + assert.Equal(t, 0, oldMap["a"], "should overwrite with zero value") + assert.Equal(t, 20, oldMap["b"]) + }) + + t.Run("complex merge scenario", func(t *testing.T) { + oldMap := map[string]int{ + "a": 1, + "b": 2, + "c": 3, + } + newMap := map[string]int{ + "b": 20, // overwrite + "c": 30, // overwrite + "d": 40, // new + "e": 50, // new + } + + UpdateMap(&oldMap, newMap) + + expected := map[string]int{ + "a": 1, // preserved + "b": 20, // overwritten + "c": 30, // overwritten + "d": 40, // new + "e": 50, // new + } + + assert.Equal(t, expected, oldMap) + }) +} diff --git a/parser/string.go b/parser/string.go new file mode 100644 index 0000000..ff1787c --- /dev/null +++ b/parser/string.go @@ -0,0 +1,186 @@ +package parser + +import ( + "fmt" + "net/url" + "strconv" + "time" +) + +// ParseStringSupportedTypes defines the type constraint for types that can be +// parsed from strings using the ParseString family of functions. +type ParseStringSupportedTypes interface { + string | + int | int8 | int16 | int32 | int64 | + uint | uint8 | uint16 | uint32 | uint64 | + float64 | + bool | time.Duration | url.URL +} + +// ParseString parses a string value into the specified type T. +// It uses the appropriate parsing function based on the target type. +// +// The function supports all types defined in ParseStringSupportedTypes. +// For integers, it parses base-10 numbers with appropriate bit sizes. +// For booleans, it accepts: "1", "t", "T", "TRUE", "true", "True", "0", "f", "F", "FALSE", "false", "False". +// For durations, it accepts strings like "300ms", "1.5h", "2h45m". +// For URLs, it parses according to RFC 3986. +// +// Returns an error if the string cannot be parsed into the target type. +// +// Example: +// +// num, err := ParseString[int]("42") +// duration, err := ParseString[time.Duration]("5s") +// isValid, err := ParseString[bool]("true") +// +//nolint:forcetypeassert +func ParseString[T ParseStringSupportedTypes](rawValue string) (T, error) { //nolint:forcetypeassert + var value T + + switch any(value).(type) { + case string: + value = any(rawValue).(T) + case int: + i, err := strconv.Atoi(rawValue) + if err != nil { + return value, err + } + value = any(i).(T) + case int8: + i, err := strconv.ParseInt(rawValue, 10, 8) + if err != nil { + return value, err + } + value = any(int8(i)).(T) + case int16: + i, err := strconv.ParseInt(rawValue, 10, 16) + if err != nil { + return value, err + } + value = any(int16(i)).(T) + case int32: + i, err := strconv.ParseInt(rawValue, 10, 32) + if err != nil { + return value, err + } + value = any(int32(i)).(T) + case int64: + i, err := strconv.ParseInt(rawValue, 10, 64) + if err != nil { + return value, err + } + value = any(i).(T) + case uint: + u, err := strconv.ParseUint(rawValue, 10, 0) + if err != nil { + return value, err + } + value = any(uint(u)).(T) + case uint8: + u, err := strconv.ParseUint(rawValue, 10, 8) + if err != nil { + return value, err + } + value = any(uint8(u)).(T) + case uint16: + u, err := strconv.ParseUint(rawValue, 10, 16) + if err != nil { + return value, err + } + value = any(uint16(u)).(T) + case uint32: + u, err := strconv.ParseUint(rawValue, 10, 32) + if err != nil { + return value, err + } + value = any(uint32(u)).(T) + case uint64: + u, err := strconv.ParseUint(rawValue, 10, 64) + if err != nil { + return value, err + } + value = any(u).(T) + case float64: + f, err := strconv.ParseFloat(rawValue, 64) + if err != nil { + return value, err + } + value = any(f).(T) + case bool: + b, err := strconv.ParseBool(rawValue) + if err != nil { + return value, err + } + value = any(b).(T) + case time.Duration: + d, err := time.ParseDuration(rawValue) + if err != nil { + return value, err + } + value = any(d).(T) + case url.URL: + u, err := url.Parse(rawValue) + if err != nil { + return value, err + } + value = any(*u).(T) + default: + return value, fmt.Errorf("unsupported type: %T", value) + } + + return value, nil +} + +// ParseStringOrZero parses a string value into the specified type T. +// If parsing fails, it returns the zero value for type T instead of an error. +// +// This is useful when you want a simple conversion that falls back to a +// default zero value on error. +// +// Example: +// +// num := ParseStringOrZero[int]("invalid") // returns 0 +// num := ParseStringOrZero[int]("42") // returns 42 +func ParseStringOrZero[T ParseStringSupportedTypes](rawValue string) T { + parsedValue, _ := ParseString[T](rawValue) + return parsedValue +} + +// ParseStringWithDefault parses a string value into the specified type T. +// If parsing fails, it returns the provided default value along with the error. +// +// Unlike ParseString, this ensures a usable value is always returned, +// but still provides error information for logging or handling. +// +// Example: +// +// num, err := ParseStringWithDefault("invalid", 10) +// // returns: 10, error +// num, err := ParseStringWithDefault("42", 10) +// // returns: 42, nil +func ParseStringWithDefault[T ParseStringSupportedTypes](rawValue string, dft T) (T, error) { + parsedValue, err := ParseString[T](rawValue) + if err != nil { + return dft, err + } + return parsedValue, nil +} + +// ParseStringOrDefault parses a string value into the specified type T. +// If parsing fails, it returns the provided default value and suppresses the error. +// +// This is the most convenient option when you want a fallback value +// and don't need to handle the error explicitly. +// +// Example: +// +// num := ParseStringOrDefault("invalid", 10) // returns 10 +// num := ParseStringOrDefault("42", 10) // returns 42 +func ParseStringOrDefault[T ParseStringSupportedTypes](rawValue string, dft T) T { + parsedValue, err := ParseString[T](rawValue) + if err != nil { + return dft + } + return parsedValue +} diff --git a/parser/string_test.go b/parser/string_test.go new file mode 100644 index 0000000..66c4e06 --- /dev/null +++ b/parser/string_test.go @@ -0,0 +1,528 @@ +package parser + +import ( + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseString(t *testing.T) { + t.Run("ParseString to string", func(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"empty string", "", ""}, + {"simple string", "hello", "hello"}, + {"string with spaces", "hello world", "hello world"}, + {"numeric string", "123", "123"}, + {"special characters", "!@#$%^&*()", "!@#$%^&*()"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, err := ParseString[string](test.input) + require.NoError(t, err) + assert.Equal(t, test.expected, result) + }) + } + }) + + t.Run("ParseString to int", func(t *testing.T) { + tests := []struct { + name string + input string + expected int + expectError bool + }{ + {"positive int", "42", 42, false}, + {"negative int", "-42", -42, false}, + {"zero", "0", 0, false}, + {"invalid int", "abc", 0, true}, + {"float string", "3.14", 0, true}, + {"empty string", "", 0, true}, + {"overflow string", "99999999999999999999", 0, true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, err := ParseString[int](test.input) + if test.expectError { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, test.expected, result) + } + }) + } + }) + + t.Run("ParseString to int8", func(t *testing.T) { + tests := []struct { + name string + input string + expected int8 + expectError bool + }{ + {"valid int8", "127", 127, false}, + {"min int8", "-128", -128, false}, + {"overflow int8", "128", 0, true}, + {"underflow int8", "-129", 0, true}, + {"invalid", "abc", 0, true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, err := ParseString[int8](test.input) + if test.expectError { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, test.expected, result) + } + }) + } + }) + + t.Run("ParseString to int16", func(t *testing.T) { + tests := []struct { + name string + input string + expected int16 + expectError bool + }{ + {"valid int16", "32767", 32767, false}, + {"min int16", "-32768", -32768, false}, + {"overflow int16", "32768", 0, true}, + {"underflow int16", "-32769", 0, true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, err := ParseString[int16](test.input) + if test.expectError { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, test.expected, result) + } + }) + } + }) + + t.Run("ParseString to int32", func(t *testing.T) { + tests := []struct { + name string + input string + expected int32 + expectError bool + }{ + {"valid int32", "2147483647", 2147483647, false}, + {"min int32", "-2147483648", -2147483648, false}, + {"overflow int32", "2147483648", 0, true}, + {"underflow int32", "-2147483649", 0, true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, err := ParseString[int32](test.input) + if test.expectError { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, test.expected, result) + } + }) + } + }) + + t.Run("ParseString to int64", func(t *testing.T) { + tests := []struct { + name string + input string + expected int64 + expectError bool + }{ + {"valid int64", "9223372036854775807", 9223372036854775807, false}, + {"min int64", "-9223372036854775808", -9223372036854775808, false}, + {"large number", "123456789012345", 123456789012345, false}, + {"invalid", "not a number", 0, true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, err := ParseString[int64](test.input) + if test.expectError { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, test.expected, result) + } + }) + } + }) + + t.Run("ParseString to uint", func(t *testing.T) { + tests := []struct { + name string + input string + expected uint + expectError bool + }{ + {"valid uint", "42", 42, false}, + {"zero", "0", 0, false}, + {"large uint", "4294967295", 4294967295, false}, + {"negative", "-1", 0, true}, + {"invalid", "abc", 0, true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, err := ParseString[uint](test.input) + if test.expectError { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, test.expected, result) + } + }) + } + }) + + t.Run("ParseString to uint8", func(t *testing.T) { + tests := []struct { + name string + input string + expected uint8 + expectError bool + }{ + {"valid uint8", "255", 255, false}, + {"min uint8", "0", 0, false}, + {"overflow uint8", "256", 0, true}, + {"negative", "-1", 0, true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, err := ParseString[uint8](test.input) + if test.expectError { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, test.expected, result) + } + }) + } + }) + + t.Run("ParseString to uint16", func(t *testing.T) { + tests := []struct { + name string + input string + expected uint16 + expectError bool + }{ + {"valid uint16", "65535", 65535, false}, + {"min uint16", "0", 0, false}, + {"overflow uint16", "65536", 0, true}, + {"negative", "-1", 0, true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, err := ParseString[uint16](test.input) + if test.expectError { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, test.expected, result) + } + }) + } + }) + + t.Run("ParseString to uint32", func(t *testing.T) { + tests := []struct { + name string + input string + expected uint32 + expectError bool + }{ + {"valid uint32", "4294967295", 4294967295, false}, + {"min uint32", "0", 0, false}, + {"overflow uint32", "4294967296", 0, true}, + {"negative", "-1", 0, true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, err := ParseString[uint32](test.input) + if test.expectError { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, test.expected, result) + } + }) + } + }) + + t.Run("ParseString to uint64", func(t *testing.T) { + tests := []struct { + name string + input string + expected uint64 + expectError bool + }{ + {"valid uint64", "18446744073709551615", 18446744073709551615, false}, + {"min uint64", "0", 0, false}, + {"large number", "123456789012345", 123456789012345, false}, + {"negative", "-1", 0, true}, + {"invalid", "not a number", 0, true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, err := ParseString[uint64](test.input) + if test.expectError { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, test.expected, result) + } + }) + } + }) + + t.Run("ParseString to float64", func(t *testing.T) { + tests := []struct { + name string + input string + expected float64 + expectError bool + }{ + {"integer", "42", 42.0, false}, + {"decimal", "3.14159", 3.14159, false}, + {"negative", "-2.5", -2.5, false}, + {"scientific notation", "1.23e10", 1.23e10, false}, + {"zero", "0", 0.0, false}, + {"invalid", "not a number", 0, true}, + {"empty", "", 0, true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, err := ParseString[float64](test.input) + if test.expectError { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.InDelta(t, test.expected, result, 0.0001) + } + }) + } + }) + + t.Run("ParseString to bool", func(t *testing.T) { + tests := []struct { + name string + input string + expected bool + expectError bool + }{ + {"true lowercase", "true", true, false}, + {"True mixed case", "True", true, false}, + {"TRUE uppercase", "TRUE", true, false}, + {"1 as true", "1", true, false}, + {"false lowercase", "false", false, false}, + {"False mixed case", "False", false, false}, + {"FALSE uppercase", "FALSE", false, false}, + {"0 as false", "0", false, false}, + {"invalid", "yes", false, true}, + {"empty", "", false, true}, + {"numeric non-binary", "2", false, true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, err := ParseString[bool](test.input) + if test.expectError { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, test.expected, result) + } + }) + } + }) + + t.Run("ParseString to time.Duration", func(t *testing.T) { + tests := []struct { + name string + input string + expected time.Duration + expectError bool + }{ + {"seconds", "10s", 10 * time.Second, false}, + {"minutes", "5m", 5 * time.Minute, false}, + {"hours", "2h", 2 * time.Hour, false}, + {"combined", "1h30m45s", time.Hour + 30*time.Minute + 45*time.Second, false}, + {"milliseconds", "500ms", 500 * time.Millisecond, false}, + {"microseconds", "100us", 100 * time.Microsecond, false}, + {"nanoseconds", "50ns", 50 * time.Nanosecond, false}, + {"negative", "-5s", -5 * time.Second, false}, + {"invalid", "5x", 0, true}, + {"empty", "", 0, true}, + {"no unit", "100", 0, true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, err := ParseString[time.Duration](test.input) + if test.expectError { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, test.expected, result) + } + }) + } + }) + + t.Run("ParseString to url.URL", func(t *testing.T) { + tests := []struct { + name string + input string + checkFunc func(t *testing.T, u url.URL) + expectError bool + }{ + { + name: "http URL", + input: "http://example.com", + checkFunc: func(t *testing.T, u url.URL) { + t.Helper() + assert.Equal(t, "http", u.Scheme) + assert.Equal(t, "example.com", u.Host) + }, + expectError: false, + }, + { + name: "https URL with path", + input: "https://example.com/path/to/resource", + checkFunc: func(t *testing.T, u url.URL) { + t.Helper() + assert.Equal(t, "https", u.Scheme) + assert.Equal(t, "example.com", u.Host) + assert.Equal(t, "/path/to/resource", u.Path) + }, + expectError: false, + }, + { + name: "URL with query parameters", + input: "https://example.com/search?q=test&page=1", + checkFunc: func(t *testing.T, u url.URL) { + t.Helper() + assert.Equal(t, "https", u.Scheme) + assert.Equal(t, "example.com", u.Host) + assert.Equal(t, "/search", u.Path) + assert.Equal(t, "q=test&page=1", u.RawQuery) + }, + expectError: false, + }, + { + name: "URL with port", + input: "http://localhost:8080/api", + checkFunc: func(t *testing.T, u url.URL) { + t.Helper() + assert.Equal(t, "http", u.Scheme) + assert.Equal(t, "localhost:8080", u.Host) + assert.Equal(t, "/api", u.Path) + }, + expectError: false, + }, + { + name: "URL with fragment", + input: "https://example.com/page#section", + checkFunc: func(t *testing.T, u url.URL) { + t.Helper() + assert.Equal(t, "https", u.Scheme) + assert.Equal(t, "example.com", u.Host) + assert.Equal(t, "/page", u.Path) + assert.Equal(t, "section", u.Fragment) + }, + expectError: false, + }, + { + name: "relative path", + input: "/path/to/resource", + checkFunc: func(t *testing.T, u url.URL) { + t.Helper() + assert.Empty(t, u.Scheme) + assert.Empty(t, u.Host) + assert.Equal(t, "/path/to/resource", u.Path) + }, + expectError: false, + }, + { + name: "empty string", + input: "", + checkFunc: func(t *testing.T, u url.URL) { + t.Helper() + assert.Empty(t, u.String()) + }, + expectError: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, err := ParseString[url.URL](test.input) + if test.expectError { + assert.Error(t, err) + } else { + require.NoError(t, err) + if test.checkFunc != nil { + test.checkFunc(t, result) + } + } + }) + } + }) + + t.Run("Edge cases", func(t *testing.T) { + t.Run("whitespace handling for numeric types", func(t *testing.T) { + result, err := ParseString[int](" 42 ") + require.Error(t, err) + assert.Equal(t, 0, result) + }) + + t.Run("leading zeros for int", func(t *testing.T) { + result, err := ParseString[int]("007") + require.NoError(t, err) + assert.Equal(t, 7, result) + }) + + t.Run("plus sign for positive numbers", func(t *testing.T) { + result, err := ParseString[int]("+42") + require.NoError(t, err) + assert.Equal(t, 42, result) + }) + + t.Run("case sensitivity for bool", func(t *testing.T) { + testCases := []string{"t", "T", "f", "F"} + for _, tc := range testCases { + result, err := ParseString[bool](tc) + require.NoError(t, err) + if tc == "t" || tc == "T" { + assert.True(t, result) + } else { + assert.False(t, result) + } + } + }) + }) +} diff --git a/slice/slice.go b/slice/slice.go new file mode 100644 index 0000000..9d83663 --- /dev/null +++ b/slice/slice.go @@ -0,0 +1,92 @@ +package slice + +import ( + "math/rand/v2" + "time" +) + +// Cycle returns a function that cycles through the provided items infinitely. +// Each call to the returned function returns the next item in sequence, wrapping +// back to the first item after the last one is returned. +// +// If no items are provided, the returned function will always return the zero +// value for type T. +// +// The returned function is not safe for concurrent use. If you need to call it +// from multiple goroutines, you must synchronize access with a mutex or similar. +// +// Example: +// +// next := Cycle(1, 2, 3) +// fmt.Println(next()) // 1 +// fmt.Println(next()) // 2 +// fmt.Println(next()) // 3 +// fmt.Println(next()) // 1 +func Cycle[T any](items ...T) func() T { + if len(items) == 0 { + var zero T + return func() T { return zero } + } + + index := 0 + return func() T { + item := items[index] + index = (index + 1) % len(items) + return item + } +} + +// RandomCycle returns a function that cycles through the provided items with +// randomization. It cycles through all items sequentially, but when it completes +// a full cycle, it randomly picks a new starting point for the next cycle. +// +// The localRand parameter can be used to provide a custom random number generator. +// If nil, a new generator will be created using the current time as the seed. +// +// The returned function is not safe for concurrent use. If you need to call it +// from multiple goroutines, you must synchronize access with a mutex or similar. +// +// Special cases: +// - If no items are provided, the returned function always returns the zero value for type T. +// - If only one item is provided, the returned function always returns that item. +// +// Example: +// +// next := RandomCycle(nil, "a", "b", "c") +// // Might produce: "b", "c", "a", "c", "a", "b", ... +// // (cycles through all items, then starts from a random position) +func RandomCycle[T any](localRand *rand.Rand, items ...T) func() T { + switch sliceLen := len(items); sliceLen { + case 0: + var zero T + return func() T { return zero } + case 1: + return func() T { return items[0] } + default: + if localRand == nil { + //nolint:gosec + localRand = rand.New( + rand.NewPCG( + uint64(time.Now().UnixNano()), + uint64(time.Now().UnixNano()>>32), + ), + ) + } + + currentIndex := localRand.IntN(sliceLen) + stopIndex := currentIndex + return func() T { + item := items[currentIndex] + currentIndex++ + if currentIndex == sliceLen { + currentIndex = 0 + } + if currentIndex == stopIndex { + currentIndex = localRand.IntN(sliceLen) + stopIndex = currentIndex + } + + return item + } + } +} diff --git a/slice/slice_test.go b/slice/slice_test.go new file mode 100644 index 0000000..7bab560 --- /dev/null +++ b/slice/slice_test.go @@ -0,0 +1,324 @@ +package slice + +import ( + "math/rand/v2" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCycle(t *testing.T) { + t.Run("cycles through items", func(t *testing.T) { + next := Cycle(1, 2, 3) + + assert.Equal(t, 1, next()) + assert.Equal(t, 2, next()) + assert.Equal(t, 3, next()) + assert.Equal(t, 1, next()) // wraps back to first + assert.Equal(t, 2, next()) + assert.Equal(t, 3, next()) + assert.Equal(t, 1, next()) // wraps again + }) + + t.Run("cycles through single item", func(t *testing.T) { + next := Cycle(42) + + assert.Equal(t, 42, next()) + assert.Equal(t, 42, next()) + assert.Equal(t, 42, next()) + }) + + t.Run("returns zero value for empty slice", func(t *testing.T) { + next := Cycle[int]() + + assert.Equal(t, 0, next()) + assert.Equal(t, 0, next()) + assert.Equal(t, 0, next()) + }) + + t.Run("works with string type", func(t *testing.T) { + next := Cycle("a", "b", "c") + + assert.Equal(t, "a", next()) + assert.Equal(t, "b", next()) + assert.Equal(t, "c", next()) + assert.Equal(t, "a", next()) + }) + + t.Run("works with struct type", func(t *testing.T) { + type Point struct { + X, Y int + } + next := Cycle( + Point{1, 2}, + Point{3, 4}, + ) + + assert.Equal(t, Point{1, 2}, next()) + assert.Equal(t, Point{3, 4}, next()) + assert.Equal(t, Point{1, 2}, next()) + }) + + t.Run("works with pointer type", func(t *testing.T) { + val1, val2 := 10, 20 + next := Cycle(&val1, &val2) + + assert.Equal(t, &val1, next()) + assert.Equal(t, &val2, next()) + assert.Equal(t, &val1, next()) + }) + + t.Run("empty string slice returns empty string", func(t *testing.T) { + next := Cycle[string]() + + assert.Empty(t, next()) + assert.Empty(t, next()) + }) + + t.Run("multiple cycles work correctly", func(t *testing.T) { + next := Cycle("x", "y") + + // First cycle + assert.Equal(t, "x", next()) + assert.Equal(t, "y", next()) + // Second cycle + assert.Equal(t, "x", next()) + assert.Equal(t, "y", next()) + // Third cycle + assert.Equal(t, "x", next()) + assert.Equal(t, "y", next()) + }) + + t.Run("each function instance maintains its own state", func(t *testing.T) { + next1 := Cycle(1, 2, 3) + next2 := Cycle(1, 2, 3) + + assert.Equal(t, 1, next1()) + assert.Equal(t, 1, next2()) + assert.Equal(t, 2, next1()) + assert.Equal(t, 2, next2()) + assert.Equal(t, 3, next1()) + assert.Equal(t, 1, next1()) + assert.Equal(t, 3, next2()) + }) + + t.Run("works with boolean type", func(t *testing.T) { + next := Cycle(true, false) + + assert.True(t, next()) + assert.False(t, next()) + assert.True(t, next()) + assert.False(t, next()) + }) + + t.Run("works with float type", func(t *testing.T) { + next := Cycle(1.1, 2.2, 3.3) + + assert.InDelta(t, 1.1, next(), 0.001) + assert.InDelta(t, 2.2, next(), 0.001) + assert.InDelta(t, 3.3, next(), 0.001) + assert.InDelta(t, 1.1, next(), 0.001) + }) +} + +func TestRandomCycle(t *testing.T) { + t.Run("returns zero value for empty slice", func(t *testing.T) { + next := RandomCycle[int](nil) + + assert.Equal(t, 0, next()) + assert.Equal(t, 0, next()) + assert.Equal(t, 0, next()) + }) + + t.Run("returns same item for single item slice", func(t *testing.T) { + next := RandomCycle(nil, 42) + + assert.Equal(t, 42, next()) + assert.Equal(t, 42, next()) + assert.Equal(t, 42, next()) + }) + + t.Run("cycles through all items with seeded random", func(t *testing.T) { + seed := rand.NewPCG(1, 2) + r := rand.New(seed) + next := RandomCycle(r, "a", "b", "c") + + // Collect items to verify all are returned + seen := make(map[string]bool) + for range 100 { + item := next() + seen[item] = true + } + + // All items should have been seen + assert.True(t, seen["a"], "should see 'a'") + assert.True(t, seen["b"], "should see 'b'") + assert.True(t, seen["c"], "should see 'c'") + }) + + t.Run("works with seeded random generator", func(t *testing.T) { + // Using same seed should produce same sequence + seed1 := rand.NewPCG(42, 42) + r1 := rand.New(seed1) + next1 := RandomCycle(r1, 1, 2, 3) + + seed2 := rand.NewPCG(42, 42) + r2 := rand.New(seed2) + next2 := RandomCycle(r2, 1, 2, 3) + + // First few calls should match + for range 10 { + assert.Equal(t, next1(), next2(), "calls with same seed should match") + } + }) + + t.Run("creates own random generator when nil provided", func(t *testing.T) { + next := RandomCycle[int](nil, 1, 2, 3) + + // Should not panic and should return valid values + for range 10 { + val := next() + assert.Contains(t, []int{1, 2, 3}, val) + } + }) + + t.Run("eventually returns all items in cycle", func(t *testing.T) { + seed := rand.NewPCG(123, 456) + r := rand.New(seed) + next := RandomCycle(r, 1, 2, 3, 4, 5) + + // Track items seen in current "window" + for range 5 { + seen := make(map[int]int) + // Collect enough items to ensure we see at least one full cycle + for range 10 { + item := next() + seen[item]++ + assert.Contains(t, []int{1, 2, 3, 4, 5}, item) + } + // Should see multiple items + assert.GreaterOrEqual(t, len(seen), 3, "should see at least 3 different items") + } + }) + + t.Run("works with string type", func(t *testing.T) { + seed := rand.NewPCG(1, 2) + r := rand.New(seed) + next := RandomCycle(r, "x", "y", "z") + + seen := make(map[string]bool) + for range 50 { + item := next() + seen[item] = true + assert.Contains(t, []string{"x", "y", "z"}, item) + } + + assert.True(t, seen["x"]) + assert.True(t, seen["y"]) + assert.True(t, seen["z"]) + }) + + t.Run("works with struct type", func(t *testing.T) { + type Item struct { + ID int + } + seed := rand.NewPCG(1, 2) + r := rand.New(seed) + next := RandomCycle(r, Item{1}, Item{2}, Item{3}) + + seen := make(map[int]bool) + for range 50 { + item := next() + seen[item.ID] = true + } + + assert.True(t, seen[1]) + assert.True(t, seen[2]) + assert.True(t, seen[3]) + }) + + t.Run("each function instance maintains its own state", func(t *testing.T) { + seed1 := rand.NewPCG(1, 2) + r1 := rand.New(seed1) + next1 := RandomCycle(r1, 1, 2, 3) + + seed2 := rand.NewPCG(3, 5) + r2 := rand.New(seed2) + next2 := RandomCycle(r2, 1, 2, 3) + + // Get a few values from each + vals1 := []int{next1(), next1(), next1()} + vals2 := []int{next2(), next2(), next2()} + + // They should be different (very high probability with different seeds) + assert.NotEqual(t, vals1, vals2, "different seeds should produce different sequences") + }) + + t.Run("with two items", func(t *testing.T) { + seed := rand.NewPCG(99, 100) + r := rand.New(seed) + next := RandomCycle(r, "a", "b") + + seen := make(map[string]bool) + for range 20 { + item := next() + seen[item] = true + assert.Contains(t, []string{"a", "b"}, item) + } + + assert.True(t, seen["a"]) + assert.True(t, seen["b"]) + }) + + t.Run("deterministic with same seed across multiple cycles", func(t *testing.T) { + // First run + seed1 := rand.NewPCG(777, 888) + r1 := rand.New(seed1) + next1 := RandomCycle(r1, 10, 20, 30) + sequence1 := make([]int, 20) + for i := range 20 { + sequence1[i] = next1() + } + + // Second run with same seed + seed2 := rand.NewPCG(777, 888) + r2 := rand.New(seed2) + next2 := RandomCycle(r2, 10, 20, 30) + sequence2 := make([]int, 20) + for i := range 20 { + sequence2[i] = next2() + } + + assert.Equal(t, sequence1, sequence2, "same seed should produce same sequence") + }) + + t.Run("empty string slice returns empty string", func(t *testing.T) { + next := RandomCycle[string](nil) + + assert.Empty(t, next()) + assert.Empty(t, next()) + }) + + t.Run("large number of items", func(t *testing.T) { + items := make([]int, 100) + for i := range 100 { + items[i] = i + } + + seed := rand.NewPCG(42, 43) + r := rand.New(seed) + next := RandomCycle(r, items...) + + seen := make(map[int]bool) + // Call enough times to likely see all items + for range 1000 { + item := next() + seen[item] = true + assert.GreaterOrEqual(t, item, 0) + assert.LessOrEqual(t, item, 99) + } + + // Should see a good variety of items + assert.Greater(t, len(seen), 90, "should see most items with 1000 calls") + }) +}