diff --git a/.github/README.md b/.github/README.md new file mode 100644 index 0000000..c7e6662 --- /dev/null +++ b/.github/README.md @@ -0,0 +1,255 @@ +# 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 new file mode 100644 index 0000000..2fdaaf0 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,29 @@ +name: Unit Tests + +on: + pull_request: + branches: [ "main" ] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.25' + + - name: Test + run: go test -v ./... + + - name: Coverage + run: | + go test -coverprofile=coverage.out ./... + + - name: Upload Coverage Artifacts + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage.out diff --git a/.gitignore b/.gitignore index aaadf73..4dda765 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,5 @@ go.work.sum # Editor/IDE # .idea/ -# .vscode/ +.vscode/ +.DS_Store diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4951501 --- /dev/null +++ b/Makefile @@ -0,0 +1,32 @@ +.PHONY: all test coverage audit fmt tidy + +all: fmt audit test + +# Run tests +test: + go test -v -race ./... + +# Run tests with coverage +coverage: + go test -coverprofile=coverage.out ./... + go tool cover -func=coverage.out + go tool cover -html=coverage.out -o coverage.html + +# Audit code (vet and staticcheck) +audit: + go vet ./... + # Check if staticcheck is installed, if not, print warning + @if command -v staticcheck >/dev/null 2>&1; then \ + staticcheck ./...; \ + else \ + echo "staticcheck not found, skipping (install with 'go install honnef.co/go/tools/cmd/staticcheck@latest')"; \ + fi + +# Format code +fmt: + go fmt ./... + +# Tidy dependencies +tidy: + go mod tidy + go mod verify diff --git a/README.md b/README.md deleted file mode 100644 index b811588..0000000 --- a/README.md +++ /dev/null @@ -1 +0,0 @@ -# dotenvgo \ No newline at end of file diff --git a/dotenvgo.go b/dotenvgo.go new file mode 100644 index 0000000..915c256 --- /dev/null +++ b/dotenvgo.go @@ -0,0 +1,303 @@ +package dotenvgo + +import ( + "encoding" + "fmt" + "os" + "reflect" + "strings" +) + +// Var represents an environment variable with type-safe access. +type Var[T any] struct { + key string + defaultValue *T + required bool + parser func(string) (T, error) + prefix string +} + +// New creates a new environment variable of type T. +// It searches for a registered parser or uses encoding.TextUnmarshaler. +func New[T any](key string) *Var[T] { + var zero T + typ := reflect.TypeOf(zero) + + // 1. Check registry + if p, ok := getParser(typ); ok { + return &Var[T]{ + key: key, + parser: func(s string) (T, error) { + v, err := p(s) + if err != nil { + return zero, err + } + return v.(T), nil + }, + } + } + + // 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) + + // 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 { + v, err := p(s) + if err != nil { + return zero, err + } + return v.(T), nil + } + + // 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 { + return zero, err + } + return valPtr.Elem().Interface().(T), nil + } + + return zero, fmt.Errorf("dotenvgo: no parser registered for type %v", typ) + }, + } +} + +// Default sets the default value if the environment variable is not set. +func (v *Var[T]) Default(value T) *Var[T] { + v.defaultValue = &value + return v +} + +// Required marks the environment variable as required. +// Get() will panic if the variable is not set. +// GetE() will return an error. +func (v *Var[T]) Required() *Var[T] { + v.required = true + return v +} + +// WithPrefix adds a prefix to the environment variable key. +// For example, WithPrefix("APP").String("PORT") will look for "APP_PORT". +func (v *Var[T]) WithPrefix(prefix string) *Var[T] { + v.prefix = prefix + return v +} + +// fullKey returns the full environment variable key with prefix. +func (v *Var[T]) fullKey() string { + if v.prefix != "" { + return v.prefix + "_" + v.key + } + return v.key +} + +// Get returns the value of the environment variable. +// Panics if the variable is required but not set. +func (v *Var[T]) Get() T { + value, err := v.GetE() + if err != nil { + panic(err) + } + return value +} + +// GetE returns the value of the environment variable or an error. +func (v *Var[T]) GetE() (T, error) { + var zero T + key := v.fullKey() + raw := os.Getenv(key) + + if raw == "" { + if v.required { + return zero, &RequiredError{Key: key} + } + if v.defaultValue != nil { + return *v.defaultValue, nil + } + return zero, nil + } + + value, err := v.parser(os.ExpandEnv(raw)) + if err != nil { + return zero, &ParseError{Key: key, Value: raw, Err: err} + } + + return value, nil +} + +// Lookup returns the value and whether it was set. +func (v *Var[T]) Lookup() (T, bool) { + var zero T + key := v.fullKey() + raw, exists := os.LookupEnv(key) + + if !exists || raw == "" { + if v.defaultValue != nil { + return *v.defaultValue, true + } + return zero, false + } + + value, err := v.parser(os.ExpandEnv(raw)) + if err != nil { + return zero, false + } + + return value, true +} + +// MustGet returns the value or panics if there's an error. +// Alias for Get(). +func (v *Var[T]) MustGet() T { + return v.Get() +} + +// IsSet returns whether the environment variable is set. +func (v *Var[T]) IsSet() bool { + key := v.fullKey() + _, exists := os.LookupEnv(key) + return exists +} + +// LoadDotEnv loads environment variables from a .env file. +// It does NOT override existing environment variables. +func LoadDotEnv(path string) error { + return loadDotEnvWithOverride(path, false) +} + +// LoadDotEnvOverride loads environment variables from a .env file. +// It DOES override existing environment variables. +func LoadDotEnvOverride(path string) error { + return loadDotEnvWithOverride(path, true) +} + +func loadDotEnvWithOverride(path string, override bool) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + + lines := strings.Split(string(data), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + + // Skip empty lines and comments + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + // Find the first '=' + idx := strings.Index(line, "=") + if idx == -1 { + continue + } + + key := strings.TrimSpace(line[:idx]) + valPart := strings.TrimSpace(line[idx+1:]) + var value string + + if len(valPart) > 0 { + quote := valPart[0] + if quote == '"' || quote == '\'' { + // Quoted value: look for matching close quote + // We start looking from index 1 + if endIdx := strings.IndexByte(valPart[1:], quote); endIdx != -1 { + // endIdx is relative to valPart[1:], so actual index in valPart is endIdx + 1 + value = valPart[1 : endIdx+1] + } else { + // Unclosed quote, take valid part or whole? + // Usually behave as if unquoted or error. + // For simplicity/robustness, treat as unquoted if not closed properly + // or just take the whole thing. + // Let's assume the user meant it to be the value if unclosed. + value = valPart + } + } else { + // Unquoted value: stop at first '#' if it is preceded by a space + value = valPart + for i := 0; i < len(valPart); i++ { + if valPart[i] == '#' { + // Comment matches if it's the first char (handled mostly by loop skip, but technically possible here if valPart is just #) + // OR if preceded by whitespace + if i == 0 { + value = "" + break + } + // Check previous char for whitespace + if i > 0 && (valPart[i-1] == ' ' || valPart[i-1] == '\t') { + value = strings.TrimSpace(valPart[:i]) + break + } + } + } + } + } + + // Only set if not already set (unless override) + if override || os.Getenv(key) == "" { + _ = os.Setenv(key, value) + } + } + + return nil +} + +// MustLoadDotEnv loads a .env file or panics. +func MustLoadDotEnv(path string) { + if err := LoadDotEnv(path); err != nil { + panic(err) + } +} + +// Export returns all environment variables as a map. +func Export() map[string]string { + result := make(map[string]string) + for _, env := range os.Environ() { + idx := strings.Index(env, "=") + if idx != -1 { + result[env[:idx]] = env[idx+1:] + } + } + return result +} + +// ExportWithPrefix returns environment variables matching a prefix. +func ExportWithPrefix(prefix string) map[string]string { + result := make(map[string]string) + prefixUpper := strings.ToUpper(prefix) + if !strings.HasSuffix(prefixUpper, "_") { + prefixUpper += "_" + } + + for _, env := range os.Environ() { + idx := strings.Index(env, "=") + if idx != -1 { + key := env[:idx] + if strings.HasPrefix(strings.ToUpper(key), prefixUpper) { + result[key] = env[idx+1:] + } + } + } + return result +} + +// Set sets an environment variable. +func Set(key, value string) { + _ = os.Setenv(key, value) +} + +// Unset removes an environment variable. +func Unset(key string) { + _ = os.Unsetenv(key) +} diff --git a/dotenvgo_test.go b/dotenvgo_test.go new file mode 100644 index 0000000..dba34b5 --- /dev/null +++ b/dotenvgo_test.go @@ -0,0 +1,89 @@ +package dotenvgo + +import ( + "os" + "testing" +) + +func TestEnvVar(t *testing.T) { + // Setup + key := "TEST_ENV_VAR" + _ = os.Setenv(key, "test_value") + defer func() { _ = os.Unsetenv(key) }() + + t.Run("New String", func(t *testing.T) { + v := New[string](key) + if val := v.Get(); val != "test_value" { + t.Errorf("Expected 'test_value', got %v", val) + } + }) + + t.Run("Default Value", func(t *testing.T) { + v := New[string]("NON_EXISTENT").Default("default") + if val := v.Get(); val != "default" { + t.Errorf("Expected 'default', got %v", val) + } + }) + + t.Run("Required Var", func(t *testing.T) { + v := New[string]("NON_EXISTENT").Required() + _, err := v.GetE() + if err == nil { + t.Error("Expected error for missing required var") + } + }) + + t.Run("With Prefix", func(t *testing.T) { + _ = os.Setenv("APP_PORT", "8080") + defer func() { _ = os.Unsetenv("APP_PORT") }() + + v := New[int]("PORT").WithPrefix("APP") + if val := v.Get(); val != 8080 { + t.Errorf("Expected 8080, got %v", val) + } + }) +} + +func TestLoadDotEnv(t *testing.T) { + content := []byte("TEST_KEY=test_value\n# Comment\nQUOTED=\"value with spaces\"") + filename := ".env.test" + if err := os.WriteFile(filename, content, 0o644); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Remove(filename) }() + defer func() { _ = os.Unsetenv("TEST_KEY") }() + defer func() { _ = os.Unsetenv("QUOTED") }() + + if err := LoadDotEnv(filename); err != nil { + t.Fatalf("LoadDotEnv failed: %v", err) + } + + if val := os.Getenv("TEST_KEY"); val != "test_value" { + t.Errorf("Expected 'test_value', got %q", val) + } + if val := os.Getenv("QUOTED"); val != "value with spaces" { + t.Errorf("Expected 'value with spaces', got %q", val) + } +} + +func TestParsers(t *testing.T) { + _ = os.Setenv("INT_VAL", "123") + _ = os.Setenv("BOOL_VAL", "true") + defer func() { _ = os.Unsetenv("INT_VAL") }() + defer func() { _ = os.Unsetenv("BOOL_VAL") }() + + if v := New[int]("INT_VAL").Get(); v != 123 { + t.Errorf("Expected 123, got %v", v) + } + if v := New[bool]("BOOL_VAL").Get(); v != true { + t.Errorf("Expected true, got %v", v) + } +} + +// Mock Generic Parser to avoid reflect error in newGeneric logic if it relies on registry +func init() { + // Register types used in tests if not implicitly available via dotenvgo logic + // The current implementation of dotenvgo uses a registry or generic unmarshaler. + // 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. +} diff --git a/dotenvgo_utils_test.go b/dotenvgo_utils_test.go new file mode 100644 index 0000000..d4659f7 --- /dev/null +++ b/dotenvgo_utils_test.go @@ -0,0 +1,230 @@ +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 new file mode 100644 index 0000000..a6b887e --- /dev/null +++ b/errors.go @@ -0,0 +1,55 @@ +package dotenvgo + +import "fmt" + +// RequiredError is returned when a required environment variable is not set. +type RequiredError struct { + Key string +} + +func (e *RequiredError) Error() string { + return fmt.Sprintf("dotenvgo: required environment variable %q is not set", e.Key) +} + +// ParseError is returned when an environment variable cannot be parsed. +type ParseError struct { + Key string + Value string + Err error +} + +func (e *ParseError) Error() string { + return fmt.Sprintf("dotenvgo: cannot parse %q=%q: %v", e.Key, e.Value, e.Err) +} + +// Unwrap returns the underlying error. +func (e *ParseError) Unwrap() error { + return e.Err +} + +// ValidationError is returned when struct validation fails. +type ValidationError struct { + Field string + Message string +} + +func (e *ValidationError) Error() string { + return fmt.Sprintf("dotenvgo: validation failed for field %q: %s", e.Field, e.Message) +} + +// MultiError contains multiple errors from struct loading. +type MultiError struct { + Errors []error +} + +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)) +} + +// Unwrap returns the list of errors. +func (e *MultiError) Unwrap() []error { + return e.Errors +} diff --git a/errors_test.go b/errors_test.go new file mode 100644 index 0000000..938c767 --- /dev/null +++ b/errors_test.go @@ -0,0 +1,57 @@ +package dotenvgo + +import ( + "errors" + "testing" +) + +func TestErrors(t *testing.T) { + t.Run("RequiredError", func(t *testing.T) { + err := &RequiredError{Key: "TEST_VAR"} + if err.Error() != "dotenvgo: required environment variable \"TEST_VAR\" is not set" { + t.Errorf("Unexpected error message: %s", err.Error()) + } + }) + + t.Run("ParseError", func(t *testing.T) { + cause := errors.New("invalid syntax") + err := &ParseError{Key: "PORT", Value: "abc", Err: cause} + + if err.Unwrap() != cause { + t.Error("Unwrap did not return cause") + } + + expected := "dotenvgo: cannot parse \"PORT\"=\"abc\": invalid syntax" + if err.Error() != expected { + t.Errorf("Expected %q, got %q", expected, err.Error()) + } + }) + + t.Run("ValidationError", func(t *testing.T) { + err := &ValidationError{Field: "Age", Message: "too low"} + expected := "dotenvgo: validation failed for field \"Age\": too low" + if err.Error() != expected { + t.Errorf("Expected %q, got %q", expected, err.Error()) + } + }) + + t.Run("MultiError", func(t *testing.T) { + e1 := errors.New("error 1") + e2 := errors.New("error 2") + err := &MultiError{Errors: []error{e1, e2}} + + if len(err.Unwrap()) != 2 { + t.Error("Unwrap returned wrong number of errors") + } + + if err.Error() != "dotenvgo: 2 errors occurred" { + t.Errorf("Unexpected error message: %s", err.Error()) + } + + // Single error + err = &MultiError{Errors: []error{e1}} + if err.Error() != "error 1" { + t.Errorf("Unexpected single error message: %s", err.Error()) + } + }) +} diff --git a/examples/basic/main.go b/examples/basic/main.go new file mode 100644 index 0000000..4741598 --- /dev/null +++ b/examples/basic/main.go @@ -0,0 +1,46 @@ +package main + +import ( + "fmt" + "time" + + "github.com/godeh/dotenvgo" +) + +func main() { + fmt.Println("=== Without Environment Variables (using defaults) ===") + + // 1. Define configuration with defaults using generic New[T] + port := dotenvgo.New[int]("PORT").Default(8080) + host := dotenvgo.New[string]("HOST").Default("localhost") + debug := dotenvgo.New[bool]("DEBUG").Default(false) + timeout := dotenvgo.New[time.Duration]("TIMEOUT").Default(30 * time.Second) + workers := dotenvgo.New[int]("WORKERS").Default(4) + + fmt.Printf("Port: %d (default: 8080)\n", port.Get()) + fmt.Printf("Host: %s (default: localhost)\n", host.Get()) + fmt.Printf("Debug: %v (default: false)\n", debug.Get()) + fmt.Printf("Timeout: %v (default: 30s)\n", timeout.Get()) + fmt.Printf("Workers: %d (default: 4)\n", workers.Get()) + + fmt.Println() + fmt.Println("=== With Environment Variables (overriding defaults) ===") + + // Simulating environment variables for the sake of example + dotenvgo.Set("APP_PORT", "3000") + dotenvgo.Set("APP_DEBUG", "true") + dotenvgo.Set("APP_TIMEOUT", "1m30s") + + // Same variables but with prefix + appPort := port.WithPrefix("APP").Get() + + // appHost uses default because APP_HOST is not set + appHost := host.WithPrefix("APP").Get() + appDebug := debug.WithPrefix("APP").Get() + appTimeout := timeout.WithPrefix("APP").Get() + + fmt.Printf("Port: %d (env: 3000)\n", appPort) + fmt.Printf("Host: %s (not set, using default)\n", appHost) + fmt.Printf("Debug: %v (env: true)\n", appDebug) + fmt.Printf("Timeout: %v (env: 1m30s)\n", appTimeout) +} diff --git a/examples/expansion/main.go b/examples/expansion/main.go new file mode 100644 index 0000000..81b0bc2 --- /dev/null +++ b/examples/expansion/main.go @@ -0,0 +1,37 @@ +package main + +import ( + "fmt" + "os" + + "github.com/godeh/dotenvgo" +) + +func main() { + // Set base variables + os.Setenv("HOST", "localhost") + os.Setenv("PORT", "8080") + + // Set variable that uses expansion + os.Setenv("SERVICE_URL_v1", "http://${HOST}:${PORT}/api/v1") + + // 1. Using standard getter + url := dotenvgo.New[string]("SERVICE_URL_v1").Get() + fmt.Printf("Expanded URL V1: %s\n", url) + + // 2. Using struct loader + type Config struct { + Host string `env:"HOST"` + Port int `env:"PORT"` + URLV1 string `env:"SERVICE_URL_v1"` + URLV2 string `env:"SERVICE_URL_v2" default:"http://${HOST}:${PORT}/api/v2"` + } + + var cfg Config + if err := dotenvgo.Load(&cfg); err != nil { + panic(err) + } + + fmt.Printf("Struct Loaded URL V1: %s\n", cfg.URLV1) + fmt.Printf("Struct Loaded URL V2: %s\n", cfg.URLV2) +} diff --git a/examples/file/main.go b/examples/file/main.go new file mode 100644 index 0000000..d960285 --- /dev/null +++ b/examples/file/main.go @@ -0,0 +1,38 @@ +package main + +import ( + "fmt" + "os" + + "github.com/godeh/dotenvgo" +) + +func main() { + // Create a temporary .env file for demonstration + envContent := ` +# Advanced .env features +APP_NAME='My App' # Value in single quotes +API_KEY="secret value" # Value in double quotes +DB_HOST=localhost # Inline comment +FLAGS=1#2#3 # Hash inside value (preserved because no space before) +FLAG="1#23" # Hash inside value (preserved because no space before) +mixed_quotes='He said "Hello"' +` + if err := os.WriteFile(".env.example", []byte(envContent), 0644); err != nil { + panic(err) + } + defer os.Remove(".env.example") + + // Load the .env file + if err := dotenvgo.LoadDotEnvOverride(".env.example"); err != nil { + panic(err) + } + + // Print parsed values + fmt.Printf("APP_NAME: %s\n", os.Getenv("APP_NAME")) + fmt.Printf("API_KEY: %s\n", os.Getenv("API_KEY")) + fmt.Printf("DB_HOST: %s\n", os.Getenv("DB_HOST")) + fmt.Printf("FLAGS: %s\n", os.Getenv("FLAGS")) + fmt.Printf("FLAG: %s\n", os.Getenv("FLAG")) + fmt.Printf("mixed_quotes: %s\n", os.Getenv("mixed_quotes")) +} diff --git a/examples/struct/main.go b/examples/struct/main.go new file mode 100644 index 0000000..8564ef3 --- /dev/null +++ b/examples/struct/main.go @@ -0,0 +1,64 @@ +// Example: Struct-based configuration loading +package main + +import ( + "fmt" + "os" + "time" + + "github.com/godeh/dotenvgo" +) + +// Config defines your application configuration +type Config struct { + // Server settings + Host string `env:"HOST" default:"0.0.0.0"` + Port int `env:"PORT" default:"8080"` + + // Feature flags + Debug bool `env:"DEBUG" default:"false"` + Verbose bool `env:"VERBOSE" default:"false"` + + // Timeouts + ReadTimeout time.Duration `env:"READ_TIMEOUT" default:"30s"` + WriteTimeout time.Duration `env:"WRITE_TIMEOUT" default:"30s"` + + // Timezone + Location *time.Location `env:"LOCATION" default:"Europe/London"` + + // Database (required) + DatabaseURL string `env:"DATABASE_URL" required:"true"` + + // Optional settings + MaxConnections int `env:"MAX_CONNECTIONS" default:"100"` + AllowedOrigins []string `env:"ALLOWED_ORIGINS" default:"*"` +} + +func main() { + // Set some environment variables for demo + os.Setenv("PORT", "3000") + os.Setenv("DEBUG", "true") + os.Setenv("DATABASE_URL", "postgres://user:pass@localhost/mydb") + os.Setenv("READ_TIMEOUT", "1m") + os.Setenv("ALLOWED_ORIGINS", "http://localhost:3000, https://myapp.com") + + // Load configuration + var cfg Config + if err := dotenvgo.Load(&cfg); err != nil { + fmt.Printf("Failed to load config: %v\n", err) + os.Exit(1) + } + + // Print loaded configuration + fmt.Println("=== Loaded Configuration ===") + fmt.Printf("Host: %s\n", cfg.Host) + fmt.Printf("Port: %d\n", cfg.Port) + fmt.Printf("Debug: %v\n", cfg.Debug) + fmt.Printf("Verbose: %v\n", cfg.Verbose) + fmt.Printf("Read Timeout: %v\n", cfg.ReadTimeout) + fmt.Printf("Write Timeout: %v\n", cfg.WriteTimeout) + fmt.Printf("Location: %v\n", cfg.Location) + fmt.Printf("Database URL: %s\n", cfg.DatabaseURL) + fmt.Printf("Max Connections: %d\n", cfg.MaxConnections) + fmt.Printf("Allowed Origins: %v\n", cfg.AllowedOrigins) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a9fe6ff --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/godeh/dotenvgo + +go 1.25.5 diff --git a/loader.go b/loader.go new file mode 100644 index 0000000..8d2e2e8 --- /dev/null +++ b/loader.go @@ -0,0 +1,185 @@ +package dotenvgo + +import ( + "encoding" + "fmt" + "os" + "reflect" + "strings" +) + +// Load populates a struct from environment variables using struct tags. +// +// Supported tags: +// - env:"VAR_NAME" - the environment variable name +// - default:"value" - default value if not set +// - required:"true" - marks the field as required +// +// Example: +// +// type Config struct { +// Port int `env:"PORT" default:"8080"` +// Debug bool `env:"DEBUG" default:"false"` +// Database string `env:"DATABASE_URL" required:"true"` +// Timeout time.Duration `env:"TIMEOUT" default:"30s"` +// } +func Load(cfg any) error { + return 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 { + 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") + } + + v = v.Elem() + if v.Kind() != reflect.Struct { + return fmt.Errorf("dotenvgo: cfg must be a pointer to a struct") + } + + return loadStruct(v, prefix) +} + +// MustLoad is like Load but panics on error. +func MustLoad(cfg any) { + if err := Load(cfg); err != nil { + panic(err) + } +} + +// MustLoadWithPrefix is like LoadWithPrefix but panics on error. +func MustLoadWithPrefix(cfg any, prefix string) { + if err := LoadWithPrefix(cfg, prefix); err != nil { + panic(err) + } +} + +func loadStruct(v reflect.Value, prefix string) error { + t := v.Type() + var errors []error + + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + fieldValue := v.Field(i) + + // Skip unexported fields + if !fieldValue.CanSet() { + continue + } + + // 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) + 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 { + errors = append(errors, err) + } + continue + } + } + + // Get struct tags + envKey := field.Tag.Get("env") + if envKey == "" { + continue + } + + defaultValue := field.Tag.Get("default") + required := field.Tag.Get("required") == "true" + + // Build full key with prefix + fullKey := envKey + if prefix != "" { + fullKey = prefix + "_" + envKey + } + + // Get value from environment + value := os.ExpandEnv(os.Getenv(fullKey)) + if value == "" { + if required { + errors = append(errors, &RequiredError{Key: fullKey}) + continue + } + value = os.ExpandEnv(defaultValue) + } + + if value == "" { + continue + } + + // Parse and set value + if err := setField(fieldValue, field.Tag, value); err != nil { + errors = append(errors, &ParseError{Key: fullKey, Value: value, Err: err}) + } + } + + if len(errors) > 0 { + return &MultiError{Errors: errors} + } + return nil +} + +func 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") + if sep != "" { + parts := strings.Split(value, sep) + slice := reflect.MakeSlice(field.Type(), 0, len(parts)) + elemType := field.Type().Elem() + + // Find parser for element type + parser, ok := getParser(elemType) + if !ok { + // Fallback to TextUnmarshaler for element? + // For now error if no parser for element + return fmt.Errorf("dotenvgo: no parser registered for slice element type %v", elemType) + } + + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + parsed, err := parser(p) + if err != nil { + return err + } + slice = reflect.Append(slice, reflect.ValueOf(parsed)) + } + field.Set(slice) + return nil + } + } + + // 1. Check if type has a registered parser + if parser, ok := getParser(field.Type()); ok { + parsed, err := parser(value) + if err != nil { + return err + } + field.Set(reflect.ValueOf(parsed)) + return nil + } + + // 2. Check if field implements encoding.TextUnmarshaler + if field.CanAddr() { + // Try pointer receiver + if u, ok := field.Addr().Interface().(encoding.TextUnmarshaler); ok { + return u.UnmarshalText([]byte(value)) + } + } else if u, ok := field.Interface().(encoding.TextUnmarshaler); ok { + // Try value receiver (less common for mutation but possible) + return u.UnmarshalText([]byte(value)) + } + + return fmt.Errorf("dotenvgo: no parser registered for type %v", field.Type()) +} diff --git a/loader_test.go b/loader_test.go new file mode 100644 index 0000000..df5c7a9 --- /dev/null +++ b/loader_test.go @@ -0,0 +1,269 @@ +package dotenvgo + +import ( + "errors" + "os" + "testing" + "time" +) + +type TestConfig 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"` + Required string `env:"REQUIRED_VAR" required:"true"` + + // Slices + Hosts []string `env:"ALLOWED_HOSTS"` + IDs []int `env:"ALLOWED_IDS" sep:";"` + + // Pointers + Optional *int `env:"OPTIONAL_INT"` + + // Unexported should be ignored + secret string `env:"SECRET"` + + // No tag should be ignored + NoTag string +} + +func TestLoad(t *testing.T) { + // Setup env + setEnv(t, "REQUIRED_VAR", "required_value") + + t.Run("Defaults", func(t *testing.T) { + var cfg TestConfig + err := Load(&cfg) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + + if cfg.Host != "localhost" { + t.Errorf("Expected Host 'localhost', got %q", cfg.Host) + } + if cfg.Port != 8080 { + t.Errorf("Expected Port 8080, got %d", cfg.Port) + } + if cfg.Debug != false { + t.Errorf("Expected Debug false, got %v", cfg.Debug) + } + if cfg.Timeout != 30*time.Second { + t.Errorf("Expected Timeout 30s, got %v", cfg.Timeout) + } + }) + + t.Run("Env Overrides", func(t *testing.T) { + setEnv(t, "HOST", "127.0.0.1") + setEnv(t, "PORT", "9090") + setEnv(t, "DEBUG", "true") + setEnv(t, "TIMEOUT", "1m") + + var cfg TestConfig + err := Load(&cfg) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + + if cfg.Host != "127.0.0.1" { + t.Errorf("Expected Host '127.0.0.1', got %q", cfg.Host) + } + if cfg.Port != 9090 { + t.Errorf("Expected Port 9090, got %d", cfg.Port) + } + if cfg.Debug != true { + t.Errorf("Expected Debug true, got %v", cfg.Debug) + } + if cfg.Timeout != 1*time.Minute { + t.Errorf("Expected Timeout 1m, got %v", cfg.Timeout) + } + }) + + t.Run("Slices", func(t *testing.T) { + setEnv(t, "ALLOWED_HOSTS", "a,b, c ") // Default comma, trimming + setEnv(t, "ALLOWED_IDS", "1; 2;3") // Custom semicolon + + var cfg TestConfig + err := Load(&cfg) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + + if len(cfg.Hosts) != 3 || cfg.Hosts[0] != "a" || cfg.Hosts[1] != "b" || cfg.Hosts[2] != "c" { + t.Errorf("Expected [a, b, c], got %v", cfg.Hosts) + } + if len(cfg.IDs) != 3 || cfg.IDs[0] != 1 || cfg.IDs[1] != 2 || cfg.IDs[2] != 3 { + t.Errorf("Expected [1, 2, 3], got %v", cfg.IDs) + } + }) +} + +func TestLoadWithPrefix(t *testing.T) { + setEnv(t, "APP_REQUIRED_VAR", "req") + setEnv(t, "APP_HOST", "app-host") + + var cfg TestConfig + err := LoadWithPrefix(&cfg, "APP") + if err != nil { + t.Fatalf("LoadWithPrefix failed: %v", err) + } + + if cfg.Host != "app-host" { + t.Errorf("Expected Host 'app-host', got %q", cfg.Host) + } + if cfg.Required != "req" { + t.Errorf("Expected Required 'req', got %q", cfg.Required) + } +} + +func TestLoadErrors(t *testing.T) { + t.Run("Nil Pointer", func(t *testing.T) { + err := Load(nil) + if err == nil { + t.Error("Expected error for nil") + } + }) + + t.Run("Not Pointer", func(t *testing.T) { + var cfg TestConfig + err := Load(cfg) + if err == nil { + t.Error("Expected error for non-pointer") + } + }) + + t.Run("Missing Required", func(t *testing.T) { + os.Unsetenv("REQUIRED_VAR") + var cfg TestConfig + err := Load(&cfg) + if err == nil { + t.Error("Expected error for missing required var") + } + + var multiErr *MultiError + if !errors.As(err, &multiErr) { + t.Errorf("Expected MultiError, got %T", err) + } + }) + + t.Run("Parse Error", func(t *testing.T) { + setEnv(t, "REQUIRED_VAR", "ok") + setEnv(t, "PORT", "invalid-int") + + var cfg TestConfig + err := Load(&cfg) + if err == nil { + t.Error("Expected error for parsing failure") + } + + var multiErr *MultiError + if !errors.As(err, &multiErr) || len(multiErr.Errors) == 0 { + t.Fatal("Expected MultiError with at least one error") + } + + var parseErr *ParseError + if !errors.As(multiErr.Errors[0], &parseErr) { + t.Errorf("Expected ParseError, got %T", multiErr.Errors[0]) + } + }) +} + +func TestMustLoad(t *testing.T) { + setEnv(t, "REQUIRED_VAR", "ok") + + defer func() { + if r := recover(); r != nil { + t.Errorf("MustLoad panicked unexpectedly: %v", r) + } + }() + + var cfg TestConfig + MustLoad(&cfg) +} + +func TestMustLoadPanic(t *testing.T) { + os.Unsetenv("REQUIRED_VAR") + + defer func() { + if r := recover(); r == nil { + t.Error("MustLoad did not panic") + } + }() + + var cfg TestConfig + MustLoad(&cfg) +} + +func TestNestedStructs(t *testing.T) { + type Database struct { + URL string `env:"URL" default:"localhost"` + } + + type App struct { + Name string `env:"NAME"` + DB Database + } + + setEnv(t, "NAME", "MyApp") + setEnv(t, "URL", "postgres://localhost:5432") + + var app App + if err := Load(&app); err != nil { + t.Fatalf("Load failed: %v", err) + } + + if app.Name != "MyApp" { + t.Errorf("Expected Name 'MyApp', got %q", app.Name) + } + if app.DB.URL != "postgres://localhost:5432" { + t.Errorf("Expected DB.URL 'postgres://localhost:5432', got %q", app.DB.URL) + } +} + +// Custom type implementing TextUnmarshaler +type CustomIP struct { + Value string +} + +func (c *CustomIP) UnmarshalText(text []byte) error { + c.Value = "IP:" + string(text) + return nil +} + +func TestCustomUnmarshaler(t *testing.T) { + type Config struct { + IP CustomIP `env:"IP"` + } + + setEnv(t, "IP", "1.2.3.4") + + var cfg Config + if err := Load(&cfg); err != nil { + t.Fatalf("Load failed: %v", err) + } + + if cfg.IP.Value != "IP:1.2.3.4" { + t.Errorf("Expected 'IP:1.2.3.4', got %q", cfg.IP.Value) + } +} + +// Helper +func setEnv(t *testing.T, key, value string) { + _ = os.Setenv(key, value) + t.Cleanup(func() { + _ = os.Unsetenv(key) + }) +} + +func TestMustLoadWithPrefix(t *testing.T) { + setEnv(t, "APP_REQUIRED_VAR", "ok") + + defer func() { + if r := recover(); r != nil { + t.Errorf("MustLoadWithPrefix panicked unexpectedly: %v", r) + } + }() + + var cfg TestConfig + MustLoadWithPrefix(&cfg, "APP") +} diff --git a/registry.go b/registry.go new file mode 100644 index 0000000..70df195 --- /dev/null +++ b/registry.go @@ -0,0 +1,118 @@ +package dotenvgo + +import ( + "fmt" + "reflect" + "strconv" + "strings" + "sync" + "time" +) + +var ( + registryMu sync.RWMutex + registry = make(map[reflect.Type]func(string) (any, error)) +) + +func init() { + // String + 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) { + v, err := strconv.ParseInt(s, 10, 8) + return int8(v), err + }) + RegisterParser(func(s string) (int16, error) { + v, err := strconv.ParseInt(s, 10, 16) + return int16(v), err + }) + 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) }) + + // Unsigned Integers + RegisterParser(func(s string) (uint, error) { + v, err := strconv.ParseUint(s, 10, 64) + return uint(v), err + }) + RegisterParser(func(s string) (uint8, error) { + v, err := strconv.ParseUint(s, 10, 8) + return uint8(v), err + }) + RegisterParser(func(s string) (uint16, error) { + v, err := strconv.ParseUint(s, 10, 16) + return uint16(v), err + }) + 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) }) + + // Floats + 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) }) + + // Bool + RegisterParser(func(s string) (bool, error) { + switch strings.ToLower(s) { + case "true", "1", "yes", "on", "y": + return true, nil + case "false", "0", "no", "off", "n": + return false, nil + default: + return false, fmt.Errorf("invalid boolean value: %q", s) + } + }) + + // Time Duration + RegisterParser(time.ParseDuration) + + // Time Location + RegisterParser(time.LoadLocation) + + // String Slice + RegisterParser(func(s string) ([]string, error) { + if s == "" { + return []string{}, nil + } + parts := strings.Split(s, ",") + result := make([]string, 0, len(parts)) + for _, p := range parts { + if trimmed := strings.TrimSpace(p); trimmed != "" { + result = append(result, trimmed) + } + } + return result, nil + }) +} + +// 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() + + var zero T + t := reflect.TypeOf(zero) + + registry[t] = func(s string) (any, error) { + return parser(s) + } +} + +// 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() + + parser, ok := registry[t] + return parser, ok +} diff --git a/registry_test.go b/registry_test.go new file mode 100644 index 0000000..104cf88 --- /dev/null +++ b/registry_test.go @@ -0,0 +1,128 @@ +package dotenvgo + +import ( + "reflect" + "testing" +) + +type CustomType int + +func TestRegistry(t *testing.T) { + // 1. Initial State + typ := reflect.TypeOf(CustomType(0)) + if _, ok := getParser(typ); ok { + t.Fatal("CustomType should not have a parser yet") + } + + // 2. Register Parser + RegisterParser(func(s string) (CustomType, error) { + if s == "valid" { + return CustomType(1), nil + } + return 0, nil + }) + + // 3. Verify Registration + parser, ok := getParser(typ) + if !ok { + t.Fatal("CustomType should have a parser now") + } + + // 4. Test Parser + val, err := parser("valid") + if err != nil { + t.Fatalf("Parser failed: %v", err) + } + + if ct, ok := val.(CustomType); !ok || ct != 1 { + t.Errorf("Expected CustomType(1), got %v", val) + } +} + +func TestConcurrentAccess(t *testing.T) { + // Run parallel tests to check for race conditions in registry + // Note: RegisterParser locks, getParser Rlocks. + // This test just ensures no panics under basic concurrent load. + type concurrentType int + + for i := 0; i < 10; i++ { + t.Run("Concurrent", func(t *testing.T) { + t.Parallel() + + // Read + _, _ = getParser(reflect.TypeOf(concurrentType(0))) + + // Write (registering same type repeated not ideal but safe) + RegisterParser(func(s string) (concurrentType, error) { return 0, nil }) + }) + } +} + +func TestRegisteredParsers(t *testing.T) { + tests := []struct { + typ reflect.Type + 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 + } + + for _, tc := range tests { + parser, ok := getParser(tc.typ) + if !ok { + t.Errorf("No parser for type %v", tc.typ) + continue + } + + val, err := parser(tc.input) + if tc.expected == nil { + if err == nil { + t.Errorf("Expected error for input %q type %v", tc.input, tc.typ) + } + } else { + if err != nil { + t.Errorf("Parser failed for input %q type %v: %v", tc.input, tc.typ, err) + } + if val != tc.expected { + t.Errorf("Expected %v, got %v", tc.expected, val) + } + } + } + + // Test Slices + sliceTyp := reflect.TypeOf([]string{}) + parser, _ := getParser(sliceTyp) + + v, _ := parser("a,b, c") + s := v.([]string) + if len(s) != 3 || s[0] != "a" || s[2] != "c" { + t.Errorf("Slice parser failed: %v", s) + } + + v, _ = parser("") + s = v.([]string) + if len(s) != 0 { + t.Errorf("Empty slice parser failed") + } +}