Files
go-config/env.go
T
eSlider 288a5b600c 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.
2026-04-24 13:41:13 +01:00

140 lines
3.8 KiB
Go

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
}