feat(config): geschichtete Konfigurationsauflösung mit injizierbaren Pfaden

Defaults → optionale config.toml → Env-Overrides (TALK_AUDIO_SINK);
Validierungsfehler sind klar, nie still. Plattform-Pfadlogik
(linux/darwin/windows, XDG_CONFIG_HOME, HOME, APPDATA) mit goos als
Parameter — alle Plattformen tischgesteuert vom Dev-Host testbar.
Erste externe Dependency: github.com/BurntSushi/toml v1.6.0 (MIT, pure Go).
This commit is contained in:
m3ta-chiron committed 2026-10-18 15:38:56 +02:00
1 parent de1ef64b8f
commit 36ad1dfa4e
6 files changed
+363 -3

No files matched your search

+206
View File
@@ -0,0 +1,206 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
func env(kv map[string]string) func(string) string {
return func(k string) string { return kv[k] }
}
func TestDirForPlattformpfade(t *testing.T) {
cases := []struct {
name string
goos string
env map[string]string
want string
errMsg string
}{
{
name: "linux mit HOME",
goos: "linux",
env: map[string]string{"HOME": "/home/u"},
want: "/home/u/.config/talk",
},
{
name: "linux XDG hat Vorrang",
goos: "linux",
env: map[string]string{"HOME": "/home/u", "XDG_CONFIG_HOME": "/xdg"},
want: "/xdg/talk",
},
{
name: "linux relativer XDG wird ignoriert",
goos: "linux",
env: map[string]string{"HOME": "/home/u", "XDG_CONFIG_HOME": "relativ"},
want: "/home/u/.config/talk",
},
{
name: "linux ohne HOME ist Fehler",
goos: "linux",
env: map[string]string{},
errMsg: ErrNoHome.Error(),
},
{
name: "macos",
goos: "darwin",
env: map[string]string{"HOME": "/Users/u"},
want: "/Users/u/Library/Application Support/talk",
},
{
name: "macos ohne HOME ist Fehler",
goos: "darwin",
env: map[string]string{},
errMsg: ErrNoHome.Error(),
},
{
name: "windows",
goos: "windows",
env: map[string]string{"APPDATA": `C:\Users\u\AppData\Roaming`},
want: filepath.Join(`C:\Users\u\AppData\Roaming`, "talk"),
},
{
name: "windows ohne APPDATA ist Fehler",
goos: "windows",
env: map[string]string{},
errMsg: ErrNoHome.Error(),
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := dirFor(tc.goos, env(tc.env))
if tc.errMsg != "" {
if err == nil || err.Error() != tc.errMsg {
t.Fatalf("dirFor(%q) Fehler = %v, want %q", tc.goos, err, tc.errMsg)
}
return
}
if err != nil {
t.Fatalf("dirFor(%q): unerwarteter Fehler %v", tc.goos, err)
}
if got != tc.want {
t.Errorf("dirFor(%q) = %q, want %q", tc.goos, got, tc.want)
}
})
}
}
// TestLoadMerge ist tischgesteuert für die Schichtenfolge
// Defaults → TOML-Datei → Env-Override.
func TestLoadMerge(t *testing.T) {
writeTOML := func(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return path
}
t.Run("nur Defaults", func(t *testing.T) {
cfg, err := Load(Options{
File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"),
Getenv: env(nil),
})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Audio.Sink != SinkOto {
t.Errorf("Audio.Sink = %q, want Default %q", cfg.Audio.Sink, SinkOto)
}
})
t.Run("TOML überschreibt Default", func(t *testing.T) {
cfg, err := Load(Options{
File: writeTOML(t, "[audio]\nsink = \"null\"\n"),
Getenv: env(nil),
})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Audio.Sink != SinkNull {
t.Errorf("Audio.Sink = %q, want %q aus TOML", cfg.Audio.Sink, SinkNull)
}
})
t.Run("leere TOML-Sektion lässt Default stehen", func(t *testing.T) {
cfg, err := Load(Options{
File: writeTOML(t, "# nur ein Kommentar\n[audio]\n"),
Getenv: env(nil),
})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Audio.Sink != SinkOto {
t.Errorf("Audio.Sink = %q, want Default %q", cfg.Audio.Sink, SinkOto)
}
})
t.Run("Env schlägt Datei", func(t *testing.T) {
cfg, err := Load(Options{
File: writeTOML(t, "[audio]\nsink = \"oto\"\n"),
Getenv: env(map[string]string{"TALK_AUDIO_SINK": "null"}),
})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Audio.Sink != SinkNull {
t.Errorf("Audio.Sink = %q, want Env-Override %q", cfg.Audio.Sink, SinkNull)
}
})
t.Run("Env allein ohne Datei", func(t *testing.T) {
cfg, err := Load(Options{
File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"),
Getenv: env(map[string]string{"TALK_AUDIO_SINK": "null"}),
})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Audio.Sink != SinkNull {
t.Errorf("Audio.Sink = %q, want %q", cfg.Audio.Sink, SinkNull)
}
})
t.Run("ungültiger Sink-Wert in Datei ist klarer Fehler", func(t *testing.T) {
_, err := Load(Options{
File: writeTOML(t, "[audio]\nsink = \"mpv\"\n"),
Getenv: env(nil),
})
if err == nil || !strings.Contains(err.Error(), "audio.sink") {
t.Fatalf("Load-Fehler = %v, will klaren audio.sink-Fehler", err)
}
})
t.Run("ungültiger Env-Wert ist klarer Fehler", func(t *testing.T) {
_, err := Load(Options{
File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"),
Getenv: env(map[string]string{"TALK_AUDIO_SINK": "bogus"}),
})
if err == nil || !strings.Contains(err.Error(), "TALK") && !strings.Contains(err.Error(), "audio.sink") {
t.Fatalf("Load-Fehler = %v, will klaren audio.sink-Fehler", err)
}
})
t.Run("kaputte TOML-Syntax ist klarer Fehler", func(t *testing.T) {
_, err := Load(Options{
File: writeTOML(t, "[audio\nsink = \n"),
Getenv: env(nil),
})
if err == nil {
t.Fatal("Load bei kaputter TOML: kein Fehler")
}
})
t.Run("ohne Home läuft Load mit Defaults weiter", func(t *testing.T) {
cfg, err := Load(Options{Getenv: env(nil)})
if err != nil {
t.Fatalf("Load ohne HOME: %v", err)
}
if cfg.Audio.Sink != SinkOto {
t.Errorf("Audio.Sink = %q, want Default %q", cfg.Audio.Sink, SinkOto)
}
})
}