From 4fa655c4e8280758b40b04e2edfa914c1aec1a14 Mon Sep 17 00:00:00 2001 From: godeh Date: Thu, 5 Feb 2026 22:58:08 -0400 Subject: [PATCH] updated projet struct --- .github/README.md | 255 ------------------------------- .github/workflows/test.yml | 17 ++- README.md | 203 ++++++++++++++++++++++++ dotenvgo.go | 54 ++++--- dotenvgo_test.go | 224 +++++++++++++++++++++++++++ dotenvgo_utils_test.go | 230 ---------------------------- errors.go | 14 +- errors_test.go | 15 +- examples/isolated_loader/main.go | 123 +++++++++++++++ examples/struct/main.go | 4 + go.mod | 2 +- loader.go | 28 ++-- registry.go | 155 ++++++++++++++----- registry_test.go | 190 +++++++++++++++++++---- 14 files changed, 928 insertions(+), 586 deletions(-) delete mode 100644 .github/README.md create mode 100644 README.md delete mode 100644 dotenvgo_utils_test.go create mode 100644 examples/isolated_loader/main.go diff --git a/.github/README.md b/.github/README.md deleted file mode 100644 index c7e6662..0000000 --- a/.github/README.md +++ /dev/null @@ -1,255 +0,0 @@ -# dotenvgo - -[![Go Version](https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat-square&logo=go)](https://golang.org) -[![License](https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square)](LICENSE) -[![Zero Dependencies](https://img.shields.io/badge/Dependencies-Zero-green?style=flat-square)](go.mod) - -**Type-safe, zero-dependency environment variable management for Go.** - -`dotenvgo` provides a modern, generic-based API for loading environment variables with full type safety, default values, validation, and struct tag support. - -## ✨ Features - -- 🔒 **Type-Safe** - Generic `New[T]` API with compile-time type checking -- 📦 **Zero Dependencies** - Only uses Go standard library -- 🏷️ **Struct Tags** - Load complex configs into structs with tags -- 📄 **`.env` File Support** - Load from `.env` files -- 🔌 **Extensible** - Register custom parsers -- ⚡ **Variable Expansion** - Supports `$VAR` and `${VAR}` expansion -- 🔧 **Prefix Support** - Namespace variables with prefixes (e.g., `APP_PORT`) - -## 📦 Installation - -```bash -go get github.com/godeh/dotenvgo -``` - -## 🚀 Quick Start - -### Basic Usage - -```go -import "github.com/godeh/dotenvgo" - -// Simple variable access with defaults -port := dotenvgo.New[int]("PORT").Default(8080).Get() -host := dotenvgo.New[string]("HOST").Default("localhost").Get() -debug := dotenvgo.New[bool]("DEBUG").Default(false).Get() - -// Required variables (panics if not set) -dbURL := dotenvgo.New[string]("DATABASE_URL").Required().Get() - -// Explicit error handling -apiKey, err := dotenvgo.New[string]("API_KEY").Required().GetE() -if err != nil { - log.Fatal("API_KEY is missing") -} - -// Check if variable is set -if dotenvgo.New[string]("OPTIONAL_VAR").IsSet() { - // Variable exists -} - -// Lookup returns value and existence flag -value, exists := dotenvgo.New[string]("MY_VAR").Default("fallback").Lookup() -``` - -### With Prefix - -```go -// Look for APP_PORT instead of PORT -port := dotenvgo.New[int]("PORT").WithPrefix("APP").Default(8080).Get() -``` - -### Load from `.env` File - -```go -// Load without overriding existing env vars -if err := dotenvgo.LoadDotEnv(".env"); err != nil { - log.Fatal(err) -} - -// Load and override existing env vars -dotenvgo.LoadDotEnvOverride(".env") - -// Panic on error -dotenvgo.MustLoadDotEnv(".env") -``` - -## 🏷️ Struct Tags - -Load complex configurations into structs using tags. - -```go -type Config struct { - Host string `env:"HOST" default:"localhost"` - Port int `env:"PORT" default:"8080"` - Debug bool `env:"DEBUG" default:"false"` - Timeout time.Duration `env:"TIMEOUT" default:"30s"` - Database string `env:"DATABASE_URL" required:"true"` - - // Slice with custom separator - Hosts []string `env:"ALLOWED_HOSTS" sep:";"` -} - -var cfg Config - -// Load from environment -if err := dotenvgo.Load(&cfg); err != nil { - log.Fatal(err) -} - -// Or with prefix (looks for APP_HOST, APP_PORT, etc.) -if err := dotenvgo.LoadWithPrefix(&cfg, "APP"); err != nil { - log.Fatal(err) -} - -// Panic version -dotenvgo.MustLoad(&cfg) -dotenvgo.MustLoadWithPrefix(&cfg, "APP") -``` - -### Supported Tags - -| Tag | Description | Example | -|-----|-------------|---------| -| `env` | Environment variable name | `env:"PORT"` | -| `default` | Default value if not set | `default:"8080"` | -| `required` | Fails if variable is not set | `required:"true"` | -| `sep` | Custom separator for slices | `sep:";"` | - -## 📋 Supported Types - -### Primitives -- `string` -- `int`, `int8`, `int16`, `int32`, `int64` -- `uint`, `uint8`, `uint16`, `uint32`, `uint64` -- `float32`, `float64` -- `bool` - Accepts: `true/false`, `1/0`, `yes/no`, `on/off`, `y/n` - -### Time Types -- `time.Duration` - e.g., `"1h30m"`, `"30s"`, `"500ms"` -- `*time.Location` - e.g., `"America/New_York"`, `"Europe/London"` - -### Collections -- `[]string` - Comma-separated by default: `"a,b,c"` - -### Custom Types -- Any type implementing `encoding.TextUnmarshaler` -- Custom parsers via `RegisterParser` - -## 🔌 Custom Parsers - -Register custom parsers for your own types: - -```go -type LogLevel int - -const ( - LevelDebug LogLevel = iota - LevelInfo - LevelWarn - LevelError -) - -func init() { - dotenvgo.RegisterParser(func(s string) (LogLevel, error) { - switch strings.ToLower(s) { - case "debug": - return LevelDebug, nil - case "info": - return LevelInfo, nil - case "warn": - return LevelWarn, nil - case "error": - return LevelError, nil - default: - return 0, fmt.Errorf("invalid log level: %s", s) - } - }) -} - -// Now you can use LogLevel directly -level := dotenvgo.New[LogLevel]("LOG_LEVEL").Default(LevelInfo).Get() -``` - -## 🛠️ Utility Functions - -```go -// Set/Unset environment variables -dotenvgo.Set("KEY", "value") -dotenvgo.Unset("KEY") - -// Export all environment variables as map -allVars := dotenvgo.Export() - -// Export variables with a specific prefix -appVars := dotenvgo.ExportWithPrefix("APP") -``` - -## 🔍 Variable Expansion - -Environment variable expansion is supported in values: - -```go -// .env file -# BASE_PATH=/app -# CONFIG_PATH=${BASE_PATH}/config - -// In code, CONFIG_PATH will be "/app/config" -``` - -## 📄 `.env` File Format - -```bash -# Comments start with # -KEY=value - -# Quoted values preserve spaces -MESSAGE="Hello World" -NAME='John Doe' - -# Inline comments (after space) -DEBUG=true # This is a comment - -# Variable expansion -BASE_URL=http://localhost -API_URL=${BASE_URL}/api -``` - -## ⚠️ Error Handling - -The library provides structured error types: - -```go -// RequiredError - when a required variable is missing -// ParseError - when a value cannot be parsed to the target type -// MultiError - when struct loading has multiple errors - -if err := dotenvgo.Load(&cfg); err != nil { - var reqErr *dotenvgo.RequiredError - if errors.As(err, &reqErr) { - fmt.Printf("Missing required: %s\n", reqErr.Key) - } - - var multiErr *dotenvgo.MultiError - if errors.As(err, &multiErr) { - for _, e := range multiErr.Errors { - fmt.Println(e) - } - } -} -``` - -## 📂 Examples - -See the [examples](./examples) directory for complete working examples: - -- [basic](./examples/basic) - Simple variable access with defaults and prefixes -- [struct](./examples/struct) - Struct-based configuration loading -- [file](./examples/file) - Loading from `.env` files -- [expansion](./examples/expansion) - Variable expansion demonstration - -## 📄 License - -[MIT](LICENSE) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2fdaaf0..0da6923 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,10 +1,23 @@ -name: Unit Tests +name: Checks on: pull_request: branches: [ "main" ] jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + cache: false + - name: golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + version: v1.60 + test: runs-on: ubuntu-latest steps: @@ -13,7 +26,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v4 with: - go-version: '1.25' + go-version: '1.22' - name: Test run: go test -v ./... diff --git a/README.md b/README.md new file mode 100644 index 0000000..2db9995 --- /dev/null +++ b/README.md @@ -0,0 +1,203 @@ +# dotenvgo + +[![Go Version](https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat-square&logo=go)](https://golang.org) +[![License](https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square)](LICENSE) +[![Zero Dependencies](https://img.shields.io/badge/Dependencies-Zero-green?style=flat-square)](go.mod) +[![Coverage](https://img.shields.io/badge/Coverage-91%25-brightgreen?style=flat-square)](https://github.com/godeh/dotenvgo) + +**Type-safe environment variables for Go with generics, struct tags, and isolated loaders.** + +## Installation + +```bash +go get github.com/godeh/dotenvgo +``` + +## Quick Examples + +### Type-Safe Variables + +```go +import "github.com/godeh/dotenvgo" + +// With defaults +port := dotenvgo.New[int]("PORT").Default(8080).Get() +debug := dotenvgo.New[bool]("DEBUG").Default(false).Get() + +// Required (panics if missing) +dbURL := dotenvgo.New[string]("DATABASE_URL").Required().Get() + +// With error handling +apiKey, err := dotenvgo.New[string]("API_KEY").Required().GetE() + +// Check existence +if dotenvgo.New[string]("FEATURE_FLAG").IsSet() { + // ... +} + +// Lookup with existence flag +value, exists := dotenvgo.New[string]("OPTIONAL").Lookup() +``` + +### Struct Loading + +```go +type Config struct { + Host string `env:"HOST" default:"localhost"` + Port int `env:"PORT" default:"8080"` + Debug bool `env:"DEBUG"` + Timeout time.Duration `env:"TIMEOUT" default:"30s"` + Database string `env:"DATABASE_URL" required:"true"` + Hosts []string `env:"ALLOWED_HOSTS" sep:","` +} + +var cfg Config +dotenvgo.Load(&cfg) + +// Or with prefix (APP_HOST, APP_PORT, etc.) +dotenvgo.LoadWithPrefix(&cfg, "APP") +``` + +### Load `.env` Files + +```go +dotenvgo.LoadDotEnv(".env") // Don't override existing vars +dotenvgo.LoadDotEnv(".env", true) // Override existing vars +dotenvgo.MustLoadDotEnv(".env") // Panic on error +``` + +### Custom Parsers + +```go +type LogLevel int + +const ( + DEBUG LogLevel = iota + INFO + ERROR +) + +// Register globally +dotenvgo.RegisterParser(func(s string) (LogLevel, error) { + switch strings.ToLower(s) { + case "debug": return DEBUG, nil + case "info": return INFO, nil + case "error": return ERROR, nil + default: return 0, fmt.Errorf("invalid: %s", s) + } +}) + +level := dotenvgo.New[LogLevel]("LOG_LEVEL").Default(INFO).Get() + +// Slices are automatically supported! +// When you register a parser for T, []T works automatically +levels := dotenvgo.New[[]LogLevel]("LOG_LEVELS").Get() // "debug,info,error" +``` + +### Isolated Loaders + +Use separate loaders when different parts of your application need different parsing logic for the same type: + +```go +// Library A: "primary" = Blue +loaderA := dotenvgo.NewLoader() +loaderA.RegisterParser(func(s string) (Color, error) { + if s == "primary" { return Blue, nil } + return Color(s), nil +}) + +// Library B: "primary" = Red +loaderB := dotenvgo.NewLoader() +loaderB.RegisterParser(func(s string) (Color, error) { + if s == "primary" { return Red, nil } + return Color(s), nil +}) + +// Each loader has its own registry +colorA := dotenvgo.WithLoader[Color](loaderA, "THEME").Get() // Blue +colorB := dotenvgo.WithLoader[Color](loaderB, "THEME").Get() // Red +``` + +## Supported Types + +| Type | Example Values | +|------|----------------| +| `string` | any text | +| `int`, `int8-64` | `42`, `-100` | +| `uint`, `uint8-64` | `42`, `0` | +| `float32`, `float64` | `3.14`, `-0.5` | +| `bool` | `true`, `false`, `1`, `0`, `yes`, `no`, `on`, `off` | +| `time.Duration` | `30s`, `1h30m`, `500ms` | +| `*time.Location` | `America/New_York`, `UTC` | +| `[]string` | `a,b,c` | +| `[]int`, `[]int8-64` | `1,2,3` | +| `[]uint`, `[]uint8-64` | `1,2,3` | +| `[]float32`, `[]float64` | `1.5,2.5` | +| `[]bool` | `true,false,1,0` | +| Custom | Via `RegisterParser` or `encoding.TextUnmarshaler` | + +## Struct Tags + +| Tag | Description | Example | +|-----|-------------|---------| +| `env` | Variable name | `env:"PORT"` | +| `default` | Default value | `default:"8080"` | +| `required` | Fail if missing | `required:"true"` | +| `sep` | Slice separator | `sep:";"` | + +## `.env` File Format + +```bash +# Comments +KEY=value +MESSAGE="Hello World" +DEBUG=true # inline comment + +# Variable expansion +BASE=/app +CONFIG=${BASE}/config +``` + +## Error Handling + +```go +err := dotenvgo.Load(&cfg) + +var reqErr *dotenvgo.RequiredError +if errors.As(err, &reqErr) { + log.Printf("Missing: %s", reqErr.Key) +} + +var multiErr *dotenvgo.MultiError +if errors.As(err, &multiErr) { + for _, e := range multiErr.Errors { + log.Println(e) + } +} +``` + +## Utilities + +```go +dotenvgo.Set("KEY", "value") +dotenvgo.Unset("KEY") + +allVars := dotenvgo.Export() +appVars := dotenvgo.ExportWithPrefix("APP") +``` + +## Examples + +See [examples/](./examples) for complete working code: + +| Example | Description | +|---------|-------------| +| [basic](./examples/basic) | Simple variable access | +| [struct](./examples/struct) | Struct-based config | +| [file](./examples/file) | Loading `.env` files | +| [expansion](./examples/expansion) | Variable expansion | +| [isolated_loader](./examples/isolated_loader) | Isolated loader demo | + +## License + +[MIT](LICENSE) diff --git a/dotenvgo.go b/dotenvgo.go index 915c256..b1def33 100644 --- a/dotenvgo.go +++ b/dotenvgo.go @@ -17,14 +17,19 @@ type Var[T any] struct { prefix string } -// New creates a new environment variable of type T. -// It searches for a registered parser or uses encoding.TextUnmarshaler. +// New creates a new environment variable of type T using the default loader. func New[T any](key string) *Var[T] { + return NewVar[T](DefaultLoader, key) +} + +// NewVar creates a new environment variable of type T using the specified Loader. +// It searches for a registered parser or uses encoding.TextUnmarshaler. +func NewVar[T any](l *Loader, key string) *Var[T] { var zero T typ := reflect.TypeOf(zero) // 1. Check registry - if p, ok := getParser(typ); ok { + if p, ok := l.getParser(typ); ok { return &Var[T]{ key: key, parser: func(s string) (T, error) { @@ -37,20 +42,13 @@ func New[T any](key string) *Var[T] { } } - // 2. Check TextUnmarshaler (not easily done without instance for Var.parser logic, - // but we can wrap it effectively inside the closure if we assume it implements it at runtime - // OR we can't easily check 'T' for interface implementation if T is concrete struct value type - // unless we use reflection inside parser) - + // 2. Check TextUnmarshaler // Better approach: Generic Parser Factory return &Var[T]{ key: key, parser: func(s string) (T, error) { - // Re-check generic logic at runtime per call, or optimize? - // Since we don't have the field reflect.Value here, we replicate setField logic but for T. - // Re-lookup registry (fast) - if p, ok := getParser(typ); ok { + if p, ok := l.getParser(typ); ok { v, err := p(s) if err != nil { return zero, err @@ -59,7 +57,6 @@ func New[T any](key string) *Var[T] { } // TextUnmarshaler - // Create pointer to new T valPtr := reflect.New(typ) if u, ok := valPtr.Interface().(encoding.TextUnmarshaler); ok { if err := u.UnmarshalText([]byte(s)); err != nil { @@ -171,18 +168,31 @@ func (v *Var[T]) IsSet() bool { } // LoadDotEnv loads environment variables from a .env file. -// It does NOT override existing environment variables. -func LoadDotEnv(path string) error { - return loadDotEnvWithOverride(path, false) +// By default, it does NOT override existing environment variables. +// Pass true as the second argument to override existing variables. +// +// Examples: +// +// LoadDotEnv(".env") // doesn't override existing vars +// LoadDotEnv(".env", false) // same as above +// LoadDotEnv(".env", true) // overrides existing vars +func LoadDotEnv(path string, override ...bool) error { + shouldOverride := false + if len(override) > 0 { + shouldOverride = override[0] + } + 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 loadDotEnvWithOverride(path, true) + return LoadDotEnv(path, true) } -func loadDotEnvWithOverride(path string, override bool) error { +func loadDotEnvInternal(path string, override bool) error { data, err := os.ReadFile(path) if err != nil { return err @@ -198,13 +208,13 @@ func loadDotEnvWithOverride(path string, override bool) error { } // Find the first '=' - idx := strings.Index(line, "=") - if idx == -1 { + before, after, ok := strings.Cut(line, "=") + if !ok { continue } - key := strings.TrimSpace(line[:idx]) - valPart := strings.TrimSpace(line[idx+1:]) + key := strings.TrimSpace(before) + valPart := strings.TrimSpace(after) var value string if len(valPart) > 0 { diff --git a/dotenvgo_test.go b/dotenvgo_test.go index dba34b5..ed66329 100644 --- a/dotenvgo_test.go +++ b/dotenvgo_test.go @@ -87,3 +87,227 @@ func init() { // Since we are black-box testing, we assume int/string/bool support is built-in or registered. // Based on dotenvgo.go, it uses `getParser`. We assume `registry.go` (not shown here but referenced in file list) exists. } + +func TestVarUtilities(t *testing.T) { + key := "TEST_UTIL_VAR" + + t.Run("Set and Unset", func(t *testing.T) { + Set(key, "util_value") + if os.Getenv(key) != "util_value" { + t.Errorf("Set failed") + } + + Unset(key) + if _, exists := os.LookupEnv(key); exists { + t.Errorf("Unset failed") + } + }) + + t.Run("IsSet", func(t *testing.T) { + Set(key, "val") + defer Unset(key) + + v := New[string](key) + if !v.IsSet() { + t.Error("Expected IsSet to return true") + } + + v2 := New[string]("NON_EXISTENT") + if v2.IsSet() { + t.Error("Expected IsSet to return false") + } + }) + + t.Run("Lookup", func(t *testing.T) { + Set(key, "val") + defer Unset(key) + + v := New[string](key) + val, exists := v.Lookup() + if !exists || val != "val" { + t.Errorf("Lookup failed: %v, %v", val, exists) + } + + // Default interaction + vDef := New[string]("NON_EXISTENT").Default("default") + val, exists = vDef.Lookup() + if !exists || val != "default" { + 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") + } + + // Parser Error + Set("INT_KEY", "invalid") + defer Unset("INT_KEY") + vInt := New[int]("INT_KEY") + _, exists = vInt.Lookup() + if exists { + t.Error("Lookup expected false for invalid value") + } + }) + + t.Run("MustGet", func(t *testing.T) { + Set(key, "val") + defer Unset(key) + + v := New[string](key) + if val := v.MustGet(); val != "val" { + t.Errorf("MustGet returned %v", val) + } + }) +} + +func TestExport(t *testing.T) { + Set("APP_TEST_1", "v1") + Set("APP_TEST_2", "v2") + Set("OTHER_VAR", "v3") + defer Unset("APP_TEST_1") + defer Unset("APP_TEST_2") + defer Unset("OTHER_VAR") + + t.Run("Export All", func(t *testing.T) { + m := Export() + if m["APP_TEST_1"] != "v1" || m["OTHER_VAR"] != "v3" { + t.Error("Export failed to return all vars") + } + }) + + t.Run("Export With Prefix", func(t *testing.T) { + m := ExportWithPrefix("APP") + if len(m) < 2 { + t.Error("ExportWithPrefix returned too few vars") + } + if m["APP_TEST_1"] != "v1" { + t.Error("Missing APP_TEST_1") + } + if _, ok := m["OTHER_VAR"]; ok { + t.Error("Should not include OTHER_VAR") + } + + // Case insensitivity check if implemented (it is in code) + m2 := ExportWithPrefix("app") + if m2["APP_TEST_1"] != "v1" { + t.Error("ExportWithPrefix (lowercase) failed") + } + }) +} + +func TestLoadDotEnvExtras(t *testing.T) { + filename := ".env.override" + content := []byte("TEST_KEY=new_value\n# Comment") + if err := os.WriteFile(filename, content, 0o644); err != nil { + t.Fatal(err) + } + defer os.Remove(filename) + + t.Run("Override", func(t *testing.T) { + Set("TEST_KEY", "old_value") + defer Unset("TEST_KEY") + + if err := LoadDotEnvOverride(filename); err != nil { + t.Fatal(err) + } + + if val := os.Getenv("TEST_KEY"); val != "new_value" { + t.Errorf("Expected 'new_value', got %q", val) + } + }) + + t.Run("MustLoadDotEnv", func(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Errorf("Panic unexpected: %v", r) + } + }() + MustLoadDotEnv(filename) + }) + + t.Run("MustLoadDotEnv Panic", func(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Error("Expected panic for missing file") + } + }() + MustLoadDotEnv("non_existent_file.env") + }) +} + +// Redefine for this package test +type TestIP struct { + Value string +} + +func (i *TestIP) UnmarshalText(text []byte) error { + i.Value = string(text) + return nil +} + +func TestGenericFallback(t *testing.T) { + t.Run("TextUnmarshaler", func(t *testing.T) { + Set("TEST_IP", "1.1.1.1") + defer Unset("TEST_IP") + + v := New[TestIP]("TEST_IP") + val, err := v.GetE() + if err != nil { + t.Fatalf("GetE failed: %v", err) + } + if val.Value != "1.1.1.1" { + t.Errorf("Expected 1.1.1.1, got %v", val) + } + }) + + t.Run("No Parser", func(t *testing.T) { + Set("NO_PARSER", "val") + defer Unset("NO_PARSER") + + // struct{} has no parser and no TextUnmarshaler + v := New[struct{}]("NO_PARSER") + _, err := v.GetE() + if err == nil { + t.Error("Expected error for type with no parser") + } + }) +} + +func TestComplexDotEnv(t *testing.T) { + filename := ".env.complex" + content := ` +# Comment +SIMPLE=value +QUOTED="quoted value" +SINGLE_QUOTED='single quoted' +WITH_HASH=val#ue +WITH_COMMENT=value # comment +UNCLOSED="unclosed + ` + if err := os.WriteFile(filename, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + defer os.Remove(filename) + + if err := LoadDotEnvOverride(filename); err != nil { + t.Fatal(err) + } + + checks := map[string]string{ + "SIMPLE": "value", + "QUOTED": "quoted value", + "SINGLE_QUOTED": "single quoted", + "WITH_HASH": "val#ue", + "WITH_COMMENT": "value", + "UNCLOSED": "\"unclosed", // Implementation dependent, usually raw + } + + for k, expected := range checks { + if got := os.Getenv(k); got != expected { + t.Errorf("Key %s: expected %q, got %q", k, expected, got) + } + } +} diff --git a/dotenvgo_utils_test.go b/dotenvgo_utils_test.go deleted file mode 100644 index d4659f7..0000000 --- a/dotenvgo_utils_test.go +++ /dev/null @@ -1,230 +0,0 @@ -package dotenvgo - -import ( - "os" - "testing" -) - -func TestVarUtilities(t *testing.T) { - key := "TEST_UTIL_VAR" - - t.Run("Set and Unset", func(t *testing.T) { - Set(key, "util_value") - if os.Getenv(key) != "util_value" { - t.Errorf("Set failed") - } - - Unset(key) - if _, exists := os.LookupEnv(key); exists { - t.Errorf("Unset failed") - } - }) - - t.Run("IsSet", func(t *testing.T) { - Set(key, "val") - defer Unset(key) - - v := New[string](key) - if !v.IsSet() { - t.Error("Expected IsSet to return true") - } - - v2 := New[string]("NON_EXISTENT") - if v2.IsSet() { - t.Error("Expected IsSet to return false") - } - }) - - t.Run("Lookup", func(t *testing.T) { - Set(key, "val") - defer Unset(key) - - v := New[string](key) - val, exists := v.Lookup() - if !exists || val != "val" { - t.Errorf("Lookup failed: %v, %v", val, exists) - } - - // Default interaction - vDef := New[string]("NON_EXISTENT").Default("default") - val, exists = vDef.Lookup() - if !exists || val != "default" { - 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") - } - - // Parser Error - Set("INT_KEY", "invalid") - defer Unset("INT_KEY") - vInt := New[int]("INT_KEY") - _, exists = vInt.Lookup() - if exists { - t.Error("Lookup expected false for invalid value") - } - }) - - t.Run("MustGet", func(t *testing.T) { - Set(key, "val") - defer Unset(key) - - v := New[string](key) - if val := v.MustGet(); val != "val" { - t.Errorf("MustGet returned %v", val) - } - }) -} - -func TestExport(t *testing.T) { - Set("APP_TEST_1", "v1") - Set("APP_TEST_2", "v2") - Set("OTHER_VAR", "v3") - defer Unset("APP_TEST_1") - defer Unset("APP_TEST_2") - defer Unset("OTHER_VAR") - - t.Run("Export All", func(t *testing.T) { - m := Export() - if m["APP_TEST_1"] != "v1" || m["OTHER_VAR"] != "v3" { - t.Error("Export failed to return all vars") - } - }) - - t.Run("Export With Prefix", func(t *testing.T) { - m := ExportWithPrefix("APP") - if len(m) < 2 { - t.Error("ExportWithPrefix returned too few vars") - } - if m["APP_TEST_1"] != "v1" { - t.Error("Missing APP_TEST_1") - } - if _, ok := m["OTHER_VAR"]; ok { - t.Error("Should not include OTHER_VAR") - } - - // Case insensitivity check if implemented (it is in code) - m2 := ExportWithPrefix("app") - if m2["APP_TEST_1"] != "v1" { - t.Error("ExportWithPrefix (lowercase) failed") - } - }) -} - -func TestLoadDotEnvExtras(t *testing.T) { - filename := ".env.override" - content := []byte("TEST_KEY=new_value\n# Comment") - if err := os.WriteFile(filename, content, 0o644); err != nil { - t.Fatal(err) - } - defer os.Remove(filename) - - t.Run("Override", func(t *testing.T) { - Set("TEST_KEY", "old_value") - defer Unset("TEST_KEY") - - if err := LoadDotEnvOverride(filename); err != nil { - t.Fatal(err) - } - - if val := os.Getenv("TEST_KEY"); val != "new_value" { - t.Errorf("Expected 'new_value', got %q", val) - } - }) - - t.Run("MustLoadDotEnv", func(t *testing.T) { - defer func() { - if r := recover(); r != nil { - t.Errorf("Panic unexpected: %v", r) - } - }() - MustLoadDotEnv(filename) - }) - - t.Run("MustLoadDotEnv Panic", func(t *testing.T) { - defer func() { - if r := recover(); r == nil { - t.Error("Expected panic for missing file") - } - }() - MustLoadDotEnv("non_existent_file.env") - }) -} - -// Redefine for this package test -type TestIP struct { - Value string -} - -func (i *TestIP) UnmarshalText(text []byte) error { - i.Value = string(text) - return nil -} - -func TestGenericFallback(t *testing.T) { - t.Run("TextUnmarshaler", func(t *testing.T) { - Set("TEST_IP", "1.1.1.1") - defer Unset("TEST_IP") - - v := New[TestIP]("TEST_IP") - val, err := v.GetE() - if err != nil { - t.Fatalf("GetE failed: %v", err) - } - if val.Value != "1.1.1.1" { - t.Errorf("Expected 1.1.1.1, got %v", val) - } - }) - - t.Run("No Parser", func(t *testing.T) { - Set("NO_PARSER", "val") - defer Unset("NO_PARSER") - - // struct{} has no parser and no TextUnmarshaler - v := New[struct{}]("NO_PARSER") - _, err := v.GetE() - if err == nil { - t.Error("Expected error for type with no parser") - } - }) -} - -func TestComplexDotEnv(t *testing.T) { - filename := ".env.complex" - content := ` -# Comment -SIMPLE=value -QUOTED="quoted value" -SINGLE_QUOTED='single quoted' -WITH_HASH=val#ue -WITH_COMMENT=value # comment -UNCLOSED="unclosed - ` - if err := os.WriteFile(filename, []byte(content), 0o644); err != nil { - t.Fatal(err) - } - defer os.Remove(filename) - - if err := LoadDotEnvOverride(filename); err != nil { - t.Fatal(err) - } - - checks := map[string]string{ - "SIMPLE": "value", - "QUOTED": "quoted value", - "SINGLE_QUOTED": "single quoted", - "WITH_HASH": "val#ue", - "WITH_COMMENT": "value", - "UNCLOSED": "\"unclosed", // Implementation dependent, usually raw - } - - for k, expected := range checks { - if got := os.Getenv(k); got != expected { - t.Errorf("Key %s: expected %q, got %q", k, expected, got) - } - } -} diff --git a/errors.go b/errors.go index a6b887e..b218d68 100644 --- a/errors.go +++ b/errors.go @@ -1,6 +1,9 @@ package dotenvgo -import "fmt" +import ( + "fmt" + "strings" +) // RequiredError is returned when a required environment variable is not set. type RequiredError struct { @@ -46,7 +49,14 @@ func (e *MultiError) Error() string { if len(e.Errors) == 1 { return e.Errors[0].Error() } - return fmt.Sprintf("dotenvgo: %d errors occurred", len(e.Errors)) + + // Build detailed error message listing all errors + var msg strings.Builder + fmt.Fprintf(&msg, "dotenvgo: %d errors occurred:\n", len(e.Errors)) + for i, err := range e.Errors { + fmt.Fprintf(&msg, " [%d] %s\n", i+1, err.Error()) + } + return msg.String() } // Unwrap returns the list of errors. diff --git a/errors_test.go b/errors_test.go index 938c767..dcd501a 100644 --- a/errors_test.go +++ b/errors_test.go @@ -2,6 +2,7 @@ package dotenvgo import ( "errors" + "strings" "testing" ) @@ -44,11 +45,19 @@ func TestErrors(t *testing.T) { t.Error("Unwrap returned wrong number of errors") } - if err.Error() != "dotenvgo: 2 errors occurred" { - t.Errorf("Unexpected error message: %s", err.Error()) + // New format: lists all errors + errMsg := err.Error() + if !strings.Contains(errMsg, "2 errors occurred") { + t.Errorf("Expected '2 errors occurred' in message: %s", errMsg) + } + if !strings.Contains(errMsg, "[1] error 1") { + t.Errorf("Expected '[1] error 1' in message: %s", errMsg) + } + if !strings.Contains(errMsg, "[2] error 2") { + t.Errorf("Expected '[2] error 2' in message: %s", errMsg) } - // Single error + // Single error - returns the error directly err = &MultiError{Errors: []error{e1}} if err.Error() != "error 1" { t.Errorf("Unexpected single error message: %s", err.Error()) diff --git a/examples/isolated_loader/main.go b/examples/isolated_loader/main.go new file mode 100644 index 0000000..4877430 --- /dev/null +++ b/examples/isolated_loader/main.go @@ -0,0 +1,123 @@ +// Package main demonstrates the isolated loader feature of dotenvgo. +// +// This example shows how different loaders can have different parsers +// for the same type, enabling library authors to customize parsing +// without affecting other parts of the application. +// +// Use case: Two libraries need to parse the same environment variable +// differently. For example: +// - Library A interprets "primary" color as "Blue" +// - Library B interprets "primary" color as "Red" +// +// Without isolated loaders, registering a parser globally would affect +// all code using that type. With isolated loaders, each library can +// have its own interpretation. +package main + +import ( + "fmt" + "os" + + "github.com/godeh/dotenvgo" +) + +// BrandColor is a custom type that we will parse differently in different loaders. +// This simulates a scenario where two libraries use the same type but need +// different parsing logic. +type BrandColor string + +func main() { + // Setup: Set the environment variable that both "libraries" will read + os.Setenv("THEME_COLOR", "primary") + fmt.Println("╔════════════════════════════════════════════════════════════╗") + fmt.Println("║ Isolated Loader Example - dotenvgo ║") + fmt.Println("╚════════════════════════════════════════════════════════════╝") + fmt.Println() + fmt.Println("Environment: THEME_COLOR=primary") + fmt.Println() + + // ═══════════════════════════════════════════════════════════════════════ + // SCENARIO: Two libraries interpret "primary" color differently + // ═══════════════════════════════════════════════════════════════════════ + + // ───────────────────────────────────────────────────────────────────────── + // Library 1: Marketing Department + // Their brand guideline says "primary" = "Blue" + // ───────────────────────────────────────────────────────────────────────── + marketingLoader := dotenvgo.NewLoader() + marketingLoader.RegisterParser(func(s string) (BrandColor, error) { + if s == "primary" { + return "Blue", nil + } + return BrandColor(s), nil + }) + + // ───────────────────────────────────────────────────────────────────────── + // Library 2: Engineering Department + // Their convention says "primary" = "Red" (because red ones go faster!) + // ───────────────────────────────────────────────────────────────────────── + engineeringLoader := dotenvgo.NewLoader() + engineeringLoader.RegisterParser(func(s string) (BrandColor, error) { + if s == "primary" { + return "Red", nil + } + return BrandColor(s), nil + }) + + // ═══════════════════════════════════════════════════════════════════════ + // TEST 1: Using Marketing Loader + // ═══════════════════════════════════════════════════════════════════════ + fmt.Println("┌─────────────────────────────────────────────────────────────┐") + fmt.Println("│ Test 1: Marketing Loader │") + fmt.Println("└─────────────────────────────────────────────────────────────┘") + + // Using the new fluent API: dotenvgo.WithLoader[T](loader, key) + mColor := dotenvgo.WithLoader[BrandColor](marketingLoader, "THEME_COLOR").Get() + fmt.Printf(" Marketing interprets 'primary' as: %s\n", mColor) + fmt.Printf(" ✅ Expected: Blue | Got: %s\n", mColor) + fmt.Println() + + // ═══════════════════════════════════════════════════════════════════════ + // TEST 2: Using Engineering Loader + // ═══════════════════════════════════════════════════════════════════════ + fmt.Println("┌─────────────────────────────────────────────────────────────┐") + fmt.Println("│ Test 2: Engineering Loader │") + fmt.Println("└─────────────────────────────────────────────────────────────┘") + + // Also using the fluent API + eColor := dotenvgo.WithLoader[BrandColor](engineeringLoader, "THEME_COLOR").Get() + fmt.Printf(" Engineering interprets 'primary' as: %s\n", eColor) + fmt.Printf(" ✅ Expected: Red | Got: %s\n", eColor) + fmt.Println() + + // ═══════════════════════════════════════════════════════════════════════ + // TEST 3: Global/Default Loader (Isolation Proof) + // ═══════════════════════════════════════════════════════════════════════ + fmt.Println("┌─────────────────────────────────────────────────────────────┐") + fmt.Println("│ Test 3: Global Loader (Isolation Proof) │") + fmt.Println("└─────────────────────────────────────────────────────────────┘") + fmt.Println() + fmt.Println(" The DefaultLoader has NO parser registered for BrandColor.") + fmt.Println(" This proves that registering parsers on isolated loaders") + fmt.Println(" does NOT pollute the global registry.") + fmt.Println() + + // Using dotenvgo.New[T] which uses DefaultLoader internally + globalVar := dotenvgo.New[BrandColor]("THEME_COLOR") + + val, err := globalVar.GetE() + if err != nil { + fmt.Printf(" ✅ Error (Expected): %v\n", err) + fmt.Println() + fmt.Println(" This error confirms isolation is working correctly!") + } else { + fmt.Printf(" ❌ Unexpected Value: %s\n", val) + fmt.Println(" ERROR: Isolation failed - DefaultLoader should not have a parser!") + } + + fmt.Println() + fmt.Println("═══════════════════════════════════════════════════════════════") + fmt.Println("Conclusion: Each Loader maintains its own parser registry.") + fmt.Println("Libraries can safely register custom parsers without conflicts.") + fmt.Println("═══════════════════════════════════════════════════════════════") +} diff --git a/examples/struct/main.go b/examples/struct/main.go index 8564ef3..3a5dae2 100644 --- a/examples/struct/main.go +++ b/examples/struct/main.go @@ -32,6 +32,8 @@ type Config struct { // Optional settings MaxConnections int `env:"MAX_CONNECTIONS" default:"100"` AllowedOrigins []string `env:"ALLOWED_ORIGINS" default:"*"` + + IDs []int `env:"IDS" default:"1,2,3,4,5"` } func main() { @@ -41,6 +43,7 @@ func main() { os.Setenv("DATABASE_URL", "postgres://user:pass@localhost/mydb") os.Setenv("READ_TIMEOUT", "1m") os.Setenv("ALLOWED_ORIGINS", "http://localhost:3000, https://myapp.com") + os.Setenv("IDS", "1, 2, 3") // Load configuration var cfg Config @@ -61,4 +64,5 @@ func main() { fmt.Printf("Database URL: %s\n", cfg.DatabaseURL) fmt.Printf("Max Connections: %d\n", cfg.MaxConnections) fmt.Printf("Allowed Origins: %v\n", cfg.AllowedOrigins) + fmt.Printf("IDs: %v\n", cfg.IDs) } diff --git a/go.mod b/go.mod index a9fe6ff..f7cf055 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/godeh/dotenvgo -go 1.25.5 +go 1.22 diff --git a/loader.go b/loader.go index 8d2e2e8..c5eccc7 100644 --- a/loader.go +++ b/loader.go @@ -24,12 +24,22 @@ import ( // Timeout time.Duration `env:"TIMEOUT" default:"30s"` // } func Load(cfg any) error { - return LoadWithPrefix(cfg, "") + return DefaultLoader.LoadWithPrefix(cfg, "") } // LoadWithPrefix populates a struct with a prefix for all env vars. // For example, LoadWithPrefix(cfg, "APP") will look for APP_PORT instead of PORT. func LoadWithPrefix(cfg any, prefix string) error { + return DefaultLoader.LoadWithPrefix(cfg, prefix) +} + +// Load populates a struct from environment variables using struct tags. +func (l *Loader) Load(cfg any) error { + return l.LoadWithPrefix(cfg, "") +} + +// LoadWithPrefix populates a struct with a prefix for all env vars. +func (l *Loader) LoadWithPrefix(cfg any, prefix string) error { v := reflect.ValueOf(cfg) if v.Kind() != reflect.Pointer || v.IsNil() { return fmt.Errorf("dotenvgo: cfg must be a non-nil pointer to a struct") @@ -40,7 +50,7 @@ func LoadWithPrefix(cfg any, prefix string) error { return fmt.Errorf("dotenvgo: cfg must be a pointer to a struct") } - return loadStruct(v, prefix) + return l.loadStruct(v, prefix) } // MustLoad is like Load but panics on error. @@ -57,7 +67,7 @@ func MustLoadWithPrefix(cfg any, prefix string) { } } -func loadStruct(v reflect.Value, prefix string) error { +func (l *Loader) loadStruct(v reflect.Value, prefix string) error { t := v.Type() var errors []error @@ -73,13 +83,13 @@ func loadStruct(v reflect.Value, prefix string) error { // Handle structs (embedded or named) that don't have a parser/unmarshaler if field.Type.Kind() == reflect.Struct { // Check if it's a "leaf" type (has parser or implements TextUnmarshaler) - _, hasParser := getParser(field.Type) + _, hasParser := l.getParser(field.Type) isUnmarshaler := field.Type.Implements(reflect.TypeFor[encoding.TextUnmarshaler]()) || reflect.PointerTo(field.Type).Implements(reflect.TypeFor[encoding.TextUnmarshaler]()) if !hasParser && !isUnmarshaler { // Recurse - if err := loadStruct(fieldValue, prefix); err != nil { + if err := l.loadStruct(fieldValue, prefix); err != nil { errors = append(errors, err) } continue @@ -116,7 +126,7 @@ func loadStruct(v reflect.Value, prefix string) error { } // Parse and set value - if err := setField(fieldValue, field.Tag, value); err != nil { + if err := l.setField(fieldValue, field.Tag, value); err != nil { errors = append(errors, &ParseError{Key: fullKey, Value: value, Err: err}) } } @@ -127,7 +137,7 @@ func loadStruct(v reflect.Value, prefix string) error { return nil } -func setField(field reflect.Value, tag reflect.StructTag, value string) error { +func (l *Loader) setField(field reflect.Value, tag reflect.StructTag, value string) error { // 0. Handle custom separator for slices if field.Kind() == reflect.Slice { sep := tag.Get("sep") @@ -137,7 +147,7 @@ func setField(field reflect.Value, tag reflect.StructTag, value string) error { elemType := field.Type().Elem() // Find parser for element type - parser, ok := getParser(elemType) + parser, ok := l.getParser(elemType) if !ok { // Fallback to TextUnmarshaler for element? // For now error if no parser for element @@ -161,7 +171,7 @@ func setField(field reflect.Value, tag reflect.StructTag, value string) error { } // 1. Check if type has a registered parser - if parser, ok := getParser(field.Type()); ok { + if parser, ok := l.getParser(field.Type()); ok { parsed, err := parser(value) if err != nil { return err diff --git a/registry.go b/registry.go index 70df195..62c2325 100644 --- a/registry.go +++ b/registry.go @@ -9,59 +9,73 @@ import ( "time" ) -var ( - registryMu sync.RWMutex - registry = make(map[reflect.Type]func(string) (any, error)) -) +// Loader manages the configuration loading and parser registry. +type Loader struct { + mu sync.RWMutex + registry map[reflect.Type]func(string) (any, error) +} + +// DefaultLoader is the default loader instance used by global functions. +var DefaultLoader = NewLoader() + +// NewLoader creates a new Loader with default parsers registered. +func NewLoader() *Loader { + l := &Loader{ + registry: make(map[reflect.Type]func(string) (any, error)), + } + l.registerDefaults() + return l +} -func init() { +// registerDefaults registers the standard parsers. +func (l *Loader) registerDefaults() { // String - RegisterParser(func(s string) (string, error) { return s, nil }) + l.RegisterParser(func(s string) (string, error) { return s, nil }) // Integers - RegisterParser(func(s string) (int, error) { return strconv.Atoi(s) }) - RegisterParser(func(s string) (int8, error) { + l.RegisterParser(func(s string) (int, error) { return strconv.Atoi(s) }) + l.RegisterParser(func(s string) (int8, error) { v, err := strconv.ParseInt(s, 10, 8) return int8(v), err }) - RegisterParser(func(s string) (int16, error) { + l.RegisterParser(func(s string) (int16, error) { v, err := strconv.ParseInt(s, 10, 16) return int16(v), err }) - RegisterParser(func(s string) (int32, error) { + l.RegisterParser(func(s string) (int32, error) { v, err := strconv.ParseInt(s, 10, 32) return int32(v), err }) - RegisterParser(func(s string) (int64, error) { return strconv.ParseInt(s, 10, 64) }) + l.RegisterParser(func(s string) (int64, error) { return strconv.ParseInt(s, 10, 64) }) // Unsigned Integers - RegisterParser(func(s string) (uint, error) { + l.RegisterParser(func(s string) (uint, error) { v, err := strconv.ParseUint(s, 10, 64) return uint(v), err }) - RegisterParser(func(s string) (uint8, error) { + l.RegisterParser(func(s string) (uint8, error) { v, err := strconv.ParseUint(s, 10, 8) return uint8(v), err }) - RegisterParser(func(s string) (uint16, error) { + l.RegisterParser(func(s string) (uint16, error) { v, err := strconv.ParseUint(s, 10, 16) return uint16(v), err }) - RegisterParser(func(s string) (uint32, error) { + l.RegisterParser(func(s string) (uint32, error) { v, err := strconv.ParseUint(s, 10, 32) return uint32(v), err }) - RegisterParser(func(s string) (uint64, error) { return strconv.ParseUint(s, 10, 64) }) + l.RegisterParser(func(s string) (uint64, error) { return strconv.ParseUint(s, 10, 64) }) // Floats - RegisterParser(func(s string) (float32, error) { + l.RegisterParser(func(s string) (float32, error) { v, err := strconv.ParseFloat(s, 64) return float32(v), err }) - RegisterParser(func(s string) (float64, error) { return strconv.ParseFloat(s, 64) }) + l.RegisterParser(func(s string) (float64, error) { return strconv.ParseFloat(s, 64) }) // Bool - RegisterParser(func(s string) (bool, error) { + l.RegisterParser(func(s string) (bool, error) { switch strings.ToLower(s) { case "true", "1", "yes", "on", "y": return true, nil @@ -73,13 +87,18 @@ func init() { }) // Time Duration - RegisterParser(time.ParseDuration) + l.RegisterParser(time.ParseDuration) // Time Location - RegisterParser(time.LoadLocation) + l.RegisterParser(time.LoadLocation) - // String Slice - RegisterParser(func(s string) ([]string, error) { + // NOTE: Slice types ([]int, []bool, etc.) are automatically supported! + // When you register a parser for type T, []T is automatically handled + // by getParser() which generates a slice parser dynamically. + // The only exception is []string which we register explicitly for efficiency. + + // String Slice (explicit for efficiency, avoiding reflection overhead) + l.RegisterParser(func(s string) ([]string, error) { if s == "" { return []string{}, nil } @@ -97,22 +116,90 @@ func init() { // RegisterParser registers a custom parser for a specific type T. // This parser will be used when loading structs with fields of type T. func RegisterParser[T any](parser func(string) (T, error)) { - registryMu.Lock() - defer registryMu.Unlock() + DefaultLoader.RegisterParser(parser) +} + +// RegisterParser registers a custom parser for a specific type T on this Loader instance. +func (l *Loader) RegisterParser(parser any) { + // We use reflection to get the function type and the return type T + v := reflect.ValueOf(parser) + if v.Kind() != reflect.Func { + panic("parser must be a function") + } + t := v.Type() + if t.NumIn() != 1 || t.In(0).Kind() != reflect.String { + panic("parser must take a single string argument") + } + if t.NumOut() != 2 || t.Out(1).Name() != "error" { + panic("parser must return (T, error)") + } + + targetType := t.Out(0) - var zero T - t := reflect.TypeOf(zero) + l.mu.Lock() + defer l.mu.Unlock() - registry[t] = func(s string) (any, error) { - return parser(s) + l.registry[targetType] = func(s string) (any, error) { + res := v.Call([]reflect.Value{reflect.ValueOf(s)}) + errVal := res[1].Interface() + if errVal != nil { + return nil, errVal.(error) + } + return res[0].Interface(), nil } } +// WithLoader creates a new environment variable of type T using the specified Loader. +// This provides a more fluent API for creating variables with isolated loaders. +// +// Example: +// +// loader := dotenvgo.NewLoader() +// loader.RegisterParser(func(s string) (MyType, error) { ... }) +// value := dotenvgo.WithLoader[MyType](loader, "MY_VAR").Get() +func WithLoader[T any](l *Loader, key string) *Var[T] { + return NewVar[T](l, key) +} + // getParser returns a registered parser for the given type, if one exists. -func getParser(t reflect.Type) (func(string) (any, error), bool) { - registryMu.RLock() - defer registryMu.RUnlock() +// If the type is a slice and no direct parser exists, it will automatically +// generate a slice parser if a parser for the element type is registered. +func (l *Loader) getParser(t reflect.Type) (func(string) (any, error), bool) { + l.mu.RLock() + defer l.mu.RUnlock() + + // 1. Check for direct parser + if parser, ok := l.registry[t]; ok { + return parser, true + } + + // 2. If it's a slice, try to generate parser from element type + if t.Kind() == reflect.Slice { + elemType := t.Elem() + if elemParser, ok := l.registry[elemType]; ok { + // Generate slice parser dynamically + sliceParser := func(s string) (any, error) { + if s == "" { + return reflect.MakeSlice(t, 0, 0).Interface(), nil + } + parts := strings.Split(s, ",") + slice := reflect.MakeSlice(t, 0, len(parts)) + for _, p := range parts { + trimmed := strings.TrimSpace(p) + if trimmed == "" { + continue + } + val, err := elemParser(trimmed) + if err != nil { + return nil, err + } + slice = reflect.Append(slice, reflect.ValueOf(val)) + } + return slice.Interface(), nil + } + return sliceParser, true + } + } - parser, ok := registry[t] - return parser, ok + return nil, false } diff --git a/registry_test.go b/registry_test.go index 104cf88..7055588 100644 --- a/registry_test.go +++ b/registry_test.go @@ -10,7 +10,7 @@ type CustomType int func TestRegistry(t *testing.T) { // 1. Initial State typ := reflect.TypeOf(CustomType(0)) - if _, ok := getParser(typ); ok { + if _, ok := DefaultLoader.getParser(typ); ok { t.Fatal("CustomType should not have a parser yet") } @@ -23,7 +23,7 @@ func TestRegistry(t *testing.T) { }) // 3. Verify Registration - parser, ok := getParser(typ) + parser, ok := DefaultLoader.getParser(typ) if !ok { t.Fatal("CustomType should have a parser now") } @@ -37,6 +37,23 @@ func TestRegistry(t *testing.T) { if ct, ok := val.(CustomType); !ok || ct != 1 { t.Errorf("Expected CustomType(1), got %v", val) } + + // 5. Test automatic slice support - []CustomType should work automatically! + sliceTyp := reflect.TypeFor[[]CustomType]() + sliceParser, ok := DefaultLoader.getParser(sliceTyp) + if !ok { + t.Fatal("[]CustomType should have an auto-generated parser") + } + + sliceVal, err := sliceParser("valid, valid") + if err != nil { + t.Fatalf("Slice parser failed: %v", err) + } + + customs := sliceVal.([]CustomType) + if len(customs) != 2 || customs[0] != 1 || customs[1] != 1 { + t.Errorf("Expected [1, 1], got %v", customs) + } } func TestConcurrentAccess(t *testing.T) { @@ -50,7 +67,7 @@ func TestConcurrentAccess(t *testing.T) { t.Parallel() // Read - _, _ = getParser(reflect.TypeOf(concurrentType(0))) + _, _ = DefaultLoader.getParser(reflect.TypeOf(concurrentType(0))) // Write (registering same type repeated not ideal but safe) RegisterParser(func(s string) (concurrentType, error) { return 0, nil }) @@ -64,32 +81,32 @@ func TestRegisteredParsers(t *testing.T) { input string expected any }{ - {reflect.TypeOf(""), "hello", "hello"}, - {reflect.TypeOf(int(0)), "123", 123}, - {reflect.TypeOf(int8(0)), "123", int8(123)}, - {reflect.TypeOf(int16(0)), "123", int16(123)}, - {reflect.TypeOf(int32(0)), "123", int32(123)}, - {reflect.TypeOf(int64(0)), "123", int64(123)}, - {reflect.TypeOf(uint(0)), "123", uint(123)}, - {reflect.TypeOf(uint8(0)), "123", uint8(123)}, - {reflect.TypeOf(uint16(0)), "123", uint16(123)}, - {reflect.TypeOf(uint32(0)), "123", uint32(123)}, - {reflect.TypeOf(uint64(0)), "123", uint64(123)}, - {reflect.TypeOf(float32(0)), "1.5", float32(1.5)}, - {reflect.TypeOf(float64(0)), "1.5", float64(1.5)}, - {reflect.TypeOf(true), "true", true}, - {reflect.TypeOf(true), "1", true}, - {reflect.TypeOf(true), "yes", true}, - {reflect.TypeOf(true), "on", true}, - {reflect.TypeOf(false), "false", false}, - {reflect.TypeOf(false), "0", false}, - {reflect.TypeOf(false), "no", false}, - {reflect.TypeOf(false), "off", false}, - {reflect.TypeOf(false), "invalid", nil}, // Should error + {reflect.TypeFor[string](), "hello", "hello"}, + {reflect.TypeFor[int](), "123", 123}, + {reflect.TypeFor[int8](), "123", int8(123)}, + {reflect.TypeFor[int16](), "123", int16(123)}, + {reflect.TypeFor[int32](), "123", int32(123)}, + {reflect.TypeFor[int64](), "123", int64(123)}, + {reflect.TypeFor[uint](), "123", uint(123)}, + {reflect.TypeFor[uint8](), "123", uint8(123)}, + {reflect.TypeFor[uint16](), "123", uint16(123)}, + {reflect.TypeFor[uint32](), "123", uint32(123)}, + {reflect.TypeFor[uint64](), "123", uint64(123)}, + {reflect.TypeFor[float32](), "1.5", float32(1.5)}, + {reflect.TypeFor[float64](), "1.5", float64(1.5)}, + {reflect.TypeFor[bool](), "true", true}, + {reflect.TypeFor[bool](), "1", true}, + {reflect.TypeFor[bool](), "yes", true}, + {reflect.TypeFor[bool](), "on", true}, + {reflect.TypeFor[bool](), "false", false}, + {reflect.TypeFor[bool](), "0", false}, + {reflect.TypeFor[bool](), "no", false}, + {reflect.TypeFor[bool](), "off", false}, + {reflect.TypeFor[bool](), "invalid", nil}, // Should error } for _, tc := range tests { - parser, ok := getParser(tc.typ) + parser, ok := DefaultLoader.getParser(tc.typ) if !ok { t.Errorf("No parser for type %v", tc.typ) continue @@ -111,8 +128,8 @@ func TestRegisteredParsers(t *testing.T) { } // Test Slices - sliceTyp := reflect.TypeOf([]string{}) - parser, _ := getParser(sliceTyp) + sliceTyp := reflect.TypeFor[[]string]() + parser, _ := DefaultLoader.getParser(sliceTyp) v, _ := parser("a,b, c") s := v.([]string) @@ -125,4 +142,121 @@ func TestRegisteredParsers(t *testing.T) { if len(s) != 0 { t.Errorf("Empty slice parser failed") } + + // Test []int + intSliceTyp := reflect.TypeFor[[]int]() + intParser, _ := DefaultLoader.getParser(intSliceTyp) + + vi, err := intParser("1, 2, 3") + if err != nil { + t.Errorf("[]int parser failed: %v", err) + } + ints := vi.([]int) + if len(ints) != 3 || ints[0] != 1 || ints[2] != 3 { + t.Errorf("[]int parser returned wrong values: %v", ints) + } + + // Test []float64 + floatSliceTyp := reflect.TypeFor[[]float64]() + floatParser, _ := DefaultLoader.getParser(floatSliceTyp) + + vf, err := floatParser("1.5, 2.5, 3.5") + if err != nil { + t.Errorf("[]float64 parser failed: %v", err) + } + floats := vf.([]float64) + if len(floats) != 3 || floats[0] != 1.5 || floats[2] != 3.5 { + t.Errorf("[]float64 parser returned wrong values: %v", floats) + } + + // Test []bool + boolSliceTyp := reflect.TypeFor[[]bool]() + boolParser, _ := DefaultLoader.getParser(boolSliceTyp) + + vb, err := boolParser("true, false, yes, no, 1, 0") + if err != nil { + t.Errorf("[]bool parser failed: %v", err) + } + bools := vb.([]bool) + expected := []bool{true, false, true, false, true, false} + if len(bools) != len(expected) { + t.Errorf("[]bool parser returned wrong length: %v", bools) + } + for i, b := range bools { + if b != expected[i] { + t.Errorf("[]bool parser returned wrong value at index %d: got %v, expected %v", i, b, expected[i]) + } + } + + // Test []int with invalid value + _, err = intParser("1, invalid, 3") + if err == nil { + t.Error("[]int parser should fail on invalid input") + } +} + +type ValidationStatus int + +const ( + StatusUnknown ValidationStatus = iota + StatusValid + StatusInvalid +) + +func TestLoaderIsolation(t *testing.T) { + // Create two independent loaders + l1 := NewLoader() + l2 := NewLoader() + + // Register parser for l1: "valid" -> StatusValid (1) + l1.RegisterParser(func(s string) (ValidationStatus, error) { + if s == "valid" { + return StatusValid, nil + } + return StatusUnknown, nil + }) + + // Register parser for l2: "valid" -> StatusInvalid (2) + l2.RegisterParser(func(s string) (ValidationStatus, error) { + if s == "valid" { + return StatusInvalid, nil + } + return StatusUnknown, nil + }) + + // Test l1 behavior + // We use NewVar generic function passing the loader + // We need to simulate parsing or use the variable. Since we can't set env var easily in parallel tests without potential race if we used os.Setenv, + // but NewVar returns a Var with a parser closure. We can test that parser closure if we could access it? + // No, Var struct fields are unexported. + // But we can use .Get() or .GetE() which reads from OS. + // Or we can just inspect if the creation worked if we had a way. + + // Actually, dotenvgo.Var reads from os.Getenv. + // To test, we need to set an env var. + // os.Setenv is global. + // This test should run purely on internal logic if possible, or we accept setting env var. + + t.Setenv("ISOLATION_TEST_VAR", "valid") + + val1 := NewVar[ValidationStatus](l1, "ISOLATION_TEST_VAR").Get() + if val1 != StatusValid { + t.Errorf("Loader 1 failed: expected StatusValid (1), got %v", val1) + } + + val2 := NewVar[ValidationStatus](l2, "ISOLATION_TEST_VAR").Get() + if val2 != StatusInvalid { + t.Errorf("Loader 2 failed: expected StatusInvalid (2), got %v", val2) + } + + // 3. Test l3 (DefaultLoader) - should have NO parser for ValidationStatus + // This ensures we didn't pollute the global registry + // Note: New[T] uses DefaultLoader + v3 := New[ValidationStatus]("ISOLATION_TEST_VAR") + // Accessing it should error or return default? + // GetE calls parser. New factory returned a parser that returns error if not found. + _, err := v3.GetE() + if err == nil { + t.Error("DefaultLoader should NOT have a parser for ValidationStatus") + } }