From 36ad1dfa4e50b7774a7b4275780f9f640be29665 Mon Sep 17 00:00:00 2001 From: m3ta-chiron Date: Sun, 18 Oct 2026 15:38:56 +0200 Subject: [PATCH] =?UTF-8?q?feat(config):=20geschichtete=20Konfigurationsau?= =?UTF-8?q?fl=C3=B6sung=20mit=20injizierbaren=20Pfaden?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- go.mod | 2 + go.sum | 2 + internal/config/config.go | 94 +++++++++++++++ internal/config/config_test.go | 206 +++++++++++++++++++++++++++++++++ internal/config/doc.go | 6 +- internal/config/path.go | 56 +++++++++ 6 files changed, 363 insertions(+), 3 deletions(-) create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/config/path.go diff --git a/go.mod b/go.mod index 6a98238..9810e47 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module code.m3ta.dev/m3tam3re/talk go 1.26.8 + +require github.com/BurntSushi/toml v1.6.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f74b269 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..d71346a --- /dev/null +++ b/internal/config/config.go @@ -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) + } +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..a71cba8 --- /dev/null +++ b/internal/config/config_test.go @@ -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) + } + }) +} diff --git a/internal/config/doc.go b/internal/config/doc.go index 2a8f008..e1cc00a 100644 --- a/internal/config/doc.go +++ b/internal/config/doc.go @@ -1,7 +1,7 @@ // Package config implementiert die Config-Auflösung von talk: Defaults + // optionale config.toml im platform-nativen Verzeichnis (~/.config/talk, // ~/Library/Application Support/talk, %APPDATA%\talk) + Env-Overrides -// (TALK_*); Env schlägt Datei. Enthält Voice-Mapping voice.. -// und Provider-Endpunkte (Fake-bar für Tests). Implementierung folgt in -// Task 02. +// (TALK_*); Env schlägt Datei. Pfade und Env sind injizierbar (Tests). +// Voice-Mapping voice.. und Provider-Endpunkte folgen mit +// Task 03. package config diff --git a/internal/config/path.go b/internal/config/path.go new file mode 100644 index 0000000..d1919ba --- /dev/null +++ b/internal/config/path.go @@ -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 + } +}