diff --git a/.golangci.yml b/.golangci.yml index 05828ef..38850f4 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -6,6 +6,7 @@ linters: - exhaustruct - wrapcheck - wsl + - tagalign settings: lll: # Max line length, lines longer will be reported. @@ -15,17 +16,6 @@ linters: # Tab width in spaces. # Default: 1 tab-width: 1 - tagliatelle: - # Checks the struct tag name case. - case: - rules: - json: snake - yaml: snake - mapstructure: snake - whatever: snake - recvcheck: - exclusions: - - "*.UnmarshalJSON" wsl_v5: allow-first-in-block: true allow-whole-block: false diff --git a/any.go b/any.go index 776843f..d9de581 100644 --- a/any.go +++ b/any.go @@ -34,22 +34,6 @@ func NewEnvAnyVariable(name string) EnvAny { } } -// UnmarshalJSON implements json.Unmarshaler. -func (ev *EnvAny) UnmarshalJSON(b []byte) error { - type Plain EnvAny - - var rawValue Plain - - err := json.Unmarshal(b, &rawValue) - if err != nil { - return err - } - - *ev = EnvAny(rawValue) - - return nil -} - // IsZero checks if the instance is empty. func (ev EnvAny) IsZero() bool { return (ev.Variable == nil || *ev.Variable == "") && diff --git a/environment.go b/environment.go index f06a0a1..c1e9691 100644 --- a/environment.go +++ b/environment.go @@ -2,7 +2,6 @@ package goenvconf import ( - "encoding/json" "errors" "os" "strconv" @@ -39,27 +38,6 @@ func NewEnvStringVariable(name string) EnvString { } } -// UnmarshalJSON implements json.Unmarshaler. -func (ev *EnvString) UnmarshalJSON(b []byte) error { - type Plain EnvString - - var rawValue Plain - - err := json.Unmarshal(b, &rawValue) - if err != nil { - return err - } - - value := EnvString(rawValue) - if value.IsZero() { - return ErrEnvironmentValueRequired - } - - *ev = value - - return nil -} - // IsZero checks if the instance is empty. func (ev EnvString) IsZero() bool { return (ev.Variable == nil || *ev.Variable == "") && @@ -185,27 +163,6 @@ func (ev EnvInt) Equal(target EnvInt) bool { (ev.Variable != nil && target.Variable != nil && *ev.Variable == *target.Variable) } -// UnmarshalJSON implements json.Unmarshaler. -func (ev *EnvInt) UnmarshalJSON(b []byte) error { - type Plain EnvInt - - var rawValue Plain - - err := json.Unmarshal(b, &rawValue) - if err != nil { - return err - } - - value := EnvInt(rawValue) - if value.IsZero() { - return ErrEnvironmentValueRequired - } - - *ev = value - - return nil -} - // Get gets literal value or from system environment. func (ev EnvInt) Get() (int64, error) { if ev.IsZero() { @@ -310,27 +267,6 @@ func (ev EnvBool) Equal(target EnvBool) bool { (ev.Variable != nil && target.Variable != nil && *ev.Variable == *target.Variable) } -// UnmarshalJSON implements json.Unmarshaler. -func (ev *EnvBool) UnmarshalJSON(b []byte) error { - type Plain EnvBool - - var rawValue Plain - - err := json.Unmarshal(b, &rawValue) - if err != nil { - return err - } - - value := EnvBool(rawValue) - if value.IsZero() { - return ErrEnvironmentValueRequired - } - - *ev = value - - return nil -} - // Get gets literal value or from system environment. func (ev EnvBool) Get() (bool, error) { if ev.IsZero() { @@ -435,27 +371,6 @@ func (ev EnvFloat) Equal(target EnvFloat) bool { (ev.Variable != nil && target.Variable != nil && *ev.Variable == *target.Variable) } -// UnmarshalJSON implements json.Unmarshaler. -func (ev *EnvFloat) UnmarshalJSON(b []byte) error { - type Plain EnvFloat - - var rawValue Plain - - err := json.Unmarshal(b, &rawValue) - if err != nil { - return err - } - - value := EnvFloat(rawValue) - if value.IsZero() { - return ErrEnvironmentValueRequired - } - - *ev = value - - return nil -} - // Get gets literal value or from system environment. func (ev EnvFloat) Get() (float64, error) { if ev.IsZero() { diff --git a/error.go b/error.go new file mode 100644 index 0000000..5e13187 --- /dev/null +++ b/error.go @@ -0,0 +1,47 @@ +package goenvconf + +import "fmt" + +var ( + // ErrEnvironmentValueRequired occurs when both value and env fields are null or empty. + ErrEnvironmentValueRequired = ParseEnvError{ + Code: "EmptyEnv", + Detail: "require either value or env", + } + + // ErrEnvironmentVariableValueRequired the error that occurs when the value from environment variable is empty. + ErrEnvironmentVariableValueRequired = ParseEnvError{ + Code: "EmptyVar", + Detail: "the environment variable value is empty", + } +) + +const ( + // ErrCodeParseEnvFailed is the error code when parsing environment variable failed. + ErrCodeParseEnvFailed = "ParseEnvFailed" +) + +// ParseEnvError structures a detailed error for parsed env. +type ParseEnvError struct { + Code string `json:"code" jsonschema:"enum=EmptyEnv,enum=EmptyVar,enum=ParseEnvFailed"` + Detail string `json:"detail"` + Hint string `json:"hint,omitempty"` +} + +// NewParseEnvFailedError creates a [ParseEnvError] for parsing env variable errors. +func NewParseEnvFailedError(detail string, hint string) ParseEnvError { + return ParseEnvError{ + Code: ErrCodeParseEnvFailed, + Detail: detail, + Hint: hint, + } +} + +// Error returns the error message. +func (pee ParseEnvError) Error() string { + if pee.Hint != "" { + return fmt.Sprintf("%s: %s. Hint: %s", pee.Code, pee.Detail, pee.Hint) + } + + return pee.Code + ": " + pee.Detail +} diff --git a/map.go b/map.go index 638d1f5..a071575 100644 --- a/map.go +++ b/map.go @@ -1,7 +1,6 @@ package goenvconf import ( - "encoding/json" "maps" "os" ) @@ -52,22 +51,6 @@ func (ev EnvMapString) Equal(target EnvMapString) bool { (ev.Value != nil && target.Value != nil && maps.Equal(ev.Value, target.Value)) } -// UnmarshalJSON implements json.Unmarshaler. -func (ev *EnvMapString) UnmarshalJSON(b []byte) error { - type Plain EnvMapString - - var rawValue Plain - - err := json.Unmarshal(b, &rawValue) - if err != nil { - return err - } - - *ev = EnvMapString(rawValue) - - return nil -} - // Get gets literal value or from system environment. func (ev EnvMapString) Get() (map[string]string, error) { if ev.Variable != nil && *ev.Variable != "" { @@ -142,22 +125,6 @@ func (ev EnvMapInt) Equal(target EnvMapInt) bool { (ev.Value != nil && target.Value != nil && maps.Equal(ev.Value, target.Value)) } -// UnmarshalJSON implements json.Unmarshaler. -func (ev *EnvMapInt) UnmarshalJSON(b []byte) error { - type Plain EnvMapInt - - var rawValue Plain - - err := json.Unmarshal(b, &rawValue) - if err != nil { - return err - } - - *ev = EnvMapInt(rawValue) - - return nil -} - // Get gets literal value or from system environment. func (ev EnvMapInt) Get() (map[string]int64, error) { if ev.Variable != nil && *ev.Variable != "" { @@ -232,22 +199,6 @@ func (ev EnvMapFloat) Equal(target EnvMapFloat) bool { (ev.Value != nil && target.Value != nil && maps.Equal(ev.Value, target.Value)) } -// UnmarshalJSON implements json.Unmarshaler. -func (ev *EnvMapFloat) UnmarshalJSON(b []byte) error { - type Plain EnvMapFloat - - var rawValue Plain - - err := json.Unmarshal(b, &rawValue) - if err != nil { - return err - } - - *ev = EnvMapFloat(rawValue) - - return nil -} - // Get gets literal value or from system environment. func (ev EnvMapFloat) Get() (map[string]float64, error) { if ev.Variable != nil && *ev.Variable != "" { @@ -322,22 +273,6 @@ func (ev EnvMapBool) Equal(target EnvMapBool) bool { (ev.Value != nil && target.Value != nil && maps.Equal(ev.Value, target.Value)) } -// UnmarshalJSON implements json.Unmarshaler. -func (ev *EnvMapBool) UnmarshalJSON(b []byte) error { - type Plain EnvMapBool - - var rawValue Plain - - err := json.Unmarshal(b, &rawValue) - if err != nil { - return err - } - - *ev = EnvMapBool(rawValue) - - return nil -} - // Get gets literal value or from system environment. func (ev EnvMapBool) Get() (map[string]bool, error) { if ev.Variable != nil && *ev.Variable != "" { diff --git a/map_test.go b/map_test.go index a73ec1b..b04971a 100644 --- a/map_test.go +++ b/map_test.go @@ -68,7 +68,7 @@ func TestEnvMapString_GetCustom(t *testing.T) { Name: "invalid_map_format", Input: NewEnvMapStringVariable("INVALID_MAP"), GetFunc: mockGetEnvFuncForMaps(map[string]string{"INVALID_MAP": "invalid_format_no_equals"}, false), - ErrorMsg: ErrParseStringFailed.Error(), + ErrorMsg: "ParseEnvFailed", }, } @@ -133,7 +133,7 @@ func TestEnvMapInt_GetCustom(t *testing.T) { Name: "invalid_int_value", Input: NewEnvMapIntVariable("INVALID_MAP"), GetFunc: mockGetEnvFuncForMaps(map[string]string{"INVALID_MAP": "key=not_a_number"}, false), - ErrorMsg: ErrParseStringFailed.Error(), + ErrorMsg: "ParseEnvFailed", }, } @@ -198,7 +198,7 @@ func TestEnvMapFloat_GetCustom(t *testing.T) { Name: "invalid_float_value", Input: NewEnvMapFloatVariable("INVALID_MAP"), GetFunc: mockGetEnvFuncForMaps(map[string]string{"INVALID_MAP": "key=not_a_float"}, false), - ErrorMsg: ErrParseStringFailed.Error(), + ErrorMsg: "ParseEnvFailed", }, } @@ -263,7 +263,7 @@ func TestEnvMapBool_GetCustom(t *testing.T) { Name: "invalid_bool_value", Input: NewEnvMapBoolVariable("INVALID_MAP"), GetFunc: mockGetEnvFuncForMaps(map[string]string{"INVALID_MAP": "key=not_a_bool"}, false), - ErrorMsg: ErrParseStringFailed.Error(), + ErrorMsg: "ParseEnvFailed", }, } diff --git a/slice.go b/slice.go new file mode 100644 index 0000000..8524f93 --- /dev/null +++ b/slice.go @@ -0,0 +1,413 @@ +package goenvconf + +import ( + "fmt" + "os" + "slices" +) + +// EnvStringSlice represents either a literal string slice or an environment reference. +type EnvStringSlice struct { + Value []string `json:"value,omitempty" jsonschema:"anyof_required=value" mapstructure:"value" yaml:"value,omitempty"` + Variable *string `json:"env,omitempty" jsonschema:"anyof_required=env" mapstructure:"env" yaml:"env,omitempty"` +} + +// NewEnvStringSlice creates an EnvStringSlice instance. +func NewEnvStringSlice(env string, value []string) EnvStringSlice { + return EnvStringSlice{ + Variable: &env, + Value: value, + } +} + +// NewEnvStringSliceValue creates an EnvStringSlice with a literal value. +func NewEnvStringSliceValue(value []string) EnvStringSlice { + return EnvStringSlice{ + Value: value, + } +} + +// NewEnvStringSliceVariable creates an EnvStringSlice with a variable name. +func NewEnvStringSliceVariable(name string) EnvStringSlice { + return EnvStringSlice{ + Variable: &name, + } +} + +// IsZero checks if the instance is empty. +func (ev EnvStringSlice) IsZero() bool { + return (ev.Variable == nil || *ev.Variable == "") && + ev.Value == nil +} + +// Equal checks if this instance equals the target value. +func (ev EnvStringSlice) Equal(target EnvStringSlice) bool { + isSameValue := slices.Equal(ev.Value, target.Value) + if !isSameValue { + return false + } + + return (ev.Variable == nil && target.Variable == nil) || + (ev.Variable != nil && target.Variable != nil && *ev.Variable == *target.Variable) +} + +// Get gets literal value or from system environment. +func (ev EnvStringSlice) Get() ([]string, error) { + if ev.IsZero() { + return nil, ErrEnvironmentValueRequired + } + + var value string + + var envExisted bool + + if ev.Variable != nil && *ev.Variable != "" { + value, envExisted = os.LookupEnv(*ev.Variable) + if value != "" { + return ParseStringSliceFromString(value), nil + } + } + + if ev.Value != nil { + return ev.Value, nil + } + + if envExisted { + return []string{}, nil + } + + return nil, getEnvVariableValueRequiredError(ev.Variable) +} + +// GetCustom gets literal value or from system environment by a custom function. +func (ev EnvStringSlice) GetCustom(getFunc GetEnvFunc) ([]string, error) { + if ev.IsZero() { + return nil, ErrEnvironmentValueRequired + } + + if ev.Variable != nil && *ev.Variable != "" { + value, err := getFunc(*ev.Variable) + if err != nil { + return nil, err + } + + if value != "" { + return ParseStringSliceFromString(value), nil + } + } + + if ev.Value != nil { + return ev.Value, nil + } + + return nil, getEnvVariableValueRequiredError(ev.Variable) +} + +// EnvIntSlice represents either a literal integer slice or an environment reference. +type EnvIntSlice struct { + Value []int64 `json:"value,omitempty" jsonschema:"anyof_required=value" mapstructure:"value" yaml:"value,omitempty"` + Variable *string `json:"env,omitempty" jsonschema:"anyof_required=env" mapstructure:"env" yaml:"env,omitempty"` +} + +// NewEnvIntSlice creates an EnvIntSlice instance. +func NewEnvIntSlice(env string, value []int64) EnvIntSlice { + return EnvIntSlice{ + Variable: &env, + Value: value, + } +} + +// NewEnvIntSliceValue creates an EnvIntSlice with a literal value. +func NewEnvIntSliceValue(value []int64) EnvIntSlice { + return EnvIntSlice{ + Value: value, + } +} + +// NewEnvIntSliceVariable creates an EnvIntSlice with a variable name. +func NewEnvIntSliceVariable(name string) EnvIntSlice { + return EnvIntSlice{ + Variable: &name, + } +} + +// IsZero checks if the instance is empty. +func (ev EnvIntSlice) IsZero() bool { + return (ev.Variable == nil || *ev.Variable == "") && + ev.Value == nil +} + +// Equal checks if this instance equals the target value. +func (ev EnvIntSlice) Equal(target EnvIntSlice) bool { + isSameValue := slices.Equal(ev.Value, target.Value) + if !isSameValue { + return false + } + + return (ev.Variable == nil && target.Variable == nil) || + (ev.Variable != nil && target.Variable != nil && *ev.Variable == *target.Variable) +} + +// Get gets literal value or from system environment. +func (ev EnvIntSlice) Get() ([]int64, error) { + if ev.IsZero() { + return nil, ErrEnvironmentValueRequired + } + + var value string + + var envExisted bool + + if ev.Variable != nil && *ev.Variable != "" { + value, envExisted = os.LookupEnv(*ev.Variable) + if value != "" { + return parseIntSliceFromStringWithErrorPrefix[int64]( + value, + fmt.Sprintf("failed to parse %s: ", *ev.Variable), + ) + } + } + + if ev.Value != nil { + return ev.Value, nil + } + + if envExisted { + return []int64{}, nil + } + + return nil, getEnvVariableValueRequiredError(ev.Variable) +} + +// GetCustom gets literal value or from system environment by a custom function. +func (ev EnvIntSlice) GetCustom(getFunc GetEnvFunc) ([]int64, error) { + if ev.IsZero() { + return nil, ErrEnvironmentValueRequired + } + + if ev.Variable != nil && *ev.Variable != "" { + value, err := getFunc(*ev.Variable) + if err != nil { + return nil, err + } + + if value != "" { + return parseIntSliceFromStringWithErrorPrefix[int64]( + value, + fmt.Sprintf("failed to parse %s: ", *ev.Variable), + ) + } + } + + if ev.Value != nil { + return ev.Value, nil + } + + return nil, getEnvVariableValueRequiredError(ev.Variable) +} + +// EnvFloatSlice represents either a literal floating-point number slice or an environment reference. +type EnvFloatSlice struct { + Value []float64 `json:"value,omitempty" jsonschema:"anyof_required=value" mapstructure:"value" yaml:"value,omitempty"` + Variable *string `json:"env,omitempty" jsonschema:"anyof_required=env" mapstructure:"env" yaml:"env,omitempty"` +} + +// NewEnvFloatSlice creates an EnvFloatSlice instance. +func NewEnvFloatSlice(env string, value []float64) EnvFloatSlice { + return EnvFloatSlice{ + Variable: &env, + Value: value, + } +} + +// NewEnvFloatSliceValue creates an EnvFloatSlice with a literal value. +func NewEnvFloatSliceValue(value []float64) EnvFloatSlice { + return EnvFloatSlice{ + Value: value, + } +} + +// NewEnvFloatSliceVariable creates an EnvFloatSlice with a variable name. +func NewEnvFloatSliceVariable(name string) EnvFloatSlice { + return EnvFloatSlice{ + Variable: &name, + } +} + +// IsZero checks if the instance is empty. +func (ev EnvFloatSlice) IsZero() bool { + return (ev.Variable == nil || *ev.Variable == "") && + ev.Value == nil +} + +// Equal checks if this instance equals the target value. +func (ev EnvFloatSlice) Equal(target EnvFloatSlice) bool { + isSameValue := slices.Equal(ev.Value, target.Value) + if !isSameValue { + return false + } + + return (ev.Variable == nil && target.Variable == nil) || + (ev.Variable != nil && target.Variable != nil && *ev.Variable == *target.Variable) +} + +// Get gets literal value or from system environment. +func (ev EnvFloatSlice) Get() ([]float64, error) { + if ev.IsZero() { + return nil, ErrEnvironmentValueRequired + } + + var value string + + var envExisted bool + + if ev.Variable != nil && *ev.Variable != "" { + value, envExisted = os.LookupEnv(*ev.Variable) + if value != "" { + return parseFloatSliceFromStringWithErrorPrefix[float64]( + value, + fmt.Sprintf("failed to parse %s: ", *ev.Variable), + ) + } + } + + if ev.Value != nil { + return ev.Value, nil + } + + if envExisted { + return []float64{}, nil + } + + return nil, getEnvVariableValueRequiredError(ev.Variable) +} + +// GetCustom gets literal value or from system environment by a custom function. +func (ev EnvFloatSlice) GetCustom(getFunc GetEnvFunc) ([]float64, error) { + if ev.IsZero() { + return nil, ErrEnvironmentValueRequired + } + + if ev.Variable != nil && *ev.Variable != "" { + value, err := getFunc(*ev.Variable) + if err != nil { + return nil, err + } + + if value != "" { + return parseFloatSliceFromStringWithErrorPrefix[float64]( + value, + fmt.Sprintf("failed to parse %s: ", *ev.Variable), + ) + } + } + + if ev.Value != nil { + return ev.Value, nil + } + + return nil, getEnvVariableValueRequiredError(ev.Variable) +} + +// EnvBoolSlice represents either a literal boolean slice or an environment reference. +type EnvBoolSlice struct { + Value []bool `json:"value,omitempty" jsonschema:"anyof_required=value" mapstructure:"value" yaml:"value,omitempty"` + Variable *string `json:"env,omitempty" jsonschema:"anyof_required=env" mapstructure:"env" yaml:"env,omitempty"` +} + +// NewEnvBoolSlice creates an EnvBoolSlice instance. +func NewEnvBoolSlice(env string, value []bool) EnvBoolSlice { + return EnvBoolSlice{ + Variable: &env, + Value: value, + } +} + +// NewEnvBoolSliceValue creates an EnvBoolSlice with a literal value. +func NewEnvBoolSliceValue(value []bool) EnvBoolSlice { + return EnvBoolSlice{ + Value: value, + } +} + +// NewEnvBoolSliceVariable creates an EnvBoolSlice with a variable name. +func NewEnvBoolSliceVariable(name string) EnvBoolSlice { + return EnvBoolSlice{ + Variable: &name, + } +} + +// IsZero checks if the instance is empty. +func (ev EnvBoolSlice) IsZero() bool { + return (ev.Variable == nil || *ev.Variable == "") && + ev.Value == nil +} + +// Equal checks if this instance equals the target value. +func (ev EnvBoolSlice) Equal(target EnvBoolSlice) bool { + isSameValue := slices.Equal(ev.Value, target.Value) + if !isSameValue { + return false + } + + return (ev.Variable == nil && target.Variable == nil) || + (ev.Variable != nil && target.Variable != nil && *ev.Variable == *target.Variable) +} + +// Get gets literal value or from system environment. +func (ev EnvBoolSlice) Get() ([]bool, error) { + if ev.IsZero() { + return nil, ErrEnvironmentValueRequired + } + + var value string + + var envExisted bool + + if ev.Variable != nil && *ev.Variable != "" { + value, envExisted = os.LookupEnv(*ev.Variable) + if value != "" { + return parseBoolSliceFromStringWithErrorPrefix( + value, + fmt.Sprintf("failed to parse %s: ", *ev.Variable), + ) + } + } + + if ev.Value != nil { + return ev.Value, nil + } + + if envExisted { + return []bool{}, nil + } + + return nil, getEnvVariableValueRequiredError(ev.Variable) +} + +// GetCustom gets literal value or from system environment by a custom function. +func (ev EnvBoolSlice) GetCustom(getFunc GetEnvFunc) ([]bool, error) { + if ev.IsZero() { + return nil, ErrEnvironmentValueRequired + } + + if ev.Variable != nil && *ev.Variable != "" { + value, err := getFunc(*ev.Variable) + if err != nil { + return nil, err + } + + if value != "" { + return parseBoolSliceFromStringWithErrorPrefix( + value, + fmt.Sprintf("failed to parse %s: ", *ev.Variable), + ) + } + } + + if ev.Value != nil { + return ev.Value, nil + } + + return nil, getEnvVariableValueRequiredError(ev.Variable) +} diff --git a/slice_test.go b/slice_test.go new file mode 100644 index 0000000..a4d2556 --- /dev/null +++ b/slice_test.go @@ -0,0 +1,981 @@ +package goenvconf + +import ( + "fmt" + "testing" +) + +// TestEnvStringSlice tests the EnvStringSlice Get method +func TestEnvStringSlice(t *testing.T) { + t.Setenv("STRING_SLICE_VAR", "foo,bar,baz") + t.Setenv("EMPTY_STRING_SLICE", "") + + testCases := []struct { + Input EnvStringSlice + Expected []string + ErrorMsg string + }{ + { + Input: NewEnvStringSliceValue([]string{"foo", "bar"}), + Expected: []string{"foo", "bar"}, + }, + { + Input: NewEnvStringSliceVariable("STRING_SLICE_VAR"), + Expected: []string{"foo", "bar", "baz"}, + }, + { + Input: EnvStringSlice{}, + ErrorMsg: ErrEnvironmentValueRequired.Error(), + }, + { + Input: NewEnvStringSlice("SOME_MISSING_VAR", []string{"fallback"}), + Expected: []string{"fallback"}, + }, + { + Input: EnvStringSlice{ + Variable: toPtr(""), + }, + ErrorMsg: ErrEnvironmentValueRequired.Error(), + }, + { + Input: NewEnvStringSliceVariable("EMPTY_STRING_SLICE"), + Expected: []string{}, + }, + { + Input: NewEnvStringSliceVariable("MISSING_VAR"), + ErrorMsg: ErrEnvironmentVariableValueRequired.Error(), + }, + } + + for i, tc := range testCases { + t.Run(fmt.Sprint(i), func(t *testing.T) { + result, err := tc.Input.Get() + if tc.ErrorMsg != "" { + assertErrorContains(t, err, tc.ErrorMsg) + } else { + assertNilError(t, err) + assertDeepEqual(t, tc.Expected, result) + } + }) + } +} + +// TestEnvStringSlice_GetCustom tests the EnvStringSlice GetCustom method +func TestEnvStringSlice_GetCustom(t *testing.T) { + testCases := []struct { + Name string + Input EnvStringSlice + GetFunc GetEnvFunc + Expected []string + ErrorMsg string + }{ + { + Name: "literal value", + Input: NewEnvStringSliceValue([]string{"foo", "bar"}), + GetFunc: mockGetEnvFunc(map[string]string{}, false), + Expected: []string{"foo", "bar"}, + }, + { + Name: "variable from custom func", + Input: NewEnvStringSliceVariable("STRING_SLICE_VAR"), + GetFunc: mockGetEnvFunc(map[string]string{"STRING_SLICE_VAR": "a,b,c"}, false), + Expected: []string{"a", "b", "c"}, + }, + { + Name: "variable with fallback value", + Input: NewEnvStringSlice("MISSING_VAR", []string{"fallback"}), + GetFunc: mockGetEnvFunc(map[string]string{}, false), + Expected: []string{"fallback"}, + }, + { + Name: "empty variable uses fallback", + Input: NewEnvStringSlice("EMPTY_VAR", []string{"fallback"}), + GetFunc: mockGetEnvFunc(map[string]string{"EMPTY_VAR": ""}, false), + Expected: []string{"fallback"}, + }, + { + Name: "nil value and no variable", + Input: EnvStringSlice{}, + GetFunc: mockGetEnvFunc(map[string]string{}, false), + ErrorMsg: ErrEnvironmentValueRequired.Error(), + }, + { + Name: "custom func error", + Input: NewEnvStringSliceVariable("ERROR_VAR"), + GetFunc: mockGetEnvFunc(map[string]string{}, true), + ErrorMsg: "mock error", + }, + { + Name: "missing variable returns error", + Input: NewEnvStringSliceVariable("MISSING_VAR"), + GetFunc: mockGetEnvFunc(map[string]string{}, false), + ErrorMsg: ErrEnvironmentVariableValueRequired.Error(), + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + result, err := tc.Input.GetCustom(tc.GetFunc) + if tc.ErrorMsg != "" { + assertErrorContains(t, err, tc.ErrorMsg) + } else { + assertNilError(t, err) + assertDeepEqual(t, tc.Expected, result) + } + }) + } +} + +// TestEnvStringSlice_IsZero tests the EnvStringSlice IsZero method +func TestEnvStringSlice_IsZero(t *testing.T) { + testCases := []struct { + Name string + Input EnvStringSlice + Expected bool + }{ + { + Name: "empty struct", + Input: EnvStringSlice{}, + Expected: true, + }, + { + Name: "with value", + Input: NewEnvStringSliceValue([]string{"foo"}), + Expected: false, + }, + { + Name: "with variable", + Input: NewEnvStringSliceVariable("VAR"), + Expected: false, + }, + { + Name: "with empty variable", + Input: EnvStringSlice{Variable: toPtr("")}, + Expected: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + result := tc.Input.IsZero() + if result != tc.Expected { + t.Errorf("Expected %v, got %v", tc.Expected, result) + } + }) + } +} + +// TestEnvIntSlice tests the EnvIntSlice Get method +func TestEnvIntSlice(t *testing.T) { + t.Setenv("INT_SLICE_VAR", "1,2,3") + t.Setenv("EMPTY_INT_SLICE", "") + t.Setenv("INVALID_INT_SLICE", "1,abc,3") + + testCases := []struct { + Input EnvIntSlice + Expected []int64 + ErrorMsg string + }{ + { + Input: NewEnvIntSliceValue([]int64{10, 20, 30}), + Expected: []int64{10, 20, 30}, + }, + { + Input: NewEnvIntSliceVariable("INT_SLICE_VAR"), + Expected: []int64{1, 2, 3}, + }, + { + Input: EnvIntSlice{}, + ErrorMsg: ErrEnvironmentValueRequired.Error(), + }, + { + Input: NewEnvIntSlice("SOME_MISSING_VAR", []int64{100}), + Expected: []int64{100}, + }, + { + Input: EnvIntSlice{ + Variable: toPtr(""), + }, + ErrorMsg: ErrEnvironmentValueRequired.Error(), + }, + { + Input: NewEnvIntSliceVariable("EMPTY_INT_SLICE"), + Expected: []int64{}, + }, + { + Input: NewEnvIntSliceVariable("MISSING_VAR"), + ErrorMsg: ErrEnvironmentVariableValueRequired.Error(), + }, + { + Input: NewEnvIntSliceVariable("INVALID_INT_SLICE"), + ErrorMsg: "ParseEnvFailed: failed to parse INVALID_INT_SLICE: invalid integer slice syntax. Hint: 1", + }, + } + + for i, tc := range testCases { + t.Run(fmt.Sprint(i), func(t *testing.T) { + result, err := tc.Input.Get() + if tc.ErrorMsg != "" { + assertErrorContains(t, err, tc.ErrorMsg) + } else { + assertNilError(t, err) + assertDeepEqual(t, tc.Expected, result) + } + }) + } +} + +// TestEnvIntSlice_GetCustom tests the EnvIntSlice GetCustom method +func TestEnvIntSlice_GetCustom(t *testing.T) { + testCases := []struct { + Name string + Input EnvIntSlice + GetFunc GetEnvFunc + Expected []int64 + ErrorMsg string + }{ + { + Name: "literal value", + Input: NewEnvIntSliceValue([]int64{10, 20}), + GetFunc: mockGetEnvFunc(map[string]string{}, false), + Expected: []int64{10, 20}, + }, + { + Name: "variable from custom func", + Input: NewEnvIntSliceVariable("INT_SLICE_VAR"), + GetFunc: mockGetEnvFunc(map[string]string{"INT_SLICE_VAR": "5,10,15"}, false), + Expected: []int64{5, 10, 15}, + }, + { + Name: "variable with fallback value", + Input: NewEnvIntSlice("MISSING_VAR", []int64{99}), + GetFunc: mockGetEnvFunc(map[string]string{}, false), + Expected: []int64{99}, + }, + { + Name: "empty variable uses fallback", + Input: NewEnvIntSlice("EMPTY_VAR", []int64{42}), + GetFunc: mockGetEnvFunc(map[string]string{"EMPTY_VAR": ""}, false), + Expected: []int64{42}, + }, + { + Name: "nil value and no variable", + Input: EnvIntSlice{}, + GetFunc: mockGetEnvFunc(map[string]string{}, false), + ErrorMsg: ErrEnvironmentValueRequired.Error(), + }, + { + Name: "custom func error", + Input: NewEnvIntSliceVariable("ERROR_VAR"), + GetFunc: mockGetEnvFunc(map[string]string{}, true), + ErrorMsg: "mock error", + }, + { + Name: "invalid int format", + Input: NewEnvIntSliceVariable("INVALID_VAR"), + GetFunc: mockGetEnvFunc(map[string]string{"INVALID_VAR": "1,abc,3"}, false), + ErrorMsg: "failed to parse INVALID_VAR: invalid integer slice syntax. Hint: 1", + }, + { + Name: "missing variable no fallback", + Input: NewEnvIntSliceVariable("MISSING_VAR"), + GetFunc: mockGetEnvFunc(map[string]string{}, false), + ErrorMsg: ErrEnvironmentVariableValueRequired.Error(), + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + result, err := tc.Input.GetCustom(tc.GetFunc) + if tc.ErrorMsg != "" { + assertErrorContains(t, err, tc.ErrorMsg) + } else { + assertNilError(t, err) + assertDeepEqual(t, tc.Expected, result) + } + }) + } +} + +// TestEnvIntSlice_IsZero tests the EnvIntSlice IsZero method +func TestEnvIntSlice_IsZero(t *testing.T) { + testCases := []struct { + Name string + Input EnvIntSlice + Expected bool + }{ + { + Name: "empty struct", + Input: EnvIntSlice{}, + Expected: true, + }, + { + Name: "with value", + Input: NewEnvIntSliceValue([]int64{1}), + Expected: false, + }, + { + Name: "with variable", + Input: NewEnvIntSliceVariable("VAR"), + Expected: false, + }, + { + Name: "with empty variable", + Input: EnvIntSlice{Variable: toPtr("")}, + Expected: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + result := tc.Input.IsZero() + if result != tc.Expected { + t.Errorf("Expected %v, got %v", tc.Expected, result) + } + }) + } +} + +// TestEnvFloatSlice tests the EnvFloatSlice Get method +func TestEnvFloatSlice(t *testing.T) { + t.Setenv("FLOAT_SLICE_VAR", "1.5,2.5,3.5") + t.Setenv("EMPTY_FLOAT_SLICE", "") + t.Setenv("INVALID_FLOAT_SLICE", "1.5,abc,3.5") + + testCases := []struct { + Input EnvFloatSlice + Expected []float64 + ErrorMsg string + }{ + { + Input: NewEnvFloatSliceValue([]float64{10.5, 20.5, 30.5}), + Expected: []float64{10.5, 20.5, 30.5}, + }, + { + Input: NewEnvFloatSliceVariable("FLOAT_SLICE_VAR"), + Expected: []float64{1.5, 2.5, 3.5}, + }, + { + Input: EnvFloatSlice{}, + ErrorMsg: ErrEnvironmentValueRequired.Error(), + }, + { + Input: NewEnvFloatSlice("SOME_MISSING_VAR", []float64{100.5}), + Expected: []float64{100.5}, + }, + { + Input: EnvFloatSlice{ + Variable: toPtr(""), + }, + ErrorMsg: ErrEnvironmentValueRequired.Error(), + }, + { + Input: NewEnvFloatSliceVariable("EMPTY_FLOAT_SLICE"), + Expected: []float64{}, + }, + { + Input: NewEnvFloatSliceVariable("MISSING_VAR"), + ErrorMsg: ErrEnvironmentVariableValueRequired.Error(), + }, + { + Input: NewEnvFloatSliceVariable("INVALID_FLOAT_SLICE"), + ErrorMsg: "ParseEnvFailed: failed to parse INVALID_FLOAT_SLICE: invalid floating-point number slice syntax. Hint: 1", + }, + } + + for i, tc := range testCases { + t.Run(fmt.Sprint(i), func(t *testing.T) { + result, err := tc.Input.Get() + if tc.ErrorMsg != "" { + assertErrorContains(t, err, tc.ErrorMsg) + } else { + assertNilError(t, err) + assertDeepEqual(t, tc.Expected, result) + } + }) + } +} + +// TestEnvFloatSlice_GetCustom tests the EnvFloatSlice GetCustom method +func TestEnvFloatSlice_GetCustom(t *testing.T) { + testCases := []struct { + Name string + Input EnvFloatSlice + GetFunc GetEnvFunc + Expected []float64 + ErrorMsg string + }{ + { + Name: "literal value", + Input: NewEnvFloatSliceValue([]float64{10.5, 20.5}), + GetFunc: mockGetEnvFunc(map[string]string{}, false), + Expected: []float64{10.5, 20.5}, + }, + { + Name: "variable from custom func", + Input: NewEnvFloatSliceVariable("FLOAT_SLICE_VAR"), + GetFunc: mockGetEnvFunc(map[string]string{"FLOAT_SLICE_VAR": "5.5,10.5,15.5"}, false), + Expected: []float64{5.5, 10.5, 15.5}, + }, + { + Name: "variable with fallback value", + Input: NewEnvFloatSlice("MISSING_VAR", []float64{99.9}), + GetFunc: mockGetEnvFunc(map[string]string{}, false), + Expected: []float64{99.9}, + }, + { + Name: "empty variable uses fallback", + Input: NewEnvFloatSlice("EMPTY_VAR", []float64{42.5}), + GetFunc: mockGetEnvFunc(map[string]string{"EMPTY_VAR": ""}, false), + Expected: []float64{42.5}, + }, + { + Name: "nil value and no variable", + Input: EnvFloatSlice{}, + GetFunc: mockGetEnvFunc(map[string]string{}, false), + ErrorMsg: ErrEnvironmentValueRequired.Error(), + }, + { + Name: "custom func error", + Input: NewEnvFloatSliceVariable("ERROR_VAR"), + GetFunc: mockGetEnvFunc(map[string]string{}, true), + ErrorMsg: "mock error", + }, + { + Name: "invalid float format", + Input: NewEnvFloatSliceVariable("INVALID_VAR"), + GetFunc: mockGetEnvFunc(map[string]string{"INVALID_VAR": "1.5,abc,3.5"}, false), + ErrorMsg: "ParseEnvFailed: failed to parse INVALID_VAR: invalid floating-point number slice syntax. Hint: 1", + }, + { + Name: "missing variable no fallback", + Input: NewEnvFloatSliceVariable("MISSING_VAR"), + GetFunc: mockGetEnvFunc(map[string]string{}, false), + ErrorMsg: ErrEnvironmentVariableValueRequired.Error(), + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + result, err := tc.Input.GetCustom(tc.GetFunc) + if tc.ErrorMsg != "" { + assertErrorContains(t, err, tc.ErrorMsg) + } else { + assertNilError(t, err) + assertDeepEqual(t, tc.Expected, result) + } + }) + } +} + +// TestEnvFloatSlice_IsZero tests the EnvFloatSlice IsZero method +func TestEnvFloatSlice_IsZero(t *testing.T) { + testCases := []struct { + Name string + Input EnvFloatSlice + Expected bool + }{ + { + Name: "empty struct", + Input: EnvFloatSlice{}, + Expected: true, + }, + { + Name: "with value", + Input: NewEnvFloatSliceValue([]float64{1.5}), + Expected: false, + }, + { + Name: "with variable", + Input: NewEnvFloatSliceVariable("VAR"), + Expected: false, + }, + { + Name: "with empty variable", + Input: EnvFloatSlice{Variable: toPtr("")}, + Expected: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + result := tc.Input.IsZero() + if result != tc.Expected { + t.Errorf("Expected %v, got %v", tc.Expected, result) + } + }) + } +} + +// TestEnvBoolSlice tests the EnvBoolSlice Get method +func TestEnvBoolSlice(t *testing.T) { + t.Setenv("BOOL_SLICE_VAR", "true,false,true") + t.Setenv("EMPTY_BOOL_SLICE", "") + t.Setenv("INVALID_BOOL_SLICE", "true,invalid,false") + + testCases := []struct { + Input EnvBoolSlice + Expected []bool + ErrorMsg string + }{ + { + Input: NewEnvBoolSliceValue([]bool{true, false, true}), + Expected: []bool{true, false, true}, + }, + { + Input: NewEnvBoolSliceVariable("BOOL_SLICE_VAR"), + Expected: []bool{true, false, true}, + }, + { + Input: EnvBoolSlice{}, + ErrorMsg: ErrEnvironmentValueRequired.Error(), + }, + { + Input: NewEnvBoolSlice("SOME_MISSING_VAR", []bool{true}), + Expected: []bool{true}, + }, + { + Input: EnvBoolSlice{ + Variable: toPtr(""), + }, + ErrorMsg: ErrEnvironmentValueRequired.Error(), + }, + { + Input: NewEnvBoolSliceVariable("EMPTY_BOOL_SLICE"), + Expected: []bool{}, + }, + { + Input: NewEnvBoolSliceVariable("MISSING_VAR"), + ErrorMsg: ErrEnvironmentVariableValueRequired.Error(), + }, + { + Input: NewEnvBoolSliceVariable("INVALID_BOOL_SLICE"), + ErrorMsg: "ParseEnvFailed: failed to parse INVALID_BOOL_SLICE: invalid boolean slice syntax. Hint: 1", + }, + } + + for i, tc := range testCases { + t.Run(fmt.Sprint(i), func(t *testing.T) { + result, err := tc.Input.Get() + if tc.ErrorMsg != "" { + assertErrorContains(t, err, tc.ErrorMsg) + } else { + assertNilError(t, err) + assertDeepEqual(t, tc.Expected, result) + } + }) + } +} + +// TestEnvBoolSlice_GetCustom tests the EnvBoolSlice GetCustom method +func TestEnvBoolSlice_GetCustom(t *testing.T) { + testCases := []struct { + Name string + Input EnvBoolSlice + GetFunc GetEnvFunc + Expected []bool + ErrorMsg string + }{ + { + Name: "literal value", + Input: NewEnvBoolSliceValue([]bool{true, false}), + GetFunc: mockGetEnvFunc(map[string]string{}, false), + Expected: []bool{true, false}, + }, + { + Name: "variable from custom func", + Input: NewEnvBoolSliceVariable("BOOL_SLICE_VAR"), + GetFunc: mockGetEnvFunc(map[string]string{"BOOL_SLICE_VAR": "true,false,true"}, false), + Expected: []bool{true, false, true}, + }, + { + Name: "variable with fallback value", + Input: NewEnvBoolSlice("MISSING_VAR", []bool{false}), + GetFunc: mockGetEnvFunc(map[string]string{}, false), + Expected: []bool{false}, + }, + { + Name: "empty variable uses fallback", + Input: NewEnvBoolSlice("EMPTY_VAR", []bool{true}), + GetFunc: mockGetEnvFunc(map[string]string{"EMPTY_VAR": ""}, false), + Expected: []bool{true}, + }, + { + Name: "nil value and no variable", + Input: EnvBoolSlice{}, + GetFunc: mockGetEnvFunc(map[string]string{}, false), + ErrorMsg: ErrEnvironmentValueRequired.Error(), + }, + { + Name: "custom func error", + Input: NewEnvBoolSliceVariable("ERROR_VAR"), + GetFunc: mockGetEnvFunc(map[string]string{}, true), + ErrorMsg: "mock error", + }, + { + Name: "invalid bool format", + Input: NewEnvBoolSliceVariable("INVALID_VAR"), + GetFunc: mockGetEnvFunc(map[string]string{"INVALID_VAR": "true,invalid,false"}, false), + ErrorMsg: "ParseEnvFailed: failed to parse INVALID_VAR: invalid boolean slice syntax. Hint: 1", + }, + { + Name: "missing variable no fallback", + Input: NewEnvBoolSliceVariable("MISSING_VAR"), + GetFunc: mockGetEnvFunc(map[string]string{}, false), + ErrorMsg: ErrEnvironmentVariableValueRequired.Error(), + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + result, err := tc.Input.GetCustom(tc.GetFunc) + if tc.ErrorMsg != "" { + assertErrorContains(t, err, tc.ErrorMsg) + } else { + assertNilError(t, err) + assertDeepEqual(t, tc.Expected, result) + } + }) + } +} + +// TestEnvBoolSlice_IsZero tests the EnvBoolSlice IsZero method +func TestEnvBoolSlice_IsZero(t *testing.T) { + testCases := []struct { + Name string + Input EnvBoolSlice + Expected bool + }{ + { + Name: "empty struct", + Input: EnvBoolSlice{}, + Expected: true, + }, + { + Name: "with value", + Input: NewEnvBoolSliceValue([]bool{true}), + Expected: false, + }, + { + Name: "with variable", + Input: NewEnvBoolSliceVariable("VAR"), + Expected: false, + }, + { + Name: "with empty variable", + Input: EnvBoolSlice{Variable: toPtr("")}, + Expected: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + result := tc.Input.IsZero() + if result != tc.Expected { + t.Errorf("Expected %v, got %v", tc.Expected, result) + } + }) + } +} + +// TestEnvStringSlice_Equal tests the EnvStringSlice Equal method +func TestEnvStringSlice_Equal(t *testing.T) { + testCases := []struct { + Name string + A EnvStringSlice + B EnvStringSlice + Expected bool + }{ + { + Name: "both nil", + A: EnvStringSlice{}, + B: EnvStringSlice{}, + Expected: true, + }, + { + Name: "same literal values", + A: NewEnvStringSliceValue([]string{"foo", "bar"}), + B: NewEnvStringSliceValue([]string{"foo", "bar"}), + Expected: true, + }, + { + Name: "different literal values", + A: NewEnvStringSliceValue([]string{"foo", "bar"}), + B: NewEnvStringSliceValue([]string{"baz", "qux"}), + Expected: false, + }, + { + Name: "different order", + A: NewEnvStringSliceValue([]string{"foo", "bar"}), + B: NewEnvStringSliceValue([]string{"bar", "foo"}), + Expected: false, + }, + { + Name: "same variable names", + A: NewEnvStringSliceVariable("VAR1"), + B: NewEnvStringSliceVariable("VAR1"), + Expected: true, + }, + { + Name: "different variable names", + A: NewEnvStringSliceVariable("VAR1"), + B: NewEnvStringSliceVariable("VAR2"), + Expected: false, + }, + { + Name: "same value and variable", + A: NewEnvStringSlice("VAR1", []string{"foo"}), + B: NewEnvStringSlice("VAR1", []string{"foo"}), + Expected: true, + }, + { + Name: "value vs variable", + A: NewEnvStringSliceValue([]string{"foo"}), + B: NewEnvStringSliceVariable("VAR1"), + Expected: false, + }, + { + Name: "nil vs empty slice", + A: EnvStringSlice{Value: nil}, + B: NewEnvStringSliceValue([]string{}), + Expected: true, // slices.Equal considers nil and empty slices as equal + }, + { + Name: "both empty slices", + A: NewEnvStringSliceValue([]string{}), + B: NewEnvStringSliceValue([]string{}), + Expected: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + result := tc.A.Equal(tc.B) + if result != tc.Expected { + t.Errorf("Expected %v, got %v", tc.Expected, result) + } + }) + } +} + +// TestEnvIntSlice_Equal tests the EnvIntSlice Equal method +func TestEnvIntSlice_Equal(t *testing.T) { + testCases := []struct { + Name string + A EnvIntSlice + B EnvIntSlice + Expected bool + }{ + { + Name: "both nil", + A: EnvIntSlice{}, + B: EnvIntSlice{}, + Expected: true, + }, + { + Name: "same literal values", + A: NewEnvIntSliceValue([]int64{1, 2, 3}), + B: NewEnvIntSliceValue([]int64{1, 2, 3}), + Expected: true, + }, + { + Name: "different literal values", + A: NewEnvIntSliceValue([]int64{1, 2, 3}), + B: NewEnvIntSliceValue([]int64{4, 5, 6}), + Expected: false, + }, + { + Name: "different order", + A: NewEnvIntSliceValue([]int64{1, 2, 3}), + B: NewEnvIntSliceValue([]int64{3, 2, 1}), + Expected: false, + }, + { + Name: "same variable names", + A: NewEnvIntSliceVariable("VAR1"), + B: NewEnvIntSliceVariable("VAR1"), + Expected: true, + }, + { + Name: "different variable names", + A: NewEnvIntSliceVariable("VAR1"), + B: NewEnvIntSliceVariable("VAR2"), + Expected: false, + }, + { + Name: "same value and variable", + A: NewEnvIntSlice("VAR1", []int64{10}), + B: NewEnvIntSlice("VAR1", []int64{10}), + Expected: true, + }, + { + Name: "value vs variable", + A: NewEnvIntSliceValue([]int64{10}), + B: NewEnvIntSliceVariable("VAR1"), + Expected: false, + }, + { + Name: "nil vs empty slice", + A: EnvIntSlice{Value: nil}, + B: NewEnvIntSliceValue([]int64{}), + Expected: true, // slices.Equal considers nil and empty slices as equal + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + result := tc.A.Equal(tc.B) + if result != tc.Expected { + t.Errorf("Expected %v, got %v", tc.Expected, result) + } + }) + } +} + +// TestEnvFloatSlice_Equal tests the EnvFloatSlice Equal method +func TestEnvFloatSlice_Equal(t *testing.T) { + testCases := []struct { + Name string + A EnvFloatSlice + B EnvFloatSlice + Expected bool + }{ + { + Name: "both nil", + A: EnvFloatSlice{}, + B: EnvFloatSlice{}, + Expected: true, + }, + { + Name: "same literal values", + A: NewEnvFloatSliceValue([]float64{1.5, 2.5, 3.5}), + B: NewEnvFloatSliceValue([]float64{1.5, 2.5, 3.5}), + Expected: true, + }, + { + Name: "different literal values", + A: NewEnvFloatSliceValue([]float64{1.5, 2.5, 3.5}), + B: NewEnvFloatSliceValue([]float64{4.5, 5.5, 6.5}), + Expected: false, + }, + { + Name: "different order", + A: NewEnvFloatSliceValue([]float64{1.5, 2.5, 3.5}), + B: NewEnvFloatSliceValue([]float64{3.5, 2.5, 1.5}), + Expected: false, + }, + { + Name: "same variable names", + A: NewEnvFloatSliceVariable("VAR1"), + B: NewEnvFloatSliceVariable("VAR1"), + Expected: true, + }, + { + Name: "different variable names", + A: NewEnvFloatSliceVariable("VAR1"), + B: NewEnvFloatSliceVariable("VAR2"), + Expected: false, + }, + { + Name: "same value and variable", + A: NewEnvFloatSlice("VAR1", []float64{10.5}), + B: NewEnvFloatSlice("VAR1", []float64{10.5}), + Expected: true, + }, + { + Name: "value vs variable", + A: NewEnvFloatSliceValue([]float64{10.5}), + B: NewEnvFloatSliceVariable("VAR1"), + Expected: false, + }, + { + Name: "nil vs empty slice", + A: EnvFloatSlice{Value: nil}, + B: NewEnvFloatSliceValue([]float64{}), + Expected: true, // slices.Equal considers nil and empty slices as equal + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + result := tc.A.Equal(tc.B) + if result != tc.Expected { + t.Errorf("Expected %v, got %v", tc.Expected, result) + } + }) + } +} + +// TestEnvBoolSlice_Equal tests the EnvBoolSlice Equal method +func TestEnvBoolSlice_Equal(t *testing.T) { + testCases := []struct { + Name string + A EnvBoolSlice + B EnvBoolSlice + Expected bool + }{ + { + Name: "both nil", + A: EnvBoolSlice{}, + B: EnvBoolSlice{}, + Expected: true, + }, + { + Name: "same literal values", + A: NewEnvBoolSliceValue([]bool{true, false, true}), + B: NewEnvBoolSliceValue([]bool{true, false, true}), + Expected: true, + }, + { + Name: "different literal values", + A: NewEnvBoolSliceValue([]bool{true, false, true}), + B: NewEnvBoolSliceValue([]bool{false, true, false}), + Expected: false, + }, + { + Name: "different order", + A: NewEnvBoolSliceValue([]bool{true, false}), + B: NewEnvBoolSliceValue([]bool{false, true}), + Expected: false, + }, + { + Name: "same variable names", + A: NewEnvBoolSliceVariable("VAR1"), + B: NewEnvBoolSliceVariable("VAR1"), + Expected: true, + }, + { + Name: "different variable names", + A: NewEnvBoolSliceVariable("VAR1"), + B: NewEnvBoolSliceVariable("VAR2"), + Expected: false, + }, + { + Name: "same value and variable", + A: NewEnvBoolSlice("VAR1", []bool{true}), + B: NewEnvBoolSlice("VAR1", []bool{true}), + Expected: true, + }, + { + Name: "value vs variable", + A: NewEnvBoolSliceValue([]bool{true}), + B: NewEnvBoolSliceVariable("VAR1"), + Expected: false, + }, + { + Name: "nil vs empty slice", + A: EnvBoolSlice{Value: nil}, + B: NewEnvBoolSliceValue([]bool{}), + Expected: true, // slices.Equal considers nil and empty slices as equal + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + result := tc.A.Equal(tc.B) + if result != tc.Expected { + t.Errorf("Expected %v, got %v", tc.Expected, result) + } + }) + } +} diff --git a/utils.go b/utils.go index 1c9e8f5..4914eb1 100644 --- a/utils.go +++ b/utils.go @@ -2,7 +2,6 @@ package goenvconf import ( "context" - "errors" "fmt" "os" "strconv" @@ -13,15 +12,6 @@ const ( keyValueLength = 2 ) -var ( - // ErrEnvironmentValueRequired occurs when both value and env fields are null or empty. - ErrEnvironmentValueRequired = errors.New("require either value or env") - // ErrEnvironmentVariableValueRequired the error happens when the value from environment variable is empty. - ErrEnvironmentVariableValueRequired = errors.New("the environment variable value is empty") - // ErrParseStringFailed is the error when failed to parse a string to another type. - ErrParseStringFailed = errors.New("ParseStringFailed") -) - // ParseStringMapFromString parses a string map from a string with format: // // =;= @@ -36,11 +26,10 @@ func ParseStringMapFromString(input string) (map[string]string, error) { for rawItem := range rawItems { keyValue := strings.Split(rawItem, "=") - if len(keyValue) != keyValueLength { - return nil, fmt.Errorf( - "%w: invalid int map from string, expected: =;=, got: %s", - ErrParseStringFailed, - input, + if len(keyValue) != keyValueLength || keyValue[0] == "" { + return nil, NewParseEnvFailedError( + "invalid string map syntax, expected: =;=", + keyValue[0], ) } @@ -50,13 +39,6 @@ func ParseStringMapFromString(input string) (map[string]string, error) { return result, nil } -// ParseIntMapFromString parses an integer map from a string with format: -// -// =;= -func ParseIntMapFromString(input string) (map[string]int, error) { - return ParseIntegerMapFromString[int](input) -} - // ParseIntegerMapFromString parses an integer map from a string with format: // // =;= @@ -71,17 +53,12 @@ func ParseIntegerMapFromString[T int | int8 | int16 | int32 | int64 | uint | uin result := make(map[string]T) for key, value := range rawValues { - intValue, err := strconv.ParseInt(value, 10, 64) + intValue, err := parseInt[T](value) if err != nil { - return nil, fmt.Errorf( - "%w: invalid integer value %s in item %s", - ErrParseStringFailed, - value, - key, - ) + return nil, NewParseEnvFailedError("invalid integer map syntax", key) } - result[key] = T(intValue) + result[key] = intValue } return result, nil @@ -99,17 +76,12 @@ func ParseFloatMapFromString[T float32 | float64](input string) (map[string]T, e result := make(map[string]T) for key, value := range rawValues { - floatValue, err := strconv.ParseFloat(value, 64) + floatValue, err := parseFloat[T](value) if err != nil { - return nil, fmt.Errorf( - "%w: invalid float value %s in item %s", - ErrParseStringFailed, - value, - key, - ) + return nil, NewParseEnvFailedError("invalid float map syntax", key) } - result[key] = T(floatValue) + result[key] = floatValue } return result, nil @@ -129,12 +101,7 @@ func ParseBoolMapFromString(input string) (map[string]bool, error) { for key, value := range rawValues { boolValue, err := strconv.ParseBool(value) if err != nil { - return nil, fmt.Errorf( - "%w: invalid bool value %s in item %s", - ErrParseStringFailed, - value, - key, - ) + return nil, NewParseEnvFailedError("invalid boolean map syntax", key) } result[key] = boolValue @@ -143,6 +110,95 @@ func ParseBoolMapFromString(input string) (map[string]bool, error) { return result, nil } +// ParseStringSliceFromString parses a string slice from a comma-separated string. +func ParseStringSliceFromString(input string) []string { + if input == "" { + return []string{} + } + + return strings.Split(input, ",") +} + +// ParseIntSliceFromString parses an integer slice from a comma-separated string. +func ParseIntSliceFromString[T int | int8 | int16 | int32 | int64 | uint | uint8 | uint16 | uint32 | uint64]( + input string, +) ([]T, error) { + return parseIntSliceFromStringWithErrorPrefix[T](input, "") +} + +func parseIntSliceFromStringWithErrorPrefix[T int | int8 | int16 | int32 | int64 | uint | uint8 | uint16 | uint32 | uint64]( + input string, + errorPrefix string, +) ([]T, error) { + rawValues := ParseStringSliceFromString(input) + results := make([]T, len(rawValues)) + + for index, val := range rawValues { + intVal, err := parseInt[T](val) + if err != nil { + return nil, NewParseEnvFailedError( + errorPrefix+"invalid integer slice syntax", + strconv.Itoa(index), + ) + } + + results[index] = intVal + } + + return results, nil +} + +// ParseFloatSliceFromString parses a floating-point number slice from a comma-separated string. +func ParseFloatSliceFromString[T float32 | float64](input string) ([]T, error) { + return parseFloatSliceFromStringWithErrorPrefix[T](input, "") +} + +func parseFloatSliceFromStringWithErrorPrefix[T float32 | float64]( + input string, + errorPrefix string, +) ([]T, error) { + rawValues := ParseStringSliceFromString(input) + results := make([]T, len(rawValues)) + + for index, val := range rawValues { + floatVal, err := parseFloat[T](val) + if err != nil { + return nil, NewParseEnvFailedError( + errorPrefix+"invalid floating-point number slice syntax", + strconv.Itoa(index), + ) + } + + results[index] = floatVal + } + + return results, nil +} + +// ParseBoolSliceFromString parses a boolean slice from a comma-separated string. +func ParseBoolSliceFromString(input string) ([]bool, error) { + return parseBoolSliceFromStringWithErrorPrefix(input, "") +} + +func parseBoolSliceFromStringWithErrorPrefix(input string, errorPrefix string) ([]bool, error) { + rawValues := ParseStringSliceFromString(input) + results := make([]bool, len(rawValues)) + + for index, val := range rawValues { + boolVal, err := strconv.ParseBool(strings.TrimSpace(val)) + if err != nil { + return nil, NewParseEnvFailedError( + errorPrefix+"invalid boolean slice syntax", + strconv.Itoa(index), + ) + } + + results[index] = boolVal + } + + return results, nil +} + // OSEnvGetter wraps the GetOSEnv function with context. func OSEnvGetter(_ context.Context) GetEnvFunc { return GetOSEnv @@ -165,3 +221,86 @@ func getEnvVariableValueRequiredError(envName *string) error { return ErrEnvironmentVariableValueRequired } + +func parseInt[T int | int8 | int16 | int32 | int64 | uint | uint8 | uint16 | uint32 | uint64]( //nolint:cyclop,ireturn + val string, +) (T, error) { + var zero T + + trimmed := strings.TrimSpace(val) + + switch any(zero).(type) { + case int, int8, int16, int32: + result, err := strconv.Atoi(trimmed) + if err != nil { + return zero, err + } + + return T(result), nil + case uint: + uintVal, err := strconv.ParseUint(trimmed, 10, strconv.IntSize) + if err != nil { + return zero, err + } + + return T(uintVal), err + case uint8: + uintVal, err := strconv.ParseUint(trimmed, 10, 8) + if err != nil { + return zero, err + } + + return T(uintVal), err + case uint16: + uintVal, err := strconv.ParseUint(trimmed, 10, 16) + if err != nil { + return zero, err + } + + return T(uintVal), err + case uint32: + uintVal, err := strconv.ParseUint(trimmed, 10, 32) + if err != nil { + return zero, err + } + + return T(uintVal), err + case uint64: + uintVal, err := strconv.ParseUint(trimmed, 10, 64) + if err != nil { + return zero, err + } + + return T(uintVal), err + default: + intVal, err := strconv.ParseInt(trimmed, 10, 64) + if err != nil { + return zero, err + } + + return T(intVal), err + } +} + +func parseFloat[T float32 | float64](val string) (T, error) { //nolint:ireturn + var zero T + + trimmed := strings.TrimSpace(val) + + switch any(zero).(type) { + case float32: + result, err := strconv.ParseFloat(trimmed, 32) + if err != nil { + return zero, err + } + + return T(result), nil + default: + result, err := strconv.ParseFloat(trimmed, 64) + if err != nil { + return zero, err + } + + return T(result), err + } +} diff --git a/utils_test.go b/utils_test.go index b26e5f1..9469be8 100644 --- a/utils_test.go +++ b/utils_test.go @@ -23,17 +23,17 @@ func TestParseIntMapFromString(t *testing.T) { }, { Input: "a;b=2", - ErrorMsg: "ParseStringFailed: invalid int map from string, expected: =;=, got: a;b=2", + ErrorMsg: "ParseEnvFailed: invalid string map syntax, expected: =;=. Hint: a", }, { Input: "a=c;b=2", - ErrorMsg: "invalid integer value c in item a", + ErrorMsg: "ParseEnvFailed: invalid integer map syntax. Hint: a", }, } for _, tc := range testCases { t.Run(tc.Input, func(t *testing.T) { - result, err := ParseIntMapFromString(tc.Input) + result, err := ParseIntegerMapFromString[int](tc.Input) if tc.ErrorMsg != "" { assertErrorContains(t, err, tc.ErrorMsg) } else {