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.
140 lines
3.8 KiB
Go
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
|
|
}
|