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:
2026-04-24 13:41:13 +01:00
commit 288a5b600c
13 changed files with 714 additions and 0 deletions
+38
View File
@@ -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
View File
@@ -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/
+23
View File
@@ -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
+50
View File
@@ -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
+32
View File
@@ -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.
+21
View File
@@ -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.
+97
View File
@@ -0,0 +1,97 @@
# go-env
[![Go Reference](https://pkg.go.dev/badge/github.com/eslider/go-env.svg)](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
+28
View File
@@ -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
+139
View File
@@ -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
View File
@@ -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"])
}
}
+5
View File
@@ -0,0 +1,5 @@
module github.com/eslider/go-env
go 1.22.2
require github.com/mitchellh/mapstructure v1.5.0
+2
View File
@@ -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
View File
@@ -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) }
}