Skip to content

Commit c5ce887

Browse files
committed
fix: pointer-to-struct nil when no env vars, add envDefault/envSeparator/envExpand tags, camelCase conversion
- Pointer-to-struct fields now remain nil when no child env vars are set, matching kelseyhightower/envconfig behavior (Task #428) - Parse envDefault tag as alias for default tag (Task #429) - Parse envSeparator tag for custom slice separators (Task #429) - Parse envExpand tag to expand $VAR references via os.ExpandEnv (Task #429) - Convert CamelCase field names to UPPER_SNAKE_CASE (e.g. DatabaseURL -> DATABASE_URL) matching envconfig's word-boundary logic (Task #429) - Fix empty string slice decode: HOSTS="" now produces []string{} not []string{""} - 81 tests pass (was 66)
1 parent c407f30 commit c5ce887

5 files changed

Lines changed: 263 additions & 18 deletions

File tree

decoder.go

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,15 @@ var (
3030
)
3131

3232
// decode sets a reflect.Value from a string value.
33+
// separator is used for splitting slice values.
3334
// It returns a ParseError if decoding fails.
34-
func decode(fv reflect.Value, val string, fieldName string, envVar string) error {
35+
func decode(fv reflect.Value, val string, fieldName string, envVar string, separator string) error {
3536
// Handle pointer: allocate and decode the element.
3637
if fv.Kind() == reflect.Ptr {
3738
if fv.IsNil() {
3839
fv.Set(reflect.New(fv.Type().Elem()))
3940
}
40-
return decode(fv.Elem(), val, fieldName, envVar)
41+
return decode(fv.Elem(), val, fieldName, envVar, separator)
4142
}
4243

4344
// Check interfaces on pointer-to-value (to catch pointer receivers).
@@ -82,7 +83,7 @@ func decode(fv reflect.Value, val string, fieldName string, envVar string) error
8283

8384
// Slices.
8485
if ft.Kind() == reflect.Slice {
85-
return decodeSlice(fv, val, fieldName, envVar)
86+
return decodeSlice(fv, val, fieldName, envVar, separator)
8687
}
8788

8889
// Maps.
@@ -128,12 +129,16 @@ func decodeScalar(fv reflect.Value, val string, fieldName string, envVar string)
128129
return nil
129130
}
130131

131-
func decodeSlice(fv reflect.Value, val string, fieldName string, envVar string) error {
132-
parts := strings.Split(val, ",")
132+
func decodeSlice(fv reflect.Value, val string, fieldName string, envVar string, separator string) error {
133+
if val == "" {
134+
fv.Set(reflect.MakeSlice(fv.Type(), 0, 0))
135+
return nil
136+
}
137+
parts := strings.Split(val, separator)
133138
slice := reflect.MakeSlice(fv.Type(), len(parts), len(parts))
134139
for i, part := range parts {
135140
part = strings.TrimSpace(part)
136-
if err := decode(slice.Index(i), part, fieldName, envVar); err != nil {
141+
if err := decode(slice.Index(i), part, fieldName, envVar, ","); err != nil {
137142
return err
138143
}
139144
}
@@ -152,11 +157,11 @@ func decodeMap(fv reflect.Value, val string, fieldName string, envVar string) er
152157
fmt.Errorf("expected key=value pair, got %q", pair))
153158
}
154159
key := reflect.New(fv.Type().Key()).Elem()
155-
if err := decode(key, strings.TrimSpace(kv[0]), fieldName, envVar); err != nil {
160+
if err := decode(key, strings.TrimSpace(kv[0]), fieldName, envVar, ","); err != nil {
156161
return err
157162
}
158163
value := reflect.New(fv.Type().Elem()).Elem()
159-
if err := decode(value, strings.TrimSpace(kv[1]), fieldName, envVar); err != nil {
164+
if err := decode(value, strings.TrimSpace(kv[1]), fieldName, envVar, ","); err != nil {
160165
return err
161166
}
162167
m.SetMapIndex(key, value)

envstruct.go

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,23 @@ import (
1010
"io"
1111
"os"
1212
"reflect"
13+
"regexp"
1314
"strings"
1415
)
1516

17+
// gatherRegexp splits CamelCase into word groups.
18+
// Matches kelseyhightower/envconfig's word-boundary logic.
19+
var gatherRegexp = regexp.MustCompile("([^A-Z]+|[A-Z]+[^A-Z]*)")
20+
21+
// camelToUpperSnake converts a CamelCase name to UPPER_SNAKE_CASE.
22+
func camelToUpperSnake(s string) string {
23+
parts := gatherRegexp.FindAllString(s, -1)
24+
for i, part := range parts {
25+
parts[i] = strings.ToUpper(part)
26+
}
27+
return strings.Join(parts, "_")
28+
}
29+
1630
// Process populates the struct pointed to by spec with values from
1731
// environment variables. The prefix is prepended to each field name
1832
// (or tag override) when looking up environment variables.
@@ -25,7 +39,8 @@ func Process(prefix string, spec interface{}) error {
2539
if rv.Kind() != reflect.Struct {
2640
return fmt.Errorf("envstruct: spec must be a pointer to a struct")
2741
}
28-
return processStruct(prefix, rv)
42+
_, err := processStruct(prefix, rv)
43+
return err
2944
}
3045

3146
// isStructField returns true if the field type is a struct that should be
@@ -51,9 +66,10 @@ func isStructField(ft reflect.Type) bool {
5166
return true
5267
}
5368

54-
func processStruct(prefix string, rv reflect.Value) error {
69+
func processStruct(prefix string, rv reflect.Value) (bool, error) {
5570
rt := rv.Type()
5671
var errs []error
72+
anySet := false
5773
for i := 0; i < rt.NumField(); i++ {
5874
f := rt.Field(i)
5975
fv := rv.Field(i)
@@ -64,7 +80,7 @@ func processStruct(prefix string, rv reflect.Value) error {
6480
}
6581

6682
// Build the env var name component from the field name.
67-
envName := strings.ToUpper(f.Name)
83+
envName := camelToUpperSnake(f.Name)
6884
spec := parseTag(f, envName)
6985
if spec.Ignored {
7086
continue
@@ -86,15 +102,22 @@ func processStruct(prefix string, rv reflect.Value) error {
86102
// Pointer-to-struct: allocate temp, recurse, assign only if
87103
// at least one env var was set.
88104
tmp := reflect.New(f.Type.Elem())
89-
if err := processStruct(nestedPrefix, tmp.Elem()); err != nil {
105+
set, err := processStruct(nestedPrefix, tmp.Elem())
106+
if err != nil {
90107
errs = append(errs, err)
91-
} else {
108+
}
109+
if set {
92110
fv.Set(tmp)
111+
anySet = true
93112
}
94113
} else {
95-
if err := processStruct(nestedPrefix, fv); err != nil {
114+
set, err := processStruct(nestedPrefix, fv)
115+
if err != nil {
96116
errs = append(errs, err)
97117
}
118+
if set {
119+
anySet = true
120+
}
98121
}
99122
continue
100123
}
@@ -123,12 +146,25 @@ func processStruct(prefix string, rv reflect.Value) error {
123146
continue
124147
}
125148

149+
// Apply envExpand: expand $VAR references in the value.
150+
if spec.Expand {
151+
val = os.ExpandEnv(val)
152+
}
153+
154+
// Determine separator for slice decoding.
155+
sep := spec.Separator
156+
if sep == "" {
157+
sep = ","
158+
}
159+
126160
// Decode and set the field value.
127-
if err := decode(fv, val, f.Name, key); err != nil {
161+
if err := decode(fv, val, f.Name, key, sep); err != nil {
128162
errs = append(errs, err)
163+
} else {
164+
anySet = true
129165
}
130166
}
131-
return errors.Join(errs...)
167+
return anySet, errors.Join(errs...)
132168
}
133169

134170
// MustProcess is like Process but panics on error.

envstruct_test.go

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,3 +333,192 @@ func TestMultipleErrorsCollected(t *testing.T) {
333333
t.Fatalf("expected both HOST and PORT errors, got: %s", errStr)
334334
}
335335
}
336+
337+
// --- Pointer-to-struct nil when no env vars set (Task #428) ---
338+
339+
func TestPointerToStructNilWhenNoEnvVars(t *testing.T) {
340+
type DB struct {
341+
Host string
342+
Port int
343+
}
344+
type Config struct {
345+
Database *DB
346+
}
347+
var c Config
348+
if err := Process("APP", &c); err != nil {
349+
t.Fatalf("unexpected error: %v", err)
350+
}
351+
if c.Database != nil {
352+
t.Fatalf("expected nil pointer when no env vars set, got %+v", c.Database)
353+
}
354+
}
355+
356+
func TestPointerToStructSetWhenPartialEnvVars(t *testing.T) {
357+
type DB struct {
358+
Host string
359+
Port int
360+
}
361+
type Config struct {
362+
Database *DB
363+
}
364+
setEnv(t, "APP_DATABASE_HOST", "partial.local")
365+
var c Config
366+
if err := Process("APP", &c); err != nil {
367+
t.Fatalf("unexpected error: %v", err)
368+
}
369+
if c.Database == nil {
370+
t.Fatal("expected non-nil pointer when env var is set")
371+
}
372+
if c.Database.Host != "partial.local" {
373+
t.Fatalf("expected 'partial.local', got %q", c.Database.Host)
374+
}
375+
}
376+
377+
// --- CamelCase to UPPER_SNAKE_CASE (Task #429) ---
378+
379+
func TestCamelCaseFieldName(t *testing.T) {
380+
type Config struct {
381+
DatabaseURL string
382+
}
383+
setEnv(t, "APP_DATABASE_URL", "postgres://localhost")
384+
var c Config
385+
if err := Process("APP", &c); err != nil {
386+
t.Fatalf("unexpected error: %v", err)
387+
}
388+
if c.DatabaseURL != "postgres://localhost" {
389+
t.Fatalf("expected 'postgres://localhost', got %q", c.DatabaseURL)
390+
}
391+
}
392+
393+
func TestCamelCaseMultiWord(t *testing.T) {
394+
type Config struct {
395+
MaxRetryCount int
396+
}
397+
setEnv(t, "MAX_RETRY_COUNT", "5")
398+
var c Config
399+
if err := Process("", &c); err != nil {
400+
t.Fatalf("unexpected error: %v", err)
401+
}
402+
if c.MaxRetryCount != 5 {
403+
t.Fatalf("expected 5, got %d", c.MaxRetryCount)
404+
}
405+
}
406+
407+
// --- envDefault tag (Task #429) ---
408+
409+
func TestEnvDefaultTag(t *testing.T) {
410+
type Config struct {
411+
Port int `envDefault:"5000"`
412+
}
413+
var c Config
414+
if err := Process("APP", &c); err != nil {
415+
t.Fatalf("unexpected error: %v", err)
416+
}
417+
if c.Port != 5000 {
418+
t.Fatalf("expected 5000, got %d", c.Port)
419+
}
420+
}
421+
422+
func TestEnvDefaultOverriddenByEnv(t *testing.T) {
423+
type Config struct {
424+
Port int `envDefault:"5000"`
425+
}
426+
setEnv(t, "APP_PORT", "9090")
427+
var c Config
428+
if err := Process("APP", &c); err != nil {
429+
t.Fatalf("unexpected error: %v", err)
430+
}
431+
if c.Port != 9090 {
432+
t.Fatalf("expected 9090, got %d", c.Port)
433+
}
434+
}
435+
436+
func TestDefaultTagTakesPrecedenceOverEnvDefault(t *testing.T) {
437+
type Config struct {
438+
Port int `default:"3000" envDefault:"5000"`
439+
}
440+
var c Config
441+
if err := Process("APP", &c); err != nil {
442+
t.Fatalf("unexpected error: %v", err)
443+
}
444+
if c.Port != 3000 {
445+
t.Fatalf("expected 3000 (default takes precedence), got %d", c.Port)
446+
}
447+
}
448+
449+
// --- envSeparator tag (Task #429) ---
450+
451+
func TestEnvSeparatorTag(t *testing.T) {
452+
type Config struct {
453+
Paths []string `envSeparator:":"`
454+
}
455+
setEnv(t, "PATHS", "/usr/bin:/usr/local/bin:/home/user/bin")
456+
var c Config
457+
if err := Process("", &c); err != nil {
458+
t.Fatalf("unexpected error: %v", err)
459+
}
460+
if len(c.Paths) != 3 || c.Paths[0] != "/usr/bin" || c.Paths[2] != "/home/user/bin" {
461+
t.Fatalf("expected 3 paths, got %v", c.Paths)
462+
}
463+
}
464+
465+
func TestEnvSeparatorDefaultComma(t *testing.T) {
466+
type Config struct {
467+
Tags []string
468+
}
469+
setEnv(t, "TAGS", "a,b,c")
470+
var c Config
471+
if err := Process("", &c); err != nil {
472+
t.Fatalf("unexpected error: %v", err)
473+
}
474+
if len(c.Tags) != 3 {
475+
t.Fatalf("expected 3 tags, got %v", c.Tags)
476+
}
477+
}
478+
479+
// --- envExpand tag (Task #429) ---
480+
481+
func TestEnvExpandTag(t *testing.T) {
482+
type Config struct {
483+
DataDir string `envExpand:"true"`
484+
}
485+
setEnv(t, "HOME", "/home/testuser")
486+
setEnv(t, "DATA_DIR", "$HOME/data")
487+
var c Config
488+
if err := Process("", &c); err != nil {
489+
t.Fatalf("unexpected error: %v", err)
490+
}
491+
if c.DataDir != "/home/testuser/data" {
492+
t.Fatalf("expected '/home/testuser/data', got %q", c.DataDir)
493+
}
494+
}
495+
496+
func TestEnvExpandDisabledByDefault(t *testing.T) {
497+
type Config struct {
498+
Val string
499+
}
500+
setEnv(t, "VAL", "$HOME/data")
501+
var c Config
502+
if err := Process("", &c); err != nil {
503+
t.Fatalf("unexpected error: %v", err)
504+
}
505+
if c.Val != "$HOME/data" {
506+
t.Fatalf("expected literal '$HOME/data', got %q", c.Val)
507+
}
508+
}
509+
510+
// --- Empty slice decode ---
511+
512+
func TestDecodeEmptySlice(t *testing.T) {
513+
type Config struct {
514+
Hosts []string
515+
}
516+
setEnv(t, "HOSTS", "")
517+
var c Config
518+
if err := Process("", &c); err != nil {
519+
t.Fatalf("unexpected error: %v", err)
520+
}
521+
if len(c.Hosts) != 0 {
522+
t.Fatalf("expected empty slice, got %v", c.Hosts)
523+
}
524+
}

tags.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ type fieldSpec struct {
1313
HasDefault bool
1414
Ignored bool
1515
Description string
16+
Separator string // custom separator for slices (envSeparator tag)
17+
Expand bool // expand env var references via os.ExpandEnv (envExpand tag)
1618
}
1719

1820
// parseTag extracts a fieldSpec from a struct field's tags.
@@ -44,10 +46,23 @@ func parseTag(f reflect.StructField, fieldName string) fieldSpec {
4446
}
4547
}
4648

47-
// Check default tag.
49+
// Check default tag, then envDefault for envconfig compat.
4850
if defVal, ok := f.Tag.Lookup("default"); ok {
4951
spec.DefaultValue = defVal
5052
spec.HasDefault = true
53+
} else if defVal, ok := f.Tag.Lookup("envDefault"); ok {
54+
spec.DefaultValue = defVal
55+
spec.HasDefault = true
56+
}
57+
58+
// Check envSeparator tag (custom slice separator).
59+
if sep, ok := f.Tag.Lookup("envSeparator"); ok {
60+
spec.Separator = sep
61+
}
62+
63+
// Check envExpand tag (expand env var references in values).
64+
if exp, ok := f.Tag.Lookup("envExpand"); ok && exp == "true" {
65+
spec.Expand = true
5166
}
5267

5368
// Check desc tag.

0 commit comments

Comments
 (0)