2020-06-08 14:52:16 +08:00
|
|
|
package sysconf
|
|
|
|
|
|
|
|
|
|
import (
|
2026-06-09 18:10:19 +08:00
|
|
|
"bytes"
|
|
|
|
|
"errors"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"strconv"
|
2020-06-08 14:52:16 +08:00
|
|
|
"testing"
|
2026-06-09 18:10:19 +08:00
|
|
|
"time"
|
2020-06-08 14:52:16 +08:00
|
|
|
)
|
|
|
|
|
|
2026-06-09 18:10:19 +08:00
|
|
|
func TestIniParseBuild(t *testing.T) {
|
2024-04-10 15:19:25 +08:00
|
|
|
ini := NewIni()
|
2026-06-09 18:10:19 +08:00
|
|
|
input := []byte("[app]\r\nname = demo\r\nname = second\r\nflag\r\n\r\n[app]\r\nother=value\r\n")
|
|
|
|
|
if err := ini.Parse(input); err != nil {
|
|
|
|
|
t.Fatalf("parse failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if got := ini.Get("app", "name"); got != "demo" {
|
|
|
|
|
t.Fatalf("unexpected first value: %q", got)
|
|
|
|
|
}
|
|
|
|
|
if got := ini.GetAll("app", "name"); len(got) != 2 {
|
|
|
|
|
t.Fatalf("expected duplicate values, got %v", got)
|
|
|
|
|
}
|
|
|
|
|
if got := len(ini.Sections("app")); got != 2 {
|
|
|
|
|
t.Fatalf("expected duplicate sections, got %d", got)
|
|
|
|
|
}
|
|
|
|
|
if !ini.Section("app").Exist("flag") {
|
|
|
|
|
t.Fatalf("expected no-value key")
|
|
|
|
|
}
|
|
|
|
|
if out := ini.Build(); !bytes.Contains(out, []byte("name = demo")) || !bytes.Contains(out, []byte("[app]\r\nother=value")) {
|
|
|
|
|
t.Fatalf("expected lossless build, got: %q", out)
|
|
|
|
|
}
|
|
|
|
|
if err := ini.Save("/tmp/sysconf-ini-test.ini"); err != nil {
|
|
|
|
|
t.Fatalf("save failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestIniSetAllReplacesDuplicates(t *testing.T) {
|
|
|
|
|
ini := NewIni()
|
|
|
|
|
if err := ini.Parse([]byte("[app]\nname=a\nname=b\n")); err != nil {
|
|
|
|
|
t.Fatalf("parse failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
sec := ini.Section("app")
|
|
|
|
|
if sec == nil {
|
|
|
|
|
t.Fatalf("missing section")
|
|
|
|
|
}
|
|
|
|
|
if err := sec.SetAll("name", []string{"x"}, ""); err != nil {
|
|
|
|
|
t.Fatalf("setall failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if got := sec.GetAll("name"); len(got) != 1 || got[0] != "x" {
|
|
|
|
|
t.Fatalf("unexpected values after setall: %v", got)
|
|
|
|
|
}
|
|
|
|
|
if out := string(ini.Build()); bytes.Contains([]byte(out), []byte("name=b")) {
|
|
|
|
|
t.Fatalf("duplicate value leaked into build: %q", out)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestIniMarshalUnmarshal(t *testing.T) {
|
|
|
|
|
type nested struct {
|
|
|
|
|
Host string `key:"host"`
|
|
|
|
|
Port int `key:"port"`
|
|
|
|
|
Tags []string `key:"tag"`
|
|
|
|
|
Meta map[string]string `key:"meta"`
|
|
|
|
|
Skip string `key:"-"`
|
|
|
|
|
}
|
|
|
|
|
type cfg struct {
|
|
|
|
|
App nested `seg:"app"`
|
|
|
|
|
}
|
|
|
|
|
src := cfg{App: nested{
|
|
|
|
|
Host: "127.0.0.1",
|
|
|
|
|
Port: 8080,
|
|
|
|
|
Tags: []string{"alpha", "beta"},
|
|
|
|
|
Meta: map[string]string{"b": "second", "a": "first"},
|
|
|
|
|
Skip: "hidden",
|
|
|
|
|
}}
|
|
|
|
|
ini := NewIni()
|
|
|
|
|
out, err := ini.Marshal(src)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("marshal failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if !bytes.Contains(out, []byte("host=127.0.0.1")) {
|
|
|
|
|
t.Fatalf("marshal output missing host: %s", out)
|
|
|
|
|
}
|
|
|
|
|
if bytes.Contains(out, []byte("tag=[alpha beta]")) || !bytes.Contains(out, []byte("tag=alpha\ntag=beta")) {
|
|
|
|
|
t.Fatalf("marshal output should write repeated tag keys: %s", out)
|
|
|
|
|
}
|
|
|
|
|
if !bytes.Contains(out, []byte("meta=a=first\nmeta=b=second")) {
|
|
|
|
|
t.Fatalf("marshal output should write map keys and values: %s", out)
|
|
|
|
|
}
|
|
|
|
|
if bytes.Contains(out, []byte("skip=")) {
|
|
|
|
|
t.Fatalf("marshal output should skip key:\"-\" fields: %s", out)
|
|
|
|
|
}
|
|
|
|
|
if ini.Section("app") != nil && ini.Section("app").Exist("skip") {
|
|
|
|
|
t.Fatalf("marshal output should not create skip key")
|
|
|
|
|
}
|
|
|
|
|
if err := ini.Parse(out); err != nil {
|
|
|
|
|
t.Fatalf("parse marshaled output failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
var dst cfg
|
|
|
|
|
if err := ini.Unmarshal(&dst); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if dst.App.Host != "127.0.0.1" || dst.App.Port != 8080 || len(dst.App.Tags) != 2 || dst.App.Tags[0] != "alpha" || dst.App.Tags[1] != "beta" {
|
|
|
|
|
t.Fatalf("unexpected result: %+v", dst.App)
|
|
|
|
|
}
|
|
|
|
|
if dst.App.Meta["a"] != "first" || dst.App.Meta["b"] != "second" {
|
|
|
|
|
t.Fatalf("unexpected map result: %+v", dst.App.Meta)
|
|
|
|
|
}
|
|
|
|
|
if dst.App.Skip != "" {
|
|
|
|
|
t.Fatalf("unmarshal should keep skip field empty, got %q", dst.App.Skip)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
input := NewIni()
|
|
|
|
|
if err := input.Parse([]byte("[app]\nskip=input-only\nhost=127.0.0.1\nport=8080\n")); err != nil {
|
|
|
|
|
t.Fatalf("parse skip input failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
var skipDst cfg
|
|
|
|
|
if err := input.Unmarshal(&skipDst); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal skip input failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if skipDst.App.Skip != "" {
|
|
|
|
|
t.Fatalf("unmarshal should ignore skip key even when input contains it, got %q", skipDst.App.Skip)
|
|
|
|
|
}
|
|
|
|
|
if skipDst.App.Host != "127.0.0.1" || skipDst.App.Port != 8080 {
|
|
|
|
|
t.Fatalf("unmarshal should still bind normal fields: %+v", skipDst.App)
|
|
|
|
|
}
|
|
|
|
|
input.Set("app", "skip", "input-only")
|
|
|
|
|
if got := input.Get("app", "skip"); got != "input-only" {
|
|
|
|
|
t.Fatalf("input setup failed: %q", got)
|
|
|
|
|
}
|
|
|
|
|
if err := input.Unmarshal(&skipDst); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal with skip key failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if skipDst.App.Skip != "" {
|
|
|
|
|
t.Fatalf("unmarshal should ignore skip key even after set, got %q", skipDst.App.Skip)
|
|
|
|
|
}
|
|
|
|
|
if err := ini.Unmarshal(&dst); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestIniMarshalUsesReceiverProfile(t *testing.T) {
|
|
|
|
|
type app struct {
|
|
|
|
|
Name string `key:"name"`
|
|
|
|
|
}
|
|
|
|
|
type cfg struct {
|
|
|
|
|
App app `seg:"app"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ini := NewIniWithProfiles(LinuxConfProfile(":"))
|
|
|
|
|
out, err := ini.Marshal(cfg{App: app{Name: "demo"}})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("marshal failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if !bytes.Contains(out, []byte("name:demo")) {
|
|
|
|
|
t.Fatalf("marshal should use receiver delimiter, got %q", out)
|
|
|
|
|
}
|
|
|
|
|
if bytes.Contains(out, []byte("name=demo")) {
|
|
|
|
|
t.Fatalf("marshal should not fall back to default delimiter: %q", out)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestIniDuplicateSectionsBindAcrossAllSections(t *testing.T) {
|
|
|
|
|
type app struct {
|
|
|
|
|
Name string `key:"name"`
|
|
|
|
|
Port int `key:"port"`
|
|
|
|
|
Tags []string `key:"tag"`
|
|
|
|
|
}
|
|
|
|
|
type cfg struct {
|
|
|
|
|
App app `seg:"app"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ini := NewIni()
|
|
|
|
|
if err := ini.Parse([]byte("[app]\nname=one\ntag=alpha\n[app]\nport=2\ntag=beta\n")); err != nil {
|
|
|
|
|
t.Fatalf("parse failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if got := ini.Get("app", "port"); got != "2" {
|
|
|
|
|
t.Fatalf("Get should see later duplicate section key, got %q", got)
|
|
|
|
|
}
|
|
|
|
|
if !ini.Has("app", "port") {
|
|
|
|
|
t.Fatal("Has should see later duplicate section key")
|
|
|
|
|
}
|
|
|
|
|
if got := ini.GetAll("app", "tag"); len(got) != 2 || got[0] != "alpha" || got[1] != "beta" {
|
|
|
|
|
t.Fatalf("GetAll should aggregate duplicate sections, got %#v", got)
|
|
|
|
|
}
|
|
|
|
|
var out cfg
|
|
|
|
|
if err := ini.Unmarshal(&out); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if out.App.Name != "one" || out.App.Port != 2 {
|
|
|
|
|
t.Fatalf("duplicate-section bind mismatch: %+v", out.App)
|
|
|
|
|
}
|
|
|
|
|
if len(out.App.Tags) != 2 || out.App.Tags[0] != "alpha" || out.App.Tags[1] != "beta" {
|
|
|
|
|
t.Fatalf("duplicate-section repeated values mismatch: %#v", out.App.Tags)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestIniMarshalAndUnmarshalNestedPointerSection(t *testing.T) {
|
|
|
|
|
type server struct {
|
|
|
|
|
Host string `key:"host"`
|
|
|
|
|
Port int `key:"port"`
|
|
|
|
|
}
|
|
|
|
|
type cfg struct {
|
|
|
|
|
Server *server `seg:"server"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ini := NewIni()
|
|
|
|
|
out, err := ini.Marshal(cfg{Server: &server{Host: "127.0.0.1", Port: 8080}})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("marshal failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if !bytes.Contains(out, []byte("[server]\nhost=127.0.0.1\nport=8080")) {
|
|
|
|
|
t.Fatalf("marshal should emit pointer section fields, got %q", out)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
parsed := NewIni()
|
|
|
|
|
if err := parsed.Parse(out); err != nil {
|
|
|
|
|
t.Fatalf("parse marshaled output failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
var got cfg
|
|
|
|
|
if err := parsed.Unmarshal(&got); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if got.Server == nil || got.Server.Host != "127.0.0.1" || got.Server.Port != 8080 {
|
|
|
|
|
t.Fatalf("pointer section round-trip mismatch: %+v", got.Server)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestIniMarshalUnmarshalRootSection(t *testing.T) {
|
|
|
|
|
type cfg struct {
|
|
|
|
|
Root string `seg:"" key:"root"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ini := NewIni()
|
|
|
|
|
out, err := ini.Marshal(cfg{Root: "ok"})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("marshal failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if !bytes.Contains(out, []byte("root=ok")) {
|
|
|
|
|
t.Fatalf("marshal should include root key, got %q", out)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
parsed := NewIni()
|
|
|
|
|
if err := parsed.Parse([]byte("root=from-input\n")); err != nil {
|
|
|
|
|
t.Fatalf("parse root input failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
var got cfg
|
|
|
|
|
if err := parsed.Unmarshal(&got); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal root input failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if got.Root != "from-input" {
|
|
|
|
|
t.Fatalf("root key did not bind: %+v", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestIniUnmarshalSkipsMissingKeys(t *testing.T) {
|
|
|
|
|
type app struct {
|
|
|
|
|
Host string `key:"host"`
|
|
|
|
|
Port int `key:"port"`
|
|
|
|
|
Enabled bool `key:"enabled"`
|
|
|
|
|
Tags []string `key:"tag"`
|
|
|
|
|
Meta map[string]string `key:"meta"`
|
|
|
|
|
}
|
|
|
|
|
type cfg struct {
|
|
|
|
|
App app `seg:"app"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ini := NewIni()
|
|
|
|
|
if err := ini.Parse([]byte("[app]\nhost=127.0.0.1\n")); err != nil {
|
|
|
|
|
t.Fatalf("parse failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
got := cfg{App: app{
|
|
|
|
|
Port: 8080,
|
|
|
|
|
Enabled: true,
|
|
|
|
|
Tags: []string{"keep"},
|
|
|
|
|
Meta: map[string]string{"keep": "value"},
|
|
|
|
|
}}
|
|
|
|
|
if err := ini.Unmarshal(&got); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal should ignore missing keys, got %v", err)
|
|
|
|
|
}
|
|
|
|
|
if got.App.Host != "127.0.0.1" || got.App.Port != 8080 || !got.App.Enabled {
|
|
|
|
|
t.Fatalf("missing scalar keys should not overwrite existing values: %+v", got.App)
|
|
|
|
|
}
|
|
|
|
|
if len(got.App.Tags) != 1 || got.App.Tags[0] != "keep" || got.App.Meta["keep"] != "value" {
|
|
|
|
|
t.Fatalf("missing collection keys should not overwrite existing values: %+v", got.App)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestMarshalCSVSkipsUnexportedFields(t *testing.T) {
|
|
|
|
|
type row struct {
|
|
|
|
|
a string
|
|
|
|
|
B string
|
|
|
|
|
}
|
|
|
|
|
out, err := MarshalCSV([]string{"B"}, []row{{a: "hidden", B: "shown"}})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("marshal csv failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if !bytes.Contains(out, []byte("shown")) {
|
|
|
|
|
t.Fatalf("expected exported field in csv, got %q", out)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestMarshalCSVRejectsMismatchedRowLength(t *testing.T) {
|
|
|
|
|
if _, err := MarshalCSV([]string{"A", "B"}, [][]string{{"a", "b"}, {"c"}}); err == nil {
|
|
|
|
|
t.Fatal("expected header row length mismatch error")
|
|
|
|
|
}
|
|
|
|
|
if _, err := MarshalCSV(nil, [][]string{{"a", "b"}, {"c"}}); err == nil {
|
|
|
|
|
t.Fatal("expected inferred row length mismatch error")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestIniNoValueInlineCommentAndQuotedComment(t *testing.T) {
|
|
|
|
|
ini := NewIni()
|
|
|
|
|
input := []byte("[app]\nflag # enabled\nvalue=\"a'b # still value\" # comment\n")
|
|
|
|
|
if err := ini.Parse(input); err != nil {
|
|
|
|
|
t.Fatalf("parse failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
sec := ini.Section("app")
|
|
|
|
|
if sec == nil {
|
|
|
|
|
t.Fatalf("missing app section")
|
|
|
|
|
}
|
|
|
|
|
if !sec.Exist("flag") || sec.Comment("flag") != "enabled" {
|
|
|
|
|
t.Fatalf("no-value inline comment parsed incorrectly")
|
|
|
|
|
}
|
|
|
|
|
if got := sec.Get("value"); got != "a'b # still value" {
|
|
|
|
|
t.Fatalf("quoted comment parsed incorrectly: %q", got)
|
|
|
|
|
}
|
|
|
|
|
if got := sec.Comment("value"); got != "comment" {
|
|
|
|
|
t.Fatalf("value comment parsed incorrectly: %q", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestIniParsesCommonDelimiterSectionCommentAndContinuation(t *testing.T) {
|
|
|
|
|
ini := NewIni()
|
|
|
|
|
input := []byte("[app] ; header comment\nname: demo\nurl = http://example.test/a#frag\nmessage = first \\\n second # tail\nquoted = \"a # b\"\n")
|
|
|
|
|
if err := ini.Parse(input); err != nil {
|
|
|
|
|
t.Fatalf("parse failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
sec := ini.Section("app")
|
|
|
|
|
if sec == nil {
|
|
|
|
|
t.Fatalf("missing app section")
|
|
|
|
|
}
|
|
|
|
|
if sec.HeaderComment != "header comment" {
|
|
|
|
|
t.Fatalf("section header comment parsed incorrectly: %q", sec.HeaderComment)
|
|
|
|
|
}
|
|
|
|
|
if got := sec.Get("name"); got != "demo" {
|
|
|
|
|
t.Fatalf("colon-delimited value parsed incorrectly: %q", got)
|
|
|
|
|
}
|
|
|
|
|
if entry := sec.Entry("name"); entry == nil || entry.Delimiter != ":" {
|
|
|
|
|
t.Fatalf("colon delimiter was not preserved: %#v", entry)
|
|
|
|
|
}
|
|
|
|
|
if got := sec.Get("url"); got != "http://example.test/a#frag" {
|
|
|
|
|
t.Fatalf("hash without leading space should stay in value: %q", got)
|
|
|
|
|
}
|
|
|
|
|
if got := sec.Get("message"); got != "first second" {
|
|
|
|
|
t.Fatalf("continued value parsed incorrectly: %q", got)
|
|
|
|
|
}
|
|
|
|
|
if got := sec.Comment("message"); got != "tail" {
|
|
|
|
|
t.Fatalf("continued line comment parsed incorrectly: %q", got)
|
|
|
|
|
}
|
|
|
|
|
if got := sec.Get("quoted"); got != "a # b" {
|
|
|
|
|
t.Fatalf("quoted value parsed incorrectly: %q", got)
|
|
|
|
|
}
|
|
|
|
|
if out := ini.Build(); !bytes.Contains(out, []byte("message = first \\\n second # tail\n")) {
|
|
|
|
|
t.Fatalf("unchanged continuation was not preserved: %q", out)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestIniWriteQuotesAmbiguousValues(t *testing.T) {
|
|
|
|
|
ini := NewIni()
|
|
|
|
|
ini.Set("app", "hash", "value # not comment")
|
|
|
|
|
ini.Set("app", "space", " leading")
|
|
|
|
|
ini.Set("app", "line", "a\nb")
|
|
|
|
|
|
|
|
|
|
out := ini.Build()
|
|
|
|
|
for _, want := range [][]byte{
|
|
|
|
|
[]byte(`hash="value # not comment"`),
|
|
|
|
|
[]byte(`space=" leading"`),
|
|
|
|
|
[]byte(`line="a\nb"`),
|
|
|
|
|
} {
|
|
|
|
|
if !bytes.Contains(out, want) {
|
|
|
|
|
t.Fatalf("quoted output missing %q in %q", want, out)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
roundTrip := NewIni()
|
|
|
|
|
if err := roundTrip.Parse(out); err != nil {
|
|
|
|
|
t.Fatalf("round-trip parse failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if got := roundTrip.Get("app", "hash"); got != "value # not comment" {
|
|
|
|
|
t.Fatalf("quoted hash value did not round trip: %q", got)
|
|
|
|
|
}
|
|
|
|
|
if got := roundTrip.Get("app", "space"); got != " leading" {
|
|
|
|
|
t.Fatalf("quoted leading space did not round trip: %q", got)
|
|
|
|
|
}
|
|
|
|
|
if got := roundTrip.Get("app", "line"); got != "a\nb" {
|
|
|
|
|
t.Fatalf("quoted newline did not round trip: %q", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestIniStrictParseErrorReportsLocation(t *testing.T) {
|
|
|
|
|
ini := NewIni()
|
|
|
|
|
ini.Strict = true
|
|
|
|
|
err := ini.Parse([]byte("[app]\n=value\n"))
|
|
|
|
|
var parseErr *ParseError
|
|
|
|
|
if !errors.As(err, &parseErr) {
|
|
|
|
|
t.Fatalf("expected ParseError, got %T: %v", err, err)
|
|
|
|
|
}
|
|
|
|
|
if parseErr.Line != 2 || parseErr.Column != 1 {
|
|
|
|
|
t.Fatalf("unexpected parse error location: line=%d column=%d", parseErr.Line, parseErr.Column)
|
|
|
|
|
}
|
|
|
|
|
if parseErr.Message == "" {
|
|
|
|
|
t.Fatalf("parse error should include message")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestIniSectionRenameRebuildsHeader(t *testing.T) {
|
|
|
|
|
ini := NewIni()
|
|
|
|
|
if err := ini.Parse([]byte("[old]\nname=value\n")); err != nil {
|
|
|
|
|
t.Fatalf("parse failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
sec := ini.Section("old")
|
|
|
|
|
if sec == nil {
|
|
|
|
|
t.Fatalf("missing old section")
|
|
|
|
|
}
|
|
|
|
|
sec.Name = "new"
|
|
|
|
|
if out := ini.Build(); !bytes.Contains(out, []byte("[new]\n")) || bytes.Contains(out, []byte("[old]\n")) {
|
|
|
|
|
t.Fatalf("section rename not reflected in build: %q", out)
|
|
|
|
|
}
|
|
|
|
|
if got := ini.Section("new"); got != sec {
|
|
|
|
|
t.Fatalf("renamed section was not indexed under new name")
|
|
|
|
|
}
|
|
|
|
|
if got := ini.Section("old"); got != nil {
|
|
|
|
|
t.Fatalf("renamed section still indexed under old name: %#v", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestParseCSVPreservesBoundaryWhitespace(t *testing.T) {
|
|
|
|
|
csvData, err := ParseCSV([]byte(" col ,name\n value ,demo \n"), true)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("parse csv failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if got := csvData.Header()[0]; got != " col " {
|
|
|
|
|
t.Fatalf("header whitespace was trimmed: %q", got)
|
|
|
|
|
}
|
|
|
|
|
row := csvData.Row(0)
|
|
|
|
|
if row == nil || row.Col(0).value != " value " || row.Col(1).value != "demo " {
|
|
|
|
|
t.Fatalf("row whitespace was not preserved: %#v", row)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type configFrameworkApp struct {
|
|
|
|
|
Name string `key:"name" required:"true"`
|
|
|
|
|
Port int `key:"port" default:"8080"`
|
|
|
|
|
Enabled bool `key:"enabled" default:"true"`
|
|
|
|
|
Timeout time.Duration `key:"timeout" default:"2s"`
|
|
|
|
|
Retries []int `key:"retry" default:"1,2" split:","`
|
|
|
|
|
Tags []string `key:"tag"`
|
|
|
|
|
Limits map[string]int `key:"limit" default:"read=10,write=20" split:","`
|
|
|
|
|
Token string `key:"token" env:"APP_SECRET"`
|
|
|
|
|
SkipEnv string `key:"skip_env" env:"-" default:"file-only"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type configFrameworkServer struct {
|
|
|
|
|
Host string `key:"host" default:"127.0.0.1"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type configFrameworkExample struct {
|
|
|
|
|
App configFrameworkApp `seg:"app"`
|
|
|
|
|
Server configFrameworkServer `seg:"server"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *configFrameworkExample) Validate() error {
|
|
|
|
|
if c.App.Port <= 0 {
|
|
|
|
|
return errors.New("port must be positive")
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestConfigFrameworkLoadsOverridesDefaultsEnvAndValidate(t *testing.T) {
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
base := filepath.Join(dir, "base.ini")
|
|
|
|
|
override := filepath.Join(dir, "override.ini")
|
|
|
|
|
if err := os.WriteFile(base, []byte("[app]\nname=demo\nport=1000\ntag=base\nretry=3\nlimit=read=11\nskip_env=from-file\n[server]\nhost=0.0.0.0\n"), 0o644); err != nil {
|
|
|
|
|
t.Fatalf("write base config failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if err := os.WriteFile(override, []byte("[app]\nport=2000\ntag=override\ntag=extra\nlimit=write=22\nenabled\n"), 0o644); err != nil {
|
|
|
|
|
t.Fatalf("write override config failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
env := map[string]string{
|
|
|
|
|
"APP_APP_PORT": "3000",
|
|
|
|
|
"APP_APP_TIMEOUT": "5s",
|
|
|
|
|
"APP_APP_RETRY": "7,8,9",
|
|
|
|
|
"APP_SECRET": "token-from-env",
|
|
|
|
|
"APP_APP_SKIP_ENV": "ignored",
|
|
|
|
|
}
|
|
|
|
|
var dst configFrameworkExample
|
|
|
|
|
cfg, err := LoadConfig(&dst, []string{base, override},
|
|
|
|
|
WithEnvPrefix("APP"),
|
|
|
|
|
WithEnvLookup(func(key string) (string, bool) {
|
|
|
|
|
value, ok := env[key]
|
|
|
|
|
return value, ok
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("load config failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if dst.App.Name != "demo" || dst.App.Port != 3000 || !dst.App.Enabled {
|
|
|
|
|
t.Fatalf("basic bind mismatch: %+v", dst.App)
|
|
|
|
|
}
|
|
|
|
|
if dst.App.Timeout != 5*time.Second {
|
|
|
|
|
t.Fatalf("duration env override mismatch: %s", dst.App.Timeout)
|
|
|
|
|
}
|
|
|
|
|
if got := dst.App.Retries; len(got) != 3 || got[0] != 7 || got[1] != 8 || got[2] != 9 {
|
|
|
|
|
t.Fatalf("slice env override mismatch: %#v", got)
|
|
|
|
|
}
|
|
|
|
|
if got := dst.App.Tags; len(got) != 2 || got[0] != "override" || got[1] != "extra" {
|
|
|
|
|
t.Fatalf("repeated key override mismatch: %#v", got)
|
|
|
|
|
}
|
|
|
|
|
if dst.App.Limits["write"] != 22 || dst.App.Limits["read"] != 0 {
|
|
|
|
|
t.Fatalf("map override mismatch: %#v", dst.App.Limits)
|
|
|
|
|
}
|
|
|
|
|
if dst.App.Token != "token-from-env" || dst.App.SkipEnv != "from-file" {
|
|
|
|
|
t.Fatalf("env handling mismatch: token=%q skip=%q", dst.App.Token, dst.App.SkipEnv)
|
|
|
|
|
}
|
|
|
|
|
if dst.Server.Host != "0.0.0.0" {
|
|
|
|
|
t.Fatalf("nested section bind mismatch: %q", dst.Server.Host)
|
|
|
|
|
}
|
|
|
|
|
if cfg.Get("app", "port") != "3000" {
|
|
|
|
|
t.Fatalf("config access did not see env override: %q", cfg.Get("app", "port"))
|
|
|
|
|
}
|
|
|
|
|
if values := cfg.GetAll("app", "tag"); len(values) != 2 || values[0] != "override" || values[1] != "extra" {
|
|
|
|
|
t.Fatalf("config repeated values mismatch: %#v", values)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cfg.Set("app", "name", "saved")
|
|
|
|
|
outPath := filepath.Join(dir, "saved.ini")
|
|
|
|
|
if err := cfg.Save(outPath); err != nil {
|
|
|
|
|
t.Fatalf("save config failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
out, err := os.ReadFile(outPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("read saved config failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if !bytes.Contains(out, []byte("name=saved")) || !bytes.Contains(out, []byte("retry=7")) || !bytes.Contains(out, []byte("retry=8")) || !bytes.Contains(out, []byte("retry=9")) {
|
|
|
|
|
t.Fatalf("saved config missing expected values: %q", out)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestConfigFrameworkReportsRequiredAndValidateErrors(t *testing.T) {
|
|
|
|
|
var missing configFrameworkExample
|
|
|
|
|
_, err := LoadConfig(&missing, nil)
|
|
|
|
|
var cfgErr *ConfigError
|
|
|
|
|
if !errors.As(err, &cfgErr) {
|
|
|
|
|
t.Fatalf("expected required ConfigError, got %T: %v", err, err)
|
|
|
|
|
}
|
|
|
|
|
if cfgErr.Section != "app" || cfgErr.Key != "name" {
|
|
|
|
|
t.Fatalf("required error points at wrong field: %#v", cfgErr)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var invalid configFrameworkExample
|
|
|
|
|
_, err = LoadConfig(&invalid, nil, WithEnvLookup(func(key string) (string, bool) {
|
|
|
|
|
switch key {
|
|
|
|
|
case "APP_NAME", "APP_APP_NAME":
|
|
|
|
|
return "demo", true
|
|
|
|
|
case "APP_APP_PORT":
|
|
|
|
|
return "-1", true
|
|
|
|
|
default:
|
|
|
|
|
return "", false
|
|
|
|
|
}
|
|
|
|
|
}), WithEnvPrefix("APP"))
|
|
|
|
|
if err == nil || err.Error() != "port must be positive" {
|
|
|
|
|
t.Fatalf("expected validate error, got %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestConfigFrameworkEnvIsExplicitOptIn(t *testing.T) {
|
|
|
|
|
t.Setenv("APP_NAME", "from-env")
|
|
|
|
|
t.Setenv("APP_PORT", "9090")
|
|
|
|
|
|
|
|
|
|
type cfg struct {
|
|
|
|
|
Name string `key:"name" required:"true"`
|
|
|
|
|
Port int `key:"port" default:"8080"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var disabled cfg
|
|
|
|
|
if _, err := LoadConfig(&disabled, nil); err == nil {
|
|
|
|
|
t.Fatalf("expected missing required value when env is not enabled")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var enabled cfg
|
|
|
|
|
if _, err := LoadConfig(&enabled, nil, WithEnvPrefix("APP")); err != nil {
|
|
|
|
|
t.Fatalf("load with explicit env failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if enabled.Name != "from-env" || enabled.Port != 9090 {
|
|
|
|
|
t.Fatalf("env override mismatch: %+v", enabled)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestConfigFrameworkRequiredNoValueDependsOnFieldType(t *testing.T) {
|
|
|
|
|
type cfg struct {
|
|
|
|
|
App struct {
|
|
|
|
|
Name string `key:"name" required:"true"`
|
|
|
|
|
Enabled bool `key:"enabled" required:"true"`
|
|
|
|
|
} `seg:"app"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var missingName cfg
|
|
|
|
|
loader := NewConfig()
|
|
|
|
|
if err := loader.LoadBytes([]byte("[app]\nname\nenabled\n")); err != nil {
|
|
|
|
|
t.Fatalf("load bytes failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
err := loader.Bind(&missingName)
|
|
|
|
|
var cfgErr *ConfigError
|
|
|
|
|
if !errors.As(err, &cfgErr) {
|
|
|
|
|
t.Fatalf("expected no-value string required error, got %T: %v", err, err)
|
|
|
|
|
}
|
|
|
|
|
if cfgErr.Key != "name" || cfgErr.Reason != "required value is empty" {
|
|
|
|
|
t.Fatalf("unexpected required error: %#v", cfgErr)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var ok cfg
|
|
|
|
|
loader = NewConfig()
|
|
|
|
|
if err := loader.LoadBytes([]byte("[app]\nname=demo\nenabled\n")); err != nil {
|
|
|
|
|
t.Fatalf("load bytes failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if err := loader.Bind(&ok); err != nil {
|
|
|
|
|
t.Fatalf("bind with bool no-value failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if ok.App.Name != "demo" || !ok.App.Enabled {
|
|
|
|
|
t.Fatalf("unexpected bind result: %+v", ok.App)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestConfigFrameworkBindsDuplicateSectionsInOrder(t *testing.T) {
|
|
|
|
|
type cfg struct {
|
|
|
|
|
App struct {
|
|
|
|
|
Name string `key:"name"`
|
|
|
|
|
Port int `key:"port"`
|
|
|
|
|
Tags []string `key:"tag"`
|
|
|
|
|
} `seg:"app"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
loader := NewConfig()
|
|
|
|
|
if err := loader.LoadBytes([]byte("[app]\nname=demo\ntag=base\n[app]\nport=2000\ntag=override\n")); err != nil {
|
|
|
|
|
t.Fatalf("load bytes failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var got cfg
|
|
|
|
|
if err := loader.Bind(&got); err != nil {
|
|
|
|
|
t.Fatalf("bind failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if got.App.Name != "demo" || got.App.Port != 2000 {
|
|
|
|
|
t.Fatalf("duplicate section bind mismatch: %+v", got.App)
|
|
|
|
|
}
|
|
|
|
|
if len(got.App.Tags) != 2 || got.App.Tags[0] != "base" || got.App.Tags[1] != "override" {
|
|
|
|
|
t.Fatalf("duplicate section repeated values mismatch: %#v", got.App.Tags)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestConfigFrameworkSplitTagIsExplicit(t *testing.T) {
|
|
|
|
|
type cfg struct {
|
|
|
|
|
App struct {
|
|
|
|
|
Implicit []string `key:"implicit" default:"a,b"`
|
|
|
|
|
CSV []string `key:"csv" default:"x,y" split:","`
|
|
|
|
|
} `seg:"app"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var got cfg
|
|
|
|
|
if _, err := LoadConfig(&got, nil); err != nil {
|
|
|
|
|
t.Fatalf("load config failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(got.App.Implicit) != 1 || got.App.Implicit[0] != "a,b" {
|
|
|
|
|
t.Fatalf("implicit split should stay scalar-like: %#v", got.App.Implicit)
|
|
|
|
|
}
|
|
|
|
|
if len(got.App.CSV) != 2 || got.App.CSV[0] != "x" || got.App.CSV[1] != "y" {
|
|
|
|
|
t.Fatalf("explicit split did not apply: %#v", got.App.CSV)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestConfigFrameworkSplitTagAppliesToFileBinding(t *testing.T) {
|
|
|
|
|
type cfg struct {
|
|
|
|
|
App struct {
|
|
|
|
|
Retries []int `key:"retry" split:"|"`
|
|
|
|
|
Tags []string `key:"tag" split:","`
|
|
|
|
|
Limits map[string]int `key:"limit" split:";"`
|
|
|
|
|
} `seg:"app"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
loader := NewConfig()
|
|
|
|
|
if err := loader.LoadBytes([]byte("[app]\nretry=1|2|3\ntag=alpha,beta\ntag=gamma\nlimit=read=10; write=20\n")); err != nil {
|
|
|
|
|
t.Fatalf("load bytes failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var got cfg
|
|
|
|
|
if err := loader.Bind(&got); err != nil {
|
|
|
|
|
t.Fatalf("bind failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(got.App.Retries) != 3 || got.App.Retries[0] != 1 || got.App.Retries[1] != 2 || got.App.Retries[2] != 3 {
|
|
|
|
|
t.Fatalf("retry split mismatch: %#v", got.App.Retries)
|
|
|
|
|
}
|
|
|
|
|
if len(got.App.Tags) != 3 || got.App.Tags[0] != "alpha" || got.App.Tags[1] != "beta" || got.App.Tags[2] != "gamma" {
|
|
|
|
|
t.Fatalf("tag split mismatch: %#v", got.App.Tags)
|
|
|
|
|
}
|
|
|
|
|
if got.App.Limits["read"] != 10 || got.App.Limits["write"] != 20 {
|
|
|
|
|
t.Fatalf("limit split mismatch: %#v", got.App.Limits)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestConfigFrameworkSourcesAndAtomicSave(t *testing.T) {
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
required := filepath.Join(dir, "app.ini")
|
|
|
|
|
missing := filepath.Join(dir, "missing.ini")
|
|
|
|
|
out := filepath.Join(dir, "saved.ini")
|
|
|
|
|
|
|
|
|
|
if err := os.WriteFile(required, []byte("[app]\nname=demo\n"), 0o644); err != nil {
|
|
|
|
|
t.Fatalf("write required config failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if err := os.WriteFile(out, []byte("stale\n"), 0o600); err != nil {
|
|
|
|
|
t.Fatalf("write existing output failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type cfg struct {
|
|
|
|
|
App struct {
|
|
|
|
|
Name string `key:"name" required:"true"`
|
|
|
|
|
} `seg:"app"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var got cfg
|
|
|
|
|
loaded, err := LoadConfigSources(&got, []ConfigSource{
|
|
|
|
|
OptionalFile(missing),
|
|
|
|
|
RequiredFile(required),
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("load sources failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if got.App.Name != "demo" {
|
|
|
|
|
t.Fatalf("unexpected loaded config: %+v", got)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
loaded.Set("app", "name", "saved")
|
|
|
|
|
if err := loaded.SaveAtomic(out); err != nil {
|
|
|
|
|
t.Fatalf("save atomic failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
data, err := os.ReadFile(out)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("read saved file failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if !bytes.Contains(data, []byte("name=saved")) {
|
|
|
|
|
t.Fatalf("saved file missing updated value: %q", data)
|
|
|
|
|
}
|
|
|
|
|
info, err := os.Stat(out)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("stat saved file failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if info.Mode().Perm() != 0o600 {
|
|
|
|
|
t.Fatalf("save atomic should preserve file mode, got %o", info.Mode().Perm())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_, err = LoadConfigSources(&cfg{}, []ConfigSource{RequiredFile(missing)})
|
|
|
|
|
var sourceErr *ConfigSourceError
|
|
|
|
|
if !errors.As(err, &sourceErr) {
|
|
|
|
|
t.Fatalf("expected ConfigSourceError, got %T: %v", err, err)
|
|
|
|
|
}
|
|
|
|
|
if sourceErr.Path != missing || sourceErr.Optional {
|
|
|
|
|
t.Fatalf("unexpected source error metadata: %#v", sourceErr)
|
|
|
|
|
}
|
|
|
|
|
if !errors.Is(err, os.ErrNotExist) {
|
|
|
|
|
t.Fatalf("expected wrapped not-exist error, got %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestConfigFrameworkMemorySourceAndTypedGetters(t *testing.T) {
|
|
|
|
|
cfg := NewConfig()
|
|
|
|
|
if err := cfg.LoadSources(StringSource("inline", "[app]\nname=demo\nport=8080\nenabled\nratio=1.5\ntimeout=3s\n")); err != nil {
|
|
|
|
|
t.Fatalf("load string source failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
name, err := cfg.GetStringE("app", "name")
|
|
|
|
|
if err != nil || name != "demo" {
|
|
|
|
|
t.Fatalf("get string mismatch: name=%q err=%v", name, err)
|
|
|
|
|
}
|
|
|
|
|
port, err := cfg.GetIntE("app", "port")
|
|
|
|
|
if err != nil || port != 8080 {
|
|
|
|
|
t.Fatalf("get int mismatch: port=%d err=%v", port, err)
|
|
|
|
|
}
|
|
|
|
|
enabled, err := cfg.GetBoolE("app", "enabled")
|
|
|
|
|
if err != nil || !enabled {
|
|
|
|
|
t.Fatalf("get bool no-value mismatch: enabled=%v err=%v", enabled, err)
|
|
|
|
|
}
|
|
|
|
|
ratio, err := cfg.GetFloat64E("app", "ratio")
|
|
|
|
|
if err != nil || ratio != 1.5 {
|
|
|
|
|
t.Fatalf("get float mismatch: ratio=%v err=%v", ratio, err)
|
|
|
|
|
}
|
|
|
|
|
timeout, err := cfg.GetDurationE("app", "timeout")
|
|
|
|
|
if err != nil || timeout != 3*time.Second {
|
|
|
|
|
t.Fatalf("get duration mismatch: timeout=%v err=%v", timeout, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if _, err := cfg.GetIntE("app", "missing"); !errors.Is(err, ErrKeyNotFound) {
|
|
|
|
|
t.Fatalf("missing typed getter should wrap ErrKeyNotFound, got %v", err)
|
|
|
|
|
}
|
|
|
|
|
cfg.Set("app", "bad", "not-int")
|
|
|
|
|
err = nil
|
|
|
|
|
if _, err = cfg.GetIntE("app", "bad"); !errors.Is(err, strconv.ErrSyntax) {
|
|
|
|
|
t.Fatalf("invalid typed getter should wrap strconv.ErrSyntax, got %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestConfigFrameworkSetStructWritesConfig(t *testing.T) {
|
|
|
|
|
type cfg struct {
|
|
|
|
|
App struct {
|
|
|
|
|
Name string `key:"name"`
|
|
|
|
|
Port int `key:"port"`
|
|
|
|
|
Enabled bool `key:"enabled"`
|
|
|
|
|
Timeout time.Duration `key:"timeout"`
|
|
|
|
|
Tags []string `key:"tag"`
|
|
|
|
|
Limits map[string]uint64 `key:"limit"`
|
|
|
|
|
} `seg:"app"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
src := cfg{}
|
|
|
|
|
src.App.Name = "demo"
|
|
|
|
|
src.App.Port = 9090
|
|
|
|
|
src.App.Enabled = true
|
|
|
|
|
src.App.Timeout = 5 * time.Second
|
|
|
|
|
src.App.Tags = []string{"alpha", "beta"}
|
|
|
|
|
src.App.Limits = map[string]uint64{"write": 20, "read": 10}
|
|
|
|
|
|
|
|
|
|
loader := NewConfig()
|
|
|
|
|
if err := loader.SetStruct(src); err != nil {
|
|
|
|
|
t.Fatalf("set struct failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if got := loader.Get("app", "name"); got != "demo" {
|
|
|
|
|
t.Fatalf("name was not written: %q", got)
|
|
|
|
|
}
|
|
|
|
|
if got := loader.Get("app", "port"); got != "9090" {
|
|
|
|
|
t.Fatalf("port was not written: %q", got)
|
|
|
|
|
}
|
|
|
|
|
if got := loader.Get("app", "timeout"); got != "5s" {
|
|
|
|
|
t.Fatalf("duration was not written: %q", got)
|
|
|
|
|
}
|
|
|
|
|
if got := loader.GetAll("app", "tag"); len(got) != 2 || got[0] != "alpha" || got[1] != "beta" {
|
|
|
|
|
t.Fatalf("slice was not written as repeated keys: %#v", got)
|
|
|
|
|
}
|
|
|
|
|
if got := loader.GetAll("app", "limit"); len(got) != 2 || got[0] != "read=10" || got[1] != "write=20" {
|
|
|
|
|
t.Fatalf("map was not written as sorted repeated keys: %#v", got)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var roundTrip cfg
|
|
|
|
|
if err := loader.Bind(&roundTrip); err != nil {
|
|
|
|
|
t.Fatalf("round-trip bind failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if roundTrip.App.Name != src.App.Name || roundTrip.App.Port != src.App.Port || roundTrip.App.Timeout != src.App.Timeout {
|
|
|
|
|
t.Fatalf("round-trip scalar mismatch: %+v", roundTrip.App)
|
|
|
|
|
}
|
|
|
|
|
if len(roundTrip.App.Tags) != 2 || roundTrip.App.Tags[0] != "alpha" || roundTrip.App.Tags[1] != "beta" {
|
|
|
|
|
t.Fatalf("round-trip slice mismatch: %#v", roundTrip.App.Tags)
|
|
|
|
|
}
|
|
|
|
|
if roundTrip.App.Limits["read"] != 10 || roundTrip.App.Limits["write"] != 20 {
|
|
|
|
|
t.Fatalf("round-trip map mismatch: %#v", roundTrip.App.Limits)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestConfigFrameworkBindErrorUnwrapsOriginalError(t *testing.T) {
|
|
|
|
|
type cfg struct {
|
|
|
|
|
App struct {
|
|
|
|
|
Port int `key:"port"`
|
|
|
|
|
} `seg:"app"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
loader := NewConfig()
|
|
|
|
|
if err := loader.LoadSources(BytesSource("bad", []byte("[app]\nport=bad\n"))); err != nil {
|
|
|
|
|
t.Fatalf("load bytes source failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
var got cfg
|
|
|
|
|
err := loader.Bind(&got)
|
|
|
|
|
var cfgErr *ConfigError
|
|
|
|
|
if !errors.As(err, &cfgErr) {
|
|
|
|
|
t.Fatalf("expected ConfigError, got %T: %v", err, err)
|
|
|
|
|
}
|
|
|
|
|
if cfgErr.Section != "app" || cfgErr.Key != "port" || cfgErr.Field != "Port" {
|
|
|
|
|
t.Fatalf("unexpected config error metadata: %#v", cfgErr)
|
|
|
|
|
}
|
|
|
|
|
if !errors.Is(err, strconv.ErrSyntax) {
|
|
|
|
|
t.Fatalf("bind error should unwrap strconv.ErrSyntax, got %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestConfigFrameworkDescribeAndSampleConfig(t *testing.T) {
|
|
|
|
|
type server struct {
|
|
|
|
|
Host string `key:"host" default:"127.0.0.1"`
|
|
|
|
|
Ports []int `key:"port" default:"8080,8081" split:","`
|
|
|
|
|
}
|
|
|
|
|
type cfg struct {
|
|
|
|
|
App struct {
|
|
|
|
|
Name string `key:"name" required:"true"`
|
|
|
|
|
Token string `key:"token" env:"APP_TOKEN"`
|
|
|
|
|
Tags []string `key:"tag"`
|
|
|
|
|
Modes []string `key:"mode" required:"true"`
|
|
|
|
|
Limits map[string]int `key:"limit" default:"read=10,write=20" split:","`
|
|
|
|
|
Labels map[string]string `key:"label" required:"true"`
|
|
|
|
|
Skip string `key:"-"`
|
|
|
|
|
} `seg:"app"`
|
|
|
|
|
Server *server `seg:"server"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var src cfg
|
|
|
|
|
fields, err := DescribeConfig(&src)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("describe config failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if src.Server != nil {
|
|
|
|
|
t.Fatalf("describe config should not allocate nil nested pointers")
|
|
|
|
|
}
|
|
|
|
|
if len(fields) != 8 {
|
|
|
|
|
t.Fatalf("unexpected field count: %#v", fields)
|
|
|
|
|
}
|
|
|
|
|
byPath := make(map[string]ConfigFieldInfo)
|
|
|
|
|
for _, field := range fields {
|
|
|
|
|
byPath[field.Field] = field
|
|
|
|
|
}
|
|
|
|
|
name := byPath["App.Name"]
|
|
|
|
|
if name.Section != "app" || name.Key != "name" || name.Default != "" || !name.Required || name.Type != "string" {
|
|
|
|
|
t.Fatalf("unexpected name field metadata: %#v", name)
|
|
|
|
|
}
|
|
|
|
|
ports := byPath["Server.Ports"]
|
|
|
|
|
if ports.Section != "server" || ports.Key != "port" || ports.Default != "8080,8081" || ports.Split != "," || ports.Type != "[]int" {
|
|
|
|
|
t.Fatalf("unexpected ports field metadata: %#v", ports)
|
|
|
|
|
}
|
|
|
|
|
if token := byPath["App.Token"]; token.Env != "APP_TOKEN" {
|
|
|
|
|
t.Fatalf("unexpected token env metadata: %#v", token)
|
|
|
|
|
}
|
|
|
|
|
if _, ok := byPath["App.Skip"]; ok {
|
|
|
|
|
t.Fatalf("key:\"-\" field should be skipped: %#v", byPath["App.Skip"])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sample, err := SampleConfig(&src)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("sample config failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if src.Server != nil {
|
|
|
|
|
t.Fatalf("sample config should not allocate nil nested pointers")
|
|
|
|
|
}
|
|
|
|
|
for _, want := range [][]byte{
|
|
|
|
|
[]byte("[app]\n"),
|
|
|
|
|
[]byte("name=value\n"),
|
|
|
|
|
[]byte("token=\n"),
|
|
|
|
|
[]byte("tag=\n"),
|
|
|
|
|
[]byte("mode=value\n"),
|
|
|
|
|
[]byte("limit=read=10\n"),
|
|
|
|
|
[]byte("limit=write=20\n"),
|
|
|
|
|
[]byte("label=key=value\n"),
|
|
|
|
|
[]byte("[server]\n"),
|
|
|
|
|
[]byte("host=127.0.0.1\n"),
|
|
|
|
|
[]byte("port=8080\n"),
|
|
|
|
|
[]byte("port=8081\n"),
|
|
|
|
|
} {
|
|
|
|
|
if !bytes.Contains(sample, want) {
|
|
|
|
|
t.Fatalf("sample config missing %q in %q", want, sample)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var got cfg
|
|
|
|
|
loader := NewConfig()
|
|
|
|
|
if err := loader.LoadBytes(sample); err != nil {
|
|
|
|
|
t.Fatalf("load sample failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if err := loader.Bind(&got); err != nil {
|
|
|
|
|
t.Fatalf("bind sample failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if got.App.Name != "value" || got.App.Limits["read"] != 10 || got.App.Limits["write"] != 20 {
|
|
|
|
|
t.Fatalf("sample app values did not bind: %+v", got.App)
|
|
|
|
|
}
|
|
|
|
|
if len(got.App.Modes) != 1 || got.App.Modes[0] != "value" || got.App.Labels["key"] != "value" {
|
|
|
|
|
t.Fatalf("required placeholder values did not bind: %+v", got.App)
|
|
|
|
|
}
|
|
|
|
|
if got.Server == nil || got.Server.Host != "127.0.0.1" || len(got.Server.Ports) != 2 || got.Server.Ports[0] != 8080 || got.Server.Ports[1] != 8081 {
|
|
|
|
|
t.Fatalf("sample server values did not bind: %+v", got.Server)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestConfigFrameworkFlattenSectionNamesAndKeys(t *testing.T) {
|
|
|
|
|
cfg := NewConfig()
|
|
|
|
|
cfg.Set("", "root", "top")
|
|
|
|
|
cfg.Set("app", "name", "demo")
|
|
|
|
|
cfg.SetAll("app", "tag", []string{"alpha", "beta"})
|
|
|
|
|
cfg.Ini().AddValue("app", "tag", "gamma")
|
|
|
|
|
cfg.Set("server", "host", "127.0.0.1")
|
|
|
|
|
if err := cfg.LoadBytes([]byte("[app]\nflag\n")); err != nil {
|
|
|
|
|
t.Fatalf("load no-value config failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if got := cfg.SectionNames(); len(got) != 3 || got[0] != "" || got[1] != "app" || got[2] != "server" {
|
|
|
|
|
t.Fatalf("unexpected section names: %#v", got)
|
|
|
|
|
}
|
|
|
|
|
if got := cfg.Keys(""); len(got) != 1 || got[0] != "root" {
|
|
|
|
|
t.Fatalf("unexpected root keys: %#v", got)
|
|
|
|
|
}
|
|
|
|
|
if got := cfg.Keys("app"); len(got) != 3 || got[0] != "flag" || got[1] != "name" || got[2] != "tag" {
|
|
|
|
|
t.Fatalf("unexpected app keys: %#v", got)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
flat := cfg.Flatten()
|
|
|
|
|
if got := flat["root"]; len(got) != 1 || got[0] != "top" {
|
|
|
|
|
t.Fatalf("unexpected root flatten values: %#v", got)
|
|
|
|
|
}
|
|
|
|
|
if got := flat["app.name"]; len(got) != 1 || got[0] != "demo" {
|
|
|
|
|
t.Fatalf("unexpected app.name flatten values: %#v", got)
|
|
|
|
|
}
|
|
|
|
|
if got := flat["app.tag"]; len(got) != 3 || got[0] != "alpha" || got[1] != "beta" || got[2] != "gamma" {
|
|
|
|
|
t.Fatalf("unexpected app.tag flatten values: %#v", got)
|
|
|
|
|
}
|
|
|
|
|
if got := flat["app.flag"]; len(got) != 1 || got[0] != "" {
|
|
|
|
|
t.Fatalf("unexpected app.flag flatten values: %#v", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestConfigFrameworkFlattenEntriesPreservesStructuredPath(t *testing.T) {
|
|
|
|
|
cfg := NewConfig()
|
|
|
|
|
cfg.Set("db.primary", "host", "127.0.0.1")
|
|
|
|
|
cfg.Set("db", "primary.host", "localhost")
|
|
|
|
|
|
|
|
|
|
flat := cfg.Flatten()
|
|
|
|
|
if got := flat["db.primary.host"]; len(got) != 2 || got[0] != "127.0.0.1" || got[1] != "localhost" {
|
|
|
|
|
t.Fatalf("flatten should keep legacy ambiguous path values: %#v", got)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
entries := cfg.FlattenEntries()
|
|
|
|
|
if len(entries) != 2 {
|
|
|
|
|
t.Fatalf("unexpected flatten entry count: %#v", entries)
|
|
|
|
|
}
|
|
|
|
|
if entries[0].Section != "db.primary" || entries[0].Key != "host" || entries[0].Path != "db.primary.host" || len(entries[0].Values) != 1 || entries[0].Values[0] != "127.0.0.1" {
|
|
|
|
|
t.Fatalf("unexpected first flatten entry: %#v", entries[0])
|
|
|
|
|
}
|
|
|
|
|
if entries[1].Section != "db" || entries[1].Key != "primary.host" || entries[1].Path != "db.primary.host" || len(entries[1].Values) != 1 || entries[1].Values[0] != "localhost" {
|
|
|
|
|
t.Fatalf("unexpected second flatten entry: %#v", entries[1])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
entries[0].Values[0] = "mutated"
|
|
|
|
|
if got := cfg.Get("db.primary", "host"); got != "127.0.0.1" {
|
|
|
|
|
t.Fatalf("flatten entries should not expose mutable config values: %q", got)
|
|
|
|
|
}
|
2024-04-10 15:19:25 +08:00
|
|
|
}
|