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,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
|
||||
}
|
||||
Reference in New Issue
Block a user