diff --git a/decoder.go b/decoder.go index 1457f8b..486f7e2 100644 --- a/decoder.go +++ b/decoder.go @@ -7,6 +7,7 @@ import ( "time" ) +// DecoderFunc is a function that converts a string value to a specific type. type DecoderFunc func(key, value string) (reflect.Value, error) var defaultDecoders = map[reflect.Type]DecoderFunc{ @@ -60,67 +61,60 @@ var defaultDecoders = map[reflect.Type]DecoderFunc{ }, } +// Setter is an interface that can be implemented by types that want to self-configure. type Setter interface { Set(value string) error } -// setFieldValue determines the type of a config field, and branch out to the correct -// function to populate that data type. -func (s settings) setFieldValue( - configFieldValue reflect.Value, - entry entry, -) error { - fieldAddr := configFieldValue.Addr() +// setFieldValue sets the value of a field. +func (s *settings) setFieldValue(fieldValue reflect.Value, key, value string) error { + fieldAddr := fieldValue.Addr() if setter, ok := fieldAddr.Interface().(Setter); ok { - return setter.Set(entry.value) + return setter.Set(value) } - if dec, ok := s.decoders[configFieldValue.Type()]; ok { - decodedValue, err := dec(entry.key, entry.value) + if dec, ok := s.decoders[fieldValue.Type()]; ok { + decodedValue, err := dec(key, value) if err != nil { return err } - configFieldValue.Set(decodedValue) + fieldValue.Set(decodedValue) return nil } - switch configFieldValue.Interface().(type) { + switch fieldValue.Interface().(type) { case string: - configFieldValue.SetString(entry.value) + fieldValue.SetString(value) case []string: - return setStringSliceFieldValue(configFieldValue, entry.value) + return setStringSliceFieldValue(fieldValue, value) case []int: - return setIntSliceFieldValue(configFieldValue, entry) + return setIntSliceFieldValue(fieldValue, key, value) case []float64: - return setFloatSliceFieldValue(configFieldValue, entry) + return setFloatSliceFieldValue(fieldValue, key, value) default: - return &UnsupportedFieldTypeError{FieldType: configFieldValue.Interface()} + return &UnsupportedFieldTypeError{FieldType: fieldValue.Interface()} } return nil } -func setStringSliceFieldValue(configFieldValue reflect.Value, environmentValue string) error { - values := strings.Split(environmentValue, ",") - slice := reflect.MakeSlice(configFieldValue.Type(), len(values), len(values)) +func setStringSliceFieldValue(fieldValue reflect.Value, value string) error { + values := strings.Split(value, ",") + slice := reflect.MakeSlice(fieldValue.Type(), len(values), len(values)) for i, v := range values { v = strings.TrimSpace(v) slice.Index(i).SetString(v) } - configFieldValue.Set(slice) - + fieldValue.Set(slice) return nil } -func setIntSliceFieldValue( - configFieldValue reflect.Value, - entry entry, -) error { - values := strings.Split(entry.value, ",") - slice := reflect.MakeSlice(configFieldValue.Type(), len(values), len(values)) +func setIntSliceFieldValue(fieldValue reflect.Value, key, value string) error { + values := strings.Split(value, ",") + slice := reflect.MakeSlice(fieldValue.Type(), len(values), len(values)) for i, v := range values { v = strings.TrimSpace(v) @@ -128,7 +122,7 @@ func setIntSliceFieldValue( parsed, err := strconv.Atoi(v) if err != nil { return &FieldConversionError{ - FieldName: entry.key, + FieldName: key, TargetType: "[]int", Err: err, } @@ -137,17 +131,13 @@ func setIntSliceFieldValue( slice.Index(i).SetInt(int64(parsed)) } - configFieldValue.Set(slice) - + fieldValue.Set(slice) return nil } -func setFloatSliceFieldValue( - configFieldValue reflect.Value, - entry entry, -) error { - values := strings.Split(entry.value, ",") - slice := reflect.MakeSlice(configFieldValue.Type(), len(values), len(values)) +func setFloatSliceFieldValue(fieldValue reflect.Value, key, value string) error { + values := strings.Split(value, ",") + slice := reflect.MakeSlice(fieldValue.Type(), len(values), len(values)) for i, v := range values { v = strings.TrimSpace(v) @@ -155,7 +145,7 @@ func setFloatSliceFieldValue( parsed, err := strconv.ParseFloat(v, 64) if err != nil { return &FieldConversionError{ - FieldName: entry.key, + FieldName: key, TargetType: "[]float64", Err: err, } @@ -164,7 +154,6 @@ func setFloatSliceFieldValue( slice.Index(i).SetFloat(parsed) } - configFieldValue.Set(slice) - + fieldValue.Set(slice) return nil } diff --git a/envconfig.go b/envconfig.go index 6e2321c..55061fa 100644 --- a/envconfig.go +++ b/envconfig.go @@ -10,14 +10,12 @@ import ( "strings" ) -type entry struct { - key, value string -} +const envExtension = ".env" // textReplacementRegex is used to detect text replacement in environment variables. var textReplacementRegex = regexp.MustCompile(`\${[^}]+}`) -// Set will parse multiple sources for config values, and use these values to populate the passed in config struct. +// Set parses multiple sources for config values and populates the passed config struct. func Set(config any, opts ...option) error { s := &settings{ source: map[string]string{}, @@ -28,22 +26,28 @@ func Set(config any, opts ...option) error { opt(s) } - s.sources = append(s.sources, EnvironmentVariableSource{}, FlagSource{}) - - if s.activeProfile != "" { - if s.filepath == "" { - return fmt.Errorf("assign active profile: %w", &IncompatibleOptionsError{ - FirstOption: "WithActiveProfile()", - SecondOption: "WithFilepath()", - Reason: "directory in filepath option must be provided when using active profile", - }) + // Process filepaths and create FileSources + for _, f := range s.filepaths { + path := f + if s.activeProfile != "" { + dir, _ := filepath.Split(path) + path = filepath.Join(dir, s.activeProfile+envExtension) } + s.sources = append(s.sources, FileSource{Filepath: path}) + } - dir, _ := filepath.Split(s.filepath) - - s.filepath = dir + s.activeProfile + envExtension + if s.activeProfile != "" && len(s.filepaths) == 0 { + return fmt.Errorf("assign active profile: %w", &IncompatibleOptionsError{ + FirstOption: "WithActiveProfile()", + SecondOption: "WithFilepath()", + Reason: "directory in filepath option must be provided when using active profile", + }) } + // Add EnvironmentVariableSource and FlagSource at the end + s.sources = append(s.sources, EnvironmentVariableSource{}, FlagSource{}) + + // Load all sources for _, source := range s.sources { values, err := source.Load() if err != nil { @@ -62,118 +66,99 @@ func Set(config any, opts ...option) error { return nil } -// populateStruct uses the items in settings.source to populate the passed in config struct. -func (s settings) populateStruct(config any) error { - configStruct := reflect.ValueOf(config) - if configStruct.Kind() != reflect.Pointer || configStruct.Elem().Kind() != reflect.Struct { +// populateStruct populates the config struct using the loaded values. +func (s *settings) populateStruct(config any) error { + configValue := reflect.ValueOf(config) + if configValue.Kind() != reflect.Pointer || configValue.Elem().Kind() != reflect.Struct { return &InvalidConfigTypeError{ProvidedType: config} } - configValue := reflect.ValueOf(config).Elem() + // Pass s.prefix to the recursive function so top-level fields respect the prefix option. + return s.populateStructRecursive(configValue.Elem(), s.prefix) +} - for i := range configValue.NumField() { - field := configValue.Type().Field(i) - configFieldValue := configValue.Field(i) +func (s *settings) populateStructRecursive(structValue reflect.Value, prefix string) error { + structType := structValue.Type() - // Ignore fields that are not exported. - if !configFieldValue.CanSet() { + for i := 0; i < structValue.NumField(); i++ { + field := structType.Field(i) + fieldValue := structValue.Field(i) + + if !fieldValue.CanSet() { continue } - jsonOptionValue, jsonOptionSet := field.Tag.Lookup(tagJSON) - if jsonOptionSet { - err := json.Unmarshal([]byte(s.source[jsonOptionValue]), configFieldValue.Addr().Interface()) - if err != nil { - return fmt.Errorf("unmarshal JSON: %w", err) + // Handle JSON tag + if jsonKey, ok := field.Tag.Lookup(tagJSON); ok { + if val, exists := s.source[jsonKey]; exists { + if err := json.Unmarshal([]byte(val), fieldValue.Addr().Interface()); err != nil { + return fmt.Errorf("unmarshal JSON for field %s: %w", field.Name, err) + } + continue } - continue } - if err := s.handlePrefixTag(field, configFieldValue, ""); err != nil { - return fmt.Errorf("handle prefix tag: %w", err) + // Handle Prefix tag (Nested Structs) + if prefixTag, ok := field.Tag.Lookup(tagPrefix); ok { + if fieldValue.Kind() == reflect.Struct { + if err := s.populateStructRecursive(fieldValue, prefix+prefixTag); err != nil { + return fmt.Errorf("populate nested struct %s: %w", field.Name, err) + } + continue + } } - key := field.Tag.Get(tagEnv) - if key == "" { + // Handle Env tag + envKey := field.Tag.Get(tagEnv) + if envKey == "" { continue } - value := s.source[key] + fullKey := prefix + envKey + value := s.source[fullKey] + + // Handle Default / Required if value == "" { - if err := checkRequiredTag(key, field); err != nil { - return fmt.Errorf("check required tag: %w", err) + if err := checkRequiredTag(fullKey, field); err != nil { + return err } - value = field.Tag.Get(tagDefault) } - value, err := s.resolveReplacement(value) - if err != nil { - return fmt.Errorf("resolve replacement: %w", err) + // Handle Text Replacement + if value != "" { + var err error + value, err = s.resolveReplacement(value) + if err != nil { + return err + } } - if err := s.setFieldValue( - configFieldValue, entry{key, value}); err != nil { - return fmt.Errorf("set field value: %w", err) + // Set Value + if value != "" { + // Pass fullKey and value directly, no entry struct. + if err := s.setFieldValue(fieldValue, fullKey, value); err != nil { + return fmt.Errorf("set field %s: %w", field.Name, err) + } } } - return nil } -// resolveReplacement checks if a string has the pattern of ${...}, and if so, uses values in settings.source to -// replace the pattern, and returns the newly created string. -func (s settings) resolveReplacement(value string) (string, error) { +// resolveReplacement resolves ${VAR} patterns. +func (s *settings) resolveReplacement(value string) (string, error) { match := textReplacementRegex.FindStringSubmatch(value) for _, m := range match { - environmentValue := strings.TrimPrefix(m, "${") - environmentValue = strings.TrimSuffix(environmentValue, "}") + key := strings.TrimSuffix(strings.TrimPrefix(m, "${"), "}") - replacementValue := s.source[environmentValue] - if replacementValue == "" { - return "", &ReplacementError{VariableName: environmentValue} + replacement, ok := s.source[key] + if !ok || replacement == "" { + return "", &ReplacementError{VariableName: key} } - value = strings.ReplaceAll(value, m, replacementValue) + value = strings.ReplaceAll(value, m, replacement) } return value, nil } - -// populateNestedConfig populates a nested struct. -func (s settings) populateNestedConfig(nestedConfig reflect.Value, prefix string) error { - for i := range nestedConfig.NumField() { - field := nestedConfig.Type().Field(i) - configFieldValue := nestedConfig.Field(i) - - if !configFieldValue.CanSet() || !configFieldValue.IsZero() { - continue - } - - jsonOptionValue, jsonOptionSet := field.Tag.Lookup(tagJSON) - if jsonOptionSet { - err := json.Unmarshal([]byte(s.source[jsonOptionValue]), &configFieldValue) - if err != nil { - return fmt.Errorf("handle JSON tag: %w", err) - } - - continue - } - - if err := s.handlePrefixTag(field, configFieldValue, prefix); err != nil { - return fmt.Errorf("handle prefix tag: %w", err) - } - - environmentVariableKey := prefix + field.Tag.Get(tagEnv) - if environmentVariableKey == prefix { // Ensure tag is set. - continue - } - if err := s.setFieldValue( - configFieldValue, entry{environmentVariableKey, s.source[environmentVariableKey]}); err != nil { - return fmt.Errorf("set field value: %w", err) - } - } - - return nil -} diff --git a/envconfig_test.go b/envconfig_test.go index 25b1895..305f846 100644 --- a/envconfig_test.go +++ b/envconfig_test.go @@ -168,9 +168,7 @@ func TestSet(t *testing.T) { for tn, tc := range testCases { t.Run(tn, func(t *testing.T) { - t.Parallel() - - loadFileIntoEnvironmentVariables(tc.filepath) + loadFileIntoEnvironmentVariables(t, tc.filepath) tc.assert(t, tc) }, @@ -178,7 +176,7 @@ func TestSet(t *testing.T) { } } -func loadFileIntoEnvironmentVariables(filepath string) { +func loadFileIntoEnvironmentVariables(t *testing.T, filepath string) { file, err := os.Open(filepath) if err != nil { log.Fatal(err) @@ -187,7 +185,15 @@ func loadFileIntoEnvironmentVariables(filepath string) { scanner := bufio.NewScanner(file) for scanner.Scan() { - key, value, _ := strings.Cut(scanner.Text(), "=") + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + key, value, found := strings.Cut(line, "=") + if !found { + continue + } // Clean environment variable key. key = strings.TrimSpace(key) @@ -195,14 +201,12 @@ func loadFileIntoEnvironmentVariables(filepath string) { // Clean a value of starting whitespace and comments. value = strings.TrimSpace(value) value, _, _ = strings.Cut(value, " #") - os.Setenv(key, value) + t.Setenv(key, value) } if err := scanner.Err(); err != nil { log.Fatal(err) } - - return } func TestSetWithPrefix(t *testing.T) { @@ -219,7 +223,7 @@ func TestSetWithPrefix(t *testing.T) { assert: func(t *testing.T, tc testCase) { t.Helper() - os.Setenv("PREFIX_DURATION", "10s") + t.Setenv("PREFIX_DURATION", "10s") var config SuccessWithPrefixOption @@ -240,8 +244,6 @@ func TestSetWithPrefix(t *testing.T) { for tn, tc := range testCases { t.Run(tn, func(t *testing.T) { - t.Parallel() - tc.assert(t, tc) }, ) @@ -260,7 +262,7 @@ func TestSetSuccessWithSliceStringField(t *testing.T) { var want Config want.SliceStringField = []string{"first", "second", "third"} - loadFileIntoEnvironmentVariables("./test_data/success_with_slice_string_field.env") + loadFileIntoEnvironmentVariables(t, "./test_data/success_with_slice_string_field.env") envconfig.Set(&config) @@ -279,7 +281,7 @@ func TestSetSuccessWithSliceIntField(t *testing.T) { var want Config want.SliceIntField = []int{1, 2, 3} - loadFileIntoEnvironmentVariables("./test_data/success_with_slice_int_field.env") + loadFileIntoEnvironmentVariables(t, "./test_data/success_with_slice_int_field.env") envconfig.Set(&config) @@ -298,7 +300,7 @@ func TestSetSuccessWithSliceFloatField(t *testing.T) { var want Config want.SliceFloatField = []float64{1.2, 2.3, 3.4} - loadFileIntoEnvironmentVariables("./test_data/success_with_slice_float_field.env") + loadFileIntoEnvironmentVariables(t, "./test_data/success_with_slice_float_field.env") envconfig.Set(&config) @@ -321,7 +323,7 @@ func TestSetSuccessWithNestedStruct(t *testing.T) { var want Config want.Server.Port = "8080" - loadFileIntoEnvironmentVariables("./test_data/success_with_nested_struct.env") + loadFileIntoEnvironmentVariables(t, "./test_data/success_with_nested_struct.env") envconfig.Set(&config) @@ -344,7 +346,7 @@ func TestSetSuccessWithDeeplyNestedStruct(t *testing.T) { var want Config want.Server.Port.Value = "1234" - loadFileIntoEnvironmentVariables("./test_data/success_with_deeply_nested_struct.env") + loadFileIntoEnvironmentVariables(t, "./test_data/success_with_deeply_nested_struct.env") envconfig.Set(&config) @@ -371,7 +373,7 @@ func TestSetSuccessWithThriceDeeplyNestedStruct(t *testing.T) { want.Server.Database.Tables.First = "example_table" want.Server.Database.Timezome = "uk/london" - loadFileIntoEnvironmentVariables("./test_data/success_with_thrice_deeply_nested_struct.env") + loadFileIntoEnvironmentVariables(t, "./test_data/success_with_thrice_deeply_nested_struct.env") envconfig.Set(&config) @@ -393,7 +395,7 @@ func TestSetSuccessWithJsonField(t *testing.T) { var want Config want.JSONField.First = "example" - loadFileIntoEnvironmentVariables("./test_data/success_with_json_field.env") + loadFileIntoEnvironmentVariables(t, "./test_data/success_with_json_field.env") envconfig.Set(&config) diff --git a/file_internal_test.go b/file_internal_test.go index 28a4f7b..9eeae88 100644 --- a/file_internal_test.go +++ b/file_internal_test.go @@ -3,8 +3,6 @@ package envconfig import ( "reflect" "testing" - - "github.com/google/go-cmp/cmp" ) func Test_identifyParser(t *testing.T) { @@ -17,7 +15,7 @@ func Test_identifyParser(t *testing.T) { testCases := map[string]testCase{ "expect env parser for env file": { filepath: "example.env", - want: envFileParser{}, + want: envFileParser{filepath: "example.env"}, }, "expect error due to invalid file extension": { filepath: "example.invalid", @@ -34,7 +32,7 @@ func Test_identifyParser(t *testing.T) { got, err := identifyFileParser(tc.filepath) - if !cmp.Equal(tc.wantErr, err) { + if !reflect.DeepEqual(tc.wantErr, err) { t.Errorf("wantErr: %#v, got: %#v", tc.wantErr, err) } diff --git a/go.mod b/go.mod index 564b77b..e6aeef1 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,3 @@ module github.com/h-dav/envconfig/v3 go 1.25 - -require github.com/google/go-cmp v0.7.0 diff --git a/go.sum b/go.sum index 40e761a..e69de29 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +0,0 @@ -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= diff --git a/load.go b/load.go deleted file mode 100644 index ca7e5ad..0000000 --- a/load.go +++ /dev/null @@ -1,146 +0,0 @@ -package envconfig - -import ( - "bufio" - "flag" - "fmt" - "os" - "path/filepath" - "strings" -) - -type source interface { - Load() (map[string]string, error) -} - -type FlagSource struct{} - -func (s FlagSource) Load() (map[string]string, error) { - flag.Parse() - - source := make(map[string]string) - - flag.Visit(func(f *flag.Flag) { - source[f.Name] = f.Value.String() - }) - - return source, nil -} - -const ( - envExtension = ".env" -) - -type parser interface { - parse() (map[string]string, error) -} - -type FileSource struct { - filepath string -} - -func (s FileSource) Load() (map[string]string, error) { - parser, err := identifyFileParser(s.filepath) - if err != nil { - return nil, fmt.Errorf("identify file parser: %w", err) - } - - source, err := parser.parse() - if err != nil { - return nil, fmt.Errorf("parse file: %w", err) - } - - - return source, nil -} - -// identifyFileParser determines the parser to use based on the filepath received. -func identifyFileParser(f string) (parser, error) { - var parser parser - - switch filepath.Ext(f) { - case envExtension: - parser = envFileParser{ - source: map[string]string{}, - filepath: f, - } - default: - return nil, &FileTypeValidationError{Filepath: f} - } - - return parser, nil -} - -type envFileParser struct { - source map[string]string - filepath string -} - -func (e envFileParser) parse() (map[string]string, error) { - file, err := os.Open(filepath.Clean(e.filepath)) - if err != nil { - return make(map[string]string), &OpenFileError{Err: err} - } - defer file.Close() //nolint:errcheck // File closure. - - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := scanner.Text() - - // Handles empty and commented lines. - if line == "" || strings.HasPrefix(line, "#") { - continue - } - - entry, err := e.parseLine(line) - if err != nil { - return make(map[string]string), fmt.Errorf("parse line: %w", err) - } - - e.source[entry.key] = entry.value - } - - if err := scanner.Err(); err != nil { - return make(map[string]string), &FileReadError{Filepath: e.filepath, Err: err} - } - - return e.source, nil -} - -// parseLine parses an individual .env line, and will detect comments. -func (e envFileParser) parseLine(line string) (entry, error) { - key, value, found := strings.Cut(line, "=") - if !found { - return entry{}, &ParseError{Line: line} - } - - // Clean environment variable key. - key = strings.TrimSpace(key) - - // Clean a value of starting whitespace and comments. - value = strings.TrimSpace(value) - value, _, _ = strings.Cut(value, " #") - - return entry{key: key, value: value}, nil -} - -type EnvironmentVariableSource struct { - prefix string -} - -// processEnvironmentVariables populates the config struct using all environment variables. -func (s EnvironmentVariableSource) Load() (map[string]string, error) { //nolint:gocognit // Complexity is reasonable. - source := make(map[string]string) - all := os.Environ() - - for _, val := range all { - key, value, found := strings.Cut(val, "=") - if !found { - continue - } - - source[key] = value - } - - return source, nil -} diff --git a/option.go b/option.go index b71a8aa..e823db7 100644 --- a/option.go +++ b/option.go @@ -3,27 +3,24 @@ package envconfig import "reflect" type settings struct { - filepath string - activeProfile string - prefix string - source map[string]string - temporaryPrefix string // temporary prefix is only used we are populating nested structs - sources []source - decoders map[reflect.Type]DecoderFunc + filepaths []string + activeProfile string + prefix string + source map[string]string + sources []Source + decoders map[reflect.Type]DecoderFunc } type option func(*settings) -// WithFilepath option will cause the file provided to be used to set variables in the environment. +// WithFilepath adds a file to be used for configuration loading. func WithFilepath(filepath string) option { return func(s *settings) { - s.filepath = filepath - s.sources = append(s.sources, FileSource{ - filepath: filepath, - }) + s.filepaths = append(s.filepaths, filepath) } } +// WithActiveProfile sets the active profile. func WithActiveProfile(activeProfile string) option { return func(s *settings) { if activeProfile == "" { @@ -33,13 +30,14 @@ func WithActiveProfile(activeProfile string) option { } } -// WithPrefix option will add the prefix to before every set and retrieval from env. +// WithPrefix sets a global prefix for environment variables. func WithPrefix(prefix string) option { return func(s *settings) { s.prefix = prefix } } +// WithDecoders adds custom decoders for specific types. func WithDecoders(decoders map[reflect.Type]DecoderFunc) option { return func(s *settings) { if s.decoders == nil { @@ -50,3 +48,10 @@ func WithDecoders(decoders map[reflect.Type]DecoderFunc) option { } } } + +// WithSource adds a custom source to the configuration loader. +func WithSource(source Source) option { + return func(s *settings) { + s.sources = append(s.sources, source) + } +} diff --git a/source.go b/source.go new file mode 100644 index 0000000..96150ab --- /dev/null +++ b/source.go @@ -0,0 +1,139 @@ +package envconfig + +import ( + "bufio" + "flag" + "fmt" + "os" + "path/filepath" + "strings" +) + +// Source is the interface that wraps the Load method. +// +// Load returns a map of key-value pairs representing the configuration. +type Source interface { + Load() (map[string]string, error) +} + +// FlagSource loads configuration from command-line flags. +type FlagSource struct{} + +// Load parses command-line flags and returns them as a map. +// It will parse flags if they haven't been parsed yet. +func (s FlagSource) Load() (map[string]string, error) { + if !flag.Parsed() { + flag.Parse() + } + + source := make(map[string]string) + + flag.Visit(func(f *flag.Flag) { + source[f.Name] = f.Value.String() + }) + + return source, nil +} + +// EnvironmentVariableSource loads configuration from environment variables. +type EnvironmentVariableSource struct {} + +// Load loads all environment variables. +func (s EnvironmentVariableSource) Load() (map[string]string, error) { + source := make(map[string]string) + all := os.Environ() + + for _, val := range all { + key, value, found := strings.Cut(val, "=") + if !found { + continue + } + + source[key] = value + } + + return source, nil +} + +// FileSource loads configuration from a file. +type FileSource struct { + Filepath string +} + +// Load loads the file and parses it based on its extension. +func (s FileSource) Load() (map[string]string, error) { + parser, err := identifyFileParser(s.Filepath) + if err != nil { + return nil, fmt.Errorf("identify file parser: %w", err) + } + + source, err := parser.Parse() + if err != nil { + return nil, fmt.Errorf("parse file: %w", err) + } + + return source, nil +} + +type parser interface { + Parse() (map[string]string, error) +} + +func identifyFileParser(f string) (parser, error) { + switch filepath.Ext(f) { + case ".env": + return envFileParser{filepath: f}, nil + default: + return nil, &FileTypeValidationError{Filepath: f} + } +} + +type envFileParser struct { + filepath string +} + +func (e envFileParser) Parse() (map[string]string, error) { + file, err := os.Open(filepath.Clean(e.filepath)) + if err != nil { + return nil, &OpenFileError{Err: err} + } + defer file.Close() + + source := make(map[string]string) + scanner := bufio.NewScanner(file) + + for scanner.Scan() { + line := scanner.Text() + + // Handles empty and commented lines. + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + key, value, err := parseEnvLine(line) + if err != nil { + return nil, fmt.Errorf("parse line: %w", err) + } + + source[key] = value + } + + if err := scanner.Err(); err != nil { + return nil, &FileReadError{Filepath: e.filepath, Err: err} + } + + return source, nil +} + +func parseEnvLine(line string) (key, value string, err error) { + k, v, found := strings.Cut(line, "=") + if !found { + return "", "", &ParseError{Line: line} + } + + k = strings.TrimSpace(k) + v = strings.TrimSpace(v) + v, _, _ = strings.Cut(v, " #") + + return k, v, nil +} diff --git a/tag.go b/tag.go index 57b893a..fe92b6a 100644 --- a/tag.go +++ b/tag.go @@ -1,68 +1,36 @@ package envconfig import ( - "fmt" "reflect" "strconv" ) const ( - // tagEnv is used for fetching the environment variable by name. - tagEnv = "env" - - // tagDefault is used to set a fallback value for a config field if the environment variable is not set. - tagDefault = "default" - - // tagRequired is used for config struct fields that are required. If the environment variable is not set, an - // error will be returned. + tagEnv = "env" + tagDefault = "default" tagRequired = "required" - - // tagJSON is used for environment variables that are JSON. - tagJSON = "envjson" - - // tagPrefix is used for nested structs inside your config struct. - tagPrefix = "prefix" + tagJSON = "envjson" + tagPrefix = "prefix" ) // checkRequiredTag checks if a field is required and returns an error if so. -// -// This function is only called when an environment variable is not set for a field. -func checkRequiredTag(environmentVariableKey string, field reflect.StructField) error { - requiredOptionValue, requiredOptionSet := field.Tag.Lookup(tagRequired) - if !requiredOptionSet { +func checkRequiredTag(key string, field reflect.StructField) error { + requiredVal, ok := field.Tag.Lookup(tagRequired) + if !ok { return nil } - requiredOption, err := strconv.ParseBool(requiredOptionValue) - if requiredOption { - return &RequiredFieldError{FieldName: environmentVariableKey} - } else if err != nil { + required, err := strconv.ParseBool(requiredVal) + if err != nil { return &InvalidOptionConversionError{ - FieldName: environmentVariableKey, + FieldName: key, Option: tagRequired, Err: err, } } - return nil -} - -func (s settings)handlePrefixTag( - field reflect.StructField, - configFieldValue reflect.Value, - prefix string, -) error { - if field.Type.Kind() != reflect.Struct { - return nil - } - - prefixOptionValue, prefixOptionSet := field.Tag.Lookup(tagPrefix) - if !prefixOptionSet { - return &PrefixOptionError{FieldName: field.Name} - } - - if err := s.populateNestedConfig(configFieldValue, prefix+prefixOptionValue); err != nil { - return fmt.Errorf("populate nested config struct: %w", err) + if required { + return &RequiredFieldError{FieldName: key} } return nil