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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 10 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,14 @@

# Features

- 🔐 **Strongly typed getters** - `int`, `bool`, `float`, `duration`, slices, maps
- 🧯 **Safe fallbacks** - never panic, never accidentally empty
- 🌎 **Application environment helpers** - `dev`, `local`, `prod`
- 🧩 **Minimal dependencies** - Pure Go, lightweight, minimal surface area
- 🧭 **Framework-agnostic** - works with any Go app
- 📐 **Enum validation** - constrain values with allowed sets
- 🧼 **Predictable behavior** - no magic, no global state surprises
- 🧱 **Composable building block** - ideal for config structs and startup wiring
- **Strongly typed getters** - `int`, `bool`, `float`, `duration`, slices, maps
- **Safe fallbacks** - never panic, never accidentally empty
- **Application environment helpers** - `dev`, `local`, `prod`
- **Minimal dependencies** - Pure Go, lightweight, minimal surface area
- **Framework-agnostic** - works with any Go app
- **Enum validation** - constrain values with allowed sets
- **Predictable behavior** - no magic, no global state surprises
- **Composable building block** - ideal for config structs and startup wiring

## Why env?

Expand Down Expand Up @@ -379,12 +379,13 @@ env.Dump(env.IsEnvLoaded())

### <a id="loadenvfileifexists"></a>LoadEnvFileIfExists · mutates-process-env

LoadEnvFileIfExists loads .env/.env.testing/.env.host when present.
LoadEnvFileIfExists loads .env/.env.testing/.env.host when present (.env first, .env.testing overlays during tests).

_Example: test-specific env file_

