commit 288a5b600ccd171b0a01b04a1ad1de86bd48404d Author: Andriy Oblivantsev Date: Fri Apr 24 13:41:13 2026 +0100 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. 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) } +}