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
+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)
}
}