package config import ( "errors" "fmt" "os" "path/filepath" "strconv" "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" ) // DefaultServerPort ist der Default-Port des Daemon-HTTP-Servers auf // 127.0.0.1 (Task-04-Brief: „Port Default 7800"). const DefaultServerPort = 7800 // Config ist der effektiv geladene Konfigurations-Stand von talk. Task 02 // führt audio.sink; Task 03 ergänzt den Provider-Pin, Provider-Endpunkte // und das Voice-Mapping; Task 04 den Daemon-Port (server.port, TALK_PORT); // spätere Tasks ergänzen weitere Felder (queue …), ohne die Auflösungs- // reihenfolge zu ändern. type Config struct { Audio Audio `toml:"audio"` // Server steuert den Daemon-HTTP-Server (talkd) auf 127.0.0.1. Server Server `toml:"server"` // Provider pinnt die Kette auf genau einen Provider (z. B. // `provider = "elevenlabs"`); leer = komplette Default-Kette // (elevenlabs → openai → edge → osbuiltin). Das CLI-Flag --provider // schlägt diesen Wert. Gültige Namen prüft die Provider-Schicht. Provider string `toml:"provider"` // Providers enthält per Provider-Einstellungen (Endpunkt, Modell) — // TOML `[providers.elevenlabs] endpoint = "…"`. Key `providers` // (Mehrzahl), damit der Pin ( singular `provider`) und die // Einstellungs-Tabelle in TOML nebeneinander existieren können. Providers map[string]ProviderSettings `toml:"providers"` // Voice ist das Voice-Mapping `voice.. = id` — z. B. // `[voice.elevenlabs] de = "21m00Tcm4TlvDq8ikWAM"`. Schlägt die // eingebauten neutralen Defaults; das CLI-Flag --voice schlägt alles. Voice map[string]map[string]string `toml:"voice"` } // 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"` } // Server sind die Netz-Einstellungen des Daemons (nur Loopback; Binding // auf 127.0.0.1 macht cmd/talkd, die Config liefert nur den Port). type Server struct { // Port des Daemon-HTTP-Servers auf 127.0.0.1 — config // `[server] port = 7800`, Env-Override TALK_PORT, Default 7800. Port int `toml:"port"` } // ProviderSettings sind die per Provider konfigurierbaren Endpunkte und // Modelle (TOML `[providers.]`). Leer = eingebaute Defaults; die // Env-Overrides TALK_ELEVENLABS_ENDPOINT / TALK_OPENAI_ENDPOINT / // TALK_EDGE_ENDPOINT (ausgewertet in der Provider-Schicht) schlagen die // Datei — Keys laufen ausschließlich über TALK_ELEVENLABS_KEY bzw. // TALK_OPENAI_KEY und werden nie in die Datei geschrieben. type ProviderSettings struct { Endpoint string `toml:"endpoint"` Model string `toml:"model"` // nur openai (Default "tts-1") } // Defaults liefert die eingebaute Default-Konfiguration (unterste Schicht // der Auflösung). func Defaults() Config { return Config{ Audio: Audio{Sink: SinkOto}, Server: Server{Port: DefaultServerPort}, } } // 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 } if v := getenv("TALK_PROVIDER"); v != "" { cfg.Provider = v } if v := getenv("TALK_PORT"); v != "" { port, err := strconv.Atoi(v) if err != nil || port < 1 || port > 65535 { return cfg, fmt.Errorf("TALK_PORT: ungültiger Wert %q (1–65535)", v) } cfg.Server.Port = port } 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) } }