From e994a766b38e01d1c63bc64eef9fbed831c38bb7 Mon Sep 17 00:00:00 2001 From: Chris Miles Date: Wed, 15 Jul 2026 22:31:07 +0000 Subject: [PATCH 1/3] feat: harden environment loading --- .github/workflows/test.yml | 59 +- README.md | 403 +++++++----- app.go | 28 +- app_test.go | 11 + container.go | 44 +- container_test.go | 67 +- doc.go | 3 + docs/examplegen/main.go | 199 +++++- docs/readme/main.go | 68 +- dump.go | 24 +- dump_test.go | 36 +- env.go | 49 +- env_test.go | 85 ++- examples/arch/main.go | 3 + examples/child/main.go | 6 +- examples/childnames/main.go | 6 +- examples/dump/main.go | 5 +- examples/example_compile_test.go | 48 +- examples/get/main.go | 6 +- examples/getappenv/main.go | 6 +- examples/getbool/main.go | 6 +- examples/getduration/main.go | 6 +- examples/getenum/main.go | 8 +- examples/getfloat/main.go | 6 +- examples/getint/main.go | 6 +- examples/getint64/main.go | 6 +- examples/getmap/main.go | 8 +- examples/getmapint/main.go | 6 +- examples/getslice/main.go | 6 +- examples/getuint/main.go | 6 +- examples/getuint64/main.go | 6 +- examples/isappenv/main.go | 6 +- examples/isappenvlocal/main.go | 6 +- examples/isappenvlocalorstaging/main.go | 6 +- examples/isappenvproduction/main.go | 6 +- examples/isappenvstaging/main.go | 6 +- examples/isappenvtesting/main.go | 6 +- examples/isappenvtestingorlocal/main.go | 6 +- examples/isbsd/main.go | 3 + examples/iscontainer/main.go | 3 + examples/iscontaineros/main.go | 3 + examples/isdocker/main.go | 3 + examples/isdockerhost/main.go | 3 + examples/isdockerindocker/main.go | 3 + examples/isenvloaded/main.go | 5 +- examples/ishostenvironment/main.go | 3 + examples/iskubernetes/main.go | 3 + examples/islinux/main.go | 3 + examples/ismac/main.go | 3 + examples/isunix/main.go | 3 + examples/iswindows/main.go | 3 + examples/kitchensink/main.go | 1 + examples/load/main.go | 24 +- examples/loadenvfileifexists/main.go | 3 + examples/mustget/main.go | 6 +- examples/mustgetbool/main.go | 8 +- examples/mustgetint/main.go | 8 +- examples/os/main.go | 3 + examples/reload/main.go | 23 +- examples/setappenv/main.go | 3 + examples/setappenvlocal/main.go | 3 + examples/setappenvproduction/main.go | 3 + examples/setappenvstaging/main.go | 3 + examples/setappenvtesting/main.go | 3 + examples/tools.go | 1 + examples/withprefix/main.go | 6 +- generate.go | 4 + godump_dep_test.go | 6 - host.go | 7 +- host_test.go | 1 + loader.go | 515 ++++++++++++---- loader_test.go | 787 +++++++++++++++--------- runtime_test.go | 10 +- scope.go | 37 ++ scope_test.go | 174 ++++-- 75 files changed, 2119 insertions(+), 829 deletions(-) create mode 100644 doc.go create mode 100644 generate.go delete mode 100644 godump_dep_test.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fa99c4f..9989203 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,13 +2,22 @@ name: Go Test on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] + +permissions: + contents: read jobs: - test: + quality: + name: Go ${{ matrix.go-version }} runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + go-version: ["1.24.x", "stable"] steps: - name: Checkout @@ -17,26 +26,42 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "stable" + go-version: ${{ matrix.go-version }} + cache-dependency-path: | + go.sum + examples/go.sum - - name: Install dependencies - run: go mod tidy + - name: Verify module metadata + run: | + go mod tidy -diff + cd examples + go mod tidy -diff - - name: Run root module tests - run: go test ./... -v + - name: Vet root module + run: go vet ./... - - name: Install examples module dependencies - working-directory: examples - run: go mod tidy + - name: Test root module with race detection + run: go test -race -covermode=atomic -coverprofile=coverage.txt ./... - - name: Run examples module tests - working-directory: examples - run: go test ./... -v + - name: Enforce coverage floor + run: | + total="$(go tool cover -func=coverage.txt | awk '/^total:/ {gsub("%", "", $3); print $3}')" + awk -v total="$total" 'BEGIN { if (total < 95) { printf "coverage %.1f%% is below 95%%\n", total; exit 1 } }' - - name: Run tests with coverage - run: go test -coverprofile=coverage.txt + - name: Verify generated documentation + if: matrix.go-version == 'stable' + run: | + go generate ./... + git diff --exit-code - - name: Upload results to Codecov + - name: Build generated examples + working-directory: examples + run: go test ./... + + - name: Upload coverage + if: matrix.go-version == 'stable' uses: codecov/codecov-action@v5 + with: + files: coverage.txt env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/README.md b/README.md index 3bca040..02ec94f 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Go Reference License: MIT Go Test - Go version + Go version Latest tag Go Report Card @@ -21,12 +21,12 @@ **env** provides strongly-typed access to environment variables with predictable fallbacks. Eliminate string parsing, centralize app environment checks, and keep configuration boring. Designed to feel native to Go - and invisible when things are working. - **Strongly typed getters** - `int`, `bool`, `float`, `duration`, slices, maps -- **Safe fallbacks** - never panic, never accidentally empty +- **Explicit fallback and required-value APIs** - fallback getters stay permissive; `MustGet*` panics on missing or invalid required values - **Application environment helpers** - `local`, `staging`, `production` - **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 +- **Transactional env loading** - discovery, parsing, and process updates succeed together or leave the prior environment intact - **Composable building block** - ideal for config structs and startup wiring ## Why env? @@ -44,7 +44,7 @@ env solves this by providing typed accessors with fallbacks, so configuration st ```bash go get github.com/goforj/env/v2 -```` +``` ## Quickstart @@ -65,18 +65,18 @@ func init() { } func main() { - addr := env.Get("ADDR", "127.0.0.1:8080") - debug := env.GetBool("DEBUG", "false") - timeout := env.GetDuration("HTTP_TIMEOUT", "5s") - - env.Dump(addr, debug, timeout) - // #string "127.0.0.1:8080" - // #bool false - // #time.Duration 5s - - env.Dump("container?", env.IsContainer()) - // #string "container?" - // #bool false + addr := env.Get("ADDR", "127.0.0.1:8080") + debug := env.GetBool("DEBUG", "false") + timeout := env.GetDuration("HTTP_TIMEOUT", "5s") + + env.Dump(addr, debug, timeout) + // #string "127.0.0.1:8080" + // #bool false + // #time.Duration 5s + + env.Dump("container?", env.IsContainer()) + // #string "container?" + // #bool false } ``` @@ -140,14 +140,36 @@ See [examples/kitchensink/main.go](examples/kitchensink/main.go) for a runnable ## Environment loading -Load loads env files in this order: +`Load` searches for and applies env files in this order: - `.env` - `.env.local`, `.env.staging`, or `.env.production`, based on `APP_ENV` (`local` by default) -- `.env.testing` when running under tests - `.env.host` when running on the host or DinD +- `.env.testing` when `APP_ENV=testing` or the process has Go test markers + +Each filename is discovered independently, starting in the working directory and checking at most nine ancestors. The nearest regular file wins; regular-file symlinks are followed. Later layers override earlier ones, and files override ambient process values. + +Discovery and parsing finish before any process mutation. `Load` returns filesystem and parse errors instead of panicking, rolls back a failed environment update, and becomes a no-op after its first success. A failed call leaves `IsEnvLoaded` false and preserves the prior process environment. + +`Load`, `Reload`, and `IsEnvLoaded` synchronize with one another. Direct `os.Setenv` or `os.Unsetenv` calls are outside that lock, so applications that mutate the same keys concurrently must coordinate those writes themselves. + +`Reload` always rediscovers the selected files. Keys previously loaded from files remain file-owned, so runtime edits to those keys are replaced. If a key disappears from every file, its exact pre-load ambient state is restored, including the difference between unset and explicitly empty. Unrelated variables are untouched. A failed reload preserves the last successful configuration. + +When no file owns `APP_ENV`, a caller-provided value selects the application layer; otherwise `APP_ENV` defaults to `local`. A file-owned `APP_ENV` is refreshed before layer selection on reload. + +### v2.4 behavior notes + +The public v2 call shapes are unchanged. The quality pass makes previously implicit failure and reload behavior explicit: + +- `Load` and `Reload` return dotenv, filesystem, and environment-application errors rather than panicking. +- `Reload` restores removed file keys to their pre-first-load ambient values instead of blindly unsetting them. +- `MustGetInt` and `MustGetBool` now honor their documented contract and panic for missing or invalid values. +- `GetUint` parses at the platform's native `uint` width, and `GetMap` trims keys and values around `=`. +- `LoadEnvFileIfExists` remains a compatibility alias for `Load`. -Later files override earlier ones. Repeated calls are no-ops. +## Debug output and secrets + +`Dump` intentionally prints the raw values passed to it and performs no redaction. Never pass credentials, tokens, private keys, or other secrets. Loader diagnostics (`ENV_DEBUG=3`) print only selected file paths and `APP_ENV`, never dotenv keys or values. ## Container detection @@ -156,18 +178,12 @@ Later files override earlier ones. Repeated calls are no-ops. | IsDocker | /.dockerenv or Docker cgroup markers | Generic Docker container | | IsDockerInDocker | /.dockerenv and docker.sock | Inner DinD container | | IsDockerHost | docker.sock present, no container cgroups | Host or DinD outer acting as host | -| IsContainer | Any common container signals (Docker, containerd, kube env/cgroup) | General container detection | +| IsContainer | Any common container signals (Docker, containerd, Podman marker/cgroup, kube env/cgroup) | General container detection | | IsKubernetes | KUBERNETES_SERVICE_HOST or kubepods cgroup | Inside a Kubernetes pod | ## Runnable examples -Every function has a corresponding runnable example under [`./examples`](./examples). - -These examples are **generated directly from the documentation blocks** of each function, ensuring the docs and code never drift. These are the same examples you see here in the README and GoDoc. - -An automated test executes **every example** to verify it builds and runs successfully. - -This guarantees all examples are valid, up-to-date, and remain functional as the API evolves. +Documented examples are generated directly from function documentation into [`./examples`](./examples), so the README, GoDoc, and example programs share one source. CI regenerates them to detect drift and builds every generated program without build tags. Examples that intentionally demonstrate panic behavior are compiled rather than executed. ## Environment file loading @@ -179,7 +195,7 @@ It is intentionally composed into the runtime detection and APP_ENV model rather **env** is part of the **GoForj toolchain** - a collection of focused, composable packages designed to make building Go applications *satisfying*. -No magic. No globals. No surprises. +Small APIs. Explicit process mutation. Predictable failure modes. @@ -191,9 +207,8 @@ No magic. No globals. No surprises. | **Container detection** | [IsContainer](#iscontainer) · [IsDocker](#isdocker) · [IsDockerHost](#isdockerhost) · [IsDockerInDocker](#isdockerindocker) · [IsHostEnvironment](#ishostenvironment) · [IsKubernetes](#iskubernetes) | | **Debugging** | [Dump](#dump) | | **Environment loading** | [IsEnvLoaded](#isenvloaded) · [Load](#load) · [LoadEnvFileIfExists](#loadenvfileifexists) · [Reload](#reload) | -| **Other** | [Get](#get) · [GetBool](#getbool) · [GetDuration](#getduration) · [GetEnum](#getenum) · [GetFloat](#getfloat) · [GetInt](#getint) · [GetInt64](#getint64) · [GetMap](#getmap) · [GetMapInt](#getmapint) · [GetSlice](#getslice) · [GetUint](#getuint) · [GetUint64](#getuint64) · [Key](#key) | | **Runtime** | [Arch](#arch) · [IsBSD](#isbsd) · [IsContainerOS](#iscontaineros) · [IsLinux](#islinux) · [IsMac](#ismac) · [IsUnix](#isunix) · [IsWindows](#iswindows) · [OS](#os) | -| **Typed getters** | [Child](#child) · [ChildNames](#childnames) · [MustGet](#mustget) · [MustGetBool](#mustgetbool) · [MustGetInt](#mustgetint) · [WithPrefix](#withprefix) | +| **Typed getters** | [Get](#get) · [GetBool](#getbool) · [GetDuration](#getduration) · [GetEnum](#getenum) · [GetFloat](#getfloat) · [GetInt](#getint) · [GetInt64](#getint64) · [GetMap](#getmap) · [GetMapInt](#getmapint) · [GetSlice](#getslice) · [GetUint](#getuint) · [GetUint64](#getuint64) · [MustGet](#mustget) · [MustGetBool](#mustgetbool) · [MustGetInt](#mustgetint) · [Scope.Child](#scope-child) · [Scope.ChildNames](#scope-childnames) · [Scope.Get](#scope-get) · [Scope.GetBool](#scope-getbool) · [Scope.GetDuration](#scope-getduration) · [Scope.GetEnum](#scope-getenum) · [Scope.GetFloat](#scope-getfloat) · [Scope.GetInt](#scope-getint) · [Scope.GetInt64](#scope-getint64) · [Scope.GetMap](#scope-getmap) · [Scope.GetMapInt](#scope-getmapint) · [Scope.GetSlice](#scope-getslice) · [Scope.GetUint](#scope-getuint) · [Scope.GetUint64](#scope-getuint64) · [Scope.Key](#scope-key) · [WithPrefix](#withprefix) | ## Application environment @@ -422,7 +437,7 @@ env.Dump(env.IsKubernetes()) ### Dump -Dump is a convenience function that calls godump.Dump. +Dump writes complete representations of its arguments to standard output. _Example: integers_ @@ -451,7 +466,7 @@ env.Dump("status", map[string]int{"ok": 1, "fail": 0}) ### IsEnvLoaded -IsEnvLoaded reports whether Load or LoadEnvFileIfExists was executed in this process. +IsEnvLoaded reports whether a Load or Reload completed successfully in this process. ```go env.Dump(env.IsEnvLoaded()) @@ -461,15 +476,19 @@ env.Dump(env.IsEnvLoaded()) ### Load -Load loads .env with optional layering for .env.local/.env.staging/.env.production, -plus .env.testing/.env.host when present. It only applies once per process; -subsequent calls return without reloading because the result is cached. Use -Reload to re-read env files after the first load. +Load loads the nearest env files with deterministic layering. + +Load applies once per process. Files override ambient values, and later files override earlier +files. Discovery and parsing complete before the process environment changes; errors leave both +the environment and loader state unchanged. Use Reload to re-read files. _Example: test-specific env file_ ```go tmp, _ := os.MkdirTemp("", "envdoc") +defer os.RemoveAll(tmp) +originalDirectory, _ := os.Getwd() +defer os.Chdir(originalDirectory) _ = os.WriteFile(filepath.Join(tmp, ".env.testing"), []byte("PORT=9090\nENV_DEBUG=0"), 0o644) _ = os.Chdir(tmp) _ = os.Setenv("APP_ENV", env.Testing) @@ -479,15 +498,6 @@ env.Dump(os.Getenv("PORT")) // #string "9090" ``` -_Example: default .env on a host_ - -```go -_ = os.WriteFile(".env", []byte("SERVICE=api\nENV_DEBUG=3"), 0o644) -_ = env.Load() -env.Dump(os.Getenv("SERVICE")) -// #string "api" -``` - ### LoadEnvFileIfExists LoadEnvFileIfExists is a compatibility alias for Load. @@ -498,25 +508,120 @@ _ = env.LoadEnvFileIfExists() ### Reload -Reload re-applies the same layered env loading as Load, even if Load already -ran earlier in the same process. +Reload re-discovers and transactionally reapplies env files even after Load has run. + +Keys loaded from files remain file-owned: Reload replaces runtime edits to those keys. When a +key disappears from all files, Reload restores the ambient value (including unset versus empty) +that existed before the first successful Load. Unrelated process variables are never changed. _Example: refresh changed env files_ ```go -_ = os.WriteFile(".env", []byte("SERVICE=api"), 0o644) +tmp, _ := os.MkdirTemp("", "envdoc") +defer os.RemoveAll(tmp) +originalDirectory, _ := os.Getwd() +defer os.Chdir(originalDirectory) +_ = os.Chdir(tmp) +_ = os.WriteFile(filepath.Join(tmp, ".env"), []byte("SERVICE=api"), 0o644) _ = env.Load() -_ = os.WriteFile(".env", []byte("SERVICE=worker"), 0o644) +_ = os.WriteFile(filepath.Join(tmp, ".env"), []byte("SERVICE=worker"), 0o644) _ = env.Reload() env.Dump(os.Getenv("SERVICE")) // #string "worker" ``` -## Other +## Runtime + +### Arch + +Arch returns the CPU architecture the binary is running on. + +_Example: print GOARCH_ + +```go +env.Dump(env.Arch()) +// #string "amd64" +// #string "arm64" +``` + +### IsBSD + +IsBSD reports whether the runtime OS is any BSD variant. + +```go +env.Dump(env.IsBSD()) +// #bool true (on BSD variants) +// #bool false (elsewhere) +``` + +### IsContainerOS + +IsContainerOS reports whether this OS is *typically* used as a container base. + +```go +env.Dump(env.IsContainerOS()) +// #bool true (on Linux) +// #bool false (on macOS/Windows) +``` + +### IsLinux + +IsLinux reports whether the runtime OS is Linux. + +```go +env.Dump(env.IsLinux()) +// #bool true (on Linux) +// #bool false (on other OSes) +``` + +### IsMac + +IsMac reports whether the runtime OS is macOS (Darwin). + +```go +env.Dump(env.IsMac()) +// #bool true (on macOS) +// #bool false (elsewhere) +``` + +### IsUnix + +IsUnix reports whether the OS is Unix-like. + +```go +env.Dump(env.IsUnix()) +// #bool true (on Unix-like OSes) +// #bool false (e.g., on Windows or Plan 9) +``` + +### IsWindows + +IsWindows reports whether the runtime OS is Windows. + +```go +env.Dump(env.IsWindows()) +// #bool true (on Windows) +// #bool false (elsewhere) +``` + +### OS + +OS returns the current operating system identifier. + +_Example: inspect GOOS_ + +```go +env.Dump(env.OS()) +// #string "linux" (on Linux) +// #string "darwin" (on macOS) +// #string "windows" (on Windows) +``` + +## Typed getters ### Get -Get returns the string value for key within the scope. +Get returns the environment variable for key or fallback when empty. _Example: fallback when unset_ @@ -538,7 +643,7 @@ env.Dump(host) ### GetBool -GetBool returns the bool value for key within the scope. +GetBool parses a boolean from an environment variable or fallback string. _Example: numeric truthy_ @@ -560,7 +665,7 @@ env.Dump(debug) ### GetDuration -GetDuration returns the duration value for key within the scope. +GetDuration parses a Go duration string (e.g. "5s", "10m", "1h"). _Example: override request timeout_ @@ -582,7 +687,7 @@ env.Dump(timeout) ### GetEnum -GetEnum returns the enum value for key within the scope. +GetEnum returns the environment value when allowed and fallback otherwise. _Example: accept only staged environments_ @@ -604,7 +709,7 @@ env.Dump(appEnv) ### GetFloat -GetFloat returns the float64 value for key within the scope. +GetFloat parses a float64 from an environment variable or fallback string. _Example: override threshold_ @@ -626,7 +731,7 @@ env.Dump(threshold) ### GetInt -GetInt returns the int value for key within the scope. +GetInt parses an int from an environment variable or fallback string. _Example: fallback used_ @@ -648,7 +753,7 @@ env.Dump(port) ### GetInt64 -GetInt64 returns the int64 value for key within the scope. +GetInt64 parses an int64 from an environment variable or fallback string. _Example: parse large numbers safely_ @@ -670,7 +775,7 @@ env.Dump(size) ### GetMap -GetMap returns the string map value for key within the scope. +GetMap parses trimmed key=value pairs separated by commas into a map. _Example: parse throttling config_ @@ -696,7 +801,8 @@ env.Dump(limits) ### GetMapInt -GetMapInt returns the int map value for key within the scope. +GetMapInt parses key=int pairs separated by commas into a map. +Invalid, missing, or non-positive values fall back to defaultValue. _Example: parse worker queue weights_ @@ -727,7 +833,7 @@ env.Dump(weights) ### GetSlice -GetSlice returns the string slice value for key within the scope. +GetSlice splits a comma-separated string into a []string with trimming. _Example: trimmed addresses_ @@ -752,7 +858,7 @@ env.Dump(peers) ### GetUint -GetUint returns the uint value for key within the scope. +GetUint parses a uint from an environment variable or fallback string. _Example: defaults to fallback when missing_ @@ -774,7 +880,7 @@ env.Dump(workers) ### GetUint64 -GetUint64 returns the uint64 value for key within the scope. +GetUint64 parses a uint64 from an environment variable or fallback string. _Example: high range values_ @@ -794,100 +900,67 @@ env.Dump(maxItems) // #uint64 100 ``` -### Key - -Key builds the fully qualified environment key for key within the scope. - -## Runtime - -### Arch +### MustGet -Arch returns the CPU architecture the binary is running on. +MustGet returns the value of key or panics if missing/empty. -_Example: print GOARCH_ +_Example: required secret_ ```go -env.Dump(env.Arch()) -// #string "amd64" -// #string "arm64" +_ = os.Setenv("API_SECRET", "s3cr3t") +secret := env.MustGet("API_SECRET") +env.Dump(secret) +// #string "s3cr3t" ``` -### IsBSD - -IsBSD reports whether the runtime OS is any BSD variant. +_Example: panic on missing value_ ```go -env.Dump(env.IsBSD()) -// #bool true (on BSD variants) -// #bool false (elsewhere) +os.Unsetenv("API_SECRET") +secret = env.MustGet("API_SECRET") // panics: env variable missing: API_SECRET ``` -### IsContainerOS - -IsContainerOS reports whether this OS is *typically* used as a container base. - -```go -env.Dump(env.IsContainerOS()) -// #bool true (on Linux) -// #bool false (on macOS/Windows) -``` +### MustGetBool -### IsLinux +MustGetBool returns a required bool or panics when the value is missing or invalid. -IsLinux reports whether the runtime OS is Linux. +_Example: gate features explicitly_ ```go -env.Dump(env.IsLinux()) -// #bool true (on Linux) -// #bool false (on other OSes) +_ = os.Setenv("FEATURE_ENABLED", "true") +enabled := env.MustGetBool("FEATURE_ENABLED") +env.Dump(enabled) +// #bool true ``` -### IsMac - -IsMac reports whether the runtime OS is macOS (Darwin). +_Example: panic on invalid value_ ```go -env.Dump(env.IsMac()) -// #bool true (on macOS) -// #bool false (elsewhere) +_ = os.Setenv("FEATURE_ENABLED", "maybe") +_ = env.MustGetBool("FEATURE_ENABLED") // panics when parsing ``` -### IsUnix - -IsUnix reports whether the OS is Unix-like. - -```go -env.Dump(env.IsUnix()) -// #bool true (on Unix-like OSes) -// #bool false (e.g., on Windows or Plan 9) -``` +### MustGetInt -### IsWindows +MustGetInt returns a required int or panics when the value is missing or invalid. -IsWindows reports whether the runtime OS is Windows. +_Example: ensure numeric port_ ```go -env.Dump(env.IsWindows()) -// #bool true (on Windows) -// #bool false (elsewhere) +_ = os.Setenv("PORT", "8080") +port := env.MustGetInt("PORT") +env.Dump(port) +// #int 8080 ``` -### OS - -OS returns the current operating system identifier. - -_Example: inspect GOOS_ +_Example: panic on bad value_ ```go -env.Dump(env.OS()) -// #string "linux" (on Linux) -// #string "darwin" (on macOS) -// #string "windows" (on Windows) +_ = os.Setenv("PORT", "not-a-number") +_ = env.MustGetInt("PORT") // panics when parsing ``` -## Typed getters - -### Child +### Scope.Child Child returns a new scope rooted at the current prefix plus name. @@ -905,7 +978,7 @@ env.Dump( // #string "storage/app/public" ``` -### ChildNames +### Scope.ChildNames ChildNames discovers named child scopes under the current prefix. @@ -931,65 +1004,57 @@ env.Dump(names) // ] ``` -### MustGet +### Scope.Get -MustGet returns the value of key or panics if missing/empty. +Get returns the string value for key within the scope. -_Example: required secret_ +### Scope.GetBool -```go -_ = os.Setenv("API_SECRET", "s3cr3t") -secret := env.MustGet("API_SECRET") -env.Dump(secret) -// #string "s3cr3t" -``` +GetBool returns the bool value for key within the scope. -_Example: panic on missing value_ +### Scope.GetDuration -```go -os.Unsetenv("API_SECRET") -secret = env.MustGet("API_SECRET") // panics: env variable missing: API_SECRET -``` +GetDuration returns the duration value for key within the scope. -### MustGetBool +### Scope.GetEnum -MustGetBool panics if missing or invalid. +GetEnum returns the enum value for key within the scope. -_Example: gate features explicitly_ +### Scope.GetFloat -```go -_ = os.Setenv("FEATURE_ENABLED", "true") -enabled := env.MustGetBool("FEATURE_ENABLED") -env.Dump(enabled) -// #bool true -``` +GetFloat returns the float64 value for key within the scope. -_Example: panic on invalid value_ +### Scope.GetInt -```go -_ = os.Setenv("FEATURE_ENABLED", "maybe") -_ = env.MustGetBool("FEATURE_ENABLED") // panics when parsing -``` +GetInt returns the int value for key within the scope. -### MustGetInt +### Scope.GetInt64 -MustGetInt panics if the value is missing or not an int. +GetInt64 returns the int64 value for key within the scope. -_Example: ensure numeric port_ +### Scope.GetMap -```go -_ = os.Setenv("PORT", "8080") -port := env.MustGetInt("PORT") -env.Dump(port) -// #int 8080 -``` +GetMap returns the string map value for key within the scope. -_Example: panic on bad value_ +### Scope.GetMapInt -```go -_ = os.Setenv("PORT", "not-a-number") -_ = env.MustGetInt("PORT") // panics when parsing -``` +GetMapInt returns the int map value for key within the scope. + +### Scope.GetSlice + +GetSlice returns the string slice value for key within the scope. + +### Scope.GetUint + +GetUint returns the uint value for key within the scope. + +### Scope.GetUint64 + +GetUint64 returns the uint64 value for key within the scope. + +### Scope.Key + +Key builds the fully qualified environment key for key within the scope. ### WithPrefix diff --git a/app.go b/app.go index 4c69d11..670c528 100644 --- a/app.go +++ b/app.go @@ -7,11 +7,14 @@ import ( "strings" ) -// environment helpers const ( - Testing = "testing" - Local = "local" - Staging = "staging" + // Testing identifies the test application environment. + Testing = "testing" + // Local identifies the local development application environment. + Local = "local" + // Staging identifies the pre-production application environment. + Staging = "staging" + // Production identifies the production application environment. Production = "production" ) @@ -33,22 +36,24 @@ const ( // env.Dump(env.IsAppEnvTesting()) // // #bool false (outside of test binaries) func IsAppEnvTesting() bool { - return os.Getenv("APP_ENV") == Testing || + return isAppEnvTestingValue(os.Getenv("APP_ENV")) +} + +// isAppEnvTestingValue reports test mode while allowing the loader to evaluate a planned APP_ENV. +func isAppEnvTestingValue(appEnv string) bool { + return appEnv == Testing || flag.Lookup("test.v") != nil || isTestSuffixFromArguments() } -// isTestSuffixFromArguments checks if the test suffix is present in the command line arguments +// isTestSuffixFromArguments reports whether command-line arguments carry a Go test marker. func isTestSuffixFromArguments() bool { - anyArgumentContainsTestSuffix := false - for _, arg := range os.Args { if strings.HasSuffix(arg, ".test") || strings.HasSuffix(arg, "-test.run") { - anyArgumentContainsTestSuffix = true + return true } } - - return anyArgumentContainsTestSuffix + return false } // GetAppEnv returns the current APP_ENV (empty string if unset). @@ -224,6 +229,7 @@ func SetAppEnvTesting() error { return SetAppEnv(Testing) } +// isValidAppEnv reports whether appEnv is one of the supported application environments. func isValidAppEnv(appEnv string) bool { switch appEnv { case Testing, Local, Staging, Production: diff --git a/app_test.go b/app_test.go index efd2c6c..12e0cc5 100644 --- a/app_test.go +++ b/app_test.go @@ -5,6 +5,7 @@ import ( "testing" ) +// TestIsTestSuffixFromArguments ensures Go test-process arguments identify the testing runtime. func TestIsTestSuffixFromArguments(t *testing.T) { original := os.Args defer func() { os.Args = original }() @@ -20,6 +21,14 @@ func TestIsTestSuffixFromArguments(t *testing.T) { } } +// TestIsAppEnvTestingRecognizesTestProcess ensures test binaries are treated as testing even without APP_ENV. +func TestIsAppEnvTestingRecognizesTestProcess(t *testing.T) { + if !IsAppEnvTesting() { + t.Fatal("expected the Go test process marker to select testing") + } +} + +// TestAppEnvHelpers ensures each named application mode has an exclusive predicate. func TestAppEnvHelpers(t *testing.T) { t.Cleanup(func() { _ = os.Unsetenv("APP_ENV") }) @@ -50,6 +59,7 @@ func TestAppEnvHelpers(t *testing.T) { } } +// TestSetAppEnv ensures explicit application mode changes update process state. func TestSetAppEnv(t *testing.T) { t.Cleanup(func() { _ = os.Unsetenv("APP_ENV") }) @@ -69,6 +79,7 @@ func TestSetAppEnv(t *testing.T) { } } +// TestSetAppEnvHelpers ensures convenience setters select their documented modes. func TestSetAppEnvHelpers(t *testing.T) { t.Cleanup(func() { _ = os.Unsetenv("APP_ENV") }) diff --git a/container.go b/container.go index 0fc69f0..251ca3d 100644 --- a/container.go +++ b/container.go @@ -1,8 +1,6 @@ package env -import ( - "os" -) +import "os" var ( // These are shims that tests override. @@ -12,10 +10,10 @@ var ( ) const ( - // files - fileDockerSock = "/var/run/docker.sock" - fileDockerEnv = "/.dockerenv" - fileCgroup = "/proc/1/cgroup" + fileDockerSock = "/var/run/docker.sock" + fileDockerEnv = "/.dockerenv" + fileContainerEnv = "/run/.containerenv" + fileCgroup = "/proc/1/cgroup" // cgroup names cgroupContainer = "container" @@ -37,12 +35,10 @@ const ( // env.Dump(env.IsDocker()) // // #bool false (unless inside Docker) func IsDocker() bool { - // Check /.dockerenv if _, err := statFile(fileDockerEnv); err == nil { return true } - // Check cgroup cgroup, err := readFile(fileCgroup) if err == nil && containsAny(cgroup, cgroupNameDocker, cgroupNameContainerd, cgroupNamePodman) { return true @@ -63,12 +59,10 @@ func IsDocker() bool { // // #bool true (inside DinD containers) // // #bool false (on hosts or non-DinD containers) func IsDockerInDocker() bool { - // If /.dockerenv does not exist → not a Docker *container* at all. if _, err := statFile(fileDockerEnv); err != nil { return false } - // If docker.sock exists → this IS an inner DinD container. if _, err := statFile(fileDockerSock); err == nil { return true } @@ -97,8 +91,15 @@ func IsDockerHost() bool { return false } - // Docker host should *not* have container-scoped cgroups - if !containsAny(cgroup, cgroupNameDocker, cgroupNameKube, cgroupNameContainerd) { + // Host-like cgroups distinguish an exposed daemon from an ordinary container socket mount. + if !containsAny(cgroup, + cgroupContainer, + cgroupNameDocker, + cgroupNameKube, + cgroupNameContainerd, + cgroupNamePodman, + cgroupNameLibpod, + ) { return true } @@ -115,9 +116,17 @@ func IsDockerHost() bool { // // #bool true (inside most containers) // // #bool false (on bare-metal/VM hosts) func IsContainer() bool { + return isContainerWithEnv(getEnv) +} + +// isContainerWithEnv detects containers while allowing callers to supply a coherent env view. +func isContainerWithEnv(getenv func(string) string) bool { if IsDocker() { return true } + if _, err := statFile(fileContainerEnv); err == nil { + return true + } cgroup, err := readFile(fileCgroup) if err == nil && containsAny(cgroup, @@ -129,7 +138,7 @@ func IsContainer() bool { return true } - if getEnv("KUBERNETES_SERVICE_HOST") != "" { + if getenv("KUBERNETES_SERVICE_HOST") != "" { return true } @@ -148,7 +157,12 @@ func IsContainer() bool { // // #bool true (inside Kubernetes pods) // // #bool false (elsewhere) func IsKubernetes() bool { - if getEnv("KUBERNETES_SERVICE_HOST") != "" { + return isKubernetesWithEnv(getEnv) +} + +// isKubernetesWithEnv detects Kubernetes while allowing planned loader values to be evaluated. +func isKubernetesWithEnv(getenv func(string) string) bool { + if getenv("KUBERNETES_SERVICE_HOST") != "" { return true } diff --git a/container_test.go b/container_test.go index d2fafc7..26ef9c0 100644 --- a/container_test.go +++ b/container_test.go @@ -12,20 +12,21 @@ var ( realGetEnv = getEnv ) -// Reset all shims after each test. +// reset restores process-detection shims so tests remain isolated. func reset() { statFile = realStatFile readFile = realReadFile getEnv = realGetEnv } -// Helper for tests. +// mockEnv isolates environment lookup from the host running the tests. func mockEnv(vars map[string]string) { getEnv = func(key string) string { return vars[key] } } +// TestIsDocker_DockerenvExists ensures Docker's root marker is sufficient evidence of containment. func TestIsDocker_DockerenvExists(t *testing.T) { defer reset() @@ -45,6 +46,7 @@ func TestIsDocker_DockerenvExists(t *testing.T) { } } +// TestIsDocker_CgroupDetectsContainer ensures Docker cgroup membership works when the marker file is absent. func TestIsDocker_CgroupDetectsContainer(t *testing.T) { defer reset() @@ -61,6 +63,7 @@ func TestIsDocker_CgroupDetectsContainer(t *testing.T) { } } +// TestIsDocker_False ensures clean host evidence is not misclassified as Docker. func TestIsDocker_False(t *testing.T) { defer reset() @@ -77,6 +80,7 @@ func TestIsDocker_False(t *testing.T) { } } +// TestIsDind_True ensures a mounted Docker socket inside Docker identifies nested daemon access. func TestIsDind_True(t *testing.T) { defer reset() @@ -100,6 +104,7 @@ func TestIsDind_True(t *testing.T) { } } +// TestIsDind_FalseWhenNormalContainer ensures ordinary containers without a daemon socket are not marked Docker-in-Docker. func TestIsDind_FalseWhenNormalContainer(t *testing.T) { defer reset() @@ -119,6 +124,7 @@ func TestIsDind_FalseWhenNormalContainer(t *testing.T) { } } +// TestIsDockerInDocker_NoDockerenv ensures a host socket alone does not imply nested Docker. func TestIsDockerInDocker_NoDockerenv(t *testing.T) { defer reset() @@ -129,6 +135,7 @@ func TestIsDockerInDocker_NoDockerenv(t *testing.T) { } } +// TestIsDockerInDocker_DockerenvNoSocket ensures a container marker alone does not imply nested daemon access. func TestIsDockerInDocker_DockerenvNoSocket(t *testing.T) { defer reset() @@ -144,6 +151,7 @@ func TestIsDockerInDocker_DockerenvNoSocket(t *testing.T) { } } +// TestIsDockerHost_True ensures a reachable Docker socket with host cgroups identifies a Docker host. func TestIsDockerHost_True(t *testing.T) { defer reset() @@ -163,6 +171,7 @@ func TestIsDockerHost_True(t *testing.T) { } } +// TestIsDockerHost_FalseWhenNamespaced ensures container cgroups take precedence over a mounted socket. func TestIsDockerHost_FalseWhenNamespaced(t *testing.T) { defer reset() @@ -182,6 +191,30 @@ func TestIsDockerHost_FalseWhenNamespaced(t *testing.T) { } } +// TestIsDockerHostFalseForNonDockerContainerMarkers ensures other container runtimes are not mistaken for Docker hosts. +func TestIsDockerHostFalseForNonDockerContainerMarkers(t *testing.T) { + markers := []string{"container", "kubepods", "containerd", "podman", "libpod"} + for _, marker := range markers { + t.Run(marker, func(t *testing.T) { + defer reset() + statFile = func(path string) (os.FileInfo, error) { + if path == fileDockerSock { + return nil, nil + } + return nil, os.ErrNotExist + } + readFile = func(string) ([]byte, error) { + return []byte("0::/" + marker + "/workload"), nil + } + + if IsDockerHost() { + t.Fatalf("expected %s cgroup marker to reject Docker-host detection", marker) + } + }) + } +} + +// TestIsDockerHost_NoSocket ensures host classification requires Docker daemon evidence. func TestIsDockerHost_NoSocket(t *testing.T) { defer reset() @@ -191,6 +224,7 @@ func TestIsDockerHost_NoSocket(t *testing.T) { } } +// TestIsDockerHost_ReadError ensures unreadable cgroup state fails closed. func TestIsDockerHost_ReadError(t *testing.T) { defer reset() @@ -207,6 +241,7 @@ func TestIsDockerHost_ReadError(t *testing.T) { } } +// TestIsKubernetes_EnvVar ensures the service environment marker identifies Kubernetes pods. func TestIsKubernetes_EnvVar(t *testing.T) { defer reset() @@ -219,6 +254,7 @@ func TestIsKubernetes_EnvVar(t *testing.T) { } } +// TestIsKubernetes_Cgroup ensures pod cgroup membership identifies Kubernetes without environment injection. func TestIsKubernetes_Cgroup(t *testing.T) { defer reset() @@ -233,6 +269,7 @@ func TestIsKubernetes_Cgroup(t *testing.T) { } } +// TestIsKubernetes_False ensures clean host evidence is not misclassified as Kubernetes. func TestIsKubernetes_False(t *testing.T) { defer reset() @@ -247,6 +284,7 @@ func TestIsKubernetes_False(t *testing.T) { } } +// TestIsContainer_Docker ensures Docker evidence contributes to the aggregate container result. func TestIsContainer_Docker(t *testing.T) { defer reset() @@ -262,6 +300,25 @@ func TestIsContainer_Docker(t *testing.T) { } } +// TestIsContainerPodmanMarker ensures Podman's standard marker participates in aggregate detection. +func TestIsContainerPodmanMarker(t *testing.T) { + defer reset() + + statFile = func(path string) (os.FileInfo, error) { + if path == fileContainerEnv { + return nil, nil + } + return nil, os.ErrNotExist + } + readFile = func(string) ([]byte, error) { return nil, os.ErrNotExist } + mockEnv(nil) + + if !IsContainer() { + t.Fatal("expected /run/.containerenv to identify a Podman container") + } +} + +// TestIsContainer_ContainerCgroup ensures generic container cgroups are recognized without vendor markers. func TestIsContainer_ContainerCgroup(t *testing.T) { defer reset() @@ -276,6 +333,7 @@ func TestIsContainer_ContainerCgroup(t *testing.T) { } } +// TestIsContainer_Kubernetes ensures Kubernetes evidence contributes to the aggregate container result. func TestIsContainer_Kubernetes(t *testing.T) { defer reset() @@ -288,6 +346,7 @@ func TestIsContainer_Kubernetes(t *testing.T) { } } +// TestIsContainer_False ensures an ordinary host remains outside the aggregate container classification. func TestIsContainer_False(t *testing.T) { defer reset() @@ -306,6 +365,7 @@ func TestIsContainer_False(t *testing.T) { } } +// TestIsContainer_ReadErrorButEnvPresent ensures strong environment evidence survives unavailable cgroup data. func TestIsContainer_ReadErrorButEnvPresent(t *testing.T) { defer reset() @@ -318,6 +378,7 @@ func TestIsContainer_ReadErrorButEnvPresent(t *testing.T) { } } +// TestIsContainer_ReadErrorNoEnv ensures missing evidence and unreadable cgroups fail closed. func TestIsContainer_ReadErrorNoEnv(t *testing.T) { defer reset() @@ -330,6 +391,7 @@ func TestIsContainer_ReadErrorNoEnv(t *testing.T) { } } +// TestIsContainer_EnvWinsWhenCgroupClean ensures explicit container metadata outranks neutral cgroup text. func TestIsContainer_EnvWinsWhenCgroupClean(t *testing.T) { defer reset() @@ -342,6 +404,7 @@ func TestIsContainer_EnvWinsWhenCgroupClean(t *testing.T) { } } +// TestIsContainer_GenericContainerCgroup ensures runtime-neutral cgroup markers remain supported. func TestIsContainer_GenericContainerCgroup(t *testing.T) { defer reset() diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..e1875aa --- /dev/null +++ b/doc.go @@ -0,0 +1,3 @@ +// Package env provides typed environment access, deterministic dotenv layering, +// scoped keys, and runtime detection helpers for Go applications. +package env diff --git a/docs/examplegen/main.go b/docs/examplegen/main.go index 0dda1d8..8095bd5 100644 --- a/docs/examplegen/main.go +++ b/docs/examplegen/main.go @@ -5,8 +5,10 @@ package main import ( "bytes" + "errors" "fmt" "go/ast" + "go/format" "go/parser" "go/token" "os" @@ -16,6 +18,9 @@ import ( "strings" ) +const generatedMarker = "// Code generated by docs/examplegen; DO NOT EDIT." + +// main renders the reproducible documentation artifacts for this module. func main() { if err := run(); err != nil { fmt.Println("Error:", err) @@ -24,6 +29,7 @@ func main() { fmt.Println("✔ Examples generated in ./examples/") } +// run regenerates the exact set of documented examples and removes only stale generated entrypoints. func run() error { root, err := findRoot() if err != nil { @@ -58,38 +64,64 @@ func run() error { funcs := map[string]*FuncDoc{} - for filename, file := range pkg.Files { + filenames := make([]string, 0, len(pkg.Files)) + for filename := range pkg.Files { + filenames = append(filenames, filename) + } + sort.Strings(filenames) + for _, filename := range filenames { + file := pkg.Files[filename] if strings.Contains(filename, "_test.go") { continue } - for name, fd := range extractFuncDocs(fset, filename, file) { - if existing, ok := funcs[name]; ok { + for identity, fd := range extractFuncDocs(fset, filename, file) { + if existing, ok := funcs[identity]; ok { existing.Examples = append(existing.Examples, fd.Examples...) } else { - funcs[name] = fd + funcs[identity] = fd } } } - for _, fd := range funcs { + identities := make([]string, 0, len(funcs)) + nameCounts := make(map[string]int, len(funcs)) + for identity := range funcs { + identities = append(identities, identity) + nameCounts[funcs[identity].Name]++ + } + sort.Strings(identities) + expectedExamples := make(map[string]struct{}, len(funcs)) + for _, identity := range identities { + fd := funcs[identity] + if len(fd.Examples) > 0 { + expectedExamples[exampleDirectoryName(fd, nameCounts[fd.Name] > 1)] = struct{}{} + } + } + if err := cleanGeneratedExamples(examplesDir, expectedExamples); err != nil { + return err + } + for _, identity := range identities { + fd := funcs[identity] sort.Slice(fd.Examples, func(i, j int) bool { return fd.Examples[i].Line < fd.Examples[j].Line }) - if err := writeMain(examplesDir, fd, modPath); err != nil { + if err := writeMain(examplesDir, fd, modPath, nameCounts[fd.Name] > 1); err != nil { return err } - // Debug / inspection hook (optional) - //env.Dump(fd) } return nil } +// findRoot anchors generation to the root module from either the root or docs directory. func findRoot() (string, error) { - wd, _ := os.Getwd() + wd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("get working directory: %w", err) + } if fileExists(filepath.Join(wd, "go.mod")) { return wd, nil } @@ -100,8 +132,10 @@ func findRoot() (string, error) { return "", fmt.Errorf("could not find project root") } +// fileExists lets root discovery ignore candidate paths that are not present. func fileExists(p string) bool { _, err := os.Stat(p); return err == nil } +// modulePath reads the canonical import prefix so generated code never hard-codes a checkout identity. func modulePath(root string) (string, error) { data, err := os.ReadFile(filepath.Join(root, "go.mod")) if err != nil { @@ -124,13 +158,16 @@ func modulePath(root string) (string, error) { // ------------------------------------------------------------ // +// FuncDoc captures the metadata needed to render one documented function. type FuncDoc struct { Name string + Receiver string Group string Description string Examples []Example } +// Example captures an executable snippet and its source location. type Example struct { FuncName string File string @@ -153,6 +190,7 @@ type docLine struct { pos token.Pos } +// extractFuncDocs limits generation to exported declarations and keys methods by receiver to prevent collisions. func extractFuncDocs( fset *token.FileSet, filename string, @@ -168,9 +206,14 @@ func extractFuncDocs( } name := fn.Name.Name + if !ast.IsExported(name) { + continue + } + receiver := receiverName(fn) - out[name] = &FuncDoc{ + out[funcIdentity(receiver, name)] = &FuncDoc{ Name: name, + Receiver: receiver, Group: extractGroup(fn.Doc), Description: extractFuncDescription(fn.Doc), Examples: extractBlocks(fset, filename, name, fn), @@ -180,6 +223,30 @@ func extractFuncDocs( return out } +// receiverName returns the named receiver so method docs remain distinct from root functions. +func receiverName(fn *ast.FuncDecl) string { + if fn.Recv == nil || len(fn.Recv.List) == 0 { + return "" + } + receiver := fn.Recv.List[0].Type + if pointer, ok := receiver.(*ast.StarExpr); ok { + receiver = pointer.X + } + if identifier, ok := receiver.(*ast.Ident); ok { + return identifier.Name + } + return "" +} + +// funcIdentity creates the stable key used to keep methods and functions distinct. +func funcIdentity(receiver, name string) string { + if receiver == "" { + return name + } + return receiver + "." + name +} + +// extractGroup honors the documentation grouping convention and places untagged APIs in Other. func extractGroup(group *ast.CommentGroup) string { lines := docLines(group) @@ -193,6 +260,7 @@ func extractGroup(group *ast.CommentGroup) string { return "Other" } +// extractFuncDescription stops before directives and examples so generated prose is not duplicated. func extractFuncDescription(group *ast.CommentGroup) string { lines := docLines(group) var desc []string @@ -219,6 +287,7 @@ func extractFuncDescription(group *ast.CommentGroup) string { return strings.Join(desc, "\n") } +// docLines preserves token positions while normalizing line-comment prefixes for parsing. func docLines(group *ast.CommentGroup) []docLine { var lines []docLine @@ -243,6 +312,7 @@ func docLines(group *ast.CommentGroup) []docLine { return lines } +// extractBlocks parses every labeled example in source order without interpreting its Go code. func extractBlocks( fset *token.FileSet, filename, funcName string, @@ -349,7 +419,8 @@ func selectPackage(pkgs map[string]*ast.Package) (string, error) { // ------------------------------------------------------------ // -func writeMain(base string, fd *FuncDoc, importPath string) error { +// writeMain formats one consumer-facing executable per documented API identity. +func writeMain(base string, fd *FuncDoc, importPath string, nameCollides bool) error { if len(fd.Examples) == 0 { return nil } @@ -358,16 +429,16 @@ func writeMain(base string, fd *FuncDoc, importPath string) error { return fmt.Errorf("import path cannot be empty") } - dir := filepath.Join(base, strings.ToLower(fd.Name)) + dir := filepath.Join(base, exampleDirectoryName(fd, nameCollides)) if err := os.MkdirAll(dir, 0o755); err != nil { return err } var buf bytes.Buffer - // Build tag buf.WriteString("//go:build ignore\n") buf.WriteString("// +build ignore\n\n") + buf.WriteString(generatedMarker + "\n\n") buf.WriteString("package main\n\n") @@ -393,28 +464,11 @@ func writeMain(base string, fd *FuncDoc, importPath string) error { } } - if len(imports) == 1 { - buf.WriteString("import ") - for imp := range imports { - buf.WriteString(fmt.Sprintf("%q", imp)) - } - buf.WriteString("\n\n") - } else { - buf.WriteString("import (\n") - keys := make([]string, 0, len(imports)) - for k := range imports { - keys = append(keys, k) - } - sort.Strings(keys) - for _, imp := range keys { - buf.WriteString("\t\"" + imp + "\"\n") - } - buf.WriteString(")\n\n") - } + writeImports(&buf, imports) + buf.WriteString("// main keeps this documented example executable so API drift fails during compilation.\n") buf.WriteString("func main() {\n") - // Description if fd.Description != "" { for _, line := range strings.Split(fd.Description, "\n") { buf.WriteString("\t// " + line + "\n") @@ -422,7 +476,6 @@ func writeMain(base string, fd *FuncDoc, importPath string) error { buf.WriteString("\n") } - // Examples for _, ex := range fd.Examples { if ex.Label != "" { buf.WriteString("\t// Example: " + ex.Label + "\n") @@ -441,5 +494,83 @@ func writeMain(base string, fd *FuncDoc, importPath string) error { buf.WriteString("}\n") - return os.WriteFile(filepath.Join(dir, "main.go"), buf.Bytes(), 0o644) + formatted, err := format.Source(buf.Bytes()) + if err != nil { + return fmt.Errorf("format example %s: %w", funcIdentity(fd.Receiver, fd.Name), err) + } + return os.WriteFile(filepath.Join(dir, "main.go"), formatted, 0o644) +} + +// cleanGeneratedExamples removes obsolete generated entrypoints while preserving manual examples. +func cleanGeneratedExamples(base string, expected map[string]struct{}) error { + entries, err := os.ReadDir(base) + if err != nil { + return fmt.Errorf("read examples directory: %w", err) + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + if _, keep := expected[entry.Name()]; keep { + continue + } + path := filepath.Join(base, entry.Name(), "main.go") + contents, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return fmt.Errorf("read generated example %s: %w", path, err) + } + if !bytes.Contains(contents, []byte(generatedMarker)) { + continue + } + if err := os.Remove(path); err != nil { + return fmt.Errorf("remove obsolete generated example %s: %w", path, err) + } + } + return nil +} + +// exampleDirectoryName qualifies colliding methods without renaming established unique examples. +func exampleDirectoryName(fd *FuncDoc, nameCollides bool) string { + if nameCollides && fd.Receiver != "" { + return strings.ToLower(fd.Receiver + "-" + fd.Name) + } + return strings.ToLower(fd.Name) +} + +// writeImports renders stable standard-library and third-party import groups. +func writeImports(buf *bytes.Buffer, imports map[string]bool) { + if len(imports) == 1 { + for path := range imports { + fmt.Fprintf(buf, "import %q\n\n", path) + } + return + } + + standard := make([]string, 0, len(imports)) + thirdParty := make([]string, 0, len(imports)) + for path := range imports { + firstSegment := strings.Split(path, "/")[0] + if strings.Contains(firstSegment, ".") { + thirdParty = append(thirdParty, path) + continue + } + standard = append(standard, path) + } + sort.Strings(standard) + sort.Strings(thirdParty) + + buf.WriteString("import (\n") + for _, path := range standard { + fmt.Fprintf(buf, "\t%q\n", path) + } + if len(standard) > 0 && len(thirdParty) > 0 { + buf.WriteString("\n") + } + for _, path := range thirdParty { + fmt.Fprintf(buf, "\t%q\n", path) + } + buf.WriteString(")\n\n") } diff --git a/docs/readme/main.go b/docs/readme/main.go index fa67439..9d37761 100644 --- a/docs/readme/main.go +++ b/docs/readme/main.go @@ -21,6 +21,7 @@ const ( apiEnd = "" ) +// main renders the reproducible documentation artifacts for this module. func main() { if err := run(); err != nil { fmt.Println("Error:", err) @@ -29,6 +30,7 @@ func main() { fmt.Println("✔ API section updated in README.md") } +// run replaces only the marked README API section so hand-written documentation remains untouched. func run() error { root, err := findRoot() if err != nil { @@ -62,8 +64,10 @@ func run() error { // ------------------------------------------------------------ // +// FuncDoc captures the metadata needed to render one documented function. type FuncDoc struct { Name string + Receiver string Group string Behavior string Fluent string @@ -71,6 +75,7 @@ type FuncDoc struct { Examples []Example } +// Example captures an executable snippet and its source location. type Example struct { Label string Code string @@ -90,6 +95,7 @@ var ( exampleHeader = regexp.MustCompile(`(?i)^\s*Example:\s*(.*)$`) ) +// parseFuncs collects exported functions and methods into a receiver-qualified, source-derived model. func parseFuncs(root string) ([]*FuncDoc, error) { fset := token.NewFileSet() @@ -130,6 +136,7 @@ func parseFuncs(root string) ([]*FuncDoc, error) { fd := &FuncDoc{ Name: fn.Name.Name, + Receiver: receiverName(fn), Group: extractGroup(fn.Doc), Behavior: extractBehavior(fn.Doc), Fluent: extractFluent(fn.Doc), @@ -137,10 +144,11 @@ func parseFuncs(root string) ([]*FuncDoc, error) { Examples: extractExamples(fset, fn), } - if existing, ok := funcs[fd.Name]; ok { + key := funcIdentity(fd.Receiver, fd.Name) + if existing, ok := funcs[key]; ok { existing.Examples = append(existing.Examples, fd.Examples...) } else { - funcs[fd.Name] = fd + funcs[key] = fd } } } @@ -156,6 +164,40 @@ func parseFuncs(root string) ([]*FuncDoc, error) { return out, nil } +// receiverName returns the named receiver for methods so root functions cannot overwrite them. +func receiverName(fn *ast.FuncDecl) string { + if fn.Recv == nil || len(fn.Recv.List) == 0 { + return "" + } + receiver := fn.Recv.List[0].Type + if pointer, ok := receiver.(*ast.StarExpr); ok { + receiver = pointer.X + } + if identifier, ok := receiver.(*ast.Ident); ok { + return identifier.Name + } + return "" +} + +// funcIdentity creates the stable key used to keep methods and functions distinct. +func funcIdentity(receiver, name string) string { + if receiver == "" { + return name + } + return receiver + "." + name +} + +// funcDisplayName qualifies methods for unambiguous API links and headings. +func funcDisplayName(fn *FuncDoc) string { + return funcIdentity(fn.Receiver, fn.Name) +} + +// funcAnchor converts a display name into a stable Markdown anchor. +func funcAnchor(fn *FuncDoc) string { + return strings.NewReplacer(".", "-", "_", "-").Replace(strings.ToLower(funcDisplayName(fn))) +} + +// extractGroup honors the documentation grouping convention and places untagged APIs in Other. func extractGroup(group *ast.CommentGroup) string { for _, c := range group.List { line := strings.TrimSpace(strings.TrimPrefix(c.Text, "//")) @@ -166,6 +208,7 @@ func extractGroup(group *ast.CommentGroup) string { return "Other" } +// extractBehavior normalizes behavior metadata used to classify generated API documentation. func extractBehavior(group *ast.CommentGroup) string { for _, c := range group.List { line := strings.TrimSpace(strings.TrimPrefix(c.Text, "//")) @@ -176,6 +219,7 @@ func extractBehavior(group *ast.CommentGroup) string { return "" } +// extractFluent normalizes the fluent marker used to annotate chainable APIs. func extractFluent(group *ast.CommentGroup) string { for _, c := range group.List { line := strings.TrimSpace(strings.TrimPrefix(c.Text, "//")) @@ -186,6 +230,7 @@ func extractFluent(group *ast.CommentGroup) string { return "" } +// extractDescription stops before generator directives and examples so prose is not duplicated. func extractDescription(group *ast.CommentGroup) string { var lines []string @@ -209,6 +254,7 @@ func extractDescription(group *ast.CommentGroup) string { return strings.TrimSpace(strings.Join(lines, "\n")) } +// extractExamples retains source positions so documented cases render in declaration order. func extractExamples(fset *token.FileSet, fn *ast.FuncDecl) []Example { var out []Example var current []string @@ -306,6 +352,7 @@ func selectPackage(pkgs map[string]*ast.Package) (string, error) { // ------------------------------------------------------------ // +// renderAPI groups and sorts the parsed model so README generation is reproducible. func renderAPI(funcs []*FuncDoc) string { byGroup := map[string][]*FuncDoc{} @@ -328,12 +375,12 @@ func renderAPI(funcs []*FuncDoc) string { for _, group := range groupNames { sort.Slice(byGroup[group], func(i, j int) bool { - return byGroup[group][i].Name < byGroup[group][j].Name + return funcDisplayName(byGroup[group][i]) < funcDisplayName(byGroup[group][j]) }) var links []string for _, fn := range byGroup[group] { - links = append(links, fmt.Sprintf("[%s](#%s)", fn.Name, strings.ToLower(fn.Name))) + links = append(links, fmt.Sprintf("[%s](#%s)", funcDisplayName(fn), funcAnchor(fn))) } buf.WriteString(fmt.Sprintf("| **%s** | %s |\n", @@ -349,9 +396,9 @@ func renderAPI(funcs []*FuncDoc) string { buf.WriteString("## " + group + "\n\n") for _, fn := range byGroup[group] { - anchor := strings.ToLower(fn.Name) + anchor := funcAnchor(fn) - header := fn.Name + header := funcDisplayName(fn) if fn.Fluent == "true" { header += " · fluent" } @@ -383,6 +430,7 @@ func renderAPI(funcs []*FuncDoc) string { // ------------------------------------------------------------ // +// replaceAPISection confines generated writes to the API markers and rejects malformed README structure. func replaceAPISection(readme, api string) (string, error) { start := strings.Index(readme, apiStart) end := strings.Index(readme, apiEnd) @@ -407,8 +455,12 @@ func replaceAPISection(readme, api string) (string, error) { // ------------------------------------------------------------ // +// findRoot anchors generation to the root module from either the root or docs directory. func findRoot() (string, error) { - wd, _ := os.Getwd() + wd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("get working directory: %w", err) + } if fileExists(filepath.Join(wd, "go.mod")) { return wd, nil } @@ -419,11 +471,13 @@ func findRoot() (string, error) { return "", fmt.Errorf("could not find project root") } +// fileExists lets root discovery ignore candidate paths that are not present. func fileExists(p string) bool { _, err := os.Stat(p) return err == nil } +// normalizeIndent removes shared documentation padding without changing relative code indentation. func normalizeIndent(lines []string) []string { min := -1 diff --git a/dump.go b/dump.go index 2c744f9..6633f8e 100644 --- a/dump.go +++ b/dump.go @@ -1,23 +1,31 @@ package env import ( - "github.com/goforj/godump" "io" "os" + "sync" + + "github.com/goforj/godump" ) -var dumpWriter io.Writer = os.Stdout +var dumpState = struct { + mu sync.Mutex + writer io.Writer +}{writer: os.Stdout} -// setDumpWriter allows tests to redirect dump output. -// Not exported — production code never needs this. +// setDumpWriter redirects complete dumps so tests can inspect output without partial writes. func setDumpWriter(w io.Writer) { - dumpWriter = w + dumpState.mu.Lock() + defer dumpState.mu.Unlock() + dumpState.writer = w } -// Dump is a convenience function that calls godump.Dump. +// Dump writes complete representations of its arguments to standard output. // @group Debugging // @behavior readonly // +// Dump does not redact values. Never pass credentials, tokens, private keys, or other secrets. +// // Example: integers // // nums := []int{1, 2, 3} @@ -37,6 +45,8 @@ func setDumpWriter(w io.Writer) { // // "ok" => 1 #int // // ] func Dump(vs ...any) { - d := godump.NewDumper(godump.WithWriter(dumpWriter)) + dumpState.mu.Lock() + defer dumpState.mu.Unlock() + d := godump.NewDumper(godump.WithWriter(dumpState.writer)) d.Dump(vs...) } diff --git a/dump_test.go b/dump_test.go index 7311ce0..b74a72a 100644 --- a/dump_test.go +++ b/dump_test.go @@ -2,15 +2,17 @@ package env import ( "bytes" + "os" + "strings" + "sync" "testing" ) +// TestDumpUsesWriter ensures diagnostics target the caller-provided destination. func TestDumpUsesWriter(t *testing.T) { - original := dumpWriter - defer func() { dumpWriter = original }() - var buf bytes.Buffer setDumpWriter(&buf) + t.Cleanup(func() { setDumpWriter(os.Stdout) }) Dump("status", 42) out := buf.String() @@ -18,3 +20,31 @@ func TestDumpUsesWriter(t *testing.T) { t.Fatalf("expected output to include values, got: %q", out) } } + +// TestDumpSerializesCompleteWrites ensures concurrent dumps cannot interleave partial environment snapshots. +func TestDumpSerializesCompleteWrites(t *testing.T) { + var first bytes.Buffer + var second bytes.Buffer + setDumpWriter(&first) + t.Cleanup(func() { setDumpWriter(os.Stdout) }) + + const workers = 40 + var wait sync.WaitGroup + for index := 0; index < workers; index++ { + wait.Add(1) + go func(index int) { + defer wait.Done() + if index%2 == 0 { + setDumpWriter(&first) + } else { + setDumpWriter(&second) + } + Dump("ENV_QPASS_DUMP_MARKER") + }(index) + } + wait.Wait() + + if got := strings.Count(first.String(), "ENV_QPASS_DUMP_MARKER") + strings.Count(second.String(), "ENV_QPASS_DUMP_MARKER"); got != workers { + t.Fatalf("expected %d complete dumps, got %d", workers, got) + } +} diff --git a/env.go b/env.go index 172b707..ffc4a5d 100644 --- a/env.go +++ b/env.go @@ -1,6 +1,7 @@ package env import ( + "math/bits" "os" "strconv" "strings" @@ -118,12 +119,12 @@ func GetInt64(key, fallback string) int64 { func GetUint(key, fallback string) uint { val := os.Getenv(key) if val != "" { - if i, err := strconv.ParseUint(val, 10, 32); err == nil { + if i, err := strconv.ParseUint(val, 10, bits.UintSize); err == nil { return uint(i) } } if fallback != "" { - if i, err := strconv.ParseUint(fallback, 10, 32); err == nil { + if i, err := strconv.ParseUint(fallback, 10, bits.UintSize); err == nil { return uint(i) } } @@ -198,7 +199,8 @@ func GetFloat(key, fallback string) float64 { // @group Typed getters // @behavior readonly // -// Accepted values: true/false, 1/0, t/f (case-insensitive). Invalid entries fall back. +// Accepted values match strconv.ParseBool: 1, t, T, TRUE, true, True and their false forms. +// Invalid entries fall back. // // Example: numeric truthy // @@ -293,7 +295,7 @@ func GetSlice(key, fallback string) []string { return parts } -// GetMap parses key=value pairs separated by commas into a map. +// GetMap parses trimmed key=value pairs separated by commas into a map. // @group Typed getters // @behavior readonly // @@ -315,7 +317,11 @@ func GetSlice(key, fallback string) []string { // env.Dump(limits) // // #map[string]string [] func GetMap(key, fallback string) map[string]string { - val := Get(key, fallback) + return parseStringMap(Get(key, fallback)) +} + +// parseStringMap applies GetMap's permissive format without consulting process state. +func parseStringMap(val string) map[string]string { m := map[string]string{} if strings.TrimSpace(val) == "" { @@ -326,7 +332,11 @@ func GetMap(key, fallback string) map[string]string { for _, p := range pairs { kv := strings.SplitN(strings.TrimSpace(p), "=", 2) if len(kv) == 2 { - m[kv[0]] = kv[1] + name := strings.TrimSpace(kv[0]) + if name == "" { + continue + } + m[name] = strings.TrimSpace(kv[1]) } } @@ -398,11 +408,11 @@ func GetMapInt(key, fallback string, defaultValue int) map[string]int { return m } -// GetEnum ensures the environment variable's value is in the allowed list. +// GetEnum returns the environment value when allowed and fallback otherwise. // @group Typed getters // @behavior readonly // -// Returns fallback when the environment value is not in the allowed slice. +// The fallback is returned as supplied and does not need to appear in allowed. // // Example: accept only staged environments // @@ -424,11 +434,6 @@ func GetEnum(key, fallback string, allowed []string) string { return val } } - for _, a := range allowed { - if fallback == a { - return fallback - } - } return fallback } @@ -455,7 +460,7 @@ func MustGet(key string) string { return val } -// MustGetInt panics if the value is missing or not an int. +// MustGetInt returns a required int or panics when the value is missing or invalid. // @group Typed getters // @behavior panic // @@ -471,10 +476,15 @@ func MustGet(key string) string { // _ = os.Setenv("PORT", "not-a-number") // _ = env.MustGetInt("PORT") // panics when parsing func MustGetInt(key string) int { - return GetInt(key, "") + value := MustGet(key) + parsed, err := strconv.Atoi(value) + if err != nil { + panic("env variable is not an int: " + key) + } + return parsed } -// MustGetBool panics if missing or invalid. +// MustGetBool returns a required bool or panics when the value is missing or invalid. // @group Typed getters // @behavior panic // @@ -490,5 +500,10 @@ func MustGetInt(key string) int { // _ = os.Setenv("FEATURE_ENABLED", "maybe") // _ = env.MustGetBool("FEATURE_ENABLED") // panics when parsing func MustGetBool(key string) bool { - return GetBool(key, "") + value := MustGet(key) + parsed, err := strconv.ParseBool(value) + if err != nil { + panic("env variable is not a bool: " + key) + } + return parsed } diff --git a/env_test.go b/env_test.go index 3dc4f74..6f376ed 100644 --- a/env_test.go +++ b/env_test.go @@ -1,24 +1,34 @@ package env import ( + "math/bits" "os" "reflect" + "strconv" + "strings" "testing" "time" ) -// Helper: temporarily set an env var and restore after +// withEnv restores presence as well as value so empty and unset remain distinct between tests. func withEnv(key, val string, fn func()) { - original := os.Getenv(key) + original, present := os.LookupEnv(key) + defer func() { + if present { + _ = os.Setenv(key, original) + return + } + _ = os.Unsetenv(key) + }() if val == "" { _ = os.Unsetenv(key) } else { _ = os.Setenv(key, val) } fn() - _ = os.Setenv(key, original) } +// TestGet ensures missing strings use the fallback while present values win. func TestGet(t *testing.T) { withEnv("FOO", "bar", func() { if got := Get("FOO", "fallback"); got != "bar" { @@ -33,6 +43,7 @@ func TestGet(t *testing.T) { }) } +// TestGetInt ensures decimal integers parse without changing fallback semantics. func TestGetInt(t *testing.T) { withEnv("PORT", "8080", func() { if got := GetInt("PORT", "1234"); got != 8080 { @@ -41,6 +52,7 @@ func TestGetInt(t *testing.T) { }) } +// TestGetInt64 ensures full-width signed values are preserved. func TestGetInt64(t *testing.T) { withEnv("MAX", "9223372036854775807", func() { if got := GetInt64("MAX", "0"); got != 9223372036854775807 { @@ -49,6 +61,7 @@ func TestGetInt64(t *testing.T) { }) } +// TestGetUint ensures unsigned values reject invalid text through the fallback path. func TestGetUint(t *testing.T) { withEnv("COUNT", "42", func() { if got := GetUint("COUNT", "1"); got != 42 { @@ -57,6 +70,20 @@ func TestGetUint(t *testing.T) { }) } +// TestGetUintUsesNativeWidth ensures overflow behavior follows the target architecture's uint size. +func TestGetUintUsesNativeWidth(t *testing.T) { + if bits.UintSize != 64 { + t.Skip("native-width assertion requires a 64-bit uint") + } + value := strconv.FormatUint(uint64(1)<<40, 10) + withEnv("ENV_QPASS_NATIVE_UINT", value, func() { + if got := GetUint("ENV_QPASS_NATIVE_UINT", "0"); got != uint(1)<<40 { + t.Fatalf("expected native-width uint, got %d", got) + } + }) +} + +// TestGetUint64 ensures full-width unsigned values are preserved. func TestGetUint64(t *testing.T) { withEnv("BIGCOUNT", "10000", func() { if got := GetUint64("BIGCOUNT", "1"); got != 10000 { @@ -65,6 +92,7 @@ func TestGetUint64(t *testing.T) { }) } +// TestGetFloat ensures floating-point values parse without losing fallback behavior. func TestGetFloat(t *testing.T) { withEnv("THRESH", "0.75", func() { if got := GetFloat("THRESH", "1.0"); got != 0.75 { @@ -73,6 +101,7 @@ func TestGetFloat(t *testing.T) { }) } +// TestGetBool ensures standard boolean spellings map predictably. func TestGetBool(t *testing.T) { withEnv("DEBUG", "true", func() { if !GetBool("DEBUG", "false") { @@ -81,6 +110,7 @@ func TestGetBool(t *testing.T) { }) } +// TestGetDuration ensures Go duration syntax is honored with a safe fallback. func TestGetDuration(t *testing.T) { withEnv("TIMEOUT", "5s", func() { if got := GetDuration("TIMEOUT", "1s"); got != 5*time.Second { @@ -89,6 +119,7 @@ func TestGetDuration(t *testing.T) { }) } +// TestGetSlice ensures delimited values are trimmed into stable elements. func TestGetSlice(t *testing.T) { withEnv("PEERS", "a,b,c", func() { got := GetSlice("PEERS", "") @@ -99,8 +130,9 @@ func TestGetSlice(t *testing.T) { }) } +// TestGetMap ensures delimited key-value text is trimmed and parsed deterministically. func TestGetMap(t *testing.T) { - withEnv("LIMITS", "read=10,write=5", func() { + withEnv("LIMITS", " read = 10 ,write= 5, =ignored ", func() { got := GetMap("LIMITS", "") expected := map[string]string{"read": "10", "write": "5"} if !reflect.DeepEqual(got, expected) { @@ -109,6 +141,7 @@ func TestGetMap(t *testing.T) { }) } +// TestGetMapInt ensures numeric map values reject malformed entries through the fallback path. func TestGetMapInt(t *testing.T) { t.Run("parses valid values", func(t *testing.T) { withEnv("QUEUE_WEIGHTS", "critical=6, default=3, low=1", func() { @@ -160,6 +193,7 @@ func TestGetMapInt(t *testing.T) { }) } +// TestGetEnum ensures only explicitly allowed values are returned. func TestGetEnum(t *testing.T) { withEnv("APP_ENV", "staging", func() { got := GetEnum("APP_ENV", "local", []string{"local", "staging", "production"}) @@ -169,6 +203,7 @@ func TestGetEnum(t *testing.T) { }) } +// TestGetEnumInvalid ensures disallowed configured values fall back safely. func TestGetEnumInvalid(t *testing.T) { withEnv("APP_ENV", "invalid", func() { if got := GetEnum("APP_ENV", "local", []string{"local", "staging", "production"}); got != "local" { @@ -177,6 +212,7 @@ func TestGetEnumInvalid(t *testing.T) { }) } +// TestGetEnumFallbackNotAllowed ensures caller fallbacks need not appear in the allowed configured set. func TestGetEnumFallbackNotAllowed(t *testing.T) { withEnv("APP_ENV", "unknown", func() { if got := GetEnum("APP_ENV", "invalid-fallback", []string{"local", "staging"}); got != "invalid-fallback" { @@ -185,6 +221,7 @@ func TestGetEnumFallbackNotAllowed(t *testing.T) { }) } +// TestSliceAndMapEmptyFallbacks ensures empty input does not fabricate collection elements. func TestSliceAndMapEmptyFallbacks(t *testing.T) { withEnv("EMPTY_SLICE", "", func() { got := GetSlice("EMPTY_SLICE", "") @@ -201,6 +238,7 @@ func TestSliceAndMapEmptyFallbacks(t *testing.T) { }) } +// TestMustGet ensures required strings are returned without fallback ambiguity. func TestMustGet(t *testing.T) { withEnv("SECRET", "abc123", func() { if MustGet("SECRET") != "abc123" { @@ -209,6 +247,7 @@ func TestMustGet(t *testing.T) { }) } +// TestMustGetMissing ensures absent required strings fail fast. func TestMustGetMissing(t *testing.T) { defer func() { if recover() == nil { @@ -220,6 +259,7 @@ func TestMustGetMissing(t *testing.T) { }) } +// TestMustGetInt ensures required integers return their parsed value. func TestMustGetInt(t *testing.T) { withEnv("PORTX", "9000", func() { if MustGetInt("PORTX") != 9000 { @@ -228,6 +268,18 @@ func TestMustGetInt(t *testing.T) { }) } +// TestMustGetIntPanicsOnMissingAndInvalid ensures required integer configuration fails fast on absence or corruption. +func TestMustGetIntPanicsOnMissingAndInvalid(t *testing.T) { + for _, value := range []string{"", "not-an-int"} { + t.Run(value, func(t *testing.T) { + withEnv("ENV_QPASS_REQUIRED_INT", value, func() { + expectPanic(t, "MustGetInt", func() { MustGetInt("ENV_QPASS_REQUIRED_INT") }) + }) + }) + } +} + +// TestMustGetBool ensures required booleans return their parsed value. func TestMustGetBool(t *testing.T) { withEnv("ENABLED", "true", func() { if !MustGetBool("ENABLED") { @@ -236,6 +288,31 @@ func TestMustGetBool(t *testing.T) { }) } +// TestMustGetBoolPanicsOnMissingAndInvalid ensures required boolean configuration fails fast on absence or corruption. +func TestMustGetBoolPanicsOnMissingAndInvalid(t *testing.T) { + for _, value := range []string{"", "not-a-bool"} { + t.Run(value, func(t *testing.T) { + withEnv("ENV_QPASS_REQUIRED_BOOL", value, func() { + expectPanic(t, "MustGetBool", func() { MustGetBool("ENV_QPASS_REQUIRED_BOOL") }) + }) + }) + } +} + +// FuzzGetMap verifies malformed map entries never panic or produce blank keys. +func FuzzGetMap(f *testing.F) { + f.Add("read=10, write = 5") + f.Add("malformed,=empty-key,key=") + f.Fuzz(func(t *testing.T, value string) { + for key := range parseStringMap(value) { + if strings.TrimSpace(key) == "" { + t.Fatal("GetMap returned an empty key") + } + } + }) +} + +// TestGettersReturnFallbackOnBadValues ensures optional typed configuration never leaks parse failures as zero values. func TestGettersReturnFallbackOnBadValues(t *testing.T) { withEnv("BAD_INT", "nope", func() { if got := GetInt("BAD_INT", "10"); got != 10 { diff --git a/examples/arch/main.go b/examples/arch/main.go index fc1c7be..176c6f6 100644 --- a/examples/arch/main.go +++ b/examples/arch/main.go @@ -1,10 +1,13 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import "github.com/goforj/env/v2" +// main keeps this documented example executable so API drift fails during compilation. func main() { // Arch returns the CPU architecture the binary is running on. diff --git a/examples/child/main.go b/examples/child/main.go index bbdc163..f0e3fc5 100644 --- a/examples/child/main.go +++ b/examples/child/main.go @@ -1,13 +1,17 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import ( - "github.com/goforj/env/v2" "os" + + "github.com/goforj/env/v2" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // Child returns a new scope rooted at the current prefix plus name. diff --git a/examples/childnames/main.go b/examples/childnames/main.go index 05ebea4..a1c21dc 100644 --- a/examples/childnames/main.go +++ b/examples/childnames/main.go @@ -1,13 +1,17 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import ( - "github.com/goforj/env/v2" "os" + + "github.com/goforj/env/v2" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // ChildNames discovers named child scopes under the current prefix. diff --git a/examples/dump/main.go b/examples/dump/main.go index 627a33a..db88350 100644 --- a/examples/dump/main.go +++ b/examples/dump/main.go @@ -1,12 +1,15 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import "github.com/goforj/env/v2" +// main keeps this documented example executable so API drift fails during compilation. func main() { - // Dump is a convenience function that calls godump.Dump. + // Dump writes complete representations of its arguments to standard output. // Example: integers nums := []int{1, 2, 3} diff --git a/examples/example_compile_test.go b/examples/example_compile_test.go index e6b7722..5b7be10 100644 --- a/examples/example_compile_test.go +++ b/examples/example_compile_test.go @@ -2,51 +2,54 @@ package examples import ( "bytes" + "context" "encoding/json" - "errors" "fmt" "os" "os/exec" "path/filepath" "strings" "testing" + "time" ) +// TestExamplesBuild ensures every generated standalone program remains valid outside the workspace. func TestExamplesBuild(t *testing.T) { entries, err := os.ReadDir(".") if err != nil { t.Fatalf("cannot read examples directory: %v", err) } - for _, e := range entries { - if !e.IsDir() { + buildSlots := make(chan struct{}, 4) + for _, entry := range entries { + if !entry.IsDir() { continue } - // Capture loop variable for parallel subtests. - name := e.Name() + name := entry.Name() t.Run(name, func(t *testing.T) { t.Parallel() + buildSlots <- struct{}{} + defer func() { <-buildSlots }() - if err := buildExampleWithoutTags(name); err != nil { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := buildExampleWithoutTags(ctx, name); err != nil { t.Fatalf("example %q failed to build:\n%s", name, err) } }) } } -func abs(p string) string { - a, err := filepath.Abs(p) +// buildExampleWithoutTags overlays generated source so ignored examples are still compiled in CI. +func buildExampleWithoutTags(ctx context.Context, exampleName string) error { + orig := filepath.Join(exampleName, "main.go") + originalPath, err := filepath.Abs(orig) if err != nil { - panic(err) + return fmt.Errorf("resolve example path: %w", err) } - return a -} - -func buildExampleWithoutTags(exampleName string) error { - orig := filepath.Join(exampleName, "main.go") - src, err := os.ReadFile(orig) + src, err := os.ReadFile(originalPath) if err != nil { return fmt.Errorf("read main.go: %w", err) } @@ -64,9 +67,13 @@ func buildExampleWithoutTags(exampleName string) error { return err } + temporaryPath, err := filepath.Abs(tmpFile) + if err != nil { + return fmt.Errorf("resolve overlay path: %w", err) + } overlay := map[string]any{ "Replace": map[string]string{ - abs(orig): abs(tmpFile), + originalPath: temporaryPath, }, } @@ -80,7 +87,8 @@ func buildExampleWithoutTags(exampleName string) error { return err } - cmd := exec.Command( + cmd := exec.CommandContext( + ctx, "go", "build", "-overlay", overlayPath, "-o", os.DevNull, @@ -91,12 +99,16 @@ func buildExampleWithoutTags(exampleName string) error { cmd.Stderr = &stderr if err := cmd.Run(); err != nil { - return errors.New(stderr.String()) + if ctx.Err() != nil { + return fmt.Errorf("go build exceeded deadline: %w", ctx.Err()) + } + return fmt.Errorf("go build: %w\n%s", err, stderr.String()) } return nil } +// stripBuildTags removes only the leading constraints that intentionally hide generated programs. func stripBuildTags(src []byte) []byte { lines := strings.Split(string(src), "\n") diff --git a/examples/get/main.go b/examples/get/main.go index ead3c65..0874398 100644 --- a/examples/get/main.go +++ b/examples/get/main.go @@ -1,13 +1,17 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import ( - "github.com/goforj/env/v2" "os" + + "github.com/goforj/env/v2" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // Get returns the environment variable for key or fallback when empty. diff --git a/examples/getappenv/main.go b/examples/getappenv/main.go index 08a0c47..ceb0518 100644 --- a/examples/getappenv/main.go +++ b/examples/getappenv/main.go @@ -1,13 +1,17 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import ( - "github.com/goforj/env/v2" "os" + + "github.com/goforj/env/v2" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // GetAppEnv returns the current APP_ENV (empty string if unset). diff --git a/examples/getbool/main.go b/examples/getbool/main.go index acd9b6e..f202eb0 100644 --- a/examples/getbool/main.go +++ b/examples/getbool/main.go @@ -1,13 +1,17 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import ( - "github.com/goforj/env/v2" "os" + + "github.com/goforj/env/v2" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // GetBool parses a boolean from an environment variable or fallback string. diff --git a/examples/getduration/main.go b/examples/getduration/main.go index 59d41ca..87b7e25 100644 --- a/examples/getduration/main.go +++ b/examples/getduration/main.go @@ -1,13 +1,17 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import ( - "github.com/goforj/env/v2" "os" + + "github.com/goforj/env/v2" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // GetDuration parses a Go duration string (e.g. "5s", "10m", "1h"). diff --git a/examples/getenum/main.go b/examples/getenum/main.go index bc36b57..5cfa008 100644 --- a/examples/getenum/main.go +++ b/examples/getenum/main.go @@ -1,15 +1,19 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import ( - "github.com/goforj/env/v2" "os" + + "github.com/goforj/env/v2" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { - // GetEnum ensures the environment variable's value is in the allowed list. + // GetEnum returns the environment value when allowed and fallback otherwise. // Example: accept only staged environments _ = os.Setenv("APP_ENV", "production") diff --git a/examples/getfloat/main.go b/examples/getfloat/main.go index da1b471..48a26ee 100644 --- a/examples/getfloat/main.go +++ b/examples/getfloat/main.go @@ -1,13 +1,17 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import ( - "github.com/goforj/env/v2" "os" + + "github.com/goforj/env/v2" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // GetFloat parses a float64 from an environment variable or fallback string. diff --git a/examples/getint/main.go b/examples/getint/main.go index aa2af89..7ae7b05 100644 --- a/examples/getint/main.go +++ b/examples/getint/main.go @@ -1,13 +1,17 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import ( - "github.com/goforj/env/v2" "os" + + "github.com/goforj/env/v2" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // GetInt parses an int from an environment variable or fallback string. diff --git a/examples/getint64/main.go b/examples/getint64/main.go index 0c83d61..f5e0a18 100644 --- a/examples/getint64/main.go +++ b/examples/getint64/main.go @@ -1,13 +1,17 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import ( - "github.com/goforj/env/v2" "os" + + "github.com/goforj/env/v2" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // GetInt64 parses an int64 from an environment variable or fallback string. diff --git a/examples/getmap/main.go b/examples/getmap/main.go index ce3c51c..c4480aa 100644 --- a/examples/getmap/main.go +++ b/examples/getmap/main.go @@ -1,15 +1,19 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import ( - "github.com/goforj/env/v2" "os" + + "github.com/goforj/env/v2" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { - // GetMap parses key=value pairs separated by commas into a map. + // GetMap parses trimmed key=value pairs separated by commas into a map. // Example: parse throttling config _ = os.Setenv("LIMITS", "read=10, write=5, burst=20") diff --git a/examples/getmapint/main.go b/examples/getmapint/main.go index c6aead8..71d30a6 100644 --- a/examples/getmapint/main.go +++ b/examples/getmapint/main.go @@ -1,13 +1,17 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import ( - "github.com/goforj/env/v2" "os" + + "github.com/goforj/env/v2" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // GetMapInt parses key=int pairs separated by commas into a map. // Invalid, missing, or non-positive values fall back to defaultValue. diff --git a/examples/getslice/main.go b/examples/getslice/main.go index db95e33..c81ea94 100644 --- a/examples/getslice/main.go +++ b/examples/getslice/main.go @@ -1,13 +1,17 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import ( - "github.com/goforj/env/v2" "os" + + "github.com/goforj/env/v2" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // GetSlice splits a comma-separated string into a []string with trimming. diff --git a/examples/getuint/main.go b/examples/getuint/main.go index fe43e09..dd4917f 100644 --- a/examples/getuint/main.go +++ b/examples/getuint/main.go @@ -1,13 +1,17 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import ( - "github.com/goforj/env/v2" "os" + + "github.com/goforj/env/v2" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // GetUint parses a uint from an environment variable or fallback string. diff --git a/examples/getuint64/main.go b/examples/getuint64/main.go index 62654aa..b468508 100644 --- a/examples/getuint64/main.go +++ b/examples/getuint64/main.go @@ -1,13 +1,17 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import ( - "github.com/goforj/env/v2" "os" + + "github.com/goforj/env/v2" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // GetUint64 parses a uint64 from an environment variable or fallback string. diff --git a/examples/isappenv/main.go b/examples/isappenv/main.go index 4619396..5976703 100644 --- a/examples/isappenv/main.go +++ b/examples/isappenv/main.go @@ -1,13 +1,17 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import ( - "github.com/goforj/env/v2" "os" + + "github.com/goforj/env/v2" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // IsAppEnv checks if APP_ENV matches any of the provided environments. diff --git a/examples/isappenvlocal/main.go b/examples/isappenvlocal/main.go index 5a80b04..b19cecd 100644 --- a/examples/isappenvlocal/main.go +++ b/examples/isappenvlocal/main.go @@ -1,13 +1,17 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import ( - "github.com/goforj/env/v2" "os" + + "github.com/goforj/env/v2" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // IsAppEnvLocal checks if APP_ENV is "local". diff --git a/examples/isappenvlocalorstaging/main.go b/examples/isappenvlocalorstaging/main.go index ef382d7..7977c91 100644 --- a/examples/isappenvlocalorstaging/main.go +++ b/examples/isappenvlocalorstaging/main.go @@ -1,13 +1,17 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import ( - "github.com/goforj/env/v2" "os" + + "github.com/goforj/env/v2" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // IsAppEnvLocalOrStaging checks if APP_ENV is either "local" or "staging". diff --git a/examples/isappenvproduction/main.go b/examples/isappenvproduction/main.go index 7385258..81b31dc 100644 --- a/examples/isappenvproduction/main.go +++ b/examples/isappenvproduction/main.go @@ -1,13 +1,17 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import ( - "github.com/goforj/env/v2" "os" + + "github.com/goforj/env/v2" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // IsAppEnvProduction checks if APP_ENV is "production". diff --git a/examples/isappenvstaging/main.go b/examples/isappenvstaging/main.go index d9a7851..f189746 100644 --- a/examples/isappenvstaging/main.go +++ b/examples/isappenvstaging/main.go @@ -1,13 +1,17 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import ( - "github.com/goforj/env/v2" "os" + + "github.com/goforj/env/v2" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // IsAppEnvStaging checks if APP_ENV is "staging". diff --git a/examples/isappenvtesting/main.go b/examples/isappenvtesting/main.go index 0903cf5..d1cab8b 100644 --- a/examples/isappenvtesting/main.go +++ b/examples/isappenvtesting/main.go @@ -1,13 +1,17 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import ( - "github.com/goforj/env/v2" "os" + + "github.com/goforj/env/v2" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // IsAppEnvTesting reports whether APP_ENV is "testing" or the process looks like `go test`. diff --git a/examples/isappenvtestingorlocal/main.go b/examples/isappenvtestingorlocal/main.go index 7e49a25..ab0c3ad 100644 --- a/examples/isappenvtestingorlocal/main.go +++ b/examples/isappenvtestingorlocal/main.go @@ -1,13 +1,17 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import ( - "github.com/goforj/env/v2" "os" + + "github.com/goforj/env/v2" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // IsAppEnvTestingOrLocal checks if APP_ENV is "testing" or "local". diff --git a/examples/isbsd/main.go b/examples/isbsd/main.go index e78abff..07847cb 100644 --- a/examples/isbsd/main.go +++ b/examples/isbsd/main.go @@ -1,10 +1,13 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import "github.com/goforj/env/v2" +// main keeps this documented example executable so API drift fails during compilation. func main() { // IsBSD reports whether the runtime OS is any BSD variant. diff --git a/examples/iscontainer/main.go b/examples/iscontainer/main.go index 683b65c..f28329c 100644 --- a/examples/iscontainer/main.go +++ b/examples/iscontainer/main.go @@ -1,10 +1,13 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import "github.com/goforj/env/v2" +// main keeps this documented example executable so API drift fails during compilation. func main() { // IsContainer detects common container runtimes (Docker, containerd, Kubernetes, Podman). diff --git a/examples/iscontaineros/main.go b/examples/iscontaineros/main.go index 2fb4a44..1f84f8a 100644 --- a/examples/iscontaineros/main.go +++ b/examples/iscontaineros/main.go @@ -1,10 +1,13 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import "github.com/goforj/env/v2" +// main keeps this documented example executable so API drift fails during compilation. func main() { // IsContainerOS reports whether this OS is *typically* used as a container base. diff --git a/examples/isdocker/main.go b/examples/isdocker/main.go index 3ebc8ca..8374b9d 100644 --- a/examples/isdocker/main.go +++ b/examples/isdocker/main.go @@ -1,10 +1,13 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import "github.com/goforj/env/v2" +// main keeps this documented example executable so API drift fails during compilation. func main() { // IsDocker reports whether the current process is running in a Docker container. diff --git a/examples/isdockerhost/main.go b/examples/isdockerhost/main.go index a6babc3..22eec2c 100644 --- a/examples/isdockerhost/main.go +++ b/examples/isdockerhost/main.go @@ -1,10 +1,13 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import "github.com/goforj/env/v2" +// main keeps this documented example executable so API drift fails during compilation. func main() { // IsDockerHost reports whether this container behaves like a Docker host. diff --git a/examples/isdockerindocker/main.go b/examples/isdockerindocker/main.go index 9cac163..572d6ed 100644 --- a/examples/isdockerindocker/main.go +++ b/examples/isdockerindocker/main.go @@ -1,10 +1,13 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import "github.com/goforj/env/v2" +// main keeps this documented example executable so API drift fails during compilation. func main() { // IsDockerInDocker reports whether we are inside a Docker-in-Docker environment. diff --git a/examples/isenvloaded/main.go b/examples/isenvloaded/main.go index 2320cd2..9b3e1be 100644 --- a/examples/isenvloaded/main.go +++ b/examples/isenvloaded/main.go @@ -1,12 +1,15 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import "github.com/goforj/env/v2" +// main keeps this documented example executable so API drift fails during compilation. func main() { - // IsEnvLoaded reports whether Load or LoadEnvFileIfExists was executed in this process. + // IsEnvLoaded reports whether a Load or Reload completed successfully in this process. env.Dump(env.IsEnvLoaded()) // #bool true (after Load) diff --git a/examples/ishostenvironment/main.go b/examples/ishostenvironment/main.go index 31a55f7..ab1d1c7 100644 --- a/examples/ishostenvironment/main.go +++ b/examples/ishostenvironment/main.go @@ -1,10 +1,13 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import "github.com/goforj/env/v2" +// main keeps this documented example executable so API drift fails during compilation. func main() { // IsHostEnvironment reports whether the process is running *outside* any // container or orchestrated runtime. diff --git a/examples/iskubernetes/main.go b/examples/iskubernetes/main.go index d8cf53c..befb777 100644 --- a/examples/iskubernetes/main.go +++ b/examples/iskubernetes/main.go @@ -1,10 +1,13 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import "github.com/goforj/env/v2" +// main keeps this documented example executable so API drift fails during compilation. func main() { // IsKubernetes reports whether the process is running inside Kubernetes. diff --git a/examples/islinux/main.go b/examples/islinux/main.go index 771d71e..a9513dc 100644 --- a/examples/islinux/main.go +++ b/examples/islinux/main.go @@ -1,10 +1,13 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import "github.com/goforj/env/v2" +// main keeps this documented example executable so API drift fails during compilation. func main() { // IsLinux reports whether the runtime OS is Linux. diff --git a/examples/ismac/main.go b/examples/ismac/main.go index a843b46..1c0c32d 100644 --- a/examples/ismac/main.go +++ b/examples/ismac/main.go @@ -1,10 +1,13 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import "github.com/goforj/env/v2" +// main keeps this documented example executable so API drift fails during compilation. func main() { // IsMac reports whether the runtime OS is macOS (Darwin). diff --git a/examples/isunix/main.go b/examples/isunix/main.go index 18ec09e..a99f62f 100644 --- a/examples/isunix/main.go +++ b/examples/isunix/main.go @@ -1,10 +1,13 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import "github.com/goforj/env/v2" +// main keeps this documented example executable so API drift fails during compilation. func main() { // IsUnix reports whether the OS is Unix-like. diff --git a/examples/iswindows/main.go b/examples/iswindows/main.go index 26dd83c..8cd6eba 100644 --- a/examples/iswindows/main.go +++ b/examples/iswindows/main.go @@ -1,10 +1,13 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import "github.com/goforj/env/v2" +// main keeps this documented example executable so API drift fails during compilation. func main() { // IsWindows reports whether the runtime OS is Windows. diff --git a/examples/kitchensink/main.go b/examples/kitchensink/main.go index d6b2fae..ce01959 100644 --- a/examples/kitchensink/main.go +++ b/examples/kitchensink/main.go @@ -45,6 +45,7 @@ type snapshot struct { IsHostEnvironment bool } +// main keeps this combined example executable so cross-feature API drift fails during compilation. func main() { // Load env files if present if err := env.Load(); err != nil { diff --git a/examples/load/main.go b/examples/load/main.go index c475838..7d90537 100644 --- a/examples/load/main.go +++ b/examples/load/main.go @@ -1,22 +1,30 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import ( - "github.com/goforj/env/v2" "os" "path/filepath" + + "github.com/goforj/env/v2" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { - // Load loads .env with optional layering for .env.local/.env.staging/.env.production, - // plus .env.testing/.env.host when present. It only applies once per process; - // subsequent calls return without reloading because the result is cached. Use - // Reload to re-read env files after the first load. + // Load loads the nearest env files with deterministic layering. + // + // Load applies once per process. Files override ambient values, and later files override earlier + // files. Discovery and parsing complete before the process environment changes; errors leave both + // the environment and loader state unchanged. Use Reload to re-read files. // Example: test-specific env file tmp, _ := os.MkdirTemp("", "envdoc") + defer os.RemoveAll(tmp) + originalDirectory, _ := os.Getwd() + defer os.Chdir(originalDirectory) _ = os.WriteFile(filepath.Join(tmp, ".env.testing"), []byte("PORT=9090\nENV_DEBUG=0"), 0o644) _ = os.Chdir(tmp) _ = os.Setenv("APP_ENV", env.Testing) @@ -24,10 +32,4 @@ func main() { _ = env.Load() env.Dump(os.Getenv("PORT")) // #string "9090" - - // Example: default .env on a host - _ = os.WriteFile(".env", []byte("SERVICE=api\nENV_DEBUG=3"), 0o644) - _ = env.Load() - env.Dump(os.Getenv("SERVICE")) - // #string "api" } diff --git a/examples/loadenvfileifexists/main.go b/examples/loadenvfileifexists/main.go index fb7ef8c..197b2e2 100644 --- a/examples/loadenvfileifexists/main.go +++ b/examples/loadenvfileifexists/main.go @@ -1,10 +1,13 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import "github.com/goforj/env/v2" +// main keeps this documented example executable so API drift fails during compilation. func main() { // LoadEnvFileIfExists is a compatibility alias for Load. diff --git a/examples/mustget/main.go b/examples/mustget/main.go index b3f7f65..1b6a08d 100644 --- a/examples/mustget/main.go +++ b/examples/mustget/main.go @@ -1,13 +1,17 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import ( - "github.com/goforj/env/v2" "os" + + "github.com/goforj/env/v2" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // MustGet returns the value of key or panics if missing/empty. diff --git a/examples/mustgetbool/main.go b/examples/mustgetbool/main.go index fe56f0b..9b8d678 100644 --- a/examples/mustgetbool/main.go +++ b/examples/mustgetbool/main.go @@ -1,15 +1,19 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import ( - "github.com/goforj/env/v2" "os" + + "github.com/goforj/env/v2" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { - // MustGetBool panics if missing or invalid. + // MustGetBool returns a required bool or panics when the value is missing or invalid. // Example: gate features explicitly _ = os.Setenv("FEATURE_ENABLED", "true") diff --git a/examples/mustgetint/main.go b/examples/mustgetint/main.go index 2629820..aa5bb44 100644 --- a/examples/mustgetint/main.go +++ b/examples/mustgetint/main.go @@ -1,15 +1,19 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import ( - "github.com/goforj/env/v2" "os" + + "github.com/goforj/env/v2" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { - // MustGetInt panics if the value is missing or not an int. + // MustGetInt returns a required int or panics when the value is missing or invalid. // Example: ensure numeric port _ = os.Setenv("PORT", "8080") diff --git a/examples/os/main.go b/examples/os/main.go index 4755b36..13b0010 100644 --- a/examples/os/main.go +++ b/examples/os/main.go @@ -1,10 +1,13 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import "github.com/goforj/env/v2" +// main keeps this documented example executable so API drift fails during compilation. func main() { // OS returns the current operating system identifier. diff --git a/examples/reload/main.go b/examples/reload/main.go index 9a38e55..6cba097 100644 --- a/examples/reload/main.go +++ b/examples/reload/main.go @@ -1,21 +1,34 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import ( - "github.com/goforj/env/v2" "os" + "path/filepath" + + "github.com/goforj/env/v2" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { - // Reload re-applies the same layered env loading as Load, even if Load already - // ran earlier in the same process. + // Reload re-discovers and transactionally reapplies env files even after Load has run. + // + // Keys loaded from files remain file-owned: Reload replaces runtime edits to those keys. When a + // key disappears from all files, Reload restores the ambient value (including unset versus empty) + // that existed before the first successful Load. Unrelated process variables are never changed. // Example: refresh changed env files - _ = os.WriteFile(".env", []byte("SERVICE=api"), 0o644) + tmp, _ := os.MkdirTemp("", "envdoc") + defer os.RemoveAll(tmp) + originalDirectory, _ := os.Getwd() + defer os.Chdir(originalDirectory) + _ = os.Chdir(tmp) + _ = os.WriteFile(filepath.Join(tmp, ".env"), []byte("SERVICE=api"), 0o644) _ = env.Load() - _ = os.WriteFile(".env", []byte("SERVICE=worker"), 0o644) + _ = os.WriteFile(filepath.Join(tmp, ".env"), []byte("SERVICE=worker"), 0o644) _ = env.Reload() env.Dump(os.Getenv("SERVICE")) // #string "worker" diff --git a/examples/setappenv/main.go b/examples/setappenv/main.go index 5bb0054..224384f 100644 --- a/examples/setappenv/main.go +++ b/examples/setappenv/main.go @@ -1,10 +1,13 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import "github.com/goforj/env/v2" +// main keeps this documented example executable so API drift fails during compilation. func main() { // SetAppEnv sets APP_ENV to a supported value. diff --git a/examples/setappenvlocal/main.go b/examples/setappenvlocal/main.go index 95b37cb..e48c761 100644 --- a/examples/setappenvlocal/main.go +++ b/examples/setappenvlocal/main.go @@ -1,10 +1,13 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import "github.com/goforj/env/v2" +// main keeps this documented example executable so API drift fails during compilation. func main() { // SetAppEnvLocal sets APP_ENV to "local". diff --git a/examples/setappenvproduction/main.go b/examples/setappenvproduction/main.go index 5afdfeb..f3fa391 100644 --- a/examples/setappenvproduction/main.go +++ b/examples/setappenvproduction/main.go @@ -1,10 +1,13 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import "github.com/goforj/env/v2" +// main keeps this documented example executable so API drift fails during compilation. func main() { // SetAppEnvProduction sets APP_ENV to "production". diff --git a/examples/setappenvstaging/main.go b/examples/setappenvstaging/main.go index f05cef7..49c7203 100644 --- a/examples/setappenvstaging/main.go +++ b/examples/setappenvstaging/main.go @@ -1,10 +1,13 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import "github.com/goforj/env/v2" +// main keeps this documented example executable so API drift fails during compilation. func main() { // SetAppEnvStaging sets APP_ENV to "staging". diff --git a/examples/setappenvtesting/main.go b/examples/setappenvtesting/main.go index a9c4de1..78a5122 100644 --- a/examples/setappenvtesting/main.go +++ b/examples/setappenvtesting/main.go @@ -1,10 +1,13 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import "github.com/goforj/env/v2" +// main keeps this documented example executable so API drift fails during compilation. func main() { // SetAppEnvTesting sets APP_ENV to "testing". diff --git a/examples/tools.go b/examples/tools.go index 74676da..f708b5d 100644 --- a/examples/tools.go +++ b/examples/tools.go @@ -1,6 +1,7 @@ //go:build tools // +build tools +// Package examples pins the library dependency used by generated examples. package examples import _ "github.com/goforj/env/v2" diff --git a/examples/withprefix/main.go b/examples/withprefix/main.go index cf8fa31..84468ba 100644 --- a/examples/withprefix/main.go +++ b/examples/withprefix/main.go @@ -1,13 +1,17 @@ //go:build ignore // +build ignore +// Code generated by docs/examplegen; DO NOT EDIT. + package main import ( - "github.com/goforj/env/v2" "os" + + "github.com/goforj/env/v2" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // WithPrefix returns a scope rooted at prefix after minimal normalization. diff --git a/generate.go b/generate.go new file mode 100644 index 0000000..8dc2cfc --- /dev/null +++ b/generate.go @@ -0,0 +1,4 @@ +package env + +//go:generate go run ./docs/examplegen/main.go +//go:generate go run ./docs/readme/main.go diff --git a/godump_dep_test.go b/godump_dep_test.go deleted file mode 100644 index 83ea7c7..0000000 --- a/godump_dep_test.go +++ /dev/null @@ -1,6 +0,0 @@ -package env - -// This blank import keeps github.com/goforj/godump as a module dependency for -// generated examples that use env.Dump. The package is only pulled in when -// running tests (including example compile tests). -import _ "github.com/goforj/godump" diff --git a/host.go b/host.go index e56710c..593c6d5 100644 --- a/host.go +++ b/host.go @@ -13,6 +13,11 @@ package env // // #bool true (on bare-metal/VM hosts) // // #bool false (inside containers) func IsHostEnvironment() bool { - return !IsContainer() && + return isHostEnvironmentWithEnv(getEnv) +} + +// isHostEnvironmentWithEnv evaluates host detection against a caller-supplied environment view. +func isHostEnvironmentWithEnv(getenv func(string) string) bool { + return !isContainerWithEnv(getenv) && !IsDockerInDocker() } diff --git a/host_test.go b/host_test.go index 66e1fd8..c474c4b 100644 --- a/host_test.go +++ b/host_test.go @@ -5,6 +5,7 @@ import ( "testing" ) +// TestIsHostEnvironment_Host ensures absent container evidence identifies a host environment. func TestIsHostEnvironment_Host(t *testing.T) { t.Cleanup(reset) diff --git a/loader.go b/loader.go index bba5f81..e09adaf 100644 --- a/loader.go +++ b/loader.go @@ -1,20 +1,22 @@ package env import ( + "errors" "fmt" - "github.com/joho/godotenv" "os" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + + "github.com/joho/godotenv" ) -// MaxDirectorySeekLevels is the number of directory -// levels a .env file needs to be searched in +// MaxDirectorySeekLevels bounds env-file discovery to the working directory and nine ancestors. const MaxDirectorySeekLevels int = 10 const ( - runtimeDarwin = "darwin" - runtimeWindows = "windows" - - // file fileEnv = ".env" fileEnvHost = ".env.host" envFileTesting = ".env.testing" @@ -23,27 +25,77 @@ const ( envFileProd = ".env.production" ) -// envLoaded is a flag to check if the environment file has been loaded -var envLoaded = false -var loadedEnvKeys = map[string]struct{}{} +var ( + envFileGetwd = os.Getwd + envFileStat = os.Stat + envFileRead = godotenv.Read + envLookup = os.LookupEnv + envSet = os.Setenv + envUnset = os.Unsetenv +) + +// environmentSnapshot retains both presence and value because an empty variable differs from an unset one. +type environmentSnapshot struct { + value string + present bool +} + +// loadedEnvironmentValue records the key's ambient value before the first successful load. +type loadedEnvironmentValue struct { + original environmentSnapshot +} + +// environmentLoaderState serializes loading and protects ownership metadata and the ambient baseline. +type environmentLoaderState struct { + mu sync.Mutex + loaded bool + values map[string]loadedEnvironmentValue + baseline map[string]environmentSnapshot +} -// Load loads .env with optional layering for .env.local/.env.staging/.env.production, -// plus .env.testing/.env.host when present. It only applies once per process; -// subsequent calls return without reloading because the result is cached. Use -// Reload to re-read env files after the first load. +var processEnvironmentLoader = environmentLoaderState{ + values: make(map[string]loadedEnvironmentValue), + baseline: make(map[string]environmentSnapshot), +} + +// environmentFile contains one parsed file before any process environment mutation occurs. +type environmentFile struct { + path string + values map[string]string +} + +// environmentLoadPlan is the complete, deterministic result of discovery and layering. +type environmentLoadPlan struct { + fileValues map[string]string + defaults map[string]string + files []string + appEnv string +} + +// Load loads the nearest env files with deterministic layering. +// +// Load applies once per process. Files override ambient values, and later files override earlier +// files. Discovery and parsing complete before the process environment changes; errors leave both +// the environment and loader state unchanged. Use Reload to re-read files. +// // @group Environment loading // @behavior mutates-process-env // -// Behavior: -// - Sets APP_ENV=local when unset. -// - Chooses .env.testing when APP_ENV indicates tests (or Go test flags are present). -// - Loads .env first when present; .env. overlays for local/staging/production. -// - Loads .env.host for host-to-container networking when running on the host or DinD. -// - Idempotent: subsequent calls no-op after the first load. +// Layer order: +// - .env +// - .env.local, .env.staging, or .env.production selected after parsing .env +// - .env.host on hosts and Docker-in-Docker +// - .env.testing when APP_ENV or the process identifies a test +// +// Each filename is searched independently from the working directory through at most nine +// ancestors. APP_ENV defaults to local when neither the ambient environment nor a file sets it. // // Example: test-specific env file // // tmp, _ := os.MkdirTemp("", "envdoc") +// defer os.RemoveAll(tmp) +// originalDirectory, _ := os.Getwd() +// defer os.Chdir(originalDirectory) // _ = os.WriteFile(filepath.Join(tmp, ".env.testing"), []byte("PORT=9090\nENV_DEBUG=0"), 0o644) // _ = os.Chdir(tmp) // _ = os.Setenv("APP_ENV", env.Testing) @@ -51,32 +103,29 @@ var loadedEnvKeys = map[string]struct{}{} // _ = env.Load() // env.Dump(os.Getenv("PORT")) // // #string "9090" -// -// Example: default .env on a host -// -// _ = os.WriteFile(".env", []byte("SERVICE=api\nENV_DEBUG=3"), 0o644) -// _ = env.Load() -// env.Dump(os.Getenv("SERVICE")) -// // #string "api" func Load() error { return load(false) } -// Reload re-applies the same layered env loading as Load, even if Load already -// ran earlier in the same process. +// Reload re-discovers and transactionally reapplies env files even after Load has run. +// +// Keys loaded from files remain file-owned: Reload replaces runtime edits to those keys. When a +// key disappears from all files, Reload restores the ambient value (including unset versus empty) +// that existed before the first successful Load. Unrelated process variables are never changed. +// // @group Environment loading // @behavior mutates-process-env // -// Behavior: -// - Sets APP_ENV=local when unset. -// - Re-runs the same .env/.env./.env.host/.env.testing layering. -// - Uses overload semantics, so reloaded values replace previously loaded ones. -// // Example: refresh changed env files // -// _ = os.WriteFile(".env", []byte("SERVICE=api"), 0o644) +// tmp, _ := os.MkdirTemp("", "envdoc") +// defer os.RemoveAll(tmp) +// originalDirectory, _ := os.Getwd() +// defer os.Chdir(originalDirectory) +// _ = os.Chdir(tmp) +// _ = os.WriteFile(filepath.Join(tmp, ".env"), []byte("SERVICE=api"), 0o644) // _ = env.Load() -// _ = os.WriteFile(".env", []byte("SERVICE=worker"), 0o644) +// _ = os.WriteFile(filepath.Join(tmp, ".env"), []byte("SERVICE=worker"), 0o644) // _ = env.Reload() // env.Dump(os.Getenv("SERVICE")) // // #string "worker" @@ -84,63 +133,296 @@ func Reload() error { return load(true) } +// load serializes discovery, application, and state publication as one loader operation. func load(force bool) error { - if force { - clearLoadedEnvKeys() + processEnvironmentLoader.mu.Lock() + defer processEnvironmentLoader.mu.Unlock() + + if processEnvironmentLoader.loaded && !force { + return nil } - if os.Getenv("APP_ENV") == "" { - _ = os.Setenv("APP_ENV", Local) + workingDirectory, err := envFileGetwd() + if err != nil { + return fmt.Errorf("get working directory for env loading: %w", err) + } + workingDirectory, err = filepath.Abs(workingDirectory) + if err != nil { + return fmt.Errorf("resolve working directory for env loading: %w", err) } - // avoid re-loading env files - if envLoaded && !force { - return nil + previous := cloneLoadedEnvironmentValues(processEnvironmentLoader.values) + baseline := cloneEnvironmentSnapshots(processEnvironmentLoader.baseline) + if !processEnvironmentLoader.loaded { + baseline = snapshotProcessEnvironment() + } + plan, err := buildEnvironmentLoadPlan(filepath.Clean(workingDirectory), previous) + if err != nil { + return err } - // load base env first; layer testing/host overrides afterward - var loadedFiles []string + next, err := applyEnvironmentLoadPlan(previous, baseline, plan) + if err != nil { + return err + } - // load top-level .env - if ok, path, keys := loadEnvFile(fileEnv); ok { - loadedFiles = append(loadedFiles, path) - recordLoadedEnvKeys(keys) + processEnvironmentLoader.values = next + processEnvironmentLoader.baseline = baseline + processEnvironmentLoader.loaded = true + + if environmentPlanInt(plan, previous, "ENV_DEBUG") >= 3 { + printLoadedEnvFiles(plan.files, plan.appEnv) + } + return nil +} + +// buildEnvironmentLoadPlan parses every selected layer before process-wide mutation begins. +func buildEnvironmentLoadPlan(startDirectory string, previous map[string]loadedEnvironmentValue) (environmentLoadPlan, error) { + plan := environmentLoadPlan{ + fileValues: make(map[string]string), + defaults: make(map[string]string), + } + + appEnv := effectiveEnvironmentValue("APP_ENV", plan.fileValues, previous) + if appEnv == "" { + appEnv = Local + } + + base, found, err := loadEnvFile(startDirectory, fileEnv) + if err != nil { + return environmentLoadPlan{}, err + } + if found { + mergeEnvironmentFile(&plan, base) + if value, ok := plan.fileValues["APP_ENV"]; ok { + appEnv = value + } + } + + if appEnvFile, ok := envFileForAppEnv(appEnv); ok { + layer, found, err := loadEnvFile(startDirectory, appEnvFile) + if err != nil { + return environmentLoadPlan{}, err + } + if found { + mergeEnvironmentFile(&plan, layer) + } } - if envFile, ok := envFileForAppEnv(os.Getenv("APP_ENV")); ok { - if ok, path, keys := loadEnvFile(envFile); ok { - loadedFiles = append(loadedFiles, path) - recordLoadedEnvKeys(keys) + lookup := func(key string) string { + return effectiveEnvironmentValue(key, plan.fileValues, previous) + } + if isHostEnvironmentWithEnv(lookup) || IsDockerInDocker() { + host, found, err := loadEnvFile(startDirectory, fileEnvHost) + if err != nil { + return environmentLoadPlan{}, err + } + if found { + mergeEnvironmentFile(&plan, host) } } - // search for global .env.host - // we're likely talking from host -> container network - // used from IDEs - if IsHostEnvironment() || IsDockerInDocker() { - if ok, path, keys := loadEnvFile(fileEnvHost); ok { - loadedFiles = append(loadedFiles, path) - recordLoadedEnvKeys(keys) + appEnv = effectiveEnvironmentValue("APP_ENV", plan.fileValues, previous) + if appEnv == "" { + appEnv = Local + } + if isAppEnvTestingValue(appEnv) { + testing, found, err := loadEnvFile(startDirectory, envFileTesting) + if err != nil { + return environmentLoadPlan{}, err + } + if found { + mergeEnvironmentFile(&plan, testing) } } - // use testing envs when the environment indicates tests - if IsAppEnvTesting() { - if ok, path, keys := loadEnvFile(envFileTesting); ok { - loadedFiles = append(loadedFiles, path) - recordLoadedEnvKeys(keys) + if _, fileOwnsAppEnv := plan.fileValues["APP_ENV"]; !fileOwnsAppEnv { + ambient := originalEnvironmentSnapshot("APP_ENV", previous) + if !ambient.present || ambient.value == "" { + plan.defaults["APP_ENV"] = Local } } + plan.appEnv = environmentPlanValue(plan, previous, "APP_ENV") + return plan, nil +} - // display loaded env files - if GetInt("ENV_DEBUG", "0") >= 3 { - printLoadedEnvFiles(loadedFiles) +// mergeEnvironmentFile applies one already parsed file to the in-memory layer map. +func mergeEnvironmentFile(plan *environmentLoadPlan, file environmentFile) { + plan.files = append(plan.files, file.path) + for key, value := range file.values { + plan.fileValues[key] = value } +} - // mark as loaded - envLoaded = true +// effectiveEnvironmentValue uses stable originals for owned keys and live ambient values otherwise. +func effectiveEnvironmentValue(key string, fileValues map[string]string, previous map[string]loadedEnvironmentValue) string { + if value, ok := fileValues[key]; ok { + return value + } + snapshot := originalEnvironmentSnapshot(key, previous) + if !snapshot.present { + return "" + } + return snapshot.value +} - return nil +// originalEnvironmentSnapshot returns the stable ambient value for file-owned keys and the live value otherwise. +func originalEnvironmentSnapshot(key string, previous map[string]loadedEnvironmentValue) environmentSnapshot { + if loaded, ok := previous[key]; ok { + return loaded.original + } + value, present := envLookup(key) + return environmentSnapshot{value: value, present: present} +} + +// environmentPlanValue returns the value that will be visible after a successful plan application. +func environmentPlanValue(plan environmentLoadPlan, previous map[string]loadedEnvironmentValue, key string) string { + if value, ok := plan.fileValues[key]; ok { + return value + } + if value, ok := plan.defaults[key]; ok { + return value + } + snapshot := originalEnvironmentSnapshot(key, previous) + if !snapshot.present { + return "" + } + return snapshot.value +} + +// environmentPlanInt parses a planned integer without consulting partially applied process state. +func environmentPlanInt(plan environmentLoadPlan, previous map[string]loadedEnvironmentValue, key string) int { + value := environmentPlanValue(plan, previous, key) + parsed, _ := strconv.Atoi(value) + return parsed +} + +// applyEnvironmentLoadPlan rolls back every affected variable when any application step fails. +func applyEnvironmentLoadPlan(previous map[string]loadedEnvironmentValue, baseline map[string]environmentSnapshot, plan environmentLoadPlan) (map[string]loadedEnvironmentValue, error) { + keys := environmentPlanKeys(previous, plan) + before := make(map[string]environmentSnapshot, len(keys)) + for _, key := range keys { + value, present := envLookup(key) + before[key] = environmentSnapshot{value: value, present: present} + } + + for _, key := range keys { + target := environmentPlanTarget(key, previous, plan) + if snapshotsEqual(before[key], target) { + continue + } + if err := writeEnvironmentSnapshot(key, target); err != nil { + rollbackErr := restoreEnvironmentSnapshots(keys, before) + applyErr := fmt.Errorf("apply env value %s: %w", key, err) + if rollbackErr != nil { + return nil, errors.Join(applyErr, rollbackErr) + } + return nil, applyErr + } + } + + next := make(map[string]loadedEnvironmentValue, len(plan.fileValues)) + for key := range plan.fileValues { + if loaded, ok := previous[key]; ok { + next[key] = loaded + continue + } + next[key] = loadedEnvironmentValue{original: baseline[key]} + } + return next, nil +} + +// environmentPlanKeys returns affected keys in stable order for deterministic application and rollback. +func environmentPlanKeys(previous map[string]loadedEnvironmentValue, plan environmentLoadPlan) []string { + set := make(map[string]struct{}, len(previous)+len(plan.fileValues)+len(plan.defaults)) + for key := range previous { + set[key] = struct{}{} + } + for key := range plan.fileValues { + set[key] = struct{}{} + } + for key := range plan.defaults { + set[key] = struct{}{} + } + keys := make([]string, 0, len(set)) + for key := range set { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +// environmentPlanTarget resolves a key to a file value, default, or restored ambient snapshot. +func environmentPlanTarget(key string, previous map[string]loadedEnvironmentValue, plan environmentLoadPlan) environmentSnapshot { + if value, ok := plan.fileValues[key]; ok { + return environmentSnapshot{value: value, present: true} + } + if value, ok := plan.defaults[key]; ok { + return environmentSnapshot{value: value, present: true} + } + if loaded, ok := previous[key]; ok { + return loaded.original + } + return environmentSnapshot{} +} + +// restoreEnvironmentSnapshots restores the pre-application process state after a failed plan. +func restoreEnvironmentSnapshots(keys []string, snapshots map[string]environmentSnapshot) error { + var rollbackErrors []error + for index := len(keys) - 1; index >= 0; index-- { + key := keys[index] + if err := writeEnvironmentSnapshot(key, snapshots[key]); err != nil { + rollbackErrors = append(rollbackErrors, fmt.Errorf("restore env value %s: %w", key, err)) + } + } + if len(rollbackErrors) == 0 { + return nil + } + return fmt.Errorf("roll back process environment: %w", errors.Join(rollbackErrors...)) +} + +// writeEnvironmentSnapshot preserves unset and explicitly empty values as distinct states. +func writeEnvironmentSnapshot(key string, snapshot environmentSnapshot) error { + if snapshot.present { + return envSet(key, snapshot.value) + } + return envUnset(key) +} + +// snapshotsEqual avoids unnecessary process-wide environment writes. +func snapshotsEqual(left, right environmentSnapshot) bool { + return left.present == right.present && (!left.present || left.value == right.value) +} + +// cloneLoadedEnvironmentValues prevents failed operations from mutating published loader ownership state. +func cloneLoadedEnvironmentValues(values map[string]loadedEnvironmentValue) map[string]loadedEnvironmentValue { + clone := make(map[string]loadedEnvironmentValue, len(values)) + for key, value := range values { + clone[key] = value + } + return clone +} + +// snapshotProcessEnvironment records the exact ambient baseline before the first successful load. +func snapshotProcessEnvironment() map[string]environmentSnapshot { + snapshots := make(map[string]environmentSnapshot) + for _, entry := range os.Environ() { + key, value, found := strings.Cut(entry, "=") + if !found { + continue + } + snapshots[key] = environmentSnapshot{value: value, present: true} + } + return snapshots +} + +// cloneEnvironmentSnapshots prevents a failed operation from changing the published baseline. +func cloneEnvironmentSnapshots(snapshots map[string]environmentSnapshot) map[string]environmentSnapshot { + clone := make(map[string]environmentSnapshot, len(snapshots)) + for key, snapshot := range snapshots { + clone[key] = snapshot + } + return clone } // LoadEnvFileIfExists is a compatibility alias for Load. @@ -167,7 +449,7 @@ func envFileForAppEnv(appEnv string) (string, bool) { } } -// IsEnvLoaded reports whether Load or LoadEnvFileIfExists was executed in this process. +// IsEnvLoaded reports whether a Load or Reload completed successfully in this process. // @group Environment loading // @behavior readonly // @@ -177,59 +459,51 @@ func envFileForAppEnv(appEnv string) (string, bool) { // // #bool true (after Load) // // #bool false (otherwise) func IsEnvLoaded() bool { - return envLoaded -} - -// searches for .env file through directory traversal -// loads .env file if found -func loadEnvFile(envFile string) (bool, string, []string) { - var path string - found := false - for i := 0; i < MaxDirectorySeekLevels; i++ { - if _, err := os.Stat(path + envFile); err == nil { - path += envFile - found = true - break - } - path += "../" - } - - if found { - values, err := godotenv.Read(path) - if err != nil { - panic(err) - } - if err := godotenv.Overload(path); err != nil { - panic(err) - } - return true, path, mapKeys(values) - } - - return found, path, nil + processEnvironmentLoader.mu.Lock() + defer processEnvironmentLoader.mu.Unlock() + return processEnvironmentLoader.loaded } -func clearLoadedEnvKeys() { - for key := range loadedEnvKeys { - _ = os.Unsetenv(key) +// loadEnvFile returns the nearest parsed regular file without changing the process environment. +func loadEnvFile(startDirectory, name string) (environmentFile, bool, error) { + path, found, err := findEnvFile(startDirectory, name) + if err != nil || !found { + return environmentFile{}, found, err } - loadedEnvKeys = map[string]struct{}{} -} - -func recordLoadedEnvKeys(keys []string) { - for _, key := range keys { - loadedEnvKeys[key] = struct{}{} + values, err := envFileRead(path) + if err != nil { + return environmentFile{}, false, fmt.Errorf("read env file %s: %w", path, err) } + return environmentFile{path: path, values: values}, true, nil } -func mapKeys(values map[string]string) []string { - keys := make([]string, 0, len(values)) - for key := range values { - keys = append(keys, key) +// findEnvFile performs exactly the documented bounded nearest-ancestor search and follows regular-file symlinks. +func findEnvFile(startDirectory, name string) (string, bool, error) { + directory := filepath.Clean(startDirectory) + for level := 0; level < MaxDirectorySeekLevels; level++ { + candidate := filepath.Join(directory, name) + info, err := envFileStat(candidate) + switch { + case err == nil: + if !info.Mode().IsRegular() { + return "", false, fmt.Errorf("env file %s is not a regular file", candidate) + } + return candidate, true, nil + case errors.Is(err, os.ErrNotExist): + // Missing candidates are the only errors that permit ancestor fallback. + default: + return "", false, fmt.Errorf("stat env file %s: %w", candidate, err) + } + + parent := filepath.Dir(directory) + if parent == directory { + break + } + directory = parent } - return keys + return "", false, nil } -// ANSI color codes const ( colorGray = "\033[90m" colorReset = "\033[0m" @@ -245,12 +519,9 @@ 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 - } +// printLoadedEnvFiles reports filenames and APP_ENV only; file values are intentionally never logged. +func printLoadedEnvFiles(paths []string, appEnv string) { for _, path := range paths { - fmt.Printf(" %s .env file loader · env [%v] file [%v]\n", debugMark(), os.Getenv("APP_ENV"), path) + fmt.Fprintf(os.Stdout, " %s .env file loader · env [%v] file [%v]\n", debugMark(), appEnv, path) } } diff --git a/loader_test.go b/loader_test.go index f37d210..d6dbda8 100644 --- a/loader_test.go +++ b/loader_test.go @@ -1,421 +1,638 @@ package env import ( + "errors" "io" "os" + "path/filepath" + "reflect" "strings" + "sync" "testing" + "time" ) -func TestLoad_testingEnv(t *testing.T) { - loadedEnvKeys = map[string]struct{}{} - tempDir := t.TempDir() - dotEnvFile := tempDir + "/.env.testing" - baseEnvFile := tempDir + "/.env" - - // Write mock .env.testing - err := os.WriteFile(dotEnvFile, []byte("FAKE_ENV_TESTING=testing_value\nENV_DEBUG=0"), 0644) +// prepareLoaderTest isolates process-wide loader state and shims for one test. +func prepareLoaderTest(t *testing.T, keys ...string) { + t.Helper() + restoreEnvironment := snapshotEnv(append(keys, "APP_ENV")) + originalDirectory, err := os.Getwd() 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) - } + t.Fatalf("get working directory: %v", err) + } + + originalGetwd := envFileGetwd + originalStat := envFileStat + originalRead := envFileRead + originalLookup := envLookup + originalSet := envSet + originalUnset := envUnset + originalStatFile := statFile + originalReadFile := readFile + originalGetEnv := getEnv + + processEnvironmentLoader.mu.Lock() + originalLoaded := processEnvironmentLoader.loaded + originalValues := cloneLoadedEnvironmentValues(processEnvironmentLoader.values) + originalBaseline := cloneEnvironmentSnapshots(processEnvironmentLoader.baseline) + processEnvironmentLoader.loaded = false + processEnvironmentLoader.values = make(map[string]loadedEnvironmentValue) + processEnvironmentLoader.baseline = make(map[string]environmentSnapshot) + processEnvironmentLoader.mu.Unlock() - // Save original working dir to restore later - originalDir, err := os.Getwd() - if err != nil { - t.Fatalf("Failed to get working directory: %v", err) - } - defer os.Chdir(originalDir) // restore after test + t.Cleanup(func() { + envFileGetwd = originalGetwd + envFileStat = originalStat + envFileRead = originalRead + envLookup = originalLookup + envSet = originalSet + envUnset = originalUnset + statFile = originalStatFile + readFile = originalReadFile + getEnv = originalGetEnv + _ = os.Chdir(originalDirectory) + restoreEnvironment() + + processEnvironmentLoader.mu.Lock() + processEnvironmentLoader.loaded = originalLoaded + processEnvironmentLoader.values = originalValues + processEnvironmentLoader.baseline = originalBaseline + processEnvironmentLoader.mu.Unlock() + }) +} + +// changeWorkingDirectory moves the process into directory until the test cleanup runs. +func changeWorkingDirectory(t *testing.T, directory string) { + t.Helper() + changeWorkingDirectoryWithin(t, directory, directory) +} - // Move to the temp directory where our mock .env.testing exists - if err := os.Chdir(tempDir); err != nil { - t.Fatalf("Failed to change working directory: %v", err) +// changeWorkingDirectoryWithin prevents unrelated ancestor files from contaminating loader tests. +func changeWorkingDirectoryWithin(t *testing.T, directory, searchRoot string) { + t.Helper() + if err := os.Chdir(directory); err != nil { + t.Fatalf("change working directory: %v", err) + } + root := filepath.Clean(searchRoot) + stat := envFileStat + envFileStat = func(path string) (os.FileInfo, error) { + relative, err := filepath.Rel(root, filepath.Clean(path)) + if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return nil, os.ErrNotExist + } + return stat(path) } +} - // Set environment to "testing" to trigger .env.testing logic - _ = os.Setenv("APP_ENV", "testing") +// writeEnvFile creates a test env file with predictable permissions. +func writeEnvFile(t *testing.T, directory, name, contents string) { + t.Helper() + if err := os.WriteFile(filepath.Join(directory, name), []byte(contents), 0o644); err != nil { + t.Fatalf("write %s: %v", name, err) + } +} - // Reset internal state - envLoaded = false +// TestLoadAppliesLayersInOrder ensures later environment-specific files override earlier base values. +func TestLoadAppliesLayersInOrder(t *testing.T) { + prepareLoaderTest(t, "ENV_QPASS_SHARED", "ENV_QPASS_BASE", "ENV_QPASS_LAYER", "ENV_QPASS_TEST") + t.Setenv("APP_ENV", Staging) + t.Setenv("ENV_QPASS_SHARED", "ambient") + directory := t.TempDir() + writeEnvFile(t, directory, fileEnv, "ENV_QPASS_BASE=base\nENV_QPASS_SHARED=base\n") + writeEnvFile(t, directory, envFileStaging, "ENV_QPASS_LAYER=staging\nENV_QPASS_SHARED=staging\n") + writeEnvFile(t, directory, envFileTesting, "ENV_QPASS_TEST=test\nENV_QPASS_SHARED=testing\n") + changeWorkingDirectory(t, directory) - err = Load() - if err != nil { - t.Fatalf("Load failed: %v", err) + if err := Load(); err != nil { + t.Fatalf("Load: %v", err) } - val := os.Getenv("FAKE_ENV_TESTING") - if val != "testing_value" { - t.Errorf("Expected FAKE_ENV_TESTING to be 'testing_value', got %s", val) + want := map[string]string{ + "ENV_QPASS_BASE": "base", + "ENV_QPASS_LAYER": "staging", + "ENV_QPASS_TEST": "test", + "ENV_QPASS_SHARED": "testing", } - baseVal := os.Getenv("FAKE_ENV_BASE") - if baseVal != "base_value" { - t.Errorf("Expected FAKE_ENV_BASE to be 'base_value', got %s", baseVal) + for key, expected := range want { + if got := os.Getenv(key); got != expected { + t.Fatalf("expected %s=%q, got %q", key, expected, got) + } } - if !IsEnvLoaded() { - t.Error("Expected IsEnvLoaded to return true") + t.Fatal("expected successful Load to publish loaded state") } } -func TestLoad_NoFile(t *testing.T) { - wd, _ := os.Getwd() - t.Cleanup(func() { - envLoaded = false - loadedEnvKeys = map[string]struct{}{} - _ = os.Chdir(wd) - }) - - tmp := t.TempDir() - _ = os.Chdir(tmp) +// TestLoadBaseSelectsApplicationLayer ensures APP_ENV selects the matching application-specific file. +func TestLoadBaseSelectsApplicationLayer(t *testing.T) { + prepareLoaderTest(t, "ENV_QPASS_LAYER") + t.Setenv("APP_ENV", Local) + directory := t.TempDir() + writeEnvFile(t, directory, fileEnv, "APP_ENV=production\n") + writeEnvFile(t, directory, envFileProd, "ENV_QPASS_LAYER=production\n") + changeWorkingDirectory(t, directory) if err := Load(); err != nil { - t.Fatalf("expected no error when env file missing: %v", err) + t.Fatalf("Load: %v", err) } - if !IsEnvLoaded() { - t.Fatalf("expected envLoaded flag set") + if got := os.Getenv("ENV_QPASS_LAYER"); got != Production { + t.Fatalf("expected base APP_ENV to select production, got %q", got) } } -func TestLoad_WithDotEnvHostBranch(t *testing.T) { - defer reset() - - wd, _ := os.Getwd() - t.Cleanup(func() { - envLoaded = false - loadedEnvKeys = map[string]struct{}{} - _ = os.Chdir(wd) - _ = os.Unsetenv("HOST_BRANCH") - }) - - tmp := t.TempDir() - if err := os.WriteFile(tmp+"/.env.testing", []byte("ENV_DEBUG=3"), 0o644); err != nil { - t.Fatalf("write .env.testing: %v", err) +// TestLoadSearchesEachLayerIndependently ensures each filename uses its own nearest ancestor match. +func TestLoadSearchesEachLayerIndependently(t *testing.T) { + prepareLoaderTest(t, "ENV_QPASS_BASE", "ENV_QPASS_LAYER") + t.Setenv("APP_ENV", Local) + parent := t.TempDir() + child := filepath.Join(parent, "child") + if err := os.Mkdir(child, 0o755); err != nil { + t.Fatalf("make child: %v", err) } - if err := os.WriteFile(tmp+"/.env.host", []byte("HOST_BRANCH=hit\nENV_DEBUG=3"), 0o644); err != nil { - t.Fatalf("write .env.host: %v", err) - } - - statFile = func(path string) (os.FileInfo, error) { - switch path { - case fileDockerEnv, fileDockerSock: - return nil, nil - default: - return nil, os.ErrNotExist - } - } - readFile = func(path string) ([]byte, error) { return []byte("0::/docker/xyz"), nil } - mockEnv(nil) + writeEnvFile(t, parent, fileEnv, "ENV_QPASS_BASE=parent\n") + writeEnvFile(t, child, envFileLocal, "ENV_QPASS_LAYER=child\n") + changeWorkingDirectoryWithin(t, child, parent) - _ = os.Chdir(tmp) - envLoaded = false if err := Load(); err != nil { t.Fatalf("Load: %v", err) } - if os.Getenv("HOST_BRANCH") != "hit" { - t.Fatalf("expected HOST_BRANCH to load from .env.host") + if os.Getenv("ENV_QPASS_BASE") != "parent" || os.Getenv("ENV_QPASS_LAYER") != "child" { + t.Fatalf("expected independent nearest-file lookup") } } -func TestLoadEnvFile_NotFound(t *testing.T) { - if ok, _, _ := loadEnvFile("does-not-exist"); ok { - t.Fatalf("expected false when file missing") +// TestLoadAppliesHostLayer ensures host-specific values participate at their documented precedence. +func TestLoadAppliesHostLayer(t *testing.T) { + prepareLoaderTest(t, "ENV_QPASS_HOST") + t.Setenv("APP_ENV", Local) + directory := t.TempDir() + writeEnvFile(t, directory, fileEnvHost, "ENV_QPASS_HOST=host\n") + changeWorkingDirectory(t, directory) + statFile = func(string) (os.FileInfo, error) { return nil, os.ErrNotExist } + readFile = func(string) ([]byte, error) { return []byte("0::/user.slice"), nil } + + if err := Load(); err != nil { + t.Fatalf("Load: %v", err) + } + if got := os.Getenv("ENV_QPASS_HOST"); got != "host" { + t.Fatalf("expected host layer, got %q", got) } } -func TestLoadEnvFile_PanicsOnBadFile(t *testing.T) { - tmp := t.TempDir() - _ = os.Mkdir(tmp+"/.env.testing", 0o755) - - wd, _ := os.Getwd() - _ = os.Chdir(tmp) - t.Cleanup(func() { _ = os.Chdir(wd) }) - - expectPanic(t, "loadEnvFile panic", func() { - loadEnvFile(".env.testing") - }) -} +// TestLoadDefaultsAppEnvWithoutOwningIt ensures a synthesized development mode remains caller-owned state. +func TestLoadDefaultsAppEnvWithoutOwningIt(t *testing.T) { + prepareLoaderTest(t, "ENV_QPASS_PRODUCTION") + _ = os.Unsetenv("APP_ENV") + directory := t.TempDir() + writeEnvFile(t, directory, envFileProd, "ENV_QPASS_PRODUCTION=yes\n") + changeWorkingDirectory(t, directory) -func TestPrintLoadedEnvFiles_NoPaths(t *testing.T) { - output := captureStdout(t, func() { - printLoadedEnvFiles(nil) - }) - if output != "" { - t.Fatalf("expected no output, got %q", output) + if err := Load(); err != nil { + t.Fatalf("Load: %v", err) + } + if got := os.Getenv("APP_ENV"); got != Local { + t.Fatalf("expected default APP_ENV=%q, got %q", Local, got) } -} -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) + t.Setenv("APP_ENV", Production) + if err := Reload(); err != nil { + t.Fatalf("Reload: %v", err) } - if !strings.Contains(output, "file [./.env]") || !strings.Contains(output, "file [./.env.testing]") { - t.Fatalf("expected output to include paths, got %q", output) + if got := os.Getenv("ENV_QPASS_PRODUCTION"); got != "yes" { + t.Fatalf("expected caller APP_ENV to select production, got %q", got) } } -func TestEnvFileForAppEnv(t *testing.T) { - cases := map[string]string{ - Local: ".env.local", - Staging: ".env.staging", - Production: ".env.production", - } +// TestLoadIsIdempotent ensures repeated loads do not drift the effective environment. +func TestLoadIsIdempotent(t *testing.T) { + prepareLoaderTest(t, "ENV_QPASS_IDEMPOTENT") + t.Setenv("APP_ENV", Local) + directory := t.TempDir() + writeEnvFile(t, directory, fileEnv, "ENV_QPASS_IDEMPOTENT=file\n") + changeWorkingDirectory(t, directory) - for appEnv, expected := range cases { - t.Run(appEnv, func(t *testing.T) { - got, ok := envFileForAppEnv(appEnv) - if !ok { - t.Fatalf("expected env file for %s", appEnv) - } - if got != expected { - t.Fatalf("expected %s, got %s", expected, got) - } - }) + if err := Load(); err != nil { + t.Fatalf("Load: %v", err) } - - if _, ok := envFileForAppEnv(Testing); ok { - t.Fatalf("expected no env file for %s", Testing) + t.Setenv("ENV_QPASS_IDEMPOTENT", "runtime") + if err := Load(); err != nil { + t.Fatalf("second Load: %v", err) } - if _, ok := envFileForAppEnv("unknown"); ok { - t.Fatalf("expected no env file for unknown") + if got := os.Getenv("ENV_QPASS_IDEMPOTENT"); got != "runtime" { + t.Fatalf("expected repeated Load to be a no-op, got %q", got) } } -func TestLoad_LayeredLocal(t *testing.T) { - wd, _ := os.Getwd() - t.Cleanup(func() { - envLoaded = false - loadedEnvKeys = map[string]struct{}{} - _ = os.Chdir(wd) - }) +// TestReloadReappliesAndRestoresFileOwnedKeys ensures removed file values return to their pre-load baseline. +func TestReloadReappliesAndRestoresFileOwnedKeys(t *testing.T) { + prepareLoaderTest(t, "ENV_QPASS_RELOAD", "ENV_QPASS_RESTORE", "ENV_QPASS_ABSENT", "ENV_QPASS_UNRELATED") + t.Setenv("APP_ENV", Local) + t.Setenv("ENV_QPASS_RESTORE", "ambient") + _ = os.Unsetenv("ENV_QPASS_ABSENT") + t.Setenv("ENV_QPASS_UNRELATED", "before") + directory := t.TempDir() + writeEnvFile(t, directory, fileEnv, "ENV_QPASS_RELOAD=first\nENV_QPASS_RESTORE=file\nENV_QPASS_ABSENT=file\n") + changeWorkingDirectory(t, directory) - tmp := t.TempDir() - if err := os.WriteFile(tmp+"/.env", []byte("BASE=base\nLAYER=base\n"), 0o644); err != nil { - t.Fatalf("write .env: %v", err) + if err := Load(); err != nil { + t.Fatalf("Load: %v", err) } - if err := os.WriteFile(tmp+"/.env.local", []byte("LAYER=local\nLOCAL_ONLY=1\n"), 0o644); err != nil { - t.Fatalf("write .env.local: %v", err) + t.Setenv("ENV_QPASS_RELOAD", "runtime") + t.Setenv("ENV_QPASS_UNRELATED", "after") + writeEnvFile(t, directory, fileEnv, "ENV_QPASS_RELOAD=second\n") + if err := Reload(); err != nil { + t.Fatalf("Reload: %v", err) } - _ = os.Chdir(tmp) - _ = os.Setenv("APP_ENV", Local) - envLoaded = false - - if err := Load(); err != nil { - t.Fatalf("Load: %v", err) + if got := os.Getenv("ENV_QPASS_RELOAD"); got != "second" { + t.Fatalf("expected file to replace runtime edit, got %q", got) } - if got := os.Getenv("LAYER"); got != "local" { - t.Fatalf("expected LAYER to be local, got %q", got) + if got := os.Getenv("ENV_QPASS_RESTORE"); got != "ambient" { + t.Fatalf("expected original ambient restoration, got %q", got) } - if got := os.Getenv("LOCAL_ONLY"); got != "1" { - t.Fatalf("expected LOCAL_ONLY to be set, got %q", got) + if _, present := os.LookupEnv("ENV_QPASS_ABSENT"); present { + t.Fatal("expected originally absent key to become absent again") } - if got := os.Getenv("BASE"); got != "base" { - t.Fatalf("expected BASE to be base, got %q", got) + if got := os.Getenv("ENV_QPASS_UNRELATED"); got != "after" { + t.Fatalf("expected unrelated key to remain untouched, got %q", got) } } -func TestLoad_LayeredStaging(t *testing.T) { - wd, _ := os.Getwd() - t.Cleanup(func() { - envLoaded = false - loadedEnvKeys = map[string]struct{}{} - _ = os.Chdir(wd) - }) +// TestReloadRestoresBaselineForKeysClaimedLater ensures newly managed keys retain their original ambient value. +func TestReloadRestoresBaselineForKeysClaimedLater(t *testing.T) { + prepareLoaderTest(t, "ENV_QPASS_LATE_CLAIM") + t.Setenv("APP_ENV", Local) + _ = os.Unsetenv("ENV_QPASS_LATE_CLAIM") + directory := t.TempDir() + changeWorkingDirectory(t, directory) - tmp := t.TempDir() - if err := os.WriteFile(tmp+"/.env", []byte("SHARED=base\n"), 0o644); err != nil { - t.Fatalf("write .env: %v", err) + if err := Load(); err != nil { + t.Fatalf("Load: %v", err) + } + t.Setenv("ENV_QPASS_LATE_CLAIM", "runtime-after-load") + writeEnvFile(t, directory, fileEnv, "ENV_QPASS_LATE_CLAIM=file\n") + if err := Reload(); err != nil { + t.Fatalf("Reload claiming key: %v", err) + } + writeEnvFile(t, directory, fileEnv, "") + if err := Reload(); err != nil { + t.Fatalf("Reload removing key: %v", err) } - if err := os.WriteFile(tmp+"/.env.staging", []byte("SHARED=staging\nSTAGING_ONLY=1\n"), 0o644); err != nil { - t.Fatalf("write .env.staging: %v", err) + if _, present := os.LookupEnv("ENV_QPASS_LATE_CLAIM"); present { + t.Fatal("expected a later-claimed key to restore the pre-first-load absent state") } +} - _ = os.Chdir(tmp) - _ = os.Setenv("APP_ENV", Staging) - envLoaded = false +// TestReloadRefreshesFileOwnedAppEnv ensures a file-controlled APP_ENV can select a new layer transactionally. +func TestReloadRefreshesFileOwnedAppEnv(t *testing.T) { + prepareLoaderTest(t, "ENV_QPASS_STAGE", "ENV_QPASS_PROD") + t.Setenv("APP_ENV", Local) + directory := t.TempDir() + writeEnvFile(t, directory, fileEnv, "APP_ENV=staging\n") + writeEnvFile(t, directory, envFileStaging, "ENV_QPASS_STAGE=yes\n") + writeEnvFile(t, directory, envFileProd, "ENV_QPASS_PROD=yes\n") + changeWorkingDirectory(t, directory) if err := Load(); err != nil { t.Fatalf("Load: %v", err) } - if got := os.Getenv("SHARED"); got != "staging" { - t.Fatalf("expected SHARED to be staging, got %q", got) + writeEnvFile(t, directory, fileEnv, "APP_ENV=production\n") + if err := Reload(); err != nil { + t.Fatalf("Reload: %v", err) } - if got := os.Getenv("STAGING_ONLY"); got != "1" { - t.Fatalf("expected STAGING_ONLY to be set, got %q", got) + if got := os.Getenv("APP_ENV"); got != Production { + t.Fatalf("expected refreshed APP_ENV, got %q", got) } -} - -func TestLoad_LayeredProduction(t *testing.T) { - wd, _ := os.Getwd() - t.Cleanup(func() { - envLoaded = false - loadedEnvKeys = map[string]struct{}{} - _ = os.Chdir(wd) - }) - - tmp := t.TempDir() - if err := os.WriteFile(tmp+"/.env", []byte("SHARED=base\n"), 0o644); err != nil { - t.Fatalf("write .env: %v", err) + if got := os.Getenv("ENV_QPASS_PROD"); got != "yes" { + t.Fatalf("expected refreshed APP_ENV to select production, got %q", got) } - if err := os.WriteFile(tmp+"/.env.production", []byte("SHARED=prod\nPROD_ONLY=1\n"), 0o644); err != nil { - t.Fatalf("write .env.production: %v", err) + if _, present := os.LookupEnv("ENV_QPASS_STAGE"); present { + t.Fatal("expected staging-only key to be restored") } - _ = os.Chdir(tmp) - _ = os.Setenv("APP_ENV", Production) - envLoaded = false - - if err := Load(); err != nil { - t.Fatalf("Load: %v", err) - } - if got := os.Getenv("SHARED"); got != "prod" { - t.Fatalf("expected SHARED to be prod, got %q", got) + writeEnvFile(t, directory, fileEnv, "") + if err := Reload(); err != nil { + t.Fatalf("Reload removing file-owned APP_ENV: %v", err) } - if got := os.Getenv("PROD_ONLY"); got != "1" { - t.Fatalf("expected PROD_ONLY to be set, got %q", got) + if got := os.Getenv("APP_ENV"); got != Local { + t.Fatalf("expected removed file-owned APP_ENV to restore caller value, got %q", got) } } -func TestLoad_NoReload(t *testing.T) { - wd, _ := os.Getwd() - t.Cleanup(func() { - envLoaded = false - loadedEnvKeys = map[string]struct{}{} - _ = os.Chdir(wd) +// TestLoadReturnsDiscoveryAndParseErrorsWithoutMutation ensures failed discovery or parsing cannot partially change process state. +func TestLoadReturnsDiscoveryAndParseErrorsWithoutMutation(t *testing.T) { + t.Run("stat error", func(t *testing.T) { + prepareLoaderTest(t, "ENV_QPASS_UNCHANGED") + t.Setenv("APP_ENV", Local) + t.Setenv("ENV_QPASS_UNCHANGED", "ambient") + directory := t.TempDir() + changeWorkingDirectory(t, directory) + envFileStat = func(path string) (os.FileInfo, error) { + if filepath.Base(path) == fileEnv { + return nil, os.ErrPermission + } + return nil, os.ErrNotExist + } + + if err := Load(); !errors.Is(err, os.ErrPermission) { + t.Fatalf("expected permission error, got %v", err) + } + if os.Getenv("ENV_QPASS_UNCHANGED") != "ambient" || IsEnvLoaded() { + t.Fatal("expected failed discovery to leave environment and state unchanged") + } }) - tmp := t.TempDir() - if err := os.WriteFile(tmp+"/.env", []byte("SHOULD_NOT_LOAD=1\n"), 0o644); err != nil { - t.Fatalf("write .env: %v", err) - } + t.Run("parse error", func(t *testing.T) { + prepareLoaderTest(t, "ENV_QPASS_UNCHANGED") + t.Setenv("APP_ENV", Local) + t.Setenv("ENV_QPASS_UNCHANGED", "ambient") + directory := t.TempDir() + writeEnvFile(t, directory, fileEnv, "ENV_QPASS_UNCHANGED='unterminated\n") + changeWorkingDirectory(t, directory) - _ = os.Chdir(tmp) - envLoaded = true + if err := Load(); err == nil { + t.Fatal("expected malformed env file error") + } + if os.Getenv("ENV_QPASS_UNCHANGED") != "ambient" || IsEnvLoaded() { + t.Fatal("expected failed parse to leave environment and state unchanged") + } + }) - if err := Load(); err != nil { - t.Fatalf("Load: %v", err) - } - if got := os.Getenv("SHOULD_NOT_LOAD"); got != "" { - t.Fatalf("expected SHOULD_NOT_LOAD to remain unset, got %q", got) - } -} + t.Run("non-regular file", func(t *testing.T) { + prepareLoaderTest(t) + t.Setenv("APP_ENV", Local) + directory := t.TempDir() + if err := os.Mkdir(filepath.Join(directory, fileEnv), 0o755); err != nil { + t.Fatalf("make env directory: %v", err) + } + changeWorkingDirectory(t, directory) -func TestReload_ReappliesEnvFiles(t *testing.T) { - wd, _ := os.Getwd() - t.Cleanup(func() { - envLoaded = false - loadedEnvKeys = map[string]struct{}{} - _ = os.Chdir(wd) - _ = os.Unsetenv("RELOAD_ME") - _ = os.Unsetenv("RELOAD_GONE") + if err := Load(); err == nil || !strings.Contains(err.Error(), "not a regular file") { + t.Fatalf("expected regular-file error, got %v", err) + } }) +} - tmp := t.TempDir() - if err := os.WriteFile(tmp+"/.env", []byte("RELOAD_ME=first\nRELOAD_GONE=present\n"), 0o644); err != nil { - t.Fatalf("write .env: %v", err) +// TestLoadRollsBackApplicationFailure ensures an application-layer failure restores every earlier mutation. +func TestLoadRollsBackApplicationFailure(t *testing.T) { + prepareLoaderTest(t, "ENV_QPASS_A", "ENV_QPASS_B") + t.Setenv("APP_ENV", Local) + t.Setenv("ENV_QPASS_A", "ambient-a") + t.Setenv("ENV_QPASS_B", "ambient-b") + directory := t.TempDir() + writeEnvFile(t, directory, fileEnv, "ENV_QPASS_A=file-a\nENV_QPASS_B=file-b\n") + changeWorkingDirectory(t, directory) + + failed := false + envSet = func(key, value string) error { + if key == "ENV_QPASS_B" && value == "file-b" && !failed { + failed = true + return errors.New("injected set failure") + } + return os.Setenv(key, value) } + if err := Load(); err == nil { + t.Fatal("expected application error") + } + if os.Getenv("ENV_QPASS_A") != "ambient-a" || os.Getenv("ENV_QPASS_B") != "ambient-b" { + t.Fatal("expected failed Load to roll back all affected keys") + } + if IsEnvLoaded() { + t.Fatal("expected failed Load to leave state unpublished") + } +} - _ = os.Chdir(tmp) - envLoaded = false - +// TestReloadFailurePreservesPreviousConfiguration ensures a failed refresh leaves the last valid configuration active. +func TestReloadFailurePreservesPreviousConfiguration(t *testing.T) { + prepareLoaderTest(t, "ENV_QPASS_A", "ENV_QPASS_B") + t.Setenv("APP_ENV", Local) + directory := t.TempDir() + writeEnvFile(t, directory, fileEnv, "ENV_QPASS_A=old-a\nENV_QPASS_B=old-b\n") + changeWorkingDirectory(t, directory) if err := Load(); err != nil { t.Fatalf("Load: %v", err) } - if got := os.Getenv("RELOAD_ME"); got != "first" { - t.Fatalf("expected RELOAD_ME=first after Load, got %q", got) + + writeEnvFile(t, directory, fileEnv, "ENV_QPASS_A=new-a\nENV_QPASS_B=new-b\n") + failed := false + envSet = func(key, value string) error { + if key == "ENV_QPASS_B" && value == "new-b" && !failed { + failed = true + return errors.New("injected reload failure") + } + return os.Setenv(key, value) } - if got := os.Getenv("RELOAD_GONE"); got != "present" { - t.Fatalf("expected RELOAD_GONE=present after Load, got %q", got) + if err := Reload(); err == nil { + t.Fatal("expected Reload application error") } - - if err := os.WriteFile(tmp+"/.env", []byte("RELOAD_ME=second\n"), 0o644); err != nil { - t.Fatalf("rewrite .env: %v", err) + if os.Getenv("ENV_QPASS_A") != "old-a" || os.Getenv("ENV_QPASS_B") != "old-b" { + t.Fatal("expected failed Reload to preserve previous successful configuration") + } + if !IsEnvLoaded() { + t.Fatal("expected previous successful loaded state to remain published") } + envSet = os.Setenv if err := Reload(); err != nil { - t.Fatalf("Reload: %v", err) - } - if got := os.Getenv("RELOAD_ME"); got != "second" { - t.Fatalf("expected RELOAD_ME=second after Reload, got %q", got) + t.Fatalf("retry Reload: %v", err) } - if got := os.Getenv("RELOAD_GONE"); got != "" { - t.Fatalf("expected RELOAD_GONE to be unset after Reload, got %q", got) + if os.Getenv("ENV_QPASS_A") != "new-a" || os.Getenv("ENV_QPASS_B") != "new-b" { + t.Fatal("expected retry to apply new configuration") } } -func TestLoad_DefaultsAppEnv(t *testing.T) { - wd, _ := os.Getwd() - t.Cleanup(func() { - envLoaded = false - loadedEnvKeys = map[string]struct{}{} - _ = os.Chdir(wd) - _ = os.Unsetenv("APP_ENV") +// TestFindEnvFileUsesBoundedNearestSearch ensures discovery prefers proximity and stops at the documented ancestor limit. +func TestFindEnvFileUsesBoundedNearestSearch(t *testing.T) { + t.Run("finds ninth ancestor", func(t *testing.T) { + root := t.TempDir() + writeEnvFile(t, root, fileEnv, "A=1\n") + start := root + for index := 0; index < MaxDirectorySeekLevels-1; index++ { + start = filepath.Join(start, "child") + if err := os.Mkdir(start, 0o755); err != nil { + t.Fatalf("make nested directory: %v", err) + } + } + path, found, err := findEnvFile(start, fileEnv) + if err != nil || !found || path != filepath.Join(root, fileEnv) { + t.Fatalf("expected ninth-ancestor file, got path=%q found=%v err=%v", path, found, err) + } }) - tmp := t.TempDir() - _ = os.Chdir(tmp) - _ = os.Unsetenv("APP_ENV") - envLoaded = false + t.Run("does not inspect tenth ancestor", func(t *testing.T) { + root := t.TempDir() + writeEnvFile(t, root, fileEnv, "A=1\n") + start := root + for index := 0; index < MaxDirectorySeekLevels; index++ { + start = filepath.Join(start, "child") + if err := os.Mkdir(start, 0o755); err != nil { + t.Fatalf("make nested directory: %v", err) + } + } + if path, found, err := findEnvFile(start, fileEnv); err != nil || found || path != "" { + t.Fatalf("expected bounded miss, got path=%q found=%v err=%v", path, found, err) + } + }) +} + +// TestLoadFollowsRegularFileSymlink ensures deployed symlinked dotenv files remain valid when their targets are regular files. +func TestLoadFollowsRegularFileSymlink(t *testing.T) { + prepareLoaderTest(t, "ENV_QPASS_SYMLINK") + t.Setenv("APP_ENV", Local) + directory := t.TempDir() + target := filepath.Join(directory, "values.env") + if err := os.WriteFile(target, []byte("ENV_QPASS_SYMLINK=yes\n"), 0o644); err != nil { + t.Fatalf("write symlink target: %v", err) + } + if err := os.Symlink(target, filepath.Join(directory, fileEnv)); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + changeWorkingDirectory(t, directory) if err := Load(); err != nil { t.Fatalf("Load: %v", err) } - if got := os.Getenv("APP_ENV"); got != Local { - t.Fatalf("expected APP_ENV to default to %s, got %q", Local, got) + if got := os.Getenv("ENV_QPASS_SYMLINK"); got != "yes" { + t.Fatalf("expected symlinked env value, got %q", got) } } -func TestLoadEnvFileIfExists_Alias(t *testing.T) { - wd, _ := os.Getwd() - t.Cleanup(func() { - envLoaded = false - loadedEnvKeys = map[string]struct{}{} - _ = os.Chdir(wd) - }) +// TestLoadReloadAndStateAreConcurrentSafe ensures process-wide environment ownership remains coherent under concurrent access. +func TestLoadReloadAndStateAreConcurrentSafe(t *testing.T) { + prepareLoaderTest(t, "ENV_QPASS_CONCURRENT") + t.Setenv("APP_ENV", Local) + directory := t.TempDir() + writeEnvFile(t, directory, fileEnv, "ENV_QPASS_CONCURRENT=value\n") + changeWorkingDirectory(t, directory) + + const workers = 60 + errorsFound := make(chan error, workers) + done := make(chan struct{}) + go func() { + var wait sync.WaitGroup + for index := 0; index < workers; index++ { + wait.Add(1) + go func(index int) { + defer wait.Done() + var err error + if index%3 == 0 { + err = Reload() + } else { + err = Load() + } + _ = IsEnvLoaded() + if err != nil { + errorsFound <- err + } + }(index) + } + wait.Wait() + close(done) + }() - tmp := t.TempDir() - if err := os.WriteFile(tmp+"/.env", []byte("ALIAS_WORKS=1\n"), 0o644); err != nil { - t.Fatalf("write .env: %v", err) + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("concurrent loader operations exceeded hard deadline") + } + close(errorsFound) + for err := range errorsFound { + t.Fatalf("concurrent loader operation: %v", err) + } + if got := os.Getenv("ENV_QPASS_CONCURRENT"); got != "value" { + t.Fatalf("expected stable concurrent value, got %q", got) } +} + +// TestLoadDebugOutputDoesNotExposeValues ensures diagnostics cannot disclose loaded secrets. +func TestLoadDebugOutputDoesNotExposeValues(t *testing.T) { + prepareLoaderTest(t, "ENV_QPASS_SECRET") + t.Setenv("APP_ENV", Local) + directory := t.TempDir() + writeEnvFile(t, directory, fileEnv, "ENV_DEBUG=3\nENV_QPASS_SECRET=do-not-print-me\n") + changeWorkingDirectory(t, directory) - _ = os.Chdir(tmp) - envLoaded = false + output := captureStdout(t, func() { + if err := Load(); err != nil { + t.Fatalf("Load: %v", err) + } + }) + if strings.Contains(output, "do-not-print-me") || strings.Contains(output, "ENV_QPASS_SECRET") { + t.Fatalf("debug output exposed env data: %q", output) + } + if !strings.Contains(output, "env [local]") || !strings.Contains(output, filepath.Join(directory, fileEnv)) { + t.Fatalf("expected paths and APP_ENV in debug output, got %q", output) + } +} + +// TestLoadEnvFileIfExistsAliasesLoad ensures the compatibility entry point retains Load semantics. +func TestLoadEnvFileIfExistsAliasesLoad(t *testing.T) { + prepareLoaderTest(t, "ENV_QPASS_ALIAS") + t.Setenv("APP_ENV", Local) + directory := t.TempDir() + writeEnvFile(t, directory, fileEnv, "ENV_QPASS_ALIAS=yes\n") + changeWorkingDirectory(t, directory) if err := LoadEnvFileIfExists(); err != nil { t.Fatalf("LoadEnvFileIfExists: %v", err) } - if got := os.Getenv("ALIAS_WORKS"); got != "1" { - t.Fatalf("expected ALIAS_WORKS to be set, got %q", got) + if got := os.Getenv("ENV_QPASS_ALIAS"); got != "yes" { + t.Fatalf("expected alias to load value, got %q", got) + } +} + +// TestEnvFileForAppEnv ensures application modes map to stable dotenv filenames. +func TestEnvFileForAppEnv(t *testing.T) { + cases := []struct { + appEnv string + file string + found bool + }{ + {appEnv: Local, file: envFileLocal, found: true}, + {appEnv: Staging, file: envFileStaging, found: true}, + {appEnv: Production, file: envFileProd, found: true}, + {appEnv: Testing}, + {appEnv: "unknown"}, + } + for _, test := range cases { + got, found := envFileForAppEnv(test.appEnv) + if got != test.file || found != test.found { + t.Fatalf("envFileForAppEnv(%q) = %q, %v; want %q, %v", test.appEnv, got, found, test.file, test.found) + } + } +} + +// TestEnvironmentPlanKeysAreDeterministic ensures transactional application order is reproducible. +func TestEnvironmentPlanKeysAreDeterministic(t *testing.T) { + previous := map[string]loadedEnvironmentValue{"B": {}, "A": {}} + plan := environmentLoadPlan{fileValues: map[string]string{"C": ""}, defaults: map[string]string{"D": ""}} + if got, want := environmentPlanKeys(previous, plan), []string{"A", "B", "C", "D"}; !reflect.DeepEqual(got, want) { + t.Fatalf("expected keys %v, got %v", want, got) } } +// captureStdout captures process output for loader diagnostics tests. func captureStdout(t *testing.T, fn func()) string { t.Helper() original := os.Stdout - r, w, err := os.Pipe() + reader, writer, err := os.Pipe() if err != nil { t.Fatalf("pipe: %v", err) } - os.Stdout = w - defer func() { - os.Stdout = original - }() + os.Stdout = writer + t.Cleanup(func() { os.Stdout = original }) done := make(chan string) go func() { - var buf strings.Builder - _, _ = io.Copy(&buf, r) - done <- buf.String() + var output strings.Builder + _, _ = io.Copy(&output, reader) + done <- output.String() }() fn() - _ = w.Close() + _ = writer.Close() output := <-done + _ = reader.Close() + os.Stdout = original return output } diff --git a/runtime_test.go b/runtime_test.go index 2c5cc39..1b9d149 100644 --- a/runtime_test.go +++ b/runtime_test.go @@ -2,12 +2,13 @@ package env import "testing" -// Reset the shims after each test. +// resetRuntime restores runtime shims so tests remain isolated. func resetRuntime() { goos = "linux" goarch = "amd64" } +// TestOSAndArch ensures runtime identity helpers expose the active platform values. func TestOSAndArch(t *testing.T) { defer resetRuntime() @@ -23,6 +24,7 @@ func TestOSAndArch(t *testing.T) { } } +// TestIsLinux ensures Linux detection does not overlap unrelated operating systems. func TestIsLinux(t *testing.T) { defer resetRuntime() @@ -38,6 +40,7 @@ func TestIsLinux(t *testing.T) { } } +// TestIsMac ensures Darwin detection does not overlap unrelated operating systems. func TestIsMac(t *testing.T) { defer resetRuntime() @@ -53,6 +56,7 @@ func TestIsMac(t *testing.T) { } } +// TestIsWindows ensures Windows detection does not overlap unrelated operating systems. func TestIsWindows(t *testing.T) { defer resetRuntime() @@ -68,6 +72,7 @@ func TestIsWindows(t *testing.T) { } } +// TestIsBSD ensures every supported BSD runtime is recognized. func TestIsBSD(t *testing.T) { defer resetRuntime() @@ -86,6 +91,7 @@ func TestIsBSD(t *testing.T) { } } +// TestIsBSD_False ensures non-BSD Unix systems are not misclassified. func TestIsBSD_False(t *testing.T) { defer resetRuntime() @@ -95,6 +101,7 @@ func TestIsBSD_False(t *testing.T) { } } +// TestIsUnix ensures the documented Unix family is recognized without including Windows. func TestIsUnix(t *testing.T) { defer resetRuntime() @@ -119,6 +126,7 @@ func TestIsUnix(t *testing.T) { } } +// TestIsContainerOS ensures container-oriented runtime identifiers remain distinguishable from host kernels. func TestIsContainerOS(t *testing.T) { defer resetRuntime() diff --git a/scope.go b/scope.go index b571ba2..e79165b 100644 --- a/scope.go +++ b/scope.go @@ -16,6 +16,9 @@ type Scope struct { // @group Typed getters // @behavior readonly // +// Normalization trims surrounding whitespace and boundary underscores while preserving case and +// internal separators. +// // Example: root scope access // // _ = os.Setenv("STORAGE_DRIVER", "local") @@ -62,6 +65,8 @@ func (s Scope) Child(name string) Scope { } // Key builds the fully qualified environment key for key within the scope. +// @group Typed getters +// @behavior readonly func (s Scope) Key(key string) string { segment := normalizeScopeSegment(key) switch { @@ -78,6 +83,10 @@ func (s Scope) Key(key string) string { // @group Typed getters // @behavior readonly // +// Discovery preserves case, matches the longest normalized root-key suffix first, removes +// duplicates, and returns child names in lexical order. Process environment entries outside the +// scope prefix are ignored. +// // Example: discover child names // // _ = os.Setenv("STORAGE_DRIVER", "local") @@ -109,6 +118,9 @@ func (s Scope) ChildNames(rootKeys []string) []string { if normalized == "" { continue } + if _, exists := rootKeySet[normalized]; exists { + continue + } rootKeySet[normalized] = struct{}{} normalizedRootKeys = append(normalizedRootKeys, normalized) } @@ -160,65 +172,90 @@ func (s Scope) ChildNames(rootKeys []string) []string { } // Get returns the string value for key within the scope. +// @group Typed getters +// @behavior readonly func (s Scope) Get(key, fallback string) string { return Get(s.Key(key), fallback) } // GetInt returns the int value for key within the scope. +// @group Typed getters +// @behavior readonly func (s Scope) GetInt(key, fallback string) int { return GetInt(s.Key(key), fallback) } // GetInt64 returns the int64 value for key within the scope. +// @group Typed getters +// @behavior readonly func (s Scope) GetInt64(key, fallback string) int64 { return GetInt64(s.Key(key), fallback) } // GetUint returns the uint value for key within the scope. +// @group Typed getters +// @behavior readonly func (s Scope) GetUint(key, fallback string) uint { return GetUint(s.Key(key), fallback) } // GetUint64 returns the uint64 value for key within the scope. +// @group Typed getters +// @behavior readonly func (s Scope) GetUint64(key, fallback string) uint64 { return GetUint64(s.Key(key), fallback) } // GetFloat returns the float64 value for key within the scope. +// @group Typed getters +// @behavior readonly func (s Scope) GetFloat(key, fallback string) float64 { return GetFloat(s.Key(key), fallback) } // GetBool returns the bool value for key within the scope. +// @group Typed getters +// @behavior readonly func (s Scope) GetBool(key, fallback string) bool { return GetBool(s.Key(key), fallback) } // GetDuration returns the duration value for key within the scope. +// @group Typed getters +// @behavior readonly func (s Scope) GetDuration(key, fallback string) time.Duration { return GetDuration(s.Key(key), fallback) } // GetEnum returns the enum value for key within the scope. +// @group Typed getters +// @behavior readonly func (s Scope) GetEnum(key, fallback string, allowed []string) string { return GetEnum(s.Key(key), fallback, allowed) } // GetSlice returns the string slice value for key within the scope. +// @group Typed getters +// @behavior readonly func (s Scope) GetSlice(key, fallback string) []string { return GetSlice(s.Key(key), fallback) } // GetMap returns the string map value for key within the scope. +// @group Typed getters +// @behavior readonly func (s Scope) GetMap(key, fallback string) map[string]string { return GetMap(s.Key(key), fallback) } // GetMapInt returns the int map value for key within the scope. +// @group Typed getters +// @behavior readonly func (s Scope) GetMapInt(key, fallback string, defaultValue int) map[string]int { return GetMapInt(s.Key(key), fallback, defaultValue) } +// normalizeScopeSegment preserves caller-selected case while removing accidental boundary separators. func normalizeScopeSegment(segment string) string { return strings.Trim(strings.TrimSpace(segment), "_") } diff --git a/scope_test.go b/scope_test.go index 0b2efdf..5a6686a 100644 --- a/scope_test.go +++ b/scope_test.go @@ -7,6 +7,7 @@ import ( "time" ) +// TestWithPrefixNormalizesPrefix ensures scoped keys use one canonical uppercase separator form. func TestWithPrefixNormalizesPrefix(t *testing.T) { scope := WithPrefix(" __STORAGE__ ") if got := scope.Key(" __ROOT__ "); got != "STORAGE_ROOT" { @@ -14,6 +15,7 @@ func TestWithPrefixNormalizesPrefix(t *testing.T) { } } +// TestScopeChildComposition ensures nested scopes compose without losing ancestor segments. func TestScopeChildComposition(t *testing.T) { scope := WithPrefix("STORAGE").Child(" _PUBLIC_ ") if got := scope.Key("ROOT"); got != "STORAGE_PUBLIC_ROOT" { @@ -21,67 +23,112 @@ func TestScopeChildComposition(t *testing.T) { } } +// TestScopeEmptySegmentsPreserveComposition ensures optional empty segments do not introduce malformed separators. +func TestScopeEmptySegmentsPreserveComposition(t *testing.T) { + if got := WithPrefix("").Child("PUBLIC").Key("ROOT"); got != "PUBLIC_ROOT" { + t.Fatalf("expected empty root to adopt child, got %q", got) + } + if got := WithPrefix("STORAGE").Child("___").Key(""); got != "STORAGE" { + t.Fatalf("expected empty child and key to preserve root, got %q", got) + } + if got := WithPrefix("").Key("ROOT"); got != "ROOT" { + t.Fatalf("expected unscoped key, got %q", got) + } +} + +// TestScopeGettersDelegate ensures every typed getter resolves through the scoped key. func TestScopeGettersDelegate(t *testing.T) { - withEnv("STORAGE_DRIVER", "local", func() { - withEnv("STORAGE_TIMEOUT", "30s", func() { - withEnv("STORAGE_PUBLIC_ENABLED", "true", func() { - withEnv("STORAGE_PUBLIC_PEERS", "a,b", func() { - withEnv("STORAGE_PUBLIC_LIMITS", "read=10,write=5", func() { - withEnv("STORAGE_PUBLIC_WEIGHTS", "critical=3,default=0", func() { - storage := WithPrefix("STORAGE") - public := storage.Child("PUBLIC") - - if got := storage.Get("DRIVER", "s3"); got != "local" { - t.Fatalf("expected local, got %q", got) - } - if got := storage.GetDuration("TIMEOUT", "5s"); got != 30*time.Second { - t.Fatalf("expected 30s, got %v", got) - } - if got := public.GetBool("ENABLED", "false"); !got { - t.Fatalf("expected true") - } - if got := public.GetSlice("PEERS", ""); !reflect.DeepEqual(got, []string{"a", "b"}) { - t.Fatalf("unexpected peers: %v", got) - } - if got := public.GetMap("LIMITS", ""); !reflect.DeepEqual(got, map[string]string{"read": "10", "write": "5"}) { - t.Fatalf("unexpected limits: %v", got) - } - if got := public.GetMapInt("WEIGHTS", "", 2); !reflect.DeepEqual(got, map[string]int{"critical": 3, "default": 2}) { - t.Fatalf("unexpected weights: %v", got) - } - }) - }) - }) - }) - }) - }) + values := map[string]string{ + "ENV_QPASS_SCOPE_DRIVER": "local", + "ENV_QPASS_SCOPE_TIMEOUT": "30s", + "ENV_QPASS_SCOPE_INT": "7", + "ENV_QPASS_SCOPE_INT64": "9223372036854775807", + "ENV_QPASS_SCOPE_UINT": "8", + "ENV_QPASS_SCOPE_UINT64": "18446744073709551615", + "ENV_QPASS_SCOPE_FLOAT": "1.5", + "ENV_QPASS_SCOPE_ENUM": "blue", + "ENV_QPASS_SCOPE_PUBLIC_ENABLED": "true", + "ENV_QPASS_SCOPE_PUBLIC_PEERS": "a,b", + "ENV_QPASS_SCOPE_PUBLIC_LIMITS": "read=10,write=5", + "ENV_QPASS_SCOPE_PUBLIC_WEIGHTS": "critical=3,default=0", + } + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + restore := snapshotEnv(keys) + defer restore() + for key, value := range values { + _ = os.Setenv(key, value) + } + + scope := WithPrefix("ENV_QPASS_SCOPE") + public := scope.Child("PUBLIC") + if got := scope.Get("DRIVER", "s3"); got != "local" { + t.Fatalf("expected local, got %q", got) + } + if got := scope.GetDuration("TIMEOUT", "5s"); got != 30*time.Second { + t.Fatalf("expected 30s, got %v", got) + } + if got := scope.GetInt("INT", "0"); got != 7 { + t.Fatalf("expected int 7, got %d", got) + } + if got := scope.GetInt64("INT64", "0"); got != 9223372036854775807 { + t.Fatalf("unexpected int64: %d", got) + } + if got := scope.GetUint("UINT", "0"); got != 8 { + t.Fatalf("expected uint 8, got %d", got) + } + if got := scope.GetUint64("UINT64", "0"); got != 18446744073709551615 { + t.Fatalf("unexpected uint64: %d", got) + } + if got := scope.GetFloat("FLOAT", "0"); got != 1.5 { + t.Fatalf("expected float 1.5, got %v", got) + } + if got := scope.GetEnum("ENUM", "red", []string{"red", "blue"}); got != "blue" { + t.Fatalf("expected enum blue, got %q", got) + } + if got := public.GetBool("ENABLED", "false"); !got { + t.Fatal("expected true") + } + if got := public.GetSlice("PEERS", ""); !reflect.DeepEqual(got, []string{"a", "b"}) { + t.Fatalf("unexpected peers: %v", got) + } + if got := public.GetMap("LIMITS", ""); !reflect.DeepEqual(got, map[string]string{"read": "10", "write": "5"}) { + t.Fatalf("unexpected limits: %v", got) + } + if got := public.GetMapInt("WEIGHTS", "", 2); !reflect.DeepEqual(got, map[string]int{"critical": 3, "default": 2}) { + t.Fatalf("unexpected weights: %v", got) + } } +// TestScopeChildNames ensures immediate child discovery is unique and deterministic. func TestScopeChildNames(t *testing.T) { keys := []string{ - "STORAGE_DRIVER", - "STORAGE_ROOT", - "STORAGE_PUBLIC_DRIVER", - "STORAGE_PUBLIC_ROOT", - "STORAGE_AVATARS_BUCKET", - "STORAGE_AVATARS_REGION", - "STORAGE_PUBLIC", + "ENV_QPASS_DISCOVERY_DRIVER", + "ENV_QPASS_DISCOVERY_ROOT", + "ENV_QPASS_DISCOVERY_PUBLIC_DRIVER", + "ENV_QPASS_DISCOVERY_PUBLIC_ROOT", + "ENV_QPASS_DISCOVERY_AVATARS_BUCKET", + "ENV_QPASS_DISCOVERY_AVATARS_REGION", + "ENV_QPASS_DISCOVERY_PUBLIC", } restore := snapshotEnv(keys) defer restore() - _ = os.Setenv("STORAGE_DRIVER", "local") - _ = os.Setenv("STORAGE_ROOT", "/tmp/storage") - _ = os.Setenv("STORAGE_PUBLIC_DRIVER", "local") - _ = os.Setenv("STORAGE_PUBLIC_ROOT", "/tmp/public") - _ = os.Setenv("STORAGE_AVATARS_BUCKET", "avatars") - _ = os.Setenv("STORAGE_AVATARS_REGION", "us-east-1") - _ = os.Setenv("STORAGE_PUBLIC", "not-a-child") + _ = os.Setenv("ENV_QPASS_DISCOVERY_DRIVER", "local") + _ = os.Setenv("ENV_QPASS_DISCOVERY_ROOT", "/tmp/storage") + _ = os.Setenv("ENV_QPASS_DISCOVERY_PUBLIC_DRIVER", "local") + _ = os.Setenv("ENV_QPASS_DISCOVERY_PUBLIC_ROOT", "/tmp/public") + _ = os.Setenv("ENV_QPASS_DISCOVERY_AVATARS_BUCKET", "avatars") + _ = os.Setenv("ENV_QPASS_DISCOVERY_AVATARS_REGION", "us-east-1") + _ = os.Setenv("ENV_QPASS_DISCOVERY_PUBLIC", "not-a-child") - names := WithPrefix("STORAGE").ChildNames([]string{ + names := WithPrefix("ENV_QPASS_DISCOVERY").ChildNames([]string{ " DRIVER ", "ROOT", + "__ROOT__", "BUCKET", "REGION", "PUBLIC", @@ -93,27 +140,28 @@ func TestScopeChildNames(t *testing.T) { } } +// TestScopeChildNamesWithMultiWordChildrenAndRootKeys ensures compound child names survive alongside values on the scope root. func TestScopeChildNamesWithMultiWordChildrenAndRootKeys(t *testing.T) { keys := []string{ - "CACHE_DRIVER", - "CACHE_PAGE_CACHE_DRIVER", - "CACHE_PAGE_CACHE_FILE_DIR", - "CACHE_USER_SESSIONS_DEFAULT_TTL_SECONDS", - "STORAGE_PUBLIC_S3_ACCESS_KEY_ID", - "STORAGE_PUBLIC_S3_SECRET_ACCESS_KEY", + "ENV_QPASS_CACHE_DRIVER", + "ENV_QPASS_CACHE_PAGE_CACHE_DRIVER", + "ENV_QPASS_CACHE_PAGE_CACHE_FILE_DIR", + "ENV_QPASS_CACHE_USER_SESSIONS_DEFAULT_TTL_SECONDS", + "ENV_QPASS_STORAGE_PUBLIC_S3_ACCESS_KEY_ID", + "ENV_QPASS_STORAGE_PUBLIC_S3_SECRET_ACCESS_KEY", } restore := snapshotEnv(keys) defer restore() - _ = os.Setenv("CACHE_DRIVER", "memory") - _ = os.Setenv("CACHE_PAGE_CACHE_DRIVER", "file") - _ = os.Setenv("CACHE_PAGE_CACHE_FILE_DIR", "/tmp/page-cache") - _ = os.Setenv("CACHE_USER_SESSIONS_DEFAULT_TTL_SECONDS", "60") - _ = os.Setenv("STORAGE_PUBLIC_S3_ACCESS_KEY_ID", "access") - _ = os.Setenv("STORAGE_PUBLIC_S3_SECRET_ACCESS_KEY", "secret") + _ = os.Setenv("ENV_QPASS_CACHE_DRIVER", "memory") + _ = os.Setenv("ENV_QPASS_CACHE_PAGE_CACHE_DRIVER", "file") + _ = os.Setenv("ENV_QPASS_CACHE_PAGE_CACHE_FILE_DIR", "/tmp/page-cache") + _ = os.Setenv("ENV_QPASS_CACHE_USER_SESSIONS_DEFAULT_TTL_SECONDS", "60") + _ = os.Setenv("ENV_QPASS_STORAGE_PUBLIC_S3_ACCESS_KEY_ID", "access") + _ = os.Setenv("ENV_QPASS_STORAGE_PUBLIC_S3_SECRET_ACCESS_KEY", "secret") - cacheNames := WithPrefix("CACHE").ChildNames([]string{ + cacheNames := WithPrefix("ENV_QPASS_CACHE").ChildNames([]string{ "DRIVER", "FILE_DIR", "DEFAULT_TTL_SECONDS", @@ -123,7 +171,7 @@ func TestScopeChildNamesWithMultiWordChildrenAndRootKeys(t *testing.T) { t.Fatalf("expected cache child names %v, got %v", expectedCache, cacheNames) } - storageNames := WithPrefix("STORAGE").ChildNames([]string{ + storageNames := WithPrefix("ENV_QPASS_STORAGE").ChildNames([]string{ "ACCESS_KEY_ID", "SECRET_ACCESS_KEY", }) @@ -133,12 +181,14 @@ func TestScopeChildNamesWithMultiWordChildrenAndRootKeys(t *testing.T) { } } +// TestScopeChildNamesEmptyPrefix ensures root discovery returns only immediate top-level segments. func TestScopeChildNamesEmptyPrefix(t *testing.T) { if got := WithPrefix("___").ChildNames([]string{"ROOT"}); len(got) != 0 { t.Fatalf("expected empty names, got %v", got) } } +// snapshotEnv restores exact process environment presence and values after a test. func snapshotEnv(keys []string) func() { originals := make(map[string]string, len(keys)) present := make(map[string]bool, len(keys)) From c6eba4c95a999be4e7babcdcf0dfd88b1d5d8104 Mon Sep 17 00:00:00 2001 From: Chris Miles Date: Wed, 15 Jul 2026 23:49:53 +0000 Subject: [PATCH 2/3] fix(env): keep native-width test portable --- .github/workflows/test.yml | 4 ++++ env_test.go | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9989203..a2c1b2e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -58,6 +58,10 @@ jobs: working-directory: examples run: go test ./... + - name: Compile 32-bit tests + if: matrix.go-version == 'stable' + run: GOOS=linux GOARCH=386 CGO_ENABLED=0 go test -c -o /tmp/env-linux-386.test . + - name: Upload coverage if: matrix.go-version == 'stable' uses: codecov/codecov-action@v5 diff --git a/env_test.go b/env_test.go index 6f376ed..561dc81 100644 --- a/env_test.go +++ b/env_test.go @@ -77,7 +77,7 @@ func TestGetUintUsesNativeWidth(t *testing.T) { } value := strconv.FormatUint(uint64(1)<<40, 10) withEnv("ENV_QPASS_NATIVE_UINT", value, func() { - if got := GetUint("ENV_QPASS_NATIVE_UINT", "0"); got != uint(1)<<40 { + if got := GetUint("ENV_QPASS_NATIVE_UINT", "0"); uint64(got) != uint64(1)<<40 { t.Fatalf("expected native-width uint, got %d", got) } }) From 401cf9b176c594c338421a26bd8c5886dc92dfb4 Mon Sep 17 00:00:00 2001 From: Chris Miles Date: Thu, 16 Jul 2026 02:59:21 +0000 Subject: [PATCH 3/3] test(env): cover loader failure paths --- loader.go | 9 +--- loader_test.go | 135 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 8 deletions(-) diff --git a/loader.go b/loader.go index e09adaf..c245c5b 100644 --- a/loader.go +++ b/loader.go @@ -146,10 +146,6 @@ func load(force bool) error { if err != nil { return fmt.Errorf("get working directory for env loading: %w", err) } - workingDirectory, err = filepath.Abs(workingDirectory) - if err != nil { - return fmt.Errorf("resolve working directory for env loading: %w", err) - } previous := cloneLoadedEnvironmentValues(processEnvironmentLoader.values) baseline := cloneEnvironmentSnapshots(processEnvironmentLoader.baseline) @@ -407,10 +403,7 @@ func cloneLoadedEnvironmentValues(values map[string]loadedEnvironmentValue) map[ func snapshotProcessEnvironment() map[string]environmentSnapshot { snapshots := make(map[string]environmentSnapshot) for _, entry := range os.Environ() { - key, value, found := strings.Cut(entry, "=") - if !found { - continue - } + key, value, _ := strings.Cut(entry, "=") snapshots[key] = environmentSnapshot{value: value, present: true} } return snapshots diff --git a/loader_test.go b/loader_test.go index d6dbda8..d5504df 100644 --- a/loader_test.go +++ b/loader_test.go @@ -321,6 +321,22 @@ func TestReloadRefreshesFileOwnedAppEnv(t *testing.T) { // TestLoadReturnsDiscoveryAndParseErrorsWithoutMutation ensures failed discovery or parsing cannot partially change process state. func TestLoadReturnsDiscoveryAndParseErrorsWithoutMutation(t *testing.T) { + t.Run("working directory error", func(t *testing.T) { + prepareLoaderTest(t, "ENV_QPASS_UNCHANGED") + t.Setenv("ENV_QPASS_UNCHANGED", "ambient") + workingDirectoryErr := errors.New("injected working directory failure") + envFileGetwd = func() (string, error) { + return "", workingDirectoryErr + } + + if err := Load(); !errors.Is(err, workingDirectoryErr) { + t.Fatalf("expected working directory error, got %v", err) + } + if os.Getenv("ENV_QPASS_UNCHANGED") != "ambient" || IsEnvLoaded() { + t.Fatal("expected failed working directory lookup to leave environment and state unchanged") + } + }) + t.Run("stat error", func(t *testing.T) { prepareLoaderTest(t, "ENV_QPASS_UNCHANGED") t.Setenv("APP_ENV", Local) @@ -342,6 +358,84 @@ func TestLoadReturnsDiscoveryAndParseErrorsWithoutMutation(t *testing.T) { } }) + t.Run("application layer stat error", func(t *testing.T) { + prepareLoaderTest(t, "ENV_QPASS_UNCHANGED") + t.Setenv("APP_ENV", Local) + t.Setenv("ENV_QPASS_UNCHANGED", "ambient") + directory := t.TempDir() + changeWorkingDirectory(t, directory) + applicationLayerErr := errors.New("injected application layer failure") + envFileStat = func(path string) (os.FileInfo, error) { + if filepath.Base(path) == envFileLocal { + return nil, applicationLayerErr + } + return nil, os.ErrNotExist + } + + if err := Load(); !errors.Is(err, applicationLayerErr) { + t.Fatalf("expected application layer error, got %v", err) + } + if os.Getenv("ENV_QPASS_UNCHANGED") != "ambient" || IsEnvLoaded() { + t.Fatal("expected failed application layer lookup to leave environment and state unchanged") + } + }) + + t.Run("host layer stat error", func(t *testing.T) { + prepareLoaderTest(t, "ENV_QPASS_UNCHANGED") + t.Setenv("APP_ENV", Local) + t.Setenv("ENV_QPASS_UNCHANGED", "ambient") + directory := t.TempDir() + changeWorkingDirectory(t, directory) + hostLayerErr := errors.New("injected host layer failure") + envFileStat = func(path string) (os.FileInfo, error) { + if filepath.Base(path) == fileEnvHost { + return nil, hostLayerErr + } + return nil, os.ErrNotExist + } + statFile = func(string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + readFile = func(string) ([]byte, error) { + return nil, os.ErrNotExist + } + + if err := Load(); !errors.Is(err, hostLayerErr) { + t.Fatalf("expected host layer error, got %v", err) + } + if os.Getenv("ENV_QPASS_UNCHANGED") != "ambient" || IsEnvLoaded() { + t.Fatal("expected failed host layer lookup to leave environment and state unchanged") + } + }) + + t.Run("testing layer stat error", func(t *testing.T) { + prepareLoaderTest(t, "ENV_QPASS_UNCHANGED") + t.Setenv("APP_ENV", Local) + t.Setenv("ENV_QPASS_UNCHANGED", "ambient") + directory := t.TempDir() + changeWorkingDirectory(t, directory) + testingLayerErr := errors.New("injected testing layer failure") + envFileStat = func(path string) (os.FileInfo, error) { + if filepath.Base(path) == envFileTesting { + return nil, testingLayerErr + } + return nil, os.ErrNotExist + } + statFile = func(path string) (os.FileInfo, error) { + if path == fileDockerEnv { + return nil, nil + } + return nil, os.ErrNotExist + } + + if err := Load(); !errors.Is(err, testingLayerErr) { + t.Fatalf("expected testing layer error, got %v", err) + } + if os.Getenv("ENV_QPASS_UNCHANGED") != "ambient" || IsEnvLoaded() { + t.Fatal("expected failed testing layer lookup to leave environment and state unchanged") + } + }) + t.Run("parse error", func(t *testing.T) { prepareLoaderTest(t, "ENV_QPASS_UNCHANGED") t.Setenv("APP_ENV", Local) @@ -402,6 +496,39 @@ func TestLoadRollsBackApplicationFailure(t *testing.T) { } } +// TestLoadJoinsApplicationAndRollbackFailures ensures callers retain both causes when recovery also fails. +func TestLoadJoinsApplicationAndRollbackFailures(t *testing.T) { + prepareLoaderTest(t, "ENV_QPASS_A", "ENV_QPASS_B") + t.Setenv("APP_ENV", Local) + t.Setenv("ENV_QPASS_A", "ambient-a") + t.Setenv("ENV_QPASS_B", "ambient-b") + directory := t.TempDir() + writeEnvFile(t, directory, fileEnv, "ENV_QPASS_A=file-a\nENV_QPASS_B=file-b\n") + changeWorkingDirectory(t, directory) + + applyErr := errors.New("injected apply failure") + rollbackErr := errors.New("injected rollback failure") + envSet = func(key, value string) error { + if key == "ENV_QPASS_B" { + switch value { + case "file-b": + return applyErr + case "ambient-b": + return rollbackErr + } + } + return os.Setenv(key, value) + } + + err := Load() + if !errors.Is(err, applyErr) || !errors.Is(err, rollbackErr) { + t.Fatalf("expected joined apply and rollback errors, got %v", err) + } + if IsEnvLoaded() { + t.Fatal("expected failed Load to leave state unpublished") + } +} + // TestReloadFailurePreservesPreviousConfiguration ensures a failed refresh leaves the last valid configuration active. func TestReloadFailurePreservesPreviousConfiguration(t *testing.T) { prepareLoaderTest(t, "ENV_QPASS_A", "ENV_QPASS_B") @@ -611,6 +738,14 @@ func TestEnvironmentPlanKeysAreDeterministic(t *testing.T) { } } +// TestEnvironmentPlanTargetReturnsAbsentForUnownedKey guards the helper's safe fallback for direct callers. +func TestEnvironmentPlanTargetReturnsAbsentForUnownedKey(t *testing.T) { + plan := environmentLoadPlan{fileValues: map[string]string{}, defaults: map[string]string{}} + if got := environmentPlanTarget("UNOWNED", nil, plan); got.present || got.value != "" { + t.Fatalf("expected absent snapshot, got %+v", got) + } +} + // captureStdout captures process output for loader diagnostics tests. func captureStdout(t *testing.T, fn func()) string { t.Helper()