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:
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, ", ")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user