```go
tmp, _ := os.MkdirTemp("", "envdoc")
_ = os.WriteFile(filepath.Join(tmp, ".env"), []byte("PORT=8080\nAPP_DEBUG=0"), 0o644)
_ = os.WriteFile(filepath.Join(tmp, ".env.testing"), []byte("PORT=9090\nAPP_DEBUG=0"), 0o644)
_ = os.Chdir(tmp)
_ = os.Setenv("APP_ENV", env.Testing)
Expand Down
1 change: 1 addition & 0 deletions examples/loadenvfileifexists/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ func main() {

// Example: test-specific env file
tmp, _ := os.MkdirTemp("", "envdoc")
_ = os.WriteFile(filepath.Join(tmp, ".env"), []byte("PORT=8080\nAPP_DEBUG=0"), 0o644)
_ = os.WriteFile(filepath.Join(tmp, ".env.testing"), []byte("PORT=9090\nAPP_DEBUG=0"), 0o644)
_ = os.Chdir(tmp)
_ = os.Setenv("APP_ENV", env.Testing)
Expand Down
87 changes: 59 additions & 28 deletions loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ var envLoaded = false
// Behavior:
// - Sets APP_ENV=local on macOS/Windows when unset.
// - Chooses .env.testing when APP_ENV indicates tests (or Go test flags are present).
// - Loads .env first when present; .env.testing overlays in testing.
// - Loads .env.host for host-to-container networking when running on the host or DinD.
// - Idempotent: subsequent calls no-op after the first load.
//
Expand All @@ -55,39 +56,43 @@ var envLoaded = false
func LoadEnvFileIfExists() error {
_ = os.Setenv("APP_ENV", Local)

if !envLoaded {
// use dev or testing envs depending on the environment
envLoadMsg := ""
envFile := fileEnv
if IsAppEnvTesting() {
envFile = envFileTesting
}
// avoid re-loading env files
if envLoaded {
return nil
}

// load top-level .env
if loadEnvFile(envFile) {
envLoadMsg = fmt.Sprintf("[LoadEnv] APP_ENV [%v] ENV_FILE [%v]", os.Getenv("APP_ENV"), envFile)
}
// load base env first; layer testing/host overrides afterward
var loadedFiles []string

// load top-level .env
if ok, path := loadEnvFile(fileEnv); ok {
loadedFiles = append(loadedFiles, path)
}

// display env: [LoadEnv] APP_ENV [local] ENV_FILE [.env]
if GetInt("APP_DEBUG", "0") >= 3 {
fmt.Println(envLoadMsg)
// search for global .env.host
// we're likely talking from host -> container network
// used from IDEs
if IsHostEnvironment() || IsDockerInDocker() {
if ok, path := loadEnvFile(fileEnvHost); ok {
loadedFiles = append(loadedFiles, path)
}
}

envLoaded = true

// search for global .env.host
// we're likely talking from host -> container network
// used from IDEs
if IsHostEnvironment() || IsDockerInDocker() {
env := fileEnvHost
if loadEnvFile(env) {
if GetInt("APP_DEBUG", "0") > 0 {
fmt.Println(fmt.Sprintf("Loaded environment [env] APP_ENV [%v] ENV_FILE [%v]", os.Getenv("APP_ENV"), env))
}
}
// use dev or testing envs depending on the environment
if IsAppEnvTesting() {
if ok, path := loadEnvFile(envFileTesting); ok {
loadedFiles = append(loadedFiles, path)
}
}

// display loaded env files
if GetInt("APP_DEBUG", "0") >= 3 {
printLoadedEnvFiles(loadedFiles)
}

// mark as loaded
envLoaded = true

return nil
}

Expand All @@ -107,7 +112,7 @@ func IsEnvLoaded() bool {

// searches for .env file through directory traversal
// loads .env file if found
func loadEnvFile(envFile string) bool {
func loadEnvFile(envFile string) (bool, string) {
var path string
found := false
for i := 0; i < MaxDirectorySeekLevels; i++ {
Expand All @@ -125,5 +130,31 @@ func loadEnvFile(envFile string) bool {
}
}

return found
return found, path
}

// ANSI color codes
const (
colorGray = "\033[90m"
colorReset = "\033[0m"
)

// debugMark returns a gray dot symbol for debug output.
func debugMark() string {
return colorMark(colorGray, "·")
}

// colorMark wraps a symbol in the provided ANSI color.
func colorMark(color, symbol string) string {
return fmt.Sprintf("%s%s%s", color, symbol, colorReset)
}

// printLoadedEnvFiles outputs loaded env files to stdout
func printLoadedEnvFiles(paths []string) {
if len(paths) == 0 {
return
}
for _, path := range paths {
fmt.Printf(" %s .env file loader · env [%v] file [%v]\n", debugMark(), os.Getenv("APP_ENV"), path)
}
}
59 changes: 58 additions & 1 deletion loader_test.go
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
package env

import (
"io"
"os"
"strings"
"testing"
)

func TestLoadEnvFileIfExists_testingEnv(t *testing.T) {
tempDir := t.TempDir()
dotEnvFile := tempDir + "/.env.testing"
baseEnvFile := tempDir + "/.env"

// Write mock .env.testing
err := os.WriteFile(dotEnvFile, []byte("FAKE_ENV_TESTING=testing_value\nAPP_DEBUG=0"), 0644)
if err != nil {
t.Fatalf("Failed to create temp .env.testing: %v", err)
}
if err := os.WriteFile(baseEnvFile, []byte("FAKE_ENV_BASE=base_value\nFAKE_ENV_TESTING=base_override\n"), 0644); err != nil {
t.Fatalf("Failed to create temp .env: %v", err)
}

// Save original working dir to restore later
originalDir, err := os.Getwd()
Expand Down Expand Up @@ -42,6 +48,10 @@ func TestLoadEnvFileIfExists_testingEnv(t *testing.T) {
if val != "testing_value" {
t.Errorf("Expected FAKE_ENV_TESTING to be 'testing_value', got %s", val)
}
baseVal := os.Getenv("FAKE_ENV_BASE")
if baseVal != "base_value" {
t.Errorf("Expected FAKE_ENV_BASE to be 'base_value', got %s", baseVal)
}

if !IsEnvLoaded() {
t.Error("Expected IsEnvLoaded to return true")
Expand Down Expand Up @@ -106,7 +116,7 @@ func TestLoadEnvFileIfExists_WithDotEnvHostBranch(t *testing.T) {
}

func TestLoadEnvFile_NotFound(t *testing.T) {
if loadEnvFile("does-not-exist") {
if ok, _ := loadEnvFile("does-not-exist"); ok {
t.Fatalf("expected false when file missing")
}
}
Expand All @@ -123,3 +133,50 @@ func TestLoadEnvFile_PanicsOnBadFile(t *testing.T) {
loadEnvFile(".env.testing")
})
}

func TestPrintLoadedEnvFiles_NoPaths(t *testing.T) {
output := captureStdout(t, func() {
printLoadedEnvFiles(nil)
})
if output != "" {
t.Fatalf("expected no output, got %q", output)
}
}

func TestPrintLoadedEnvFiles_WithPaths(t *testing.T) {
t.Setenv("APP_ENV", Testing)
output := captureStdout(t, func() {
printLoadedEnvFiles([]string{"./.env", "./.env.testing"})
})
if !strings.Contains(output, "env [testing]") {
t.Fatalf("expected output to include APP_ENV, got %q", output)
}
if !strings.Contains(output, "file [./.env]") || !strings.Contains(output, "file [./.env.testing]") {
t.Fatalf("expected output to include paths, got %q", output)
}
}

func captureStdout(t *testing.T, fn func()) string {
t.Helper()
original := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("pipe: %v", err)
}
os.Stdout = w
defer func() {
os.Stdout = original
}()

done := make(chan string)
go func() {
var buf strings.Builder
_, _ = io.Copy(&buf, r)
done <- buf.String()
}()

fn()
_ = w.Close()
output := <-done
return output
}