feat(go-config): multi-format codecs, envc CLI, fixtures, ASRs

- Module github.com/eslider/go-config with env/yaml/json/ini packages
- internal: source, keymap, merge, structconv, bytesutil
- cmd/envc: convert, get, merge
- docs/asr ASR-0001..0008, README/CHANGELOG/AGENTS refresh
- golangci-lint v2 config; tests + fixtures

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-02 14:04:01 +01:00
co-authored by Cursor
parent 288a5b600c
commit c575341178
75 changed files with 2810 additions and 528 deletions
+19
View File
@@ -0,0 +1,19 @@
// Package bytesutil reads entire sources into memory.
package bytesutil
import (
"context"
"io"
"github.com/eslider/go-config/internal/source"
)
// ReadAll reads and closes the stream opened by s.
func ReadAll(ctx context.Context, s source.Source) ([]byte, error) {
rc, err := s.Open(ctx)
if err != nil {
return nil, err
}
defer func() { _ = rc.Close() }()
return io.ReadAll(rc)
}
+56
View File
@@ -0,0 +1,56 @@
// Package keymap normalizes map keys recursively for cross-format matching.
package keymap
import "strings"
// Normalizer transforms a single path segment or key string.
type Normalizer func(string) string
// LowerAlnum lowercases and strips any rune that is not [a-z0-9].
func LowerAlnum(s string) string {
var b strings.Builder
b.Grow(len(s))
for _, r := range strings.ToLower(s) {
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
b.WriteRune(r)
}
}
return b.String()
}
// Identity returns s unchanged.
func Identity(s string) string { return s }
// Walk recursively rewrites every map key in place using n. Slices of maps are walked.
func Walk(m map[string]any, n Normalizer) {
if m == nil || n == nil {
return
}
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
for _, k := range keys {
v := m[k]
delete(m, k)
nk := n(k)
if nk == "" {
nk = k
}
switch vv := v.(type) {
case map[string]any:
Walk(vv, n)
m[nk] = vv
case []any:
for i := range vv {
if sm, ok := vv[i].(map[string]any); ok {
Walk(sm, n)
vv[i] = sm
}
}
m[nk] = vv
default:
m[nk] = v
}
}
}
+25
View File
@@ -0,0 +1,25 @@
package keymap
import (
"reflect"
"testing"
)
func TestLowerAlnum(t *testing.T) {
if got, want := LowerAlnum("sub-Service"), "subservice"; got != want {
t.Fatalf("got %q want %q", got, want)
}
}
func TestWalk(t *testing.T) {
m := map[string]any{
"Sub-Service": map[string]any{"Pool-Size": "10"},
}
Walk(m, LowerAlnum)
want := map[string]any{
"subservice": map[string]any{"poolsize": "10"},
}
if !reflect.DeepEqual(m, want) {
t.Fatalf("got %#v want %#v", m, want)
}
}
+77
View File
@@ -0,0 +1,77 @@
// Package merge provides deep map merging with configurable slice behaviour.
package merge
// SliceStrategy controls how two []any values are combined at the same key.
type SliceStrategy int
const (
// Replace overwrites the destination slice with the source slice.
Replace SliceStrategy = iota
// Concat appends source elements to the destination slice.
Concat
)
// Option configures DeepMerge.
type Option func(*config)
type config struct {
slice SliceStrategy
}
// WithSliceStrategy sets slice merge behaviour (default Replace).
func WithSliceStrategy(s SliceStrategy) Option {
return func(c *config) { c.slice = s }
}
// DeepMerge folds src into dst in place. Maps recurse; scalar leaves use
// last-write-wins (src overwrites dst). For slice values, behaviour follows cfg.slice.
func DeepMerge(dst, src map[string]any, opts ...Option) {
cfg := config{slice: Replace}
for _, o := range opts {
o(&cfg)
}
if dst == nil || src == nil {
return
}
for k, sv := range src {
dv, ok := dst[k]
if !ok {
dst[k] = cloneValue(sv)
continue
}
dm, dIsMap := dv.(map[string]any)
sm, sIsMap := sv.(map[string]any)
if dIsMap && sIsMap {
DeepMerge(dm, sm, opts...)
dst[k] = dm
continue
}
if dsa, dIsSlice := dv.([]any); dIsSlice {
if ssa, sIsSlice := sv.([]any); sIsSlice {
switch cfg.slice {
case Concat:
dst[k] = append(append([]any{}, dsa...), ssa...)
default:
dst[k] = append([]any{}, ssa...)
}
continue
}
}
dst[k] = cloneValue(sv)
}
}
func cloneValue(v any) any {
switch t := v.(type) {
case map[string]any:
out := make(map[string]any, len(t))
DeepMerge(out, t)
return out
case []any:
cp := make([]any, len(t))
copy(cp, t)
return cp
default:
return v
}
}
+52
View File
@@ -0,0 +1,52 @@
package merge
import (
"reflect"
"testing"
)
func TestDeepMerge_MapRecursion(t *testing.T) {
dst := map[string]any{
"a": map[string]any{"x": "1"},
}
src := map[string]any{
"a": map[string]any{"y": "2"},
}
DeepMerge(dst, src)
want := map[string]any{
"a": map[string]any{"x": "1", "y": "2"},
}
if !reflect.DeepEqual(dst, want) {
t.Fatalf("got %#v want %#v", dst, want)
}
}
func TestDeepMerge_ScalarLastWins(t *testing.T) {
dst := map[string]any{"k": "first"}
src := map[string]any{"k": "second"}
DeepMerge(dst, src)
if dst["k"] != "second" {
t.Fatalf("got %v", dst["k"])
}
}
func TestDeepMerge_SliceReplace(t *testing.T) {
dst := map[string]any{"s": []any{"a", "b"}}
src := map[string]any{"s": []any{"c"}}
DeepMerge(dst, src, WithSliceStrategy(Replace))
got := dst["s"].([]any)
if len(got) != 1 || got[0] != "c" {
t.Fatalf("got %#v", got)
}
}
func TestDeepMerge_SliceConcat(t *testing.T) {
dst := map[string]any{"s": []any{"a", "b"}}
src := map[string]any{"s": []any{"c"}}
DeepMerge(dst, src, WithSliceStrategy(Concat))
got := dst["s"].([]any)
want := []any{"a", "b", "c"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %#v want %#v", got, want)
}
}
+129
View File
@@ -0,0 +1,129 @@
// Package source provides pluggable configuration inputs (bytes, files, URLs).
package source
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
// Source opens a byte stream for reading. Callers must close the ReadCloser.
type Source interface {
Open(ctx context.Context) (io.ReadCloser, error)
String() string
}
// Bytes holds raw bytes in memory.
type Bytes struct {
Data []byte
Name string
}
// Open returns a ReadCloser over a copy of Data.
func (b Bytes) Open(_ context.Context) (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(b.Data)), nil
}
// String returns a label for errors (defaults to "bytes").
func (b Bytes) String() string {
if b.Name != "" {
return b.Name
}
return "bytes"
}
// Reader wraps an io.Reader. The reader is consumed once per Open call.
type Reader struct {
R io.Reader
Name string
}
// Open returns the reader as a ReadCloser when possible, otherwise wraps r.R.
func (r Reader) Open(_ context.Context) (io.ReadCloser, error) {
rc, ok := r.R.(io.ReadCloser)
if ok {
return rc, nil
}
return io.NopCloser(r.R), nil
}
// String returns a label for errors (defaults to "reader").
func (r Reader) String() string {
if r.Name != "" {
return r.Name
}
return "reader"
}
// File opens a path on the local filesystem.
type File struct {
Path string
}
// Open opens the file path.
func (f File) Open(_ context.Context) (io.ReadCloser, error) {
return os.Open(f.Path)
}
// String returns the file path.
func (f File) String() string {
return f.Path
}
// URL fetches remote content over HTTP or HTTPS.
type URL struct {
Raw string
Header http.Header
HTTPClient *http.Client
}
func defaultClient() *http.Client {
return &http.Client{Timeout: 30 * time.Second}
}
// Open performs a GET request and returns the response body.
func (u URL) Open(ctx context.Context) (io.ReadCloser, error) {
client := u.HTTPClient
if client == nil {
client = defaultClient()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.Raw, nil)
if err != nil {
return nil, fmt.Errorf("source url: new request: %w", err)
}
if u.Header != nil {
req.Header = u.Header.Clone()
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("source url %q: %w", u.Raw, err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
_ = resp.Body.Close()
return nil, fmt.Errorf("source url %q: status %s", u.Raw, resp.Status)
}
return resp.Body, nil
}
func (u URL) String() string {
return u.Raw
}
// JoinNames joins source labels for error messages.
func JoinNames(srcs []Source) string {
if len(srcs) == 0 {
return ""
}
parts := make([]string, 0, len(srcs))
for _, s := range srcs {
if s != nil {
parts = append(parts, s.String())
}
}
return strings.Join(parts, ", ")
}
+43
View File
@@ -0,0 +1,43 @@
package source
import (
"context"
"io"
"net/http"
"net/http/httptest"
"testing"
)
func TestURL_OK(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("hello"))
}))
defer srv.Close()
u := URL{Raw: srv.URL}
rc, err := u.Open(context.Background())
if err != nil {
t.Fatal(err)
}
defer func() { _ = rc.Close() }()
b, err := io.ReadAll(rc)
if err != nil {
t.Fatal(err)
}
if string(b) != "hello" {
t.Fatalf("got %q", b)
}
}
func TestURL_StatusError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
u := URL{Raw: srv.URL}
_, err := u.Open(context.Background())
if err == nil {
t.Fatal("expected error")
}
}
+74
View File
@@ -0,0 +1,74 @@
// Package structconv wraps mapstructure for map<->struct conversion.
package structconv
import (
"fmt"
"reflect"
"strings"
"github.com/go-viper/mapstructure/v2"
)
// Options configures Decode and Encode.
type Options struct {
TagName string
WeaklyTyped bool
Trim bool
ExtraHooks []mapstructure.DecodeHookFunc
}
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
}
func composeHooks(o Options) mapstructure.DecodeHookFunc {
var hooks []mapstructure.DecodeHookFunc
if o.Trim {
hooks = append(hooks, trimStringHook)
}
hooks = append(hooks, o.ExtraHooks...)
if len(hooks) == 0 {
return nil
}
return mapstructure.ComposeDecodeHookFunc(hooks...)
}
// Decode maps in into struct or map dst (dst must be pointer).
func Decode(in map[string]any, dst any, o Options) error {
tag := o.TagName
if tag == "" {
tag = "mapstructure"
}
cfg := &mapstructure.DecoderConfig{
Result: dst,
WeaklyTypedInput: o.WeaklyTyped,
TagName: tag,
DecodeHook: composeHooks(o),
}
dec, err := mapstructure.NewDecoder(cfg)
if err != nil {
return fmt.Errorf("structconv: decoder: %w", err)
}
if err := dec.Decode(in); err != nil {
return fmt.Errorf("structconv: decode: %w", err)
}
return nil
}
// Encode flattens src (struct or map) into a new map[string]any.
func Encode(src any) (map[string]any, error) {
out := make(map[string]any)
dec, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{Result: &out})
if err != nil {
return nil, fmt.Errorf("structconv: encoder: %w", err)
}
if err := dec.Decode(src); err != nil {
return nil, fmt.Errorf("structconv: encode: %w", err)
}
return out, nil
}
+28
View File
@@ -0,0 +1,28 @@
package structconv
import (
"fmt"
"testing"
)
func TestDecodeEncodeRoundTrip(t *testing.T) {
type nested struct {
V int `mapstructure:"v"`
}
type root struct {
A nested `mapstructure:"a"`
}
in := map[string]any{"a": map[string]any{"v": 7}}
var dst root
if err := Decode(in, &dst, Options{WeaklyTyped: true, Trim: true}); err != nil {
t.Fatal(err)
}
out, err := Encode(&dst)
if err != nil {
t.Fatal(err)
}
am := out["a"].(map[string]any)
if fmt.Sprint(am["v"]) != "7" {
t.Fatalf("%#v", out)
}
}