Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,22 @@ dotenvgo.Load(&cfg)

// Or with prefix (APP_HOST, APP_PORT, etc.)
dotenvgo.LoadWithPrefix(&cfg, "APP")

type DatabaseConfig struct {
URL string `env:"URL"`
}

type AppConfig struct {
DB DatabaseConfig `env:"DB"`
}

var appCfg AppConfig

// Reads DB_URL
dotenvgo.Load(&appCfg)

// Reads APP_DB_URL
dotenvgo.LoadWithPrefix(&appCfg, "APP")
```

### Load `.env` Files
Expand All @@ -66,6 +82,33 @@ dotenvgo.LoadDotEnv(".env", true) // Override existing vars
dotenvgo.MustLoadDotEnv(".env") // Panic on error
```

### Missing Vs Empty Values

`dotenvgo` distinguishes between a variable that is missing and a variable that is explicitly set to an empty string.

```go
os.Unsetenv("DATABASE_URL")
dbURL := dotenvgo.New[string]("DATABASE_URL").Default("postgres://localhost").Get()
// dbURL == "postgres://localhost"

os.Setenv("DATABASE_URL", "")
dbURL = dotenvgo.New[string]("DATABASE_URL").Default("postgres://localhost").Get()
// dbURL == ""

type Config struct {
DSN *string `env:"DATABASE_URL"`
}

var cfg Config
dotenvgo.Load(&cfg)
// cfg.DSN points to ""
```

This also affects `required` and `.env` loading:

- `required:"true"` only fails when the variable is missing.
- `LoadDotEnv(path)` will not overwrite an existing variable, even if its value is empty.

### Custom Parsers

```go
Expand Down Expand Up @@ -140,7 +183,7 @@ colorB := dotenvgo.WithLoader[Color](loaderB, "THEME").Get() // Red

| Tag | Description | Example |
|-----|-------------|---------|
| `env` | Variable name | `env:"PORT"` |
| `env` | Variable name, or nested struct prefix when used on a struct field | `env:"PORT"` / `env:"DB"` |
| `default` | Default value | `default:"8080"` |
| `required` | Fail if missing | `required:"true"` |
| `sep` | Slice separator | `sep:";"` |
Expand Down Expand Up @@ -194,6 +237,8 @@ See [examples/](./examples) for complete working code:
|---------|-------------|
| [basic](./examples/basic) | Simple variable access |
| [struct](./examples/struct) | Struct-based config |
| [nested_prefix](./examples/nested_prefix) | Nested structs with env tag prefixes |
| [empty_values](./examples/empty_values) | Missing vs empty value semantics |
| [file](./examples/file) | Loading `.env` files |
| [expansion](./examples/expansion) | Variable expansion |
| [isolated_loader](./examples/isolated_loader) | Isolated loader demo |
Expand Down
21 changes: 9 additions & 12 deletions dotenvgo.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,9 @@ func (v *Var[T]) Get() T {
func (v *Var[T]) GetE() (T, error) {
var zero T
key := v.fullKey()
raw := os.Getenv(key)
raw, exists := os.LookupEnv(key)

if raw == "" {
if !exists {
if v.required {
return zero, &RequiredError{Key: key}
}
Expand All @@ -139,7 +139,7 @@ func (v *Var[T]) Lookup() (T, bool) {
key := v.fullKey()
raw, exists := os.LookupEnv(key)

if !exists || raw == "" {
if !exists {
if v.defaultValue != nil {
return *v.defaultValue, true
}
Expand Down Expand Up @@ -184,14 +184,6 @@ func LoadDotEnv(path string, override ...bool) error {
return loadDotEnvInternal(path, shouldOverride)
}

// LoadDotEnvOverride loads environment variables from a .env file.
// It DOES override existing environment variables.
//
// Deprecated: Use LoadDotEnv(path, true) instead.
func LoadDotEnvOverride(path string) error {
return LoadDotEnv(path, true)
}

func loadDotEnvInternal(path string, override bool) error {
data, err := os.ReadFile(path)
if err != nil {
Expand Down Expand Up @@ -255,7 +247,12 @@ func loadDotEnvInternal(path string, override bool) error {
}

// Only set if not already set (unless override)
if override || os.Getenv(key) == "" {
if override {
_ = os.Setenv(key, value)
continue
}

if _, exists := os.LookupEnv(key); !exists {
_ = os.Setenv(key, value)
}
}
Expand Down
83 changes: 68 additions & 15 deletions dotenvgo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,20 @@ func TestEnvVar(t *testing.T) {
}
})

t.Run("Empty String Is A Value", func(t *testing.T) {
_ = os.Setenv("EMPTY_VALUE", "")
defer func() { _ = os.Unsetenv("EMPTY_VALUE") }()

v := New[string]("EMPTY_VALUE").Default("default")
val, err := v.GetE()
if err != nil {
t.Fatalf("GetE failed: %v", err)
}
if val != "" {
t.Errorf("Expected empty string, got %q", val)
}
})

t.Run("Required Var", func(t *testing.T) {
v := New[string]("NON_EXISTENT").Required()
_, err := v.GetE()
Expand All @@ -33,6 +47,20 @@ func TestEnvVar(t *testing.T) {
}
})

t.Run("Required Empty String Does Not Error", func(t *testing.T) {
_ = os.Setenv("REQUIRED_EMPTY", "")
defer func() { _ = os.Unsetenv("REQUIRED_EMPTY") }()

v := New[string]("REQUIRED_EMPTY").Required()
val, err := v.GetE()
if err != nil {
t.Fatalf("Expected empty string to satisfy required, got %v", err)
}
if val != "" {
t.Errorf("Expected empty string, got %q", val)
}
})

t.Run("With Prefix", func(t *testing.T) {
_ = os.Setenv("APP_PORT", "8080")
defer func() { _ = os.Unsetenv("APP_PORT") }()
Expand Down Expand Up @@ -118,9 +146,9 @@ func TestVarUtilities(t *testing.T) {
}
})

t.Run("Lookup", func(t *testing.T) {
Set(key, "val")
defer Unset(key)
t.Run("Lookup", func(t *testing.T) {
Set(key, "val")
defer Unset(key)

v := New[string](key)
val, exists := v.Lookup()
Expand All @@ -135,15 +163,23 @@ func TestVarUtilities(t *testing.T) {
t.Errorf("Lookup default failed: %v, %v", val, exists)
}

// Missing
vMiss := New[string]("NON_EXISTENT_2")
_, exists = vMiss.Lookup()
if exists {
t.Error("Lookup expected false for missing var")
}
// Missing
vMiss := New[string]("NON_EXISTENT_2")
_, exists = vMiss.Lookup()
if exists {
t.Error("Lookup expected false for missing var")
}

Set("EMPTY_LOOKUP", "")
defer Unset("EMPTY_LOOKUP")
vEmpty := New[string]("EMPTY_LOOKUP").Default("default")
val, exists = vEmpty.Lookup()
if !exists || val != "" {
t.Errorf("Lookup expected empty string value, got %q, %v", val, exists)
}

// Parser Error
Set("INT_KEY", "invalid")
// Parser Error
Set("INT_KEY", "invalid")
defer Unset("INT_KEY")
vInt := New[int]("INT_KEY")
_, exists = vInt.Lookup()
Expand Down Expand Up @@ -210,15 +246,32 @@ func TestLoadDotEnvExtras(t *testing.T) {
Set("TEST_KEY", "old_value")
defer Unset("TEST_KEY")

if err := LoadDotEnvOverride(filename); err != nil {
t.Fatal(err)
}
if err := LoadDotEnv(filename, true); err != nil {
t.Fatal(err)
}

if val := os.Getenv("TEST_KEY"); val != "new_value" {
t.Errorf("Expected 'new_value', got %q", val)
}
})

t.Run("Empty Existing Value Is Not Overridden", func(t *testing.T) {
Set("TEST_KEY", "")
defer Unset("TEST_KEY")

if err := LoadDotEnv(filename); err != nil {
t.Fatal(err)
}

val, exists := os.LookupEnv("TEST_KEY")
if !exists {
t.Fatal("Expected TEST_KEY to remain set")
}
if val != "" {
t.Errorf("Expected empty value to remain unchanged, got %q", val)
}
})

t.Run("MustLoadDotEnv", func(t *testing.T) {
defer func() {
if r := recover(); r != nil {
Expand Down Expand Up @@ -292,7 +345,7 @@ UNCLOSED="unclosed
}
defer os.Remove(filename)

if err := LoadDotEnvOverride(filename); err != nil {
if err := LoadDotEnv(filename, true); err != nil {
t.Fatal(err)
}

Expand Down
40 changes: 40 additions & 0 deletions examples/empty_values/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Example: Missing versus empty environment values
package main

import (
"fmt"
"os"

"github.com/godeh/dotenvgo"
)

type Config struct {
DSN *string `env:"DATABASE_URL"`
}

func main() {
os.Unsetenv("DATABASE_URL")

missing := dotenvgo.New[string]("DATABASE_URL").Default("postgres://localhost").Get()
fmt.Printf("Missing DATABASE_URL uses default: %q\n", missing)

os.Setenv("DATABASE_URL", "")

empty := dotenvgo.New[string]("DATABASE_URL").Default("postgres://localhost").Get()
fmt.Printf("Empty DATABASE_URL stays empty: %q\n", empty)

required, err := dotenvgo.New[string]("DATABASE_URL").Required().GetE()
fmt.Printf("Required empty DATABASE_URL succeeds: value=%q err=%v\n", required, err)

var cfg Config
if err := dotenvgo.Load(&cfg); err != nil {
panic(err)
}

if cfg.DSN == nil {
fmt.Println("Struct load produced nil pointer")
return
}

fmt.Printf("Struct load keeps pointer to empty string: %q\n", *cfg.DSN)
}
2 changes: 1 addition & 1 deletion examples/file/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ mixed_quotes='He said "Hello"'
defer os.Remove(".env.example")

// Load the .env file
if err := dotenvgo.LoadDotEnvOverride(".env.example"); err != nil {
if err := dotenvgo.LoadDotEnv(".env.example", true); err != nil {
panic(err)
}

Expand Down
51 changes: 51 additions & 0 deletions examples/nested_prefix/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Example: Nested structs using the parent env tag as a prefix
package main

import (
"fmt"
"os"

"github.com/godeh/dotenvgo"
)

type Replica struct {
URL string `env:"URL" default:"postgres://localhost:5432/replica"`
}

type Database struct {
URL string `env:"URL" default:"postgres://localhost:5432/primary"`
Replica Replica `env:"REPLICA"`
}

type Cache struct {
Host string `env:"HOST" default:"127.0.0.1"`
Port int `env:"PORT" default:"6379"`
}

type Config struct {
Name string `env:"NAME"`
DB Database `env:"DB"`
Cache *Cache `env:"CACHE"`
}

func main() {
// APP_DB_URL comes from the parent struct tag: DB + URL
// APP_DB_REPLICA_URL composes prefixes across nested structs.
// CACHE is not set, so nested defaults populate APP_CACHE_HOST/PORT implicitly.
os.Setenv("APP_NAME", "nested-demo")
os.Setenv("APP_DB_URL", "postgres://localhost:5432/app-primary")
os.Setenv("APP_DB_REPLICA_URL", "postgres://localhost:5432/app-replica")

var cfg Config
if err := dotenvgo.LoadWithPrefix(&cfg, "APP"); err != nil {
fmt.Printf("Failed to load config: %v\n", err)
os.Exit(1)
}

fmt.Println("=== Nested Prefix Configuration ===")
fmt.Printf("Name: %s\n", cfg.Name)
fmt.Printf("DB URL: %s\n", cfg.DB.URL)
fmt.Printf("Replica URL: %s\n", cfg.DB.Replica.URL)
fmt.Printf("Cache Host: %s\n", cfg.Cache.Host)
fmt.Printf("Cache Port: %d\n", cfg.Cache.Port)
}
Loading
Loading