diff --git a/decode.go b/decode.go index 7e63861..1e3f319 100644 --- a/decode.go +++ b/decode.go @@ -16,31 +16,31 @@ func (s *settings) setFieldValue(field reflect.Value, e entry) error { case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: n, err := strconv.ParseInt(e.value, 10, field.Type().Bits()) if err != nil { - return &FieldConversionError{FieldName: e.key, TargetType: field.Type().String(), Err: err} + return &FieldConversionError{FieldName: e.fieldName, TargetType: field.Type().String(), Err: err} } field.SetInt(n) case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: n, err := strconv.ParseUint(e.value, 10, field.Type().Bits()) if err != nil { - return &FieldConversionError{FieldName: e.key, TargetType: field.Type().String(), Err: err} + return &FieldConversionError{FieldName: e.fieldName, TargetType: field.Type().String(), Err: err} } field.SetUint(n) case reflect.Float32, reflect.Float64: f, err := strconv.ParseFloat(e.value, field.Type().Bits()) if err != nil { - return &FieldConversionError{FieldName: e.key, TargetType: field.Type().String(), Err: err} + return &FieldConversionError{FieldName: e.fieldName, TargetType: field.Type().String(), Err: err} } field.SetFloat(f) case reflect.Bool: b, err := strconv.ParseBool(e.value) if err != nil { - return &FieldConversionError{FieldName: e.key, TargetType: "bool", Err: err} + return &FieldConversionError{FieldName: e.fieldName, TargetType: "bool", Err: err} } field.SetBool(b) case reflect.Slice: return setSliceField(field, e) default: - return &UnsupportedFieldTypeError{FieldType: field.Interface()} + return &UnsupportedFieldTypeError{FieldName: e.fieldName, FieldType: field.Type().String()} } return nil } @@ -50,19 +50,19 @@ func setSliceField(field reflect.Value, e entry) error { case reflect.String: field.Set(splitStringSlice(e.value, field.Type())) case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - v, err := splitIntSlice(e.key, e.value, field.Type()) + v, err := splitIntSlice(e.fieldName, e.value, field.Type()) if err != nil { return err } field.Set(v) case reflect.Float32, reflect.Float64: - v, err := splitFloatSlice(e.key, e.value, field.Type()) + v, err := splitFloatSlice(e.fieldName, e.value, field.Type()) if err != nil { return err } field.Set(v) default: - return &UnsupportedFieldTypeError{FieldType: field.Interface()} + return &UnsupportedFieldTypeError{FieldName: e.fieldName, FieldType: field.Type().String()} } return nil } @@ -76,28 +76,28 @@ func splitStringSlice(value string, sliceType reflect.Type) reflect.Value { return result } -func splitIntSlice(key, value string, sliceType reflect.Type) (reflect.Value, error) { +func splitIntSlice(fieldName, value string, sliceType reflect.Type) (reflect.Value, error) { parts := strings.Split(value, ",") result := reflect.MakeSlice(sliceType, len(parts), len(parts)) bits := sliceType.Elem().Bits() for i, v := range parts { n, err := strconv.ParseInt(strings.TrimSpace(v), 10, bits) if err != nil { - return reflect.Value{}, &FieldConversionError{FieldName: key, TargetType: sliceType.String(), Err: err} + return reflect.Value{}, &FieldConversionError{FieldName: fieldName, TargetType: sliceType.String(), Err: err} } result.Index(i).SetInt(n) } return result, nil } -func splitFloatSlice(key, value string, sliceType reflect.Type) (reflect.Value, error) { +func splitFloatSlice(fieldName, value string, sliceType reflect.Type) (reflect.Value, error) { parts := strings.Split(value, ",") result := reflect.MakeSlice(sliceType, len(parts), len(parts)) bits := sliceType.Elem().Bits() for i, v := range parts { f, err := strconv.ParseFloat(strings.TrimSpace(v), bits) if err != nil { - return reflect.Value{}, &FieldConversionError{FieldName: key, TargetType: sliceType.String(), Err: err} + return reflect.Value{}, &FieldConversionError{FieldName: fieldName, TargetType: sliceType.String(), Err: err} } result.Index(i).SetFloat(f) } diff --git a/error.go b/error.go index 420074f..c302339 100644 --- a/error.go +++ b/error.go @@ -15,6 +15,7 @@ var ( ErrConversion = errors.New("conversion error") ErrReplacement = errors.New("replacement error") ErrTag = errors.New("tag error") + ErrSyntax = errors.New("invalid syntax") ) // FileTypeValidationError occurs when the .env config file fails to open. @@ -26,19 +27,19 @@ func (e *FileTypeValidationError) Error() string { return fmt.Sprintf("file extension is not a valid environment file: %q", e.Filepath) } -func (e *FileTypeValidationError) Is(target error) bool { return target == ErrFile } +func (e *FileTypeValidationError) Unwrap() error { return ErrFile } // OpenFileError occurs when the .env config file fails to open. type OpenFileError struct { - Err error + Filepath string + Err error } func (e *OpenFileError) Error() string { - return fmt.Sprintf("failed to open config file: %v", e.Err) + return fmt.Sprintf("opening config file %q: %v", e.Filepath, e.Err) } -func (e *OpenFileError) Unwrap() error { return e.Err } -func (e *OpenFileError) Is(target error) bool { return target == ErrFile } +func (e *OpenFileError) Unwrap() []error { return []error{e.Err, ErrFile} } // FieldConversionError occurs when a field on the config struct fails to be set. type FieldConversionError struct { @@ -48,22 +49,22 @@ type FieldConversionError struct { } func (e *FieldConversionError) Error() string { - return fmt.Sprintf("failed to convert field %v to %v: %v", e.FieldName, e.TargetType, e.Err) + return fmt.Sprintf("failed to convert field %q to %v: %v", e.FieldName, e.TargetType, e.Err) } -func (e *FieldConversionError) Unwrap() error { return e.Err } -func (e *FieldConversionError) Is(target error) bool { return target == ErrConversion } +func (e *FieldConversionError) Unwrap() []error { return []error{e.Err, ErrConversion} } // UnsupportedFieldTypeError occurs when the a field type on the config struct is not compatible. type UnsupportedFieldTypeError struct { - FieldType any + FieldName string + FieldType string } func (e *UnsupportedFieldTypeError) Error() string { - return fmt.Sprintf("unsupported field type: %T", e.FieldType) + return fmt.Sprintf("unsupported field type %q: %s", e.FieldName, e.FieldType) } -func (e *UnsupportedFieldTypeError) Is(target error) bool { return target == ErrUnsupported } +func (e *UnsupportedFieldTypeError) Unwrap() error { return ErrUnsupported } // InvalidConfigTypeError occurs when config is not a pointer to a struct. type InvalidConfigTypeError struct { @@ -74,7 +75,7 @@ func (e *InvalidConfigTypeError) Error() string { return fmt.Sprintf("output must be a pointer to a struct, got %T", e.ProvidedType) } -func (e *InvalidConfigTypeError) Is(target error) bool { return target == ErrInvalidConfig } +func (e *InvalidConfigTypeError) Unwrap() error { return ErrInvalidConfig } // RequiredFieldError occurs when a required field is not set in the configuration. type RequiredFieldError struct { @@ -82,10 +83,10 @@ type RequiredFieldError struct { } func (e *RequiredFieldError) Error() string { - return fmt.Sprintf("required field is not set in configuration: %v", e.FieldName) + return fmt.Sprintf("required field is not set in configuration: %q", e.FieldName) } -func (e *RequiredFieldError) Is(target error) bool { return target == ErrRequired } +func (e *RequiredFieldError) Unwrap() error { return ErrRequired } // ReplacementError occurs when the configuration variable being used for replacement is not set. type ReplacementError struct { @@ -96,7 +97,7 @@ func (e *ReplacementError) Error() string { return fmt.Sprintf("configuration variable for replacement is not set: %v", e.VariableName) } -func (e *ReplacementError) Is(target error) bool { return target == ErrReplacement } +func (e *ReplacementError) Unwrap() error { return ErrReplacement } // ParseError occurs when a line from the .env config file has been parsed incorrectly. type ParseError struct { @@ -104,15 +105,11 @@ type ParseError struct { Err error } -// ErrSyntax indicates that a line is invalid syntax. -var ErrSyntax = errors.New("invalid syntax") - func (e *ParseError) Error() string { return fmt.Sprintf("parse line: %v: %v", e.Line, e.Err) } -func (e *ParseError) Unwrap() error { return e.Err } -func (e *ParseError) Is(target error) bool { return target == ErrParse } +func (e *ParseError) Unwrap() []error { return []error{e.Err, ErrParse} } // FileReadError occurs when an error occurs when scanning the .env file. type FileReadError struct { @@ -124,8 +121,7 @@ func (e *FileReadError) Error() string { return fmt.Sprintf("reading %v: %v", e.Filepath, e.Err) } -func (e *FileReadError) Unwrap() error { return e.Err } -func (e *FileReadError) Is(target error) bool { return target == ErrFile } +func (e *FileReadError) Unwrap() []error { return []error{e.Err, ErrFile} } // MalformedTagError occurs when a config struct tag is invalid. type MalformedTagError struct { @@ -140,23 +136,7 @@ func (e *MalformedTagError) Error() string { return fmt.Sprintf("malformed tag %q", e.Tag) } -func (e *MalformedTagError) Unwrap() error { return e.Err } -func (e *MalformedTagError) Is(target error) bool { return target == ErrTag } - -// FieldError wraps an error with the name of the field that caused it. -type FieldError struct { - FieldName string - Err error -} - -func (e *FieldError) Error() string { - if e.FieldName != "" { - return fmt.Sprintf("field %q: %v", e.FieldName, e.Err) - } - return e.Err.Error() -} - -func (e *FieldError) Unwrap() error { return e.Err } +func (e *MalformedTagError) Unwrap() []error { return []error{e.Err, ErrTag} } // MalformedDefaultError occurs when the default value in a struct tag cannot be // parsed into the field's type. This is a developer error in the tag definition. @@ -170,5 +150,4 @@ func (e *MalformedDefaultError) Error() string { return fmt.Sprintf("default value %q is invalid for field %q: %v", e.Default, e.FieldName, e.Err) } -func (e *MalformedDefaultError) Unwrap() error { return e.Err } -func (e *MalformedDefaultError) Is(target error) bool { return target == ErrTag } +func (e *MalformedDefaultError) Unwrap() []error { return []error{e.Err, ErrTag} } diff --git a/error_test.go b/error_test.go index 9716168..452250a 100644 --- a/error_test.go +++ b/error_test.go @@ -15,9 +15,9 @@ func TestErrorsIs(t *testing.T) { target error }{ {"FileTypeValidationError is ErrFile", &configutil.FileTypeValidationError{Filepath: "bad.txt"}, configutil.ErrFile}, - {"OpenFileError is ErrFile", &configutil.OpenFileError{Err: fmt.Errorf("open failed")}, configutil.ErrFile}, + {"OpenFileError is ErrFile", &configutil.OpenFileError{Filepath: "test.env", Err: fmt.Errorf("open failed")}, configutil.ErrFile}, {"FieldConversionError is ErrConversion", &configutil.FieldConversionError{FieldName: "Port", TargetType: "int", Err: fmt.Errorf("bad")}, configutil.ErrConversion}, - {"UnsupportedFieldTypeError is ErrUnsupported", &configutil.UnsupportedFieldTypeError{FieldType: "complex128"}, configutil.ErrUnsupported}, + {"UnsupportedFieldTypeError is ErrUnsupported", &configutil.UnsupportedFieldTypeError{FieldName: "X", FieldType: "complex128"}, configutil.ErrUnsupported}, {"InvalidConfigTypeError is ErrInvalidConfig", &configutil.InvalidConfigTypeError{ProvidedType: "string"}, configutil.ErrInvalidConfig}, {"RequiredFieldError is ErrRequired", &configutil.RequiredFieldError{FieldName: "Name"}, configutil.ErrRequired}, {"ReplacementError is ErrReplacement", &configutil.ReplacementError{VariableName: "HOST"}, configutil.ErrReplacement}, @@ -60,43 +60,75 @@ func TestErrorsUnwrap(t *testing.T) { inner := fmt.Errorf("inner error") tests := []struct { - name string - err error + name string + err error + inner error }{ - {"OpenFileError", &configutil.OpenFileError{Err: inner}}, - {"FieldConversionError", &configutil.FieldConversionError{FieldName: "X", TargetType: "int", Err: inner}}, - {"ParseError", &configutil.ParseError{Line: "bad", Err: inner}}, - {"FileReadError", &configutil.FileReadError{Filepath: "x.env", Err: inner}}, - {"MalformedTagError", &configutil.MalformedTagError{Tag: "bad", Err: inner}}, - {"FieldError", &configutil.FieldError{FieldName: "X", Err: inner}}, - {"MalformedDefaultError", &configutil.MalformedDefaultError{FieldName: "X", Default: "bad", Err: inner}}, + {"OpenFileError", &configutil.OpenFileError{Err: inner}, inner}, + {"FieldConversionError", &configutil.FieldConversionError{FieldName: "X", TargetType: "int", Err: inner}, inner}, + {"ParseError", &configutil.ParseError{Line: "bad", Err: inner}, inner}, + {"FileReadError", &configutil.FileReadError{Filepath: "x.env", Err: inner}, inner}, + {"MalformedTagError", &configutil.MalformedTagError{Tag: "bad", Err: inner}, inner}, + {"MalformedDefaultError", &configutil.MalformedDefaultError{FieldName: "X", Default: "bad", Err: inner}, inner}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - unwrapped := errors.Unwrap(tc.err) - if unwrapped != inner { - t.Errorf("Unwrap() = %v, want %v", unwrapped, inner) + if !errors.Is(tc.err, tc.inner) { + t.Errorf("errors.Is(%T, inner) = false, want true", tc.err) } }) } } -func TestErrorsAs_ThroughFieldError(t *testing.T) { - inner := &configutil.RequiredFieldError{FieldName: "Name"} - wrapped := &configutil.FieldError{FieldName: "Name", Err: inner} +func TestSentinelUnwrap(t *testing.T) { + tests := []struct { + name string + err error + sentinel error + }{ + {"FileTypeValidationError", &configutil.FileTypeValidationError{Filepath: "bad.txt"}, configutil.ErrFile}, + {"InvalidConfigTypeError", &configutil.InvalidConfigTypeError{ProvidedType: "string"}, configutil.ErrInvalidConfig}, + {"RequiredFieldError", &configutil.RequiredFieldError{FieldName: "Name"}, configutil.ErrRequired}, + {"ReplacementError", &configutil.ReplacementError{VariableName: "HOST"}, configutil.ErrReplacement}, + {"UnsupportedFieldTypeError", &configutil.UnsupportedFieldTypeError{FieldName: "X", FieldType: "chan int"}, configutil.ErrUnsupported}, + } - var target *configutil.RequiredFieldError - if !errors.As(wrapped, &target) { - t.Error("errors.As through FieldError failed to find RequiredFieldError") + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if !errors.Is(tc.err, tc.sentinel) { + t.Errorf("errors.Is(%T, sentinel) = false, want true", tc.err) + } + }) } - if target.FieldName != "Name" { - t.Errorf("FieldName = %q, want %q", target.FieldName, "Name") +} + +func TestDualUnwrap(t *testing.T) { + inner := fmt.Errorf("inner error") + + tests := []struct { + name string + err error + inner error + sentinel error + }{ + {"OpenFileError", &configutil.OpenFileError{Filepath: "test.env", Err: inner}, inner, configutil.ErrFile}, + {"FieldConversionError", &configutil.FieldConversionError{FieldName: "X", TargetType: "int", Err: inner}, inner, configutil.ErrConversion}, + {"ParseError", &configutil.ParseError{Line: "bad", Err: inner}, inner, configutil.ErrParse}, + {"FileReadError", &configutil.FileReadError{Filepath: "x.env", Err: inner}, inner, configutil.ErrFile}, + {"MalformedTagError", &configutil.MalformedTagError{Tag: "bad", Err: inner}, inner, configutil.ErrTag}, + {"MalformedDefaultError", &configutil.MalformedDefaultError{FieldName: "X", Default: "bad", Err: inner}, inner, configutil.ErrTag}, } - // FieldError wrapping RequiredFieldError should match ErrRequired via the chain. - if !errors.Is(wrapped, configutil.ErrRequired) { - t.Error("errors.Is(FieldError{RequiredFieldError}, ErrRequired) = false, want true") + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if !errors.Is(tc.err, tc.inner) { + t.Errorf("errors.Is(%T, inner) = false, want true", tc.err) + } + if !errors.Is(tc.err, tc.sentinel) { + t.Errorf("errors.Is(%T, sentinel) = false, want true", tc.err) + } + }) } } @@ -107,17 +139,15 @@ func TestErrorMessages(t *testing.T) { want string }{ {"FileTypeValidationError", &configutil.FileTypeValidationError{Filepath: "bad.txt"}, `file extension is not a valid environment file: "bad.txt"`}, - {"OpenFileError", &configutil.OpenFileError{Err: fmt.Errorf("no such file")}, "failed to open config file: no such file"}, - {"UnsupportedFieldTypeError", &configutil.UnsupportedFieldTypeError{FieldType: "test"}, "unsupported field type: string"}, + {"OpenFileError", &configutil.OpenFileError{Filepath: "no such file", Err: fmt.Errorf("no such file")}, `opening config file "no such file": no such file`}, + {"UnsupportedFieldTypeError", &configutil.UnsupportedFieldTypeError{FieldName: "X", FieldType: "chan int"}, `unsupported field type "X": chan int`}, {"InvalidConfigTypeError", &configutil.InvalidConfigTypeError{ProvidedType: "test"}, "output must be a pointer to a struct, got string"}, - {"RequiredFieldError", &configutil.RequiredFieldError{FieldName: "Name"}, "required field is not set in configuration: Name"}, + {"RequiredFieldError", &configutil.RequiredFieldError{FieldName: "Name"}, `required field is not set in configuration: "Name"`}, {"ReplacementError", &configutil.ReplacementError{VariableName: "HOST"}, "configuration variable for replacement is not set: HOST"}, {"MalformedTagError with err", &configutil.MalformedTagError{Tag: "bad", Err: fmt.Errorf("reason")}, `malformed tag "bad": reason`}, {"MalformedTagError without err", &configutil.MalformedTagError{Tag: "bad"}, `malformed tag "bad"`}, - {"FieldError with name", &configutil.FieldError{FieldName: "X", Err: fmt.Errorf("fail")}, `field "X": fail`}, - {"FieldError without name", &configutil.FieldError{Err: fmt.Errorf("fail")}, "fail"}, {"MalformedDefaultError", &configutil.MalformedDefaultError{FieldName: "Port", Default: "abc", Err: fmt.Errorf("bad")}, `default value "abc" is invalid for field "Port": bad`}, - {"FieldConversionError", &configutil.FieldConversionError{FieldName: "Port", TargetType: "int", Err: fmt.Errorf("bad")}, "failed to convert field Port to int: bad"}, + {"FieldConversionError", &configutil.FieldConversionError{FieldName: "Port", TargetType: "int", Err: fmt.Errorf("bad")}, `failed to convert field "Port" to int: bad`}, {"ParseError", &configutil.ParseError{Line: "BADLINE", Err: configutil.ErrSyntax}, "parse line: BADLINE: invalid syntax"}, {"FileReadError", &configutil.FileReadError{Filepath: "x.env", Err: fmt.Errorf("read err")}, "reading x.env: read err"}, } diff --git a/populate.go b/populate.go index 54654d7..d10fafa 100644 --- a/populate.go +++ b/populate.go @@ -25,7 +25,7 @@ func (s *settings) walkFields(v reflect.Value, prefix string) error { } if err := s.handleField(field, fieldVal, prefix); err != nil { - return &FieldError{FieldName: field.Name, Err: err} + return err } } return nil @@ -52,7 +52,7 @@ func (s *settings) handleField(field reflect.StructField, value reflect.Value, p if err != nil { return err } - if err := s.setFieldValue(value, entry{key: key, value: resolved}); err != nil { + if err := s.setFieldValue(value, entry{key: key, value: resolved, fieldName: field.Name}); err != nil { return err } wasSet = true @@ -60,7 +60,7 @@ func (s *settings) handleField(field reflect.StructField, value reflect.Value, p } if !wasSet && value.IsZero() && metadata.Default != "" { - if err := s.setFieldValue(value, entry{key: field.Name, value: metadata.Default}); err != nil { + if err := s.setFieldValue(value, entry{key: field.Name, value: metadata.Default, fieldName: field.Name}); err != nil { return &MalformedDefaultError{FieldName: field.Name, Default: metadata.Default, Err: err} } } diff --git a/settings.go b/settings.go index b2fba3c..3c76166 100644 --- a/settings.go +++ b/settings.go @@ -6,5 +6,5 @@ type settings struct { } type entry struct { - key, value string + key, value, fieldName string } diff --git a/source.go b/source.go index 38b1342..a02ea92 100644 --- a/source.go +++ b/source.go @@ -44,7 +44,7 @@ func (s fileSource) Load() (map[string]string, error) { func parseEnvFile(path string) (map[string]string, error) { file, err := os.Open(filepath.Clean(path)) if err != nil { - return nil, &OpenFileError{Err: err} + return nil, &OpenFileError{Filepath: path, Err: err} } defer file.Close() //nolint:errcheck // read-only handle; close error is non-consequential