From 288a5b600ccd171b0a01b04a1ad1de86bd48404d Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Fri, 24 Apr 2026 13:41:13 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20initial=20release=20=E2=80=94=20env=20d?= =?UTF-8?q?ecoder=20extracted=20from=20ai-fabric?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .cursor/rules/no-synthetic-mocks.mdc | 38 +++++ .gitignore | 10 ++ .golangci.yml | 23 +++ AGENTS.md | 50 ++++++ CHANGELOG.md | 32 ++++ LICENSE | 21 +++ README.md | 97 ++++++++++++ doc.go | 28 ++++ env.go | 139 +++++++++++++++++ env_test.go | 222 +++++++++++++++++++++++++++ go.mod | 5 + go.sum | 2 + options.go | 47 ++++++ 13 files changed, 714 insertions(+) create mode 100644 .cursor/rules/no-synthetic-mocks.mdc create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 AGENTS.md create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 doc.go create mode 100644 env.go create mode 100644 env_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 options.go diff --git a/.cursor/rules/no-synthetic-mocks.mdc b/.cursor/rules/no-synthetic-mocks.mdc new file mode 100644 index 0000000..a334fbd --- /dev/null +++ b/.cursor/rules/no-synthetic-mocks.mdc @@ -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). diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5abc53f --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..2128a5f --- /dev/null +++ b/.golangci.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..cbb54ca --- /dev/null +++ b/AGENTS.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..debac89 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..265d11c --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8913366 --- /dev/null +++ b/README.md @@ -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 diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..63ce588 --- /dev/null +++ b/doc.go @@ -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 diff --git a/env.go b/env.go new file mode 100644 index 0000000..ebb3043 --- /dev/null +++ b/env.go @@ -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 +} diff --git a/env_test.go b/env_test.go new file mode 100644 index 0000000..f0a39bd --- /dev/null +++ b/env_test.go @@ -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"]) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1a70f16 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/eslider/go-env + +go 1.22.2 + +require github.com/mitchellh/mapstructure v1.5.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..59f4b8e --- /dev/null +++ b/go.sum @@ -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= diff --git a/options.go b/options.go new file mode 100644 index 0000000..ef0a78c --- /dev/null +++ b/options.go @@ -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) } +}