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:
1 parent
de1ef64b8f
commit
36ad1dfa4e
6 files changed
+363
-3
No files matched your search
@@ -1,3 +1,5 @@
|
|||||||
module code.m3ta.dev/m3tam3re/talk
|
module code.m3ta.dev/m3tam3re/talk
|
||||||
|
|
||||||
go 1.26.8
|
go 1.26.8
|
||||||
|
|
||||||
|
require github.com/BurntSushi/toml v1.6.0
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||||
|
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/BurntSushi/toml"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Erlaubte Werte für audio.sink (Teil des öffentlichen Vertrags; Spec:
|
||||||
|
// „Audio-Sink wählbar inkl. null").
|
||||||
|
const (
|
||||||
|
SinkOto = "oto"
|
||||||
|
SinkNull = "null"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config ist der effektiv geladene Konfigurations-Stand von talk. Task 02
|
||||||
|
// führt nur audio.sink; spätere Tasks ergänzen Felder (Provider-Endpunkte,
|
||||||
|
// Voice-Mapping, server, queue …), ohne die Auflösungsreihenfolge zu ändern.
|
||||||
|
type Config struct {
|
||||||
|
Audio Audio `toml:"audio"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio steuert die Audio-Ausgabe.
|
||||||
|
type Audio struct {
|
||||||
|
// Sink wählt die Wiedergabe: "oto" (embedded Playback, Default) oder
|
||||||
|
// "null" (verwirft — CI, Server, Tests).
|
||||||
|
Sink string `toml:"sink"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defaults liefert die eingebaute Default-Konfiguration (unterste Schicht
|
||||||
|
// der Auflösung).
|
||||||
|
func Defaults() Config {
|
||||||
|
return Config{Audio: Audio{Sink: SinkOto}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options steuert Load. Alle Felder sind injizierbar, damit die Unit-Tests
|
||||||
|
// ohne echte HOME-/Env-Umgebung und ohne echte Dateien auskommen.
|
||||||
|
type Options struct {
|
||||||
|
// File überschreibt den platform-nativen Pfad der config.toml (Tests
|
||||||
|
// injizieren hier eine Temp-Datei). Leer: Dir() + "config.toml".
|
||||||
|
File string
|
||||||
|
|
||||||
|
// Getenv liefert Umgebungsvariablen (Default: os.Getenv). Über TALK_*
|
||||||
|
// laufen die Env-Overrides sowie die Pfadauflösung (HOME,
|
||||||
|
// XDG_CONFIG_HOME, APPDATA).
|
||||||
|
Getenv func(string) string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load löst die Konfiguration schichtweise auf: Defaults → config.toml
|
||||||
|
// (falls vorhanden) → Env-Overrides (TALK_AUDIO_SINK schlägt die Datei).
|
||||||
|
// Env- und Datei-Validierungsfehler werden als klarer Fehler gemeldet, nie
|
||||||
|
// still ignoriert. Fehlt das Config-Verzeichnis (z. B. keine HOME-Variable),
|
||||||
|
// läuft talk mit Defaults + Env weiter — die Datei ist optional, kaputte
|
||||||
|
// Umgebungen dürfen talk nicht unbrauchbar machen.
|
||||||
|
func Load(opts Options) (Config, error) {
|
||||||
|
if opts.Getenv == nil {
|
||||||
|
opts.Getenv = os.Getenv
|
||||||
|
}
|
||||||
|
cfg := Defaults()
|
||||||
|
|
||||||
|
path := opts.File
|
||||||
|
if path == "" {
|
||||||
|
dir, err := Dir(opts.Getenv)
|
||||||
|
if err != nil {
|
||||||
|
// Kein auffindbares Config-Verzeichnis: optionale Datei entfällt.
|
||||||
|
return applyEnv(cfg, opts.Getenv)
|
||||||
|
}
|
||||||
|
path = filepath.Join(dir, fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := toml.DecodeFile(path, &cfg); err != nil {
|
||||||
|
if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return cfg, fmt.Errorf("config %s: %w", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return applyEnv(cfg, opts.Getenv)
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyEnv wendet die Env-Overrides an und validiert das Ergebnis.
|
||||||
|
func applyEnv(cfg Config, getenv func(string) string) (Config, error) {
|
||||||
|
if v := getenv("TALK_AUDIO_SINK"); v != "" {
|
||||||
|
cfg.Audio.Sink = v
|
||||||
|
}
|
||||||
|
switch cfg.Audio.Sink {
|
||||||
|
case SinkOto, SinkNull:
|
||||||
|
return cfg, nil
|
||||||
|
default:
|
||||||
|
return cfg, fmt.Errorf("audio.sink: ungültiger Wert %q (erlaubt: %q, %q)",
|
||||||
|
cfg.Audio.Sink, SinkOto, SinkNull)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// Package config implementiert die Config-Auflösung von talk: Defaults +
|
// Package config implementiert die Config-Auflösung von talk: Defaults +
|
||||||
// optionale config.toml im platform-nativen Verzeichnis (~/.config/talk,
|
// optionale config.toml im platform-nativen Verzeichnis (~/.config/talk,
|
||||||
// ~/Library/Application Support/talk, %APPDATA%\talk) + Env-Overrides
|
// ~/Library/Application Support/talk, %APPDATA%\talk) + Env-Overrides
|
||||||
// (TALK_*); Env schlägt Datei. Enthält Voice-Mapping voice.<provider>.<lang>
|
// (TALK_*); Env schlägt Datei. Pfade und Env sind injizierbar (Tests).
|
||||||
// und Provider-Endpunkte (Fake-bar für Tests). Implementierung folgt in
|
// Voice-Mapping voice.<provider>.<lang> und Provider-Endpunkte folgen mit
|
||||||
// Task 02.
|
// Task 03.
|
||||||
package config
|
package config
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fileName ist der Name der Config-Datei im Config-Verzeichnis.
|
||||||
|
const fileName = "config.toml"
|
||||||
|
|
||||||
|
// ErrNoHome meldet, dass das Heimatverzeichnis nicht bestimmt werden kann
|
||||||
|
// (keine HOME- bzw. APPDATA-Umgebungsvariable).
|
||||||
|
var ErrNoHome = errors.New("Config-Verzeichnis nicht bestimmbar (HOME bzw. APPDATA fehlt)")
|
||||||
|
|
||||||
|
// Dir liefert das platform-native Config-Verzeichnis von talk auf dem
|
||||||
|
// aktuellen System: Linux ~/.config/talk (respektive $XDG_CONFIG_HOME/talk),
|
||||||
|
// macOS ~/Library/Application Support/talk, Windows %APPDATA%\talk.
|
||||||
|
func Dir(getenv func(string) string) (string, error) {
|
||||||
|
return dirFor(runtime.GOOS, getenv)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dirFor ist die testbare Plattformlogik hinter Dir: goos wird als
|
||||||
|
// Parameter übergeben, damit die Pfade für alle Betriebssysteme vom
|
||||||
|
// Dev-Host aus tischgesteuert geprüft werden können.
|
||||||
|
//
|
||||||
|
// XDG_CONFIG_HOME wird nur berücksichtigt, wenn es einen absoluten Pfad
|
||||||
|
// enthält (XDG-Spezifikation); relative Werte werden ignoriert.
|
||||||
|
func dirFor(goos string, getenv func(string) string) (string, error) {
|
||||||
|
if getenv == nil {
|
||||||
|
return "", ErrNoHome
|
||||||
|
}
|
||||||
|
switch goos {
|
||||||
|
case "windows":
|
||||||
|
appdata := getenv("APPDATA")
|
||||||
|
if appdata == "" {
|
||||||
|
return "", ErrNoHome
|
||||||
|
}
|
||||||
|
return filepath.Join(appdata, "talk"), nil
|
||||||
|
case "darwin":
|
||||||
|
home := getenv("HOME")
|
||||||
|
if home == "" {
|
||||||
|
return "", ErrNoHome
|
||||||
|
}
|
||||||
|
return filepath.Join(home, "Library", "Application Support", "talk"), nil
|
||||||
|
default: // linux und sonstige unixartige Systeme
|
||||||
|
if xdg := getenv("XDG_CONFIG_HOME"); xdg != "" && filepath.IsAbs(xdg) {
|
||||||
|
return filepath.Join(xdg, "talk"), nil
|
||||||
|
}
|
||||||
|
home := getenv("HOME")
|
||||||
|
if home == "" {
|
||||||
|
return "", ErrNoHome
|
||||||
|
}
|
||||||
|
return filepath.Join(home, ".config", "talk"), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user