mirror of
https://github.com/aykhans/go-utils.git
synced 2025-10-15 10:25:56 +00:00
first commit
This commit is contained in:
23
.github/workflows/lint.yaml
vendored
Normal file
23
.github/workflows/lint.yaml
vendored
Normal file
@@ -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
|
21
.github/workflows/test.yaml
vendored
Normal file
21
.github/workflows/test.yaml
vendored
Normal file
@@ -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 ./...
|
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
bin/*
|
102
.golangci.yaml
Normal file
102
.golangci.yaml
Normal file
@@ -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:]"
|
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -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.
|
147
README.md
Normal file
147
README.md
Normal file
@@ -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
|
41
Taskfile.yaml
Normal file
41
Taskfile.yaml
Normal file
@@ -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}}
|
5
common/convert.go
Normal file
5
common/convert.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package common
|
||||
|
||||
func ToPtr[T any](value T) *T {
|
||||
return &value
|
||||
}
|
146
errors/handler.go
Normal file
146
errors/handler.go
Normal file
@@ -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,
|
||||
}
|
||||
}
|
384
errors/handler_test.go
Normal file
384
errors/handler_test.go
Normal file
@@ -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")
|
||||
})
|
||||
}
|
14
go.mod
Normal file
14
go.mod
Normal file
@@ -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
|
||||
)
|
23
go.sum
Normal file
23
go.sum
Normal file
@@ -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=
|
38
maps/base.go
Normal file
38
maps/base.go
Normal file
@@ -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)
|
||||
}
|
317
maps/base_test.go
Normal file
317
maps/base_test.go
Normal file
@@ -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)
|
||||
})
|
||||
}
|
186
parser/string.go
Normal file
186
parser/string.go
Normal file
@@ -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
|
||||
}
|
528
parser/string_test.go
Normal file
528
parser/string_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
92
slice/slice.go
Normal file
92
slice/slice.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
324
slice/slice_test.go
Normal file
324
slice/slice_test.go
Normal file
@@ -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")
|
||||
})
|
||||
}
|
Reference in New Issue
Block a user