package sysconf import ( "bytes" "errors" "os" "path/filepath" "strconv" "testing" "time" ) func TestIniParseBuild(t *testing.T) { ini := NewIni() 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) } }