feat: initial release — env decoder extracted from ai-fabric
Public API: - Unmarshal(dst, opts...) - UnmarshalPrefix(dst, prefix, opts...) - AsMap() / AsMapPrefix(prefix) - Options: WithTrim, WithWeaklyTyped, WithTagName, WithDecodeHook Merges three divergent in-repo copies (ai-fabric/pkg/env, markets-platform/TP-general-code/pkg/system, and the var/agents/issue-* snapshots), adds error wrapping, prefix filter, and pluggable hooks. 15 pure-Go unit tests, no synthetic mocks, lint clean. Extracted per inventar/docs/asr/ASR-0008-ai-fabric-audit.md.
This commit is contained in:
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
description: No synthetic OnlyOffice/Gitea mocks; prefer real integration tests
|
||||||
|
globs:
|
||||||
|
- "**/*_test.go"
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
# Testing policy — no synthetic vendor mockups
|
||||||
|
|
||||||
|
When authoring tests under `github.com/eslider/go-onlyoffice`, do **not**
|
||||||
|
build `httptest.NewServer` fixtures that emulate OnlyOffice, Gitea, or any
|
||||||
|
other third-party API. Simulated vendor responses drift from reality, give
|
||||||
|
false green signals, and hide protocol changes.
|
||||||
|
|
||||||
|
## What to do instead
|
||||||
|
|
||||||
|
1. **Unit tests** — pure Go, no network. Use them for parsers, encoders,
|
||||||
|
struct conversions, pure helpers. No `httptest` that fakes the vendor.
|
||||||
|
2. **Integration tests** — `//go:build integration` tag in a `*_integration_test.go`
|
||||||
|
file. Read credentials from env:
|
||||||
|
- `ONLYOFFICE_URL` / `ONLYOFFICE_HOST`
|
||||||
|
- `ONLYOFFICE_USER` / `ONLYOFFICE_NAME`
|
||||||
|
- `ONLYOFFICE_PASS` / `ONLYOFFICE_PASSWORD`
|
||||||
|
Call `t.Skip("ONLYOFFICE_URL not set")` when credentials are absent so the
|
||||||
|
regular `go test ./...` stays green in CI.
|
||||||
|
3. **Run integration**: `go test -tags=integration ./...`.
|
||||||
|
4. **Every new endpoint** ships with an integration test in the same PR.
|
||||||
|
|
||||||
|
## Narrow exception
|
||||||
|
|
||||||
|
`httptest.NewServer` is OK when verifying the **caller's own** HTTP
|
||||||
|
behaviour (e.g. a user's handler or middleware we are wrapping). It is **not**
|
||||||
|
OK when the test server is pretending to be OnlyOffice or Gitea.
|
||||||
|
|
||||||
|
## Migrating existing tests
|
||||||
|
|
||||||
|
If you find a test that handles routes like `/api/2.0/...` and returns canned
|
||||||
|
JSON, convert it to an integration test (or delete it if the behaviour is
|
||||||
|
already covered by integration).
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
# Local environment
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Go workspace (for local multi-module dev; see inventar ASR-0008)
|
||||||
|
go.work
|
||||||
|
go.work.sum
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
/bin/
|
||||||
|
/dist/
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
run:
|
||||||
|
timeout: 5m
|
||||||
|
tests: true
|
||||||
|
|
||||||
|
linters:
|
||||||
|
disable-all: true
|
||||||
|
enable:
|
||||||
|
- errcheck
|
||||||
|
- gofmt
|
||||||
|
- goimports
|
||||||
|
- govet
|
||||||
|
- ineffassign
|
||||||
|
- revive
|
||||||
|
- staticcheck
|
||||||
|
- unused
|
||||||
|
|
||||||
|
linters-settings:
|
||||||
|
revive:
|
||||||
|
rules:
|
||||||
|
- name: var-naming
|
||||||
|
- name: exported
|
||||||
|
- name: package-comments
|
||||||
|
- name: indent-error-flow
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# AGENTS.md — `go-env`
|
||||||
|
|
||||||
|
This module is part of the eSlider `go-*` library standard (inventar ASR-0008).
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Decode `os.Environ()` into Go structs. Extracted from
|
||||||
|
`produktor.io/ai-fabric/pkg/env` on 2026-04-24.
|
||||||
|
|
||||||
|
## Public API surface
|
||||||
|
|
||||||
|
- `env.Unmarshal(dst any, opts ...Option) error`
|
||||||
|
- `env.UnmarshalPrefix(dst any, prefix string, opts ...Option) error`
|
||||||
|
- `env.AsMap() map[string]any`
|
||||||
|
- `env.AsMapPrefix(prefix string) map[string]any`
|
||||||
|
- Options: `WithTrim`, `WithWeaklyTyped`, `WithTagName`, `WithDecodeHook`
|
||||||
|
|
||||||
|
Breaking changes require a new major version tag (SemVer). Internal helpers
|
||||||
|
(`asMapFromEnviron`, `trimStringHook`, `insertPath`) are unexported and
|
||||||
|
may change without notice.
|
||||||
|
|
||||||
|
## Testing policy
|
||||||
|
|
||||||
|
Follows the eSlider "no synthetic mocks" policy:
|
||||||
|
|
||||||
|
- **Unit tests** (`env_test.go`): pure inputs only. We pass synthetic
|
||||||
|
`environ` slices to `asMapFromEnviron` — that is not a mock of anything
|
||||||
|
external; it's the normal way to test a pure function. Use `t.Setenv`
|
||||||
|
for `Unmarshal*` happy-path tests since Go's `os.Environ` lookup is
|
||||||
|
well-defined local behaviour, not a vendor protocol.
|
||||||
|
- **No `httptest`** — this library has no HTTP surface.
|
||||||
|
- **No integration-test build tag** — everything is in-process.
|
||||||
|
|
||||||
|
## Checklist before release
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd go-env
|
||||||
|
go mod tidy
|
||||||
|
go vet ./...
|
||||||
|
go test -race -count=1 ./...
|
||||||
|
golangci-lint run --timeout 5m # same preset as go-onlyoffice
|
||||||
|
```
|
||||||
|
|
||||||
|
Bump `CHANGELOG.md`, tag `vX.Y.Z`, push.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- `inventar/docs/asr/ASR-0008.md` — Go library module conventions
|
||||||
|
- `inventar/docs/asr/ASR-0008-ai-fabric-audit.md` — why this module exists
|
||||||
|
- `go-onlyoffice/AGENTS.md` — reference for the eSlider library template
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to `go-env` are documented here.
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-04-24
|
||||||
|
|
||||||
|
Initial release. Extracted from `produktor.io/ai-fabric/pkg/env` per
|
||||||
|
`inventar/docs/asr/ASR-0008-ai-fabric-audit.md`.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `Unmarshal(dst, opts...)` — decode all process env vars into a Go struct.
|
||||||
|
- `UnmarshalPrefix(dst, prefix, opts...)` — prefix-scoped variant that
|
||||||
|
strips the prefix before building the path.
|
||||||
|
- `AsMap()` / `AsMapPrefix(prefix)` — escape hatch returning the nested
|
||||||
|
`map[string]any` built from `os.Environ()`.
|
||||||
|
- Options: `WithTrim`, `WithWeaklyTyped`, `WithTagName`, `WithDecodeHook`.
|
||||||
|
- TrimSpace on string values is enabled by default; disable with
|
||||||
|
`WithTrim(false)`.
|
||||||
|
- Error wrapping with `%w` on both decoder configuration and decode
|
||||||
|
failures — matches `markets-platform/TP-general-code/pkg/system`
|
||||||
|
behaviour, improves on the original ai-fabric version.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- 15 pure-Go unit tests. No mocks. `asMapFromEnviron` is tested with
|
||||||
|
synthetic `environ` slices (not a mock — a pure input). Follows the
|
||||||
|
eSlider `go-*` no-synthetic-mocks policy.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Andriy Oblivantsev
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# go-env
|
||||||
|
|
||||||
|
[](https://pkg.go.dev/github.com/eslider/go-env)
|
||||||
|
|
||||||
|
Tiny, zero-ceremony library for decoding process environment variables into
|
||||||
|
Go structs.
|
||||||
|
|
||||||
|
- Uses `_` as a path separator: `SERVICE_HTTP_PORT` → `Service.HTTP.Port`.
|
||||||
|
- Weakly-typed by default (`"1"` → `int`, `"true"` → `bool`).
|
||||||
|
- Optional prefix filter with automatic stripping.
|
||||||
|
- Pluggable decode hooks via [`mitchellh/mapstructure`][mapstructure].
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get github.com/eslider/go-env
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/eslider/go-env"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Service struct {
|
||||||
|
HTTP struct {
|
||||||
|
Port int
|
||||||
|
}
|
||||||
|
Key string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var cfg Config
|
||||||
|
if err := env.Unmarshal(&cfg); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
fmt.Printf("%+v\n", cfg)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
With a prefix:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Only looks at APP_* variables; strips the APP_ prefix before decoding.
|
||||||
|
_ = env.UnmarshalPrefix(&cfg, "APP_")
|
||||||
|
```
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| Function | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `Unmarshal(dst, opts...)` | Decode all env vars into `dst`. |
|
||||||
|
| `UnmarshalPrefix(dst, prefix, opts...)` | Same, but only vars starting with `prefix`. |
|
||||||
|
| `AsMap()` / `AsMapPrefix(prefix)` | Return the nested `map[string]any` used by the decoder (debugging, custom decoders). |
|
||||||
|
|
||||||
|
### Options
|
||||||
|
|
||||||
|
| Option | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `WithTrim(bool)` | `true` | TrimSpace every string value. |
|
||||||
|
| `WithWeaklyTyped(bool)` | `true` | `mapstructure`'s weakly-typed coercion. |
|
||||||
|
| `WithTagName(string)` | `"mapstructure"` | Struct tag name for field overrides. |
|
||||||
|
| `WithDecodeHook(h)` | — | Append a `mapstructure.DecodeHookFunc` to the chain. |
|
||||||
|
|
||||||
|
## Semantics
|
||||||
|
|
||||||
|
- **Path collisions**: first write wins. If both `FOO=1` and `FOO=2` exist
|
||||||
|
in the environ, only `FOO=1` is kept. This matches the original
|
||||||
|
`ai-fabric/pkg/env` behaviour.
|
||||||
|
- **Case-insensitive keys**: all path components are lower-cased; struct
|
||||||
|
fields are matched via `mapstructure` which is also case-insensitive.
|
||||||
|
- **`_`-only delimiter**: there's no escape — if a variable legitimately
|
||||||
|
contains `_` inside a "leaf" name, you must restructure your struct to
|
||||||
|
match the nested layout.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Extracted from `produktor.io/ai-fabric` as part of the eSlider `go-*`
|
||||||
|
library standard (ASR-0008). Merges the best parts of three previously
|
||||||
|
divergent copies:
|
||||||
|
|
||||||
|
- `produktor.io/ai-fabric/pkg/env`
|
||||||
|
- `markets-platform/TP-general-code/pkg/system/env.go`
|
||||||
|
- the various `pkg/system/env.go` snapshots inside `var/agents/issue-*/`
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT © Andriy Oblivantsev
|
||||||
|
|
||||||
|
[mapstructure]: https://github.com/mitchellh/mapstructure
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// Package env reads process environment variables and decodes them into
|
||||||
|
// Go structs using underscore-delimited paths.
|
||||||
|
//
|
||||||
|
// A variable named "SERVICE_HTTP_PORT" becomes the path Service.HTTP.Port
|
||||||
|
// (case-insensitive, '_' is a path separator). Values are decoded via
|
||||||
|
// github.com/mitchellh/mapstructure, so numeric, boolean and slice
|
||||||
|
// conversions happen automatically.
|
||||||
|
//
|
||||||
|
// var cfg struct {
|
||||||
|
// Service struct {
|
||||||
|
// HTTP struct {
|
||||||
|
// Port int
|
||||||
|
// }
|
||||||
|
// Key string
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// if err := env.Unmarshal(&cfg); err != nil { ... }
|
||||||
|
//
|
||||||
|
// UnmarshalPrefix ignores variables that don't start with the given
|
||||||
|
// prefix and strips it before building the path:
|
||||||
|
//
|
||||||
|
// _ = os.Setenv("APP_DB_HOST", "localhost")
|
||||||
|
// _ = env.UnmarshalPrefix(&cfg, "APP_")
|
||||||
|
// // cfg.Db.Host == "localhost"
|
||||||
|
//
|
||||||
|
// String values are TrimSpace-trimmed by default; pass WithTrim(false) to
|
||||||
|
// opt out.
|
||||||
|
package env
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package env
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mitchellh/mapstructure"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Unmarshal decodes all process environment variables into dst. The variable
|
||||||
|
// name is split by '_' and the resulting path is matched against dst's fields
|
||||||
|
// case-insensitively.
|
||||||
|
//
|
||||||
|
// dst must be a pointer to a struct (or a map that mapstructure can populate).
|
||||||
|
// See the package documentation for details and examples.
|
||||||
|
func Unmarshal(dst any, opts ...Option) error {
|
||||||
|
return UnmarshalPrefix(dst, "", opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalPrefix is like Unmarshal but only considers environment variables
|
||||||
|
// that start with prefix. The prefix is stripped from each variable name
|
||||||
|
// before the path is built.
|
||||||
|
//
|
||||||
|
// An empty prefix is equivalent to Unmarshal.
|
||||||
|
func UnmarshalPrefix(dst any, prefix string, opts ...Option) error {
|
||||||
|
o := defaultOptions()
|
||||||
|
for _, f := range opts {
|
||||||
|
f(&o)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := asMapFromEnviron(os.Environ(), prefix)
|
||||||
|
|
||||||
|
hooks := []mapstructure.DecodeHookFunc{}
|
||||||
|
if o.trim {
|
||||||
|
hooks = append(hooks, trimStringHook)
|
||||||
|
}
|
||||||
|
hooks = append(hooks, o.extraHooks...)
|
||||||
|
|
||||||
|
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
|
||||||
|
Result: dst,
|
||||||
|
WeaklyTypedInput: o.weaklyTyped,
|
||||||
|
TagName: o.tagName,
|
||||||
|
DecodeHook: mapstructure.ComposeDecodeHookFunc(hooks...),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("env: configure decoder: %w", err)
|
||||||
|
}
|
||||||
|
if err := decoder.Decode(data); err != nil {
|
||||||
|
return fmt.Errorf("env: decode: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AsMap returns a nested map built from all current process environment
|
||||||
|
// variables, using '_' as path separator. Keys are lower-cased.
|
||||||
|
//
|
||||||
|
// It's primarily useful for debugging or for callers that want to plug
|
||||||
|
// their own decoder; normal code should prefer Unmarshal.
|
||||||
|
func AsMap() map[string]any {
|
||||||
|
return asMapFromEnviron(os.Environ(), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// AsMapPrefix is the prefix-scoped variant of AsMap. Variables whose names
|
||||||
|
// don't start with prefix are skipped; the prefix itself is stripped before
|
||||||
|
// the map is built.
|
||||||
|
func AsMapPrefix(prefix string) map[string]any {
|
||||||
|
return asMapFromEnviron(os.Environ(), prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// asMapFromEnviron is the core building block. Exposed as an unexported
|
||||||
|
// function so tests can feed it a deterministic environ slice instead of
|
||||||
|
// mutating the real process env.
|
||||||
|
func asMapFromEnviron(environ []string, prefix string) map[string]any {
|
||||||
|
out := make(map[string]any)
|
||||||
|
|
||||||
|
for _, entry := range environ {
|
||||||
|
eq := strings.IndexByte(entry, '=')
|
||||||
|
if eq < 0 {
|
||||||
|
// Malformed entry — shouldn't happen on Unix but guard anyway.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name, value := entry[:eq], entry[eq+1:]
|
||||||
|
|
||||||
|
if prefix != "" {
|
||||||
|
if !strings.HasPrefix(name, prefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name = name[len(prefix):]
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
insertPath(out, strings.Split(name, "_"), value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// insertPath walks path inside root, creating intermediate nested maps as
|
||||||
|
// needed, and stores value at the leaf.
|
||||||
|
//
|
||||||
|
// Collisions are resolved by preferring the first write: once a leaf value
|
||||||
|
// is set for a path, a later sibling with the same path prefix will NOT
|
||||||
|
// overwrite it. This matches the behaviour of the ai-fabric original so
|
||||||
|
// that tools migrating to go-env don't observe new surprises.
|
||||||
|
func insertPath(root map[string]any, path []string, value string) {
|
||||||
|
current := root
|
||||||
|
last := len(path) - 1
|
||||||
|
for i, raw := range path {
|
||||||
|
k := strings.ToLower(raw)
|
||||||
|
|
||||||
|
if i == last {
|
||||||
|
if _, exists := current[k]; exists {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
current[k] = value
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next, ok := current[k].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
next = make(map[string]any)
|
||||||
|
current[k] = next
|
||||||
|
}
|
||||||
|
current = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimStringHook(from, to reflect.Type, data any) (any, error) {
|
||||||
|
if from.Kind() == reflect.String && to.Kind() == reflect.String {
|
||||||
|
if s, ok := data.(string); ok {
|
||||||
|
return strings.TrimSpace(s), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
+222
@@ -0,0 +1,222 @@
|
|||||||
|
package env
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// asMapFromEnviron is tested with a synthetic environ slice — this is NOT a
|
||||||
|
// mock of anything external. It's a pure-input unit test that isolates the
|
||||||
|
// core mapping logic from process-global state.
|
||||||
|
|
||||||
|
func TestAsMapFromEnviron_FlatAndNested(t *testing.T) {
|
||||||
|
got := asMapFromEnviron([]string{
|
||||||
|
"FOO=bar",
|
||||||
|
"SERVICE_HTTP_PORT=8080",
|
||||||
|
"SERVICE_KEY=abc",
|
||||||
|
}, "")
|
||||||
|
|
||||||
|
want := map[string]any{
|
||||||
|
"foo": "bar",
|
||||||
|
"service": map[string]any{
|
||||||
|
"http": map[string]any{"port": "8080"},
|
||||||
|
"key": "abc",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("got %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAsMapFromEnviron_PreservesValuesWithEquals(t *testing.T) {
|
||||||
|
got := asMapFromEnviron([]string{"TOKEN=a=b=c="}, "")
|
||||||
|
if got["token"] != "a=b=c=" {
|
||||||
|
t.Fatalf("expected full value preserved, got %#v", got["token"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAsMapFromEnviron_PrefixFilterAndStrip(t *testing.T) {
|
||||||
|
got := asMapFromEnviron([]string{
|
||||||
|
"APP_DB_HOST=localhost",
|
||||||
|
"APP_DB_PORT=5432",
|
||||||
|
"OTHER_THING=skip",
|
||||||
|
}, "APP_")
|
||||||
|
|
||||||
|
want := map[string]any{
|
||||||
|
"db": map[string]any{"host": "localhost", "port": "5432"},
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("got %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAsMapFromEnviron_EmptyAndMalformedAreSkipped(t *testing.T) {
|
||||||
|
got := asMapFromEnviron([]string{"NOEQUALS", ""}, "")
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Fatalf("expected no entries, got %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAsMapFromEnviron_FirstWriteWinsOnCollision(t *testing.T) {
|
||||||
|
// This matches the ai-fabric original: once a leaf is set for a path,
|
||||||
|
// subsequent entries that would overwrite it are ignored.
|
||||||
|
got := asMapFromEnviron([]string{
|
||||||
|
"A=1",
|
||||||
|
"A=2",
|
||||||
|
}, "")
|
||||||
|
if got["a"] != "1" {
|
||||||
|
t.Fatalf("expected first-write-wins (a=1), got %#v", got["a"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAsMapFromEnviron_PrefixOnlyVariableIsSkipped(t *testing.T) {
|
||||||
|
// "APP_=xxx" with prefix "APP_" would strip to empty key — we skip it.
|
||||||
|
got := asMapFromEnviron([]string{"APP_=oops"}, "APP_")
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Fatalf("expected empty map, got %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Decoder / option tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestUnmarshal_BasicStruct(t *testing.T) {
|
||||||
|
t.Setenv("SERVICE_KEY", "abc")
|
||||||
|
t.Setenv("SERVICE_PORT", "8080")
|
||||||
|
|
||||||
|
var cfg struct {
|
||||||
|
Service struct {
|
||||||
|
Key string
|
||||||
|
Port int
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := Unmarshal(&cfg); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Service.Key != "abc" {
|
||||||
|
t.Errorf("Key = %q, want abc", cfg.Service.Key)
|
||||||
|
}
|
||||||
|
if cfg.Service.Port != 8080 {
|
||||||
|
t.Errorf("Port = %d, want 8080 (weakly-typed string->int)", cfg.Service.Port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmarshal_TrimsStringsByDefault(t *testing.T) {
|
||||||
|
t.Setenv("NAME", " hello ")
|
||||||
|
|
||||||
|
var cfg struct{ Name string }
|
||||||
|
if err := Unmarshal(&cfg); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Name != "hello" {
|
||||||
|
t.Fatalf("expected trimmed value, got %q", cfg.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmarshal_WithTrimOff(t *testing.T) {
|
||||||
|
t.Setenv("NAME", " keep me ")
|
||||||
|
|
||||||
|
var cfg struct{ Name string }
|
||||||
|
if err := Unmarshal(&cfg, WithTrim(false)); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Name != " keep me " {
|
||||||
|
t.Fatalf("expected untrimmed value, got %q", cfg.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmarshalPrefix_StripsAndFilters(t *testing.T) {
|
||||||
|
t.Setenv("APP_DB_HOST", "localhost")
|
||||||
|
t.Setenv("APP_DB_PORT", "5432")
|
||||||
|
t.Setenv("OTHER_DB_HOST", "should-be-ignored")
|
||||||
|
|
||||||
|
var cfg struct {
|
||||||
|
DB struct {
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
} `mapstructure:"db"`
|
||||||
|
}
|
||||||
|
if err := UnmarshalPrefix(&cfg, "APP_"); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.DB.Host != "localhost" {
|
||||||
|
t.Errorf("Host = %q, want localhost", cfg.DB.Host)
|
||||||
|
}
|
||||||
|
if cfg.DB.Port != 5432 {
|
||||||
|
t.Errorf("Port = %d, want 5432", cfg.DB.Port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmarshal_WithTagName(t *testing.T) {
|
||||||
|
t.Setenv("DATABASE_URL", "postgres://x")
|
||||||
|
|
||||||
|
// The map is always nested by '_', so tags also reference the nested
|
||||||
|
// path, one component per struct level. Here we override the outer
|
||||||
|
// field name via a custom tag.
|
||||||
|
var cfg struct {
|
||||||
|
DB struct {
|
||||||
|
URL string
|
||||||
|
} `env:"database"`
|
||||||
|
}
|
||||||
|
if err := Unmarshal(&cfg, WithTagName("env")); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.DB.URL != "postgres://x" {
|
||||||
|
t.Fatalf("URL = %q, want postgres://x", cfg.DB.URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmarshal_WeaklyTypedOff(t *testing.T) {
|
||||||
|
t.Setenv("PORT", "8080")
|
||||||
|
|
||||||
|
var cfg struct{ Port int }
|
||||||
|
err := Unmarshal(&cfg, WithWeaklyTyped(false))
|
||||||
|
// With strict typing, "8080" (string) cannot decode into an int.
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected strict typing to reject string->int coercion")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmarshal_SpecialCharactersSurvive(t *testing.T) {
|
||||||
|
// Regression: keys with special chars in their values must pass through
|
||||||
|
// unchanged (modulo TrimSpace).
|
||||||
|
t.Setenv("SPECIAL", "@!#$%^&*()")
|
||||||
|
|
||||||
|
var cfg struct{ Special string }
|
||||||
|
if err := Unmarshal(&cfg); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Special != "@!#$%^&*()" {
|
||||||
|
t.Fatalf("Special = %q, want @!#$%%^&*()", cfg.Special)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmarshal_RejectsNonPointer(t *testing.T) {
|
||||||
|
var cfg struct{ A string }
|
||||||
|
err := Unmarshal(cfg) // no pointer
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when passing a non-pointer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAsMap_ExposesCurrentEnv(t *testing.T) {
|
||||||
|
t.Setenv("GO_ENV_ASMAP_PROBE", "hit")
|
||||||
|
|
||||||
|
m := AsMap()
|
||||||
|
goenv, ok := m["go"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected nested map under 'go', got %T", m["go"])
|
||||||
|
}
|
||||||
|
env, ok := goenv["env"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected nested map under 'go.env', got %T", goenv["env"])
|
||||||
|
}
|
||||||
|
asmap, ok := env["asmap"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected nested map under 'go.env.asmap', got %T", env["asmap"])
|
||||||
|
}
|
||||||
|
if asmap["probe"] != "hit" {
|
||||||
|
t.Fatalf("probe = %v, want hit", asmap["probe"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
module github.com/eslider/go-env
|
||||||
|
|
||||||
|
go 1.22.2
|
||||||
|
|
||||||
|
require github.com/mitchellh/mapstructure v1.5.0
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
package env
|
||||||
|
|
||||||
|
import "github.com/mitchellh/mapstructure"
|
||||||
|
|
||||||
|
// Option customises Unmarshal/UnmarshalPrefix behaviour.
|
||||||
|
type Option func(*options)
|
||||||
|
|
||||||
|
type options struct {
|
||||||
|
trim bool
|
||||||
|
weaklyTyped bool
|
||||||
|
tagName string
|
||||||
|
extraHooks []mapstructure.DecodeHookFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultOptions() options {
|
||||||
|
return options{
|
||||||
|
trim: true,
|
||||||
|
weaklyTyped: true,
|
||||||
|
tagName: "mapstructure",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithTrim toggles automatic TrimSpace on string values. Default: true.
|
||||||
|
// Use WithTrim(false) when whitespace is semantically meaningful.
|
||||||
|
func WithTrim(enable bool) Option {
|
||||||
|
return func(o *options) { o.trim = enable }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithWeaklyTyped toggles mapstructure's WeaklyTypedInput. Default: true.
|
||||||
|
// When true, "1" decodes into int, "true" into bool, etc. Disable for
|
||||||
|
// strict string-only decoding.
|
||||||
|
func WithWeaklyTyped(enable bool) Option {
|
||||||
|
return func(o *options) { o.weaklyTyped = enable }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithTagName selects the struct tag mapstructure uses for field names.
|
||||||
|
// Default: "mapstructure". Set "env" to use `env:"FIELD_NAME"` tags.
|
||||||
|
func WithTagName(name string) Option {
|
||||||
|
return func(o *options) { o.tagName = name }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithDecodeHook appends a user hook to the decoder chain. Hooks run after
|
||||||
|
// the built-in trim hook (unless trimming is disabled) and in the order
|
||||||
|
// they're added.
|
||||||
|
func WithDecodeHook(h mapstructure.DecodeHookFunc) Option {
|
||||||
|
return func(o *options) { o.extraHooks = append(o.extraHooks, h) }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user