From d698ad5c26316b8c1a75584a02d9b3f3ad3a41dd Mon Sep 17 00:00:00 2001 From: h-dav Date: Sat, 2 May 2026 18:35:03 +0100 Subject: [PATCH 1/2] refactor(errors): use idiomatic Go error wrapping --- decode.go | 4 +-- error.go | 42 +++++++++++++++------------- error_test.go | 77 ++++++++++++++++++++++++++++++++++++++++++--------- 3 files changed, 89 insertions(+), 34 deletions(-) diff --git a/decode.go b/decode.go index 7e63861..99b026d 100644 --- a/decode.go +++ b/decode.go @@ -40,7 +40,7 @@ func (s *settings) setFieldValue(field reflect.Value, e entry) error { case reflect.Slice: return setSliceField(field, e) default: - return &UnsupportedFieldTypeError{FieldType: field.Interface()} + return &UnsupportedFieldTypeError{FieldType: field.Type().String()} } return nil } @@ -62,7 +62,7 @@ func setSliceField(field reflect.Value, e entry) error { } field.Set(v) default: - return &UnsupportedFieldTypeError{FieldType: field.Interface()} + return &UnsupportedFieldTypeError{FieldType: field.Type().String()} } return nil } diff --git a/error.go b/error.go index 420074f..b71bfbd 100644 --- a/error.go +++ b/error.go @@ -26,7 +26,7 @@ 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 { @@ -37,8 +37,7 @@ func (e *OpenFileError) Error() string { return fmt.Sprintf("failed to open config file: %v", 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 { @@ -51,19 +50,18 @@ func (e *FieldConversionError) Error() string { return fmt.Sprintf("failed to convert field %v 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 + FieldType string } func (e *UnsupportedFieldTypeError) Error() string { - return fmt.Sprintf("unsupported field type: %T", e.FieldType) + return fmt.Sprintf("unsupported field type: %s", 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 +72,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 { @@ -85,7 +83,7 @@ func (e *RequiredFieldError) Error() string { return fmt.Sprintf("required field is not set in configuration: %v", 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 +94,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 { @@ -111,8 +109,7 @@ 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,8 +136,12 @@ 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 } +func (e *MalformedTagError) Unwrap() []error { + if e.Err != nil { + return []error{e.Err, ErrTag} + } + return []error{ErrTag} +} // FieldError wraps an error with the name of the field that caused it. type FieldError struct { @@ -170,5 +170,9 @@ 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 { + if e.Err != nil { + return []error{e.Err, ErrTag} + } + return []error{ErrTag} +} diff --git a/error_test.go b/error_test.go index 9716168..560f1a3 100644 --- a/error_test.go +++ b/error_test.go @@ -60,23 +60,74 @@ 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}, 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}, + {"FieldError", &configutil.FieldError{FieldName: "X", 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) { + if !errors.Is(tc.err, tc.inner) { + t.Errorf("errors.Is(%T, inner) = false, want true", tc.err) + } + }) + } +} + +func TestSentinelUnwrap(t *testing.T) { + tests := []struct { + name string + err error + sentinel 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}}, + {"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{FieldType: "chan int"}, configutil.ErrUnsupported}, } 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.sentinel) { + t.Errorf("errors.Is(%T, sentinel) = false, want true", tc.err) + } + }) + } +} + +func TestDualUnwrap(t *testing.T) { + inner := fmt.Errorf("inner error") + + tests := []struct { + name string + err error + inner error + sentinel error + }{ + {"OpenFileError", &configutil.OpenFileError{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}, + } + + 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) } }) } @@ -108,7 +159,7 @@ func TestErrorMessages(t *testing.T) { }{ {"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"}, + {"UnsupportedFieldTypeError", &configutil.UnsupportedFieldTypeError{FieldType: "chan int"}, "unsupported field type: 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"}, {"ReplacementError", &configutil.ReplacementError{VariableName: "HOST"}, "configuration variable for replacement is not set: HOST"}, From f44bfe735c69ae6fc26d23db7ee3a5b5c335661d Mon Sep 17 00:00:00 2001 From: h-dav Date: Sun, 3 May 2026 13:26:10 +0100 Subject: [PATCH 2/2] refactor(errors): remove FieldError wrapper and embed field context in errors Remove the FieldError wrapper type that was creating redundant wrapping of errors that already contained field context. Instead, thread fieldName through the decode chain so each error type is self-contained with complete context (struct field name + type info + underlying cause), following the os.PathError pattern. Key changes: - Remove FieldError struct and all wrapping in populate.go - Add fieldName to entry struct and thread it through handleField and decode functions - Add Filepath field to OpenFileError for consistency with FileReadError - Add FieldName field to UnsupportedFieldTypeError - Simplify MalformedTagError and MalformedDefaultError Unwrap() methods - Update error message formatting to use %q for identifiers (field names) - Move ErrSyntax into the sentinel vars block - Update all error tests and error message expectations --- decode.go | 24 ++++++++++++------------ error.go | 45 ++++++++++----------------------------------- error_test.go | 49 ++++++++++++++----------------------------------- populate.go | 6 +++--- settings.go | 2 +- source.go | 2 +- 6 files changed, 41 insertions(+), 87 deletions(-) diff --git a/decode.go b/decode.go index 99b026d..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.Type().String()} + 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.Type().String()} + 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 b71bfbd..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. @@ -30,11 +31,12 @@ 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 []error{e.Err, ErrFile} } @@ -47,18 +49,19 @@ 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 []error{e.Err, ErrConversion} } // UnsupportedFieldTypeError occurs when the a field type on the config struct is not compatible. type UnsupportedFieldTypeError struct { + FieldName string FieldType string } func (e *UnsupportedFieldTypeError) Error() string { - return fmt.Sprintf("unsupported field type: %s", e.FieldType) + return fmt.Sprintf("unsupported field type %q: %s", e.FieldName, e.FieldType) } func (e *UnsupportedFieldTypeError) Unwrap() error { return ErrUnsupported } @@ -80,7 +83,7 @@ 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) Unwrap() error { return ErrRequired } @@ -102,9 +105,6 @@ 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) } @@ -136,27 +136,7 @@ func (e *MalformedTagError) Error() string { return fmt.Sprintf("malformed tag %q", e.Tag) } -func (e *MalformedTagError) Unwrap() []error { - if e.Err != nil { - return []error{e.Err, ErrTag} - } - return []error{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,9 +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 { - if e.Err != nil { - return []error{e.Err, ErrTag} - } - return []error{ErrTag} -} +func (e *MalformedDefaultError) Unwrap() []error { return []error{e.Err, ErrTag} } diff --git a/error_test.go b/error_test.go index 560f1a3..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,16 +60,15 @@ func TestErrorsUnwrap(t *testing.T) { inner := fmt.Errorf("inner error") tests := []struct { - name string - err error - inner error + name string + err error + inner error }{ {"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}, - {"FieldError", &configutil.FieldError{FieldName: "X", Err: inner}, inner}, {"MalformedDefaultError", &configutil.MalformedDefaultError{FieldName: "X", Default: "bad", Err: inner}, inner}, } @@ -92,7 +91,7 @@ func TestSentinelUnwrap(t *testing.T) { {"InvalidConfigTypeError", &configutil.InvalidConfigTypeError{ProvidedType: "string"}, configutil.ErrInvalidConfig}, {"RequiredFieldError", &configutil.RequiredFieldError{FieldName: "Name"}, configutil.ErrRequired}, {"ReplacementError", &configutil.ReplacementError{VariableName: "HOST"}, configutil.ErrReplacement}, - {"UnsupportedFieldTypeError", &configutil.UnsupportedFieldTypeError{FieldType: "chan int"}, configutil.ErrUnsupported}, + {"UnsupportedFieldTypeError", &configutil.UnsupportedFieldTypeError{FieldName: "X", FieldType: "chan int"}, configutil.ErrUnsupported}, } for _, tc := range tests { @@ -108,12 +107,12 @@ func TestDualUnwrap(t *testing.T) { inner := fmt.Errorf("inner error") tests := []struct { - name string - err error - inner error + name string + err error + inner error sentinel error }{ - {"OpenFileError", &configutil.OpenFileError{Err: inner}, inner, configutil.ErrFile}, + {"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}, @@ -133,24 +132,6 @@ func TestDualUnwrap(t *testing.T) { } } -func TestErrorsAs_ThroughFieldError(t *testing.T) { - inner := &configutil.RequiredFieldError{FieldName: "Name"} - wrapped := &configutil.FieldError{FieldName: "Name", Err: inner} - - var target *configutil.RequiredFieldError - if !errors.As(wrapped, &target) { - t.Error("errors.As through FieldError failed to find RequiredFieldError") - } - if target.FieldName != "Name" { - t.Errorf("FieldName = %q, want %q", target.FieldName, "Name") - } - - // 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") - } -} - func TestErrorMessages(t *testing.T) { tests := []struct { name string @@ -158,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: "chan int"}, "unsupported field type: chan int"}, + {"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