refactor: switch the whole project to English

talk is going to be published. Code comments, CLI output, error messages,
daemon log lines, MCP descriptions and results, test names and assertions,
README, spec, skill, build scripts and flake are now English. Only the
spoken announcement texts stay bilingual: the wrap result and the test
sentence are German or English depending on the detected language.

Language fallback is now configurable: `lang = "en" | "de"` (env
TALK_LANG, default en) is used when detection cannot tell (numbers, single
words, ties) and for talk's own announcements. langdetect.Detect takes the
fallback as an argument instead of hard-coding German. `talk setup` asks for
it; `talk doctor` and `talk status` show it.

Vocabulary pinned across packages so the process-boundary tests observe
stable strings: chain log lines ("chain: tier X skipped (not available)",
"falling back to the next tier", "spoken via X"), queue lines ("discarded:
quiet hours ... active", "replaced by toast", "max_age"), CLI notes
("speaking directly", "daemon started at", "no daemon", "nothing to cancel"),
status block with aligned English labels, MCP messages, origin guard 403.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
m3ta-chironandClaude Fable 5.1 committed 2026-10-24 11:15:35 +02:00
1 parent 4d30bd95ad
commit 85fa64cfe6
95 files changed
+4707 -4854

No files matched your search

+116 -116
View File
@@ -14,183 +14,179 @@ import (
"code.m3ta.dev/m3tam3re/talk/internal/queue"
)
// Erlaubte Werte für audio.sink (Teil des öffentlichen Vertrags; Spec:
// „Audio-Sink wählbar inkl. null").
// Allowed values for audio.sink (public contract).
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").
// DefaultServerPort is the daemon's default HTTP port on 127.0.0.1.
const DefaultServerPort = 7800
// DefaultRateLimit ist das Daemon-Gesetz gegen Spam (Task-05-Brief):
// Default 1 Ansage / 30 s. „0s" schaltet das Limit ab.
// DefaultRateLimit is the anti-spam law's default: one announcement per
// 30 seconds. "0s" disables the limit.
const DefaultRateLimit = 30 * time.Second
// DefaultMaxText ist die Default-Obergrenze der Ansage-Länge in Zeichen —
// etwa zwei bis drei Sätze, rund 25 Sekunden Sprache. 0 = unbegrenzt.
// DefaultMaxText is the default cap on announcement length in characters:
// two to three sentences, roughly 25 seconds of speech. 0 = unlimited.
const DefaultMaxText = 400
// DefaultMaxAge ist die Default-Höchstwartezeit einer Ansage in der Queue;
// was länger parkt, ist veraltet und wird verworfen. „0s" = unbegrenzt.
// DefaultMaxAge is the default longest wait in the queue; whatever parks
// longer is stale and gets discarded. "0s" = unlimited.
const DefaultMaxAge = 2 * time.Minute
// 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);
// Task 05 die Anti-Spam-Gesetze (queue.rate_limit, queue.allow_high_burst,
// quiet_hours, quiet_mode); spätere Tasks ergänzen weitere Felder, ohne die
// Auflösungs-Reihenfolge zu ändern.
// DefaultLang is the language assumed when detection cannot decide (empty,
// numbers, ties). Also the language of talk's own announcements such as the
// wrap result when the label gives no hint.
const DefaultLang = "en"
// Config is the effective configuration after all layers were applied.
type Config struct {
Audio Audio `toml:"audio"`
// Server steuert den Daemon-HTTP-Server (talkd) auf 127.0.0.1.
// Server configures the daemon's HTTP server on 127.0.0.1.
Server Server `toml:"server"`
// Queue steuert die Anti-Spam-Gesetze des Daemons (Task 05).
// Queue holds the daemon's anti-spam laws.
Queue Queue `toml:"queue"`
// Daemon steuert den Hintergrundprozess (Autostart).
// Daemon configures the background process (autostart).
Daemon Daemon `toml:"daemon"`
// 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.
// Lang is the fallback language (de | en) when detection cannot decide,
// and the language of talk's own announcements. Default "en"; env
// TALK_LANG.
Lang string `toml:"lang"`
// Provider pins the chain to exactly one provider (for example
// `provider = "elevenlabs"`); empty = the full default chain
// (elevenlabs → openai → edge → osbuiltin). The --provider flag beats
// this value. Valid names are checked by the provider layer.
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 holds per-provider settings (endpoint, model):
// `[providers.elevenlabs] endpoint = "…"`. The key is plural so the pin
// (`provider`) and the settings table can coexist in TOML.
Providers map[string]ProviderSettings `toml:"providers"`
// Voice ist das Voice-Mapping `voice.<provider>.<lang> = id` — z. B.
// `[voice.elevenlabs] de = "21m00Tcm4TlvDq8ikWAM"`. Schlägt die
// eingebauten neutralen Defaults; das CLI-Flag --voice schlägt alles.
// Voice is the voice mapping `voice.<provider>.<lang> = id`, for example
// `[voice.elevenlabs] de = "21m00Tcm4TlvDq8ikWAM"`. It beats the built-in
// neutral defaults; the --voice flag beats everything.
Voice map[string]map[string]string `toml:"voice"`
// Wrap steuert die Befehlsummwicklung `talk wrap` (Task 07).
// Wrap configures `talk wrap`.
Wrap Wrap `toml:"wrap"`
// QuietHours ist das Ruhezeit-Fenster "HH:MM-HH:MM" in lokaler Zeit
// (überspannt Mitternacht, z. B. "18:00-08:00"); leer = deaktiviert.
// Bewusst kein Default-Fenster: Ein aktives Gesetz, das nach Uhrzeit
// verwirft, würde jeden Cron/Test in den Abendstunden stilllegen —
// Ruhezeiten sind Opt-in wie Mute.
// QuietHours is the quiet window "HH:MM-HH:MM" in local time (may span
// midnight, for example "18:00-08:00"); empty = disabled. Deliberately
// no default window: a law that discards by time of day would silence
// every evening cron job; quiet hours are opt-in like mute.
QuietHours string `toml:"quiet_hours"`
// QuietMode bestimmt das Verhalten in der Ruhezeit: "discard" (Default,
// still verwerfen) oder "toast" (Benachrichtigung statt Audio).
// QuietMode is the behaviour during quiet hours: "discard" (default,
// drop silently) or "toast" (desktop notification instead of audio).
QuietMode string `toml:"quiet_mode"`
}
// Audio steuert die Audio-Ausgabe.
// Audio configures playback.
type Audio struct {
// Sink wählt die Wiedergabe: "oto" (embedded Playback, Default) oder
// "null" (verwirft — CI, Server, Tests).
// Sink selects playback: "oto" (embedded playback, default) or "null"
// (discard; CI, servers, 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).
// Server holds the daemon's network settings (loopback only; the daemon
// binds 127.0.0.1, the config only supplies the 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 of the daemon's HTTP server on 127.0.0.1: `[server] port = 7800`,
// env override TALK_PORT, default 7800.
Port int `toml:"port"`
// AllowedOrigins ist die CORS-Erlaubnisliste für POST /speak (Task 06):
// exakte Origins wie "https://chat.az-gruppe.com". Der Eintrag "*"
// erlaubt jede Origin ausdrücklich. Bewusster Default: LEER — ohne
// Einträge sendet der Daemon gar keine CORS-Header und der Browser
// blockt Cross-Origin-Anfragen; die Tür ist zu, bis sie konfiguriert
// wird (Env-Override TALK_ALLOWED_ORIGINS, kommagetrennt; "off" leert).
// AllowedOrigins is the CORS allow-list for POST /speak: exact origins
// such as "https://chat.example.com". The entry "*" allows every origin
// explicitly. Deliberate default: EMPTY, meaning no CORS headers at all
// and the server-side origin guard rejects browser requests; the door
// stays shut until configured (env TALK_ALLOWED_ORIGINS, comma-separated;
// "off" clears it).
AllowedOrigins []string `toml:"allowed_origins"`
}
// Daemon sind die Einstellungen des Hintergrundprozesses (TOML `[daemon]`).
// Daemon holds the background process settings (TOML `[daemon]`).
type Daemon struct {
// Autostart lässt talk den Daemon beim ersten Sprechbefehl selbst im
// Hintergrund starten (Default true; Env TALK_AUTOSTART). false ist für
// Setups, die den Daemon selbst verwalten (systemd, launchd, Task
// Scheduler): say spricht dann ohne Daemon direkt, mit Hinweis.
// Autostart lets talk start the daemon itself in the background on the
// first speak command (default true; env TALK_AUTOSTART). false is for
// setups that manage the daemon themselves (systemd, launchd, Task
// Scheduler): without a daemon, say then speaks directly, with a note.
Autostart bool `toml:"autostart"`
}
// Wrap sind die Einstellungen von `talk wrap` (TOML `[wrap]`).
// Wrap holds the `talk wrap` settings (TOML `[wrap]`).
type Wrap struct {
// Label ersetzt den Basisnamen des Befehls in der Ansage — z. B.
// `label = "Deployment"` für „Deployment erfolgreich. Dauer: …"
// statt „terraform erfolgreich. Dauer: …" (Env-Override
// TALK_WRAP_LABEL schlägt die Datei).
// Label replaces the command's base name in the announcement, for
// example `label = "Deployment"` to hear "Deployment succeeded" instead
// of "terraform succeeded" (env override TALK_WRAP_LABEL).
Label string `toml:"label"`
}
// Queue sind die Rate-Limit-Gesetze des Daemons (TOML `[queue]`).
// Queue holds the daemon's anti-spam laws (TOML `[queue]`).
type Queue struct {
// RateLimit ist der Mindestabstand zwischen zwei Ansage-Starten als
// Dauer-String (z. B. "30s"; "0s" = unbegrenzt). Default: 30 s.
// RateLimit is the minimum gap between two announcement starts, as a
// duration string ("30s"; "0s" = unlimited). Default 30 s.
RateLimit time.Duration `toml:"rate_limit"`
// AllowHighBurst erlaubt priority=high, das Rate-Limit bewusst zu
// verstoßen (Default true). Die Ruhezeit gilt davon unberührt.
// AllowHighBurst lets priority=high deliberately break the rate limit
// (default true). Quiet hours still apply.
AllowHighBurst bool `toml:"allow_high_burst"`
// MaxText ist die Obergrenze der Ansage-Länge in Zeichen; längere
// Texte werden an einer Wortgrenze gekürzt (Default 400; 0 =
// unbegrenzt; Env TALK_MAX_TEXT).
// MaxText caps announcement length in characters; longer texts are cut
// at a word boundary (default 400; 0 = unlimited; env TALK_MAX_TEXT).
MaxText int `toml:"max_text"`
// MaxAge ist die längste Wartezeit einer Ansage in der Queue; ältere
// werden beim Fälligwerden verworfen (Default "2m"; "0s" = unbegrenzt;
// Env TALK_MAX_AGE).
// MaxAge is the longest an announcement may wait in the queue; older
// ones are discarded when they come due (default "2m"; "0s" =
// unlimited; env TALK_MAX_AGE).
MaxAge time.Duration `toml:"max_age"`
}
// ProviderSettings sind die per Provider konfigurierbaren Endpunkte und
// Modelle (TOML `[providers.<name>]`). 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.
// ProviderSettings are the per-provider endpoint and model (TOML
// `[providers.<name>]`). Empty = built-in defaults; the env overrides
// TALK_ELEVENLABS_ENDPOINT / TALK_OPENAI_ENDPOINT / TALK_EDGE_ENDPOINT
// (read by the provider layer) beat the file. Keys come exclusively from
// TALK_ELEVENLABS_KEY / TALK_OPENAI_KEY and are never written to the file.
type ProviderSettings struct {
Endpoint string `toml:"endpoint"`
Model string `toml:"model"` // nur openai (Default "tts-1")
Model string `toml:"model"` // openai only (default "tts-1")
}
// Defaults liefert die eingebaute Default-Konfiguration (unterste Schicht
// der Auflösung).
// Defaults returns the built-in default configuration (the lowest layer).
func Defaults() Config {
return Config{
Audio: Audio{Sink: SinkOto},
Server: Server{Port: DefaultServerPort},
Queue: Queue{RateLimit: DefaultRateLimit, AllowHighBurst: true, MaxText: DefaultMaxText, MaxAge: DefaultMaxAge},
Daemon: Daemon{Autostart: true},
Lang: DefaultLang,
QuietMode: queue.QuietModeDiscard,
}
}
// Options steuert Load. Alle Felder sind injizierbar, damit die Unit-Tests
// ohne echte HOME-/Env-Umgebung und ohne echte Dateien auskommen.
// Options controls Load. Every field is injectable so unit tests run without
// a real HOME, environment or files.
type Options struct {
// File überschreibt den platform-nativen Pfad der config.toml (Tests
// injizieren hier eine Temp-Datei). Leer: Dir() + "config.toml".
// File overrides the platform-native path of config.toml (tests inject
// a temp file). Empty: 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 supplies environment variables (default os.Getenv): the TALK_*
// overrides and the path resolution (HOME, XDG_CONFIG_HOME, APPDATA).
Getenv func(string) string
}
// Load löst die Konfiguration schichtweise auf: Defaults → config.toml
// (falls vorhanden) → Env-Overrides (TALK_* 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.
// Load resolves the configuration in layers: defaults → config.toml (if
// present) → env overrides (TALK_* beats the file). Env and file validation
// errors are reported clearly, never ignored. If the config directory
// cannot be determined (no HOME), talk runs with defaults + env: the file is
// optional, a broken environment must not make talk unusable.
func Load(opts Options) (Config, error) {
if opts.Getenv == nil {
opts.Getenv = os.Getenv
@@ -201,7 +197,6 @@ func Load(opts Options) (Config, error) {
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)
@@ -215,7 +210,7 @@ func Load(opts Options) (Config, error) {
return applyEnv(cfg, opts.Getenv)
}
// applyEnv wendet die Env-Overrides an und validiert das Ergebnis.
// applyEnv applies the env overrides and validates the result.
func applyEnv(cfg Config, getenv func(string) string) (Config, error) {
if v := getenv("TALK_AUDIO_SINK"); v != "" {
cfg.Audio.Sink = v
@@ -223,10 +218,13 @@ func applyEnv(cfg Config, getenv func(string) string) (Config, error) {
if v := getenv("TALK_PROVIDER"); v != "" {
cfg.Provider = v
}
if v := getenv("TALK_LANG"); v != "" {
cfg.Lang = 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)
return cfg, fmt.Errorf("TALK_PORT: invalid value %q (1–65535)", v)
}
cfg.Server.Port = port
}
@@ -244,21 +242,21 @@ func applyEnv(cfg Config, getenv func(string) string) (Config, error) {
if v := getenv("TALK_MAX_TEXT"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 0 {
return cfg, fmt.Errorf("TALK_MAX_TEXT: ungültiger Wert %q (Zeichen; 0 = unbegrenzt)", v)
return cfg, fmt.Errorf("TALK_MAX_TEXT: invalid value %q (characters; 0 = unlimited)", v)
}
cfg.Queue.MaxText = n
}
if v := getenv("TALK_MAX_AGE"); v != "" {
d, err := time.ParseDuration(v)
if err != nil || d < 0 {
return cfg, fmt.Errorf("TALK_MAX_AGE: ungültiger Wert %q (Dauer, z. B. \"2m\"; \"0s\" = unbegrenzt)", v)
return cfg, fmt.Errorf("TALK_MAX_AGE: invalid value %q (duration such as \"2m\"; \"0s\" = unlimited)", v)
}
cfg.Queue.MaxAge = d
}
if v := getenv("TALK_AUTOSTART"); v != "" {
b, err := strconv.ParseBool(v)
if err != nil {
return cfg, fmt.Errorf("TALK_AUTOSTART: ungültiger Wert %q (true/false)", v)
return cfg, fmt.Errorf("TALK_AUTOSTART: invalid value %q (true/false)", v)
}
cfg.Daemon.Autostart = b
}
@@ -268,14 +266,14 @@ func applyEnv(cfg Config, getenv func(string) string) (Config, error) {
if v := getenv("TALK_RATE_LIMIT"); v != "" {
d, err := time.ParseDuration(v)
if err != nil || d < 0 {
return cfg, fmt.Errorf("TALK_RATE_LIMIT: ungültiger Wert %q (Dauer, z. B. \"30s\"; \"0s\" = unbegrenzt)", v)
return cfg, fmt.Errorf("TALK_RATE_LIMIT: invalid value %q (duration such as \"30s\"; \"0s\" = unlimited)", v)
}
cfg.Queue.RateLimit = d
}
if v := getenv("TALK_ALLOW_HIGH_BURST"); v != "" {
b, err := strconv.ParseBool(v)
if err != nil {
return cfg, fmt.Errorf("TALK_ALLOW_HIGH_BURST: ungültiger Wert %q (true/false)", v)
return cfg, fmt.Errorf("TALK_ALLOW_HIGH_BURST: invalid value %q (true/false)", v)
}
cfg.Queue.AllowHighBurst = b
}
@@ -284,37 +282,39 @@ func applyEnv(cfg Config, getenv func(string) string) (Config, error) {
cfg.QuietHours = ""
} else {
if _, err := queue.ParseQuietHours(v); err != nil {
return cfg, fmt.Errorf("TALK_QUIET_HOURS: ungültiges Fenster %q (HH:MM-HH:MM, \"off\" = deaktiviert)", v)
return cfg, fmt.Errorf("TALK_QUIET_HOURS: invalid window %q (HH:MM-HH:MM, \"off\" = disabled)", v)
}
cfg.QuietHours = v
}
}
if v := getenv("TALK_QUIET_MODE"); v != "" {
if v != queue.QuietModeDiscard && v != queue.QuietModeToast {
return cfg, fmt.Errorf("TALK_QUIET_MODE: ungültiger Wert %q (erlaubt: %q, %q)",
return cfg, fmt.Errorf("TALK_QUIET_MODE: invalid value %q (allowed: %q, %q)",
v, queue.QuietModeDiscard, queue.QuietModeToast)
}
cfg.QuietMode = v
}
if cfg.Queue.MaxText < 0 || cfg.Queue.MaxAge < 0 || cfg.Queue.RateLimit < 0 {
return cfg, fmt.Errorf("queue: max_text, max_age und rate_limit dürfen nicht negativ sein (0 = unbegrenzt)")
return cfg, fmt.Errorf("queue: max_text, max_age and rate_limit must not be negative (0 = unlimited)")
}
switch cfg.Audio.Sink {
case SinkOto, SinkNull:
default:
return cfg, fmt.Errorf("audio.sink: ungültiger Wert %q (erlaubt: %q, %q)",
return cfg, fmt.Errorf("audio.sink: invalid value %q (allowed: %q, %q)",
cfg.Audio.Sink, SinkOto, SinkNull)
}
// allowed_origins aus der Datei prüfen (Env wurde oben geparst): Eine
// leere Liste ist gültig (Tür zu), einzelne Einträge müssen aber
// substanziell sein — klarer Fehler statt stiller Ignoranz.
if cfg.Lang != "de" && cfg.Lang != "en" {
return cfg, fmt.Errorf("lang: invalid value %q (allowed: de, en)", cfg.Lang)
}
// Validate allowed_origins from the file (env was parsed above): an empty
// list is fine (door shut), but entries must have substance.
for _, o := range cfg.Server.AllowedOrigins {
if strings.TrimSpace(o) == "" {
return cfg, fmt.Errorf("server.allowed_origins: leerer Eintrag in %v", cfg.Server.AllowedOrigins)
return cfg, fmt.Errorf("server.allowed_origins: empty entry in %v", cfg.Server.AllowedOrigins)
}
}
// quiet_hours aus der Datei validieren (Env wurde oben geprüft) und
// quiet_mode leer (Datei ohne Wert) auf den Default heben.
// Validate quiet_hours from the file (env was checked above) and lift an
// empty quiet_mode to the default.
if cfg.QuietHours != "" {
if _, err := queue.ParseQuietHours(cfg.QuietHours); err != nil {
return cfg, err
@@ -326,16 +326,16 @@ func applyEnv(cfg Config, getenv func(string) string) (Config, error) {
return cfg, nil
}
// splitOrigins zerlegt eine kommagetrennte Origin-Liste (Env-Form von
// server.allowed_origins, z. B. "https://a.example, https://b.example").
// Whitespace um Einträge toleriert, leere Einträge sind ein klarer Fehler.
// splitOrigins splits a comma-separated origin list (the env form of
// server.allowed_origins, for example "https://a.example, https://b.example").
// Whitespace around entries is tolerated; empty entries are an error.
func splitOrigins(v string) ([]string, error) {
parts := strings.Split(v, ",")
origins := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
return nil, fmt.Errorf("leerer Eintrag in %q (komma-getrennte Origins oder \"off\")", v)
return nil, fmt.Errorf("empty entry in %q (comma-separated origins, or \"off\")", v)
}
origins = append(origins, p)
}
+191 -348
View File
@@ -14,7 +14,23 @@ func env(kv map[string]string) func(string) string {
return func(k string) string { return kv[k] }
}
func TestDirForPlattformpfade(t *testing.T) {
// writeTOML writes content to a temp config.toml and returns its path.
func writeTOML(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
}
// missingFile returns a path that does not exist (defaults + env only).
func missingFile(t *testing.T) string {
t.Helper()
return filepath.Join(t.TempDir(), "does-not-exist.toml")
}
func TestDirForPlatformPaths(t *testing.T) {
cases := []struct {
name string
goos string
@@ -22,66 +38,26 @@ func TestDirForPlattformpfade(t *testing.T) {
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(),
},
{"linux with HOME", "linux", map[string]string{"HOME": "/home/u"}, "/home/u/.config/talk", ""},
{"linux XDG wins", "linux", map[string]string{"HOME": "/home/u", "XDG_CONFIG_HOME": "/xdg"}, "/xdg/talk", ""},
{"linux relative XDG is ignored", "linux", map[string]string{"HOME": "/home/u", "XDG_CONFIG_HOME": "relative"}, "/home/u/.config/talk", ""},
{"linux without HOME is an error", "linux", map[string]string{}, "", ErrNoHome.Error()},
{"macos", "darwin", map[string]string{"HOME": "/Users/u"}, "/Users/u/Library/Application Support/talk", ""},
{"macos without HOME is an error", "darwin", map[string]string{}, "", ErrNoHome.Error()},
{"windows", "windows", map[string]string{"APPDATA": `C:\Users\u\AppData\Roaming`}, filepath.Join(`C:\Users\u\AppData\Roaming`, "talk"), ""},
{"windows without APPDATA is an error", "windows", map[string]string{}, "", 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)
t.Fatalf("dirFor(%q) error = %v, want %q", tc.goos, err, tc.errMsg)
}
return
}
if err != nil {
t.Fatalf("dirFor(%q): unerwarteter Fehler %v", tc.goos, err)
t.Fatalf("dirFor(%q): unexpected error %v", tc.goos, err)
}
if got != tc.want {
t.Errorf("dirFor(%q) = %q, want %q", tc.goos, got, tc.want)
@@ -90,58 +66,39 @@ func TestDirForPlattformpfade(t *testing.T) {
}
}
// TestLoadMerge ist tischgesteuert für die Schichtenfolge
// Defaults → TOML-Datei → Env-Override.
// TestLoadMerge pins the layering defaults → TOML file → 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),
})
t.Run("defaults only", func(t *testing.T) {
cfg, err := Load(Options{File: missingFile(t), 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.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),
})
t.Run("TOML overrides 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.Errorf("Audio.Sink = %q, want %q from 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),
})
t.Run("empty TOML section keeps default", func(t *testing.T) {
cfg, err := Load(Options{File: writeTOML(t, "# just a comment\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.Errorf("Audio.Sink = %q, want default %q", cfg.Audio.Sink, SinkOto)
}
})
t.Run("Env schlägt Datei", func(t *testing.T) {
t.Run("env beats file", func(t *testing.T) {
cfg, err := Load(Options{
File: writeTOML(t, "[audio]\nsink = \"oto\"\n"),
Getenv: env(map[string]string{"TALK_AUDIO_SINK": "null"}),
@@ -150,15 +107,12 @@ func TestLoadMerge(t *testing.T) {
t.Fatalf("Load: %v", err)
}
if cfg.Audio.Sink != SinkNull {
t.Errorf("Audio.Sink = %q, want Env-Override %q", 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"}),
})
t.Run("env alone without file", func(t *testing.T) {
cfg, err := Load(Options{File: missingFile(t), Getenv: env(map[string]string{"TALK_AUDIO_SINK": "null"})})
if err != nil {
t.Fatalf("Load: %v", err)
}
@@ -167,59 +121,40 @@ func TestLoadMerge(t *testing.T) {
}
})
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),
})
t.Run("invalid sink in file is a clear error", 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.Fatalf("Load error = %v, want a clear audio.sink error", 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("invalid env value is a clear error", func(t *testing.T) {
_, err := Load(Options{File: missingFile(t), Getenv: env(map[string]string{"TALK_AUDIO_SINK": "bogus"})})
if err == nil || !strings.Contains(err.Error(), "audio.sink") {
t.Fatalf("Load error = %v, want a clear audio.sink error", 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("broken TOML syntax is a clear error", func(t *testing.T) {
if _, err := Load(Options{File: writeTOML(t, "[audio\nsink = \n"), Getenv: env(nil)}); err == nil {
t.Fatal("Load with broken TOML: no error")
}
})
t.Run("ohne Home läuft Load mit Defaults weiter", func(t *testing.T) {
t.Run("without HOME Load continues with defaults", func(t *testing.T) {
cfg, err := Load(Options{Getenv: env(nil)})
if err != nil {
t.Fatalf("Load ohne HOME: %v", err)
t.Fatalf("Load without HOME: %v", err)
}
if cfg.Audio.Sink != SinkOto {
t.Errorf("Audio.Sink = %q, want Default %q", cfg.Audio.Sink, SinkOto)
t.Errorf("Audio.Sink = %q, want default %q", cfg.Audio.Sink, SinkOto)
}
})
}
// TestLoadProviderKette deckt die Task-03-Felder ab: Provider-Pin per
// Datei/Env, Provider-Endpunkte/-Modelle und das Voice-Mapping.
func TestLoadProviderKette(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
}
// TestLoadProviderChain covers the provider pin (file/env), per-provider
// endpoints/models and the voice mapping.
func TestLoadProviderChain(t *testing.T) {
toml := `provider = "openai"
[providers.elevenlabs]
@@ -233,14 +168,14 @@ model = "gpt-4o-mini-tts"
endpoint = "wss://edge.example.com/synth"
[voice.elevenlabs]
de = "MeineStimme"
de = "MyGermanVoice"
en = "MyVoice"
[voice.edge]
de = "de-DE-KatjaNeural"
`
t.Run("TOML liefert Pin, Endpunkte und Voice-Mapping", func(t *testing.T) {
t.Run("TOML yields pin, endpoints and voice mapping", func(t *testing.T) {
cfg, err := Load(Options{File: writeTOML(t, toml), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
@@ -257,7 +192,7 @@ de = "de-DE-KatjaNeural"
if got := cfg.Providers["edge"].Endpoint; got != "wss://edge.example.com/synth" {
t.Errorf("Providers[edge].Endpoint = %q", got)
}
if got := cfg.Voice["elevenlabs"]["de"]; got != "MeineStimme" {
if got := cfg.Voice["elevenlabs"]["de"]; got != "MyGermanVoice" {
t.Errorf("Voice[elevenlabs][de] = %q", got)
}
if got := cfg.Voice["elevenlabs"]["en"]; got != "MyVoice" {
@@ -265,74 +200,51 @@ de = "de-DE-KatjaNeural"
}
})
t.Run("TALK_PROVIDER schlägt Datei-Pin", func(t *testing.T) {
cfg, err := Load(Options{
File: writeTOML(t, toml),
Getenv: env(map[string]string{"TALK_PROVIDER": "edge"}),
})
t.Run("TALK_PROVIDER beats the file pin", func(t *testing.T) {
cfg, err := Load(Options{File: writeTOML(t, toml), Getenv: env(map[string]string{"TALK_PROVIDER": "edge"})})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Provider != "edge" {
t.Errorf("Provider = %q, want Env-Override %q", cfg.Provider, "edge")
t.Errorf("Provider = %q, want env override %q", cfg.Provider, "edge")
}
})
t.Run("ohne alles: keine Pins, keine Endpunkte", func(t *testing.T) {
cfg, err := Load(Options{
File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"),
Getenv: env(nil),
})
t.Run("nothing configured: no pin, no endpoints", func(t *testing.T) {
cfg, err := Load(Options{File: missingFile(t), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Provider != "" || cfg.Providers != nil || cfg.Voice != nil {
t.Errorf("Provider-Konfiguration = %+v, will leer", cfg)
t.Errorf("provider configuration = %+v, want empty", cfg)
}
})
}
// TestLoadServerPort deckt die Task-04-Felder ab: Daemon-Port per Defaults
// (7800), TOML [server] port, Env-Override TALK_PORT samt Validierung.
// TestLoadWrapLabel: das Ansage-Label von `talk wrap` (Task 07) —
// Defaults → TOML → Env nach dem üblichen Schichten-Muster.
// TestLoadWrapLabel: the `talk wrap` announcement label, defaults → TOML →
// env in the usual layering.
func TestLoadWrapLabel(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("Default leer (Basisname des Befehls)", func(t *testing.T) {
cfg, err := Load(Options{
File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"),
Getenv: env(nil),
})
t.Run("default empty (command base name)", func(t *testing.T) {
cfg, err := Load(Options{File: missingFile(t), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Wrap.Label != "" {
t.Errorf("Wrap.Label = %q, want leer (Default)", cfg.Wrap.Label)
t.Errorf("Wrap.Label = %q, want empty (default)", cfg.Wrap.Label)
}
})
t.Run("TOML überschreibt Default", func(t *testing.T) {
cfg, err := Load(Options{
File: writeTOML(t, "[wrap]\nlabel = \"Deployment\"\n"),
Getenv: env(nil),
})
t.Run("TOML overrides default", func(t *testing.T) {
cfg, err := Load(Options{File: writeTOML(t, "[wrap]\nlabel = \"Deployment\"\n"), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Wrap.Label != "Deployment" {
t.Errorf("Wrap.Label = %q, want %q aus TOML", cfg.Wrap.Label, "Deployment")
t.Errorf("Wrap.Label = %q, want %q from TOML", cfg.Wrap.Label, "Deployment")
}
})
t.Run("TALK_WRAP_LABEL schlägt Datei", func(t *testing.T) {
t.Run("TALK_WRAP_LABEL beats the file", func(t *testing.T) {
cfg, err := Load(Options{
File: writeTOML(t, "[wrap]\nlabel = \"Deployment\"\n"),
Getenv: env(map[string]string{"TALK_WRAP_LABEL": "Nightly Build"}),
@@ -341,48 +253,35 @@ func TestLoadWrapLabel(t *testing.T) {
t.Fatalf("Load: %v", err)
}
if cfg.Wrap.Label != "Nightly Build" {
t.Errorf("Wrap.Label = %q, want Env-Override %q", cfg.Wrap.Label, "Nightly Build")
t.Errorf("Wrap.Label = %q, want env override %q", cfg.Wrap.Label, "Nightly Build")
}
})
}
// TestLoadServerPort: daemon port by default (7800), TOML [server] port and
// env override TALK_PORT including validation.
func TestLoadServerPort(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("Default 7800", func(t *testing.T) {
cfg, err := Load(Options{
File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"),
Getenv: env(nil),
})
t.Run("default 7800", func(t *testing.T) {
cfg, err := Load(Options{File: missingFile(t), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Server.Port != DefaultServerPort {
t.Errorf("Server.Port = %d, want Default %d", cfg.Server.Port, DefaultServerPort)
t.Errorf("Server.Port = %d, want default %d", cfg.Server.Port, DefaultServerPort)
}
})
t.Run("TOML überschreibt Default", func(t *testing.T) {
cfg, err := Load(Options{
File: writeTOML(t, "[server]\nport = 7999\n"),
Getenv: env(nil),
})
t.Run("TOML overrides default", func(t *testing.T) {
cfg, err := Load(Options{File: writeTOML(t, "[server]\nport = 7999\n"), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Server.Port != 7999 {
t.Errorf("Server.Port = %d, want 7999 aus TOML", cfg.Server.Port)
t.Errorf("Server.Port = %d, want 7999 from TOML", cfg.Server.Port)
}
})
t.Run("TALK_PORT schlägt Datei", func(t *testing.T) {
t.Run("TALK_PORT beats the file", func(t *testing.T) {
cfg, err := Load(Options{
File: writeTOML(t, "[server]\nport = 7999\n"),
Getenv: env(map[string]string{"TALK_PORT": "7912"}),
@@ -391,92 +290,73 @@ func TestLoadServerPort(t *testing.T) {
t.Fatalf("Load: %v", err)
}
if cfg.Server.Port != 7912 {
t.Errorf("Server.Port = %d, want Env-Override 7912", cfg.Server.Port)
t.Errorf("Server.Port = %d, want env override 7912", cfg.Server.Port)
}
})
t.Run("ungültiger TALK_PORT-Wert ist klarer Fehler", func(t *testing.T) {
t.Run("invalid TALK_PORT is a clear error", func(t *testing.T) {
for _, bad := range []string{"null", "0", "-1", "70000"} {
_, err := Load(Options{
File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"),
Getenv: env(map[string]string{"TALK_PORT": bad}),
})
_, err := Load(Options{File: missingFile(t), Getenv: env(map[string]string{"TALK_PORT": bad})})
if err == nil || !strings.Contains(err.Error(), "TALK_PORT") {
t.Fatalf("TALK_PORT=%q: Fehler = %v, will klaren TALK_PORT-Fehler", bad, err)
t.Fatalf("TALK_PORT=%q: error = %v, want a clear TALK_PORT error", bad, err)
}
}
})
}
func TestLoadAllowedOrigins(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("Default: leere Liste (kein CORS)", func(t *testing.T) {
cfg, err := Load(Options{
File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"),
Getenv: env(nil),
})
t.Run("default: empty list (no CORS)", func(t *testing.T) {
cfg, err := Load(Options{File: missingFile(t), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(cfg.Server.AllowedOrigins) != 0 {
t.Errorf("AllowedOrigins = %v, want leer (Tür zu)", cfg.Server.AllowedOrigins)
t.Errorf("AllowedOrigins = %v, want empty (door shut)", cfg.Server.AllowedOrigins)
}
})
t.Run("TOML-Liste", func(t *testing.T) {
t.Run("TOML list", func(t *testing.T) {
cfg, err := Load(Options{
File: writeTOML(t, "[server]\nallowed_origins = [\"https://chat.az-gruppe.com\"]\n"),
File: writeTOML(t, "[server]\nallowed_origins = [\"https://chat.example.com\"]\n"),
Getenv: env(nil),
})
if err != nil {
t.Fatalf("Load: %v", err)
}
want := []string{"https://chat.az-gruppe.com"}
if strings.Join(cfg.Server.AllowedOrigins, "|") != strings.Join(want, "|") {
t.Errorf("AllowedOrigins = %v, want %v", cfg.Server.AllowedOrigins, want)
if strings.Join(cfg.Server.AllowedOrigins, "|") != "https://chat.example.com" {
t.Errorf("AllowedOrigins = %v", cfg.Server.AllowedOrigins)
}
})
t.Run("TALK_ALLOWED_ORIGINS schlägt Datei (mehrere, mit Leerzeichen)", func(t *testing.T) {
t.Run("TALK_ALLOWED_ORIGINS beats the file (several, with spaces)", func(t *testing.T) {
cfg, err := Load(Options{
File: writeTOML(t, "[server]\nallowed_origins = [\"https://datei.example\"]\n"),
Getenv: env(map[string]string{"TALK_ALLOWED_ORIGINS": "https://chat.az-gruppe.com, https://grid.ag"}),
File: writeTOML(t, "[server]\nallowed_origins = [\"https://file.example\"]\n"),
Getenv: env(map[string]string{"TALK_ALLOWED_ORIGINS": "https://chat.example.com, https://grid.example"}),
})
if err != nil {
t.Fatalf("Load: %v", err)
}
want := []string{"https://chat.az-gruppe.com", "https://grid.ag"}
if strings.Join(cfg.Server.AllowedOrigins, "|") != strings.Join(want, "|") {
want := "https://chat.example.com|https://grid.example"
if strings.Join(cfg.Server.AllowedOrigins, "|") != want {
t.Errorf("AllowedOrigins = %v, want %v", cfg.Server.AllowedOrigins, want)
}
})
t.Run("TALK_ALLOWED_ORIGINS=off leert die Datei-Liste", func(t *testing.T) {
t.Run("TALK_ALLOWED_ORIGINS=off clears the file list", func(t *testing.T) {
cfg, err := Load(Options{
File: writeTOML(t, "[server]\nallowed_origins = [\"https://datei.example\"]\n"),
File: writeTOML(t, "[server]\nallowed_origins = [\"https://file.example\"]\n"),
Getenv: env(map[string]string{"TALK_ALLOWED_ORIGINS": "off"}),
})
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(cfg.Server.AllowedOrigins) != 0 {
t.Errorf("AllowedOrigins = %v, want leer (off)", cfg.Server.AllowedOrigins)
t.Errorf("AllowedOrigins = %v, want empty (off)", cfg.Server.AllowedOrigins)
}
})
t.Run("Wildcard ist erlaubter Wert", func(t *testing.T) {
cfg, err := Load(Options{
File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"),
Getenv: env(map[string]string{"TALK_ALLOWED_ORIGINS": "*"}),
})
t.Run("wildcard is an allowed value", func(t *testing.T) {
cfg, err := Load(Options{File: missingFile(t), Getenv: env(map[string]string{"TALK_ALLOWED_ORIGINS": "*"})})
if err != nil {
t.Fatalf("Load: %v", err)
}
@@ -485,69 +365,53 @@ func TestLoadAllowedOrigins(t *testing.T) {
}
})
t.Run("leere Einträge sind klare Fehler", func(t *testing.T) {
t.Run("empty entries are clear errors", func(t *testing.T) {
for _, tc := range []struct {
name string
file string
envv string
}{
{"env", "", "https://a.example,,https://b.example"},
{"datei", "[server]\nallowed_origins = [\"https://a.example\", \"\"]\n", ""},
{"file", "[server]\nallowed_origins = [\"https://a.example\", \"\"]\n", ""},
} {
t.Run(tc.name, func(t *testing.T) {
file := filepath.Join(t.TempDir(), "gibt-es-nicht.toml")
file := missingFile(t)
if tc.file != "" {
file = writeTOML(t, tc.file)
}
_, err := Load(Options{
File: file,
Getenv: env(map[string]string{"TALK_ALLOWED_ORIGINS": tc.envv}),
})
_, err := Load(Options{File: file, Getenv: env(map[string]string{"TALK_ALLOWED_ORIGINS": tc.envv})})
if err == nil || !strings.Contains(err.Error(), "allowed_origins") && !strings.Contains(err.Error(), "ALLOWED_ORIGINS") {
t.Fatalf("Fehler = %v, will klaren allowed_origins-Fehler", err)
t.Fatalf("error = %v, want a clear allowed_origins error", err)
}
})
}
})
}
// TestLoadGesetze deckt die Task-05-Felder ab: Rate-Limit (Default 30 s,
// "0s" = unbegrenzt), High-Burst (Default an), Ruhezeiten (Default aus;
// Format validiert) samt Env-Overrides und klaren Fehlern.
func TestLoadGesetze(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
}
fehlendeDatei := func(t *testing.T) string {
t.Helper()
return filepath.Join(t.TempDir(), "gibt-es-nicht.toml")
}
t.Run("Defaults: 30 s, Burst an, Ruhezeit aus, Modus discard", func(t *testing.T) {
cfg, err := Load(Options{File: fehlendeDatei(t), Getenv: env(nil)})
// TestLoadLaws covers rate limit (default 30 s, "0s" = unlimited), high
// burst (default on) and quiet hours (default off; format validated) with
// env overrides and clear errors.
func TestLoadLaws(t *testing.T) {
t.Run("defaults: 30 s, burst on, quiet hours off, mode discard", func(t *testing.T) {
cfg, err := Load(Options{File: missingFile(t), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Queue.RateLimit != DefaultRateLimit {
t.Errorf("Queue.RateLimit = %v, want Default %v", cfg.Queue.RateLimit, DefaultRateLimit)
t.Errorf("Queue.RateLimit = %v, want default %v", cfg.Queue.RateLimit, DefaultRateLimit)
}
if !cfg.Queue.AllowHighBurst {
t.Error("Queue.AllowHighBurst = false, want Default true")
t.Error("Queue.AllowHighBurst = false, want default true")
}
if cfg.QuietHours != "" {
t.Errorf("QuietHours = %q, want Default leer (aus)", cfg.QuietHours)
t.Errorf("QuietHours = %q, want default empty (off)", cfg.QuietHours)
}
if cfg.QuietMode != queue.QuietModeDiscard {
t.Errorf("QuietMode = %q, want Default %q", cfg.QuietMode, queue.QuietModeDiscard)
t.Errorf("QuietMode = %q, want default %q", cfg.QuietMode, queue.QuietModeDiscard)
}
})
t.Run("TOML setzt alle Gesetz-Felder", func(t *testing.T) {
t.Run("TOML sets all law fields", func(t *testing.T) {
cfg, err := Load(Options{
File: writeTOML(t, `
quiet_hours = "18:00-08:00"
@@ -566,36 +430,33 @@ allow_high_burst = false
t.Errorf("Queue.RateLimit = %v, want 10s", cfg.Queue.RateLimit)
}
if cfg.Queue.AllowHighBurst {
t.Error("Queue.AllowHighBurst = true, want false aus TOML")
t.Error("Queue.AllowHighBurst = true, want false from TOML")
}
if cfg.QuietHours != "18:00-08:00" {
t.Errorf("QuietHours = %q, want Fenster aus TOML", cfg.QuietHours)
t.Errorf("QuietHours = %q, want window from TOML", cfg.QuietHours)
}
if cfg.QuietMode != queue.QuietModeToast {
t.Errorf("QuietMode = %q, want toast", cfg.QuietMode)
}
})
t.Run("partielle [queue]-Tabelle behält Defaults", func(t *testing.T) {
cfg, err := Load(Options{
File: writeTOML(t, "[queue]\nrate_limit = \"0s\"\n"),
Getenv: env(nil),
})
t.Run("partial [queue] table keeps defaults", func(t *testing.T) {
cfg, err := Load(Options{File: writeTOML(t, "[queue]\nrate_limit = \"0s\"\n"), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Queue.RateLimit != 0 {
t.Errorf("Queue.RateLimit = %v, want 0 (unbegrenzt)", cfg.Queue.RateLimit)
t.Errorf("Queue.RateLimit = %v, want 0 (unlimited)", cfg.Queue.RateLimit)
}
if !cfg.Queue.AllowHighBurst {
t.Error("Queue.AllowHighBurst = false, want unveränderten Default true")
t.Error("Queue.AllowHighBurst = false, want unchanged default true")
}
if cfg.QuietMode != queue.QuietModeDiscard {
t.Errorf("QuietMode = %q, want unveränderten Default discard", cfg.QuietMode)
t.Errorf("QuietMode = %q, want unchanged default discard", cfg.QuietMode)
}
})
t.Run("Env überschreibt Datei (inkl. off)", func(t *testing.T) {
t.Run("env overrides file (including off)", func(t *testing.T) {
cfg, err := Load(Options{
File: writeTOML(t, `
quiet_hours = "18:00-08:00"
@@ -616,96 +477,58 @@ allow_high_burst = false
t.Fatalf("Load: %v", err)
}
if cfg.Queue.RateLimit != time.Second {
t.Errorf("Queue.RateLimit = %v, want 1s aus Env", cfg.Queue.RateLimit)
t.Errorf("Queue.RateLimit = %v, want 1s from env", cfg.Queue.RateLimit)
}
if !cfg.Queue.AllowHighBurst {
t.Error("Queue.AllowHighBurst = false, want true aus Env")
t.Error("Queue.AllowHighBurst = false, want true from env")
}
if cfg.QuietHours != "" {
t.Errorf("QuietHours = %q, want leer (off aus Env)", cfg.QuietHours)
t.Errorf("QuietHours = %q, want empty (off from env)", cfg.QuietHours)
}
if cfg.QuietMode != queue.QuietModeToast {
t.Errorf("QuietMode = %q, want toast aus Env", cfg.QuietMode)
t.Errorf("QuietMode = %q, want toast from env", cfg.QuietMode)
}
})
t.Run("ungültige Gesetz-Werte sind klare Fehler", func(t *testing.T) {
t.Run("invalid law values are clear errors", func(t *testing.T) {
cases := []struct {
name string
toml string
envs map[string]string
want string
}{
{
name: "TALK_RATE_LIMIT Müll",
envs: map[string]string{"TALK_RATE_LIMIT": "schnell"},
want: "TALK_RATE_LIMIT",
},
{
name: "TALK_RATE_LIMIT negativ",
envs: map[string]string{"TALK_RATE_LIMIT": "-5s"},
want: "TALK_RATE_LIMIT",
},
{
name: "rate_limit Müll in Datei",
toml: "[queue]\nrate_limit = \"müll\"\n",
want: "rate_limit",
},
{
name: "TALK_ALLOW_HIGH_BURST Müll",
envs: map[string]string{"TALK_ALLOW_HIGH_BURST": "vielleicht"},
want: "TALK_ALLOW_HIGH_BURST",
},
{
name: "quiet_hours Müll in Datei",
toml: "quiet_hours = \"abends\"\n",
want: "quiet_hours",
},
{
name: "TALK_QUIET_HOURS Müll",
envs: map[string]string{"TALK_QUIET_HOURS": "25:00-26:00"},
want: "TALK_QUIET_HOURS",
},
{
name: "TALK_QUIET_MODE Müll",
envs: map[string]string{"TALK_QUIET_MODE": "vibrieren"},
want: "TALK_QUIET_MODE",
},
{"TALK_RATE_LIMIT garbage", "", map[string]string{"TALK_RATE_LIMIT": "fast"}, "TALK_RATE_LIMIT"},
{"TALK_RATE_LIMIT negative", "", map[string]string{"TALK_RATE_LIMIT": "-5s"}, "TALK_RATE_LIMIT"},
{"rate_limit garbage in file", "[queue]\nrate_limit = \"garbage\"\n", nil, "rate_limit"},
{"TALK_ALLOW_HIGH_BURST garbage", "", map[string]string{"TALK_ALLOW_HIGH_BURST": "maybe"}, "TALK_ALLOW_HIGH_BURST"},
{"quiet_hours garbage in file", "quiet_hours = \"evenings\"\n", nil, "quiet_hours"},
{"TALK_QUIET_HOURS garbage", "", map[string]string{"TALK_QUIET_HOURS": "25:00-26:00"}, "TALK_QUIET_HOURS"},
{"TALK_QUIET_MODE garbage", "", map[string]string{"TALK_QUIET_MODE": "vibrate"}, "TALK_QUIET_MODE"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
file := fehlendeDatei(t)
file := missingFile(t)
if tc.toml != "" {
file = writeTOML(t, tc.toml)
}
_, err := Load(Options{File: file, Getenv: env(tc.envs)})
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("Fehler = %v, will klaren Fehler mit %q", err, tc.want)
t.Fatalf("error = %v, want a clear error mentioning %q", err, tc.want)
}
})
}
})
}
// TestLoadDaemonAutostart deckt [daemon] autostart ab: Default an, TOML
// kann es ausschalten, Env TALK_AUTOSTART schlägt die Datei, Unsinn ist ein
// klarer Fehler.
// TestLoadDaemonAutostart covers [daemon] autostart: default on, TOML can
// switch it off, env TALK_AUTOSTART beats the file, garbage is an error.
func TestLoadDaemonAutostart(t *testing.T) {
fehlendeDatei := filepath.Join(t.TempDir(), "gibt-es-nicht.toml")
writeTOML := func(t *testing.T, content string) string {
path := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return path
}
cfg, err := Load(Options{File: fehlendeDatei, Getenv: env(nil)})
cfg, err := Load(Options{File: missingFile(t), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
}
if !cfg.Daemon.Autostart {
t.Error("Daemon.Autostart = false, want Default true")
t.Error("Daemon.Autostart = false, want default true")
}
cfg, err = Load(Options{File: writeTOML(t, "[daemon]\nautostart = false\n"), Getenv: env(nil)})
@@ -713,7 +536,7 @@ func TestLoadDaemonAutostart(t *testing.T) {
t.Fatalf("Load: %v", err)
}
if cfg.Daemon.Autostart {
t.Error("Daemon.Autostart = true, want false aus TOML")
t.Error("Daemon.Autostart = true, want false from TOML")
}
cfg, err = Load(Options{
@@ -724,33 +547,24 @@ func TestLoadDaemonAutostart(t *testing.T) {
t.Fatalf("Load: %v", err)
}
if !cfg.Daemon.Autostart {
t.Error("Daemon.Autostart = false, want true aus Env (Env schlägt Datei)")
t.Error("Daemon.Autostart = false, want true from env (env beats file)")
}
if _, err := Load(Options{File: fehlendeDatei, Getenv: env(map[string]string{"TALK_AUTOSTART": "vielleicht"})}); err == nil {
t.Error("TALK_AUTOSTART=vielleicht: kein Fehler, want klarer Fehler")
if _, err := Load(Options{File: missingFile(t), Getenv: env(map[string]string{"TALK_AUTOSTART": "maybe"})}); err == nil {
t.Error("TALK_AUTOSTART=maybe: no error, want a clear error")
}
}
// TestLoadLimits deckt queue.max_text / queue.max_age ab: Defaults 400 / 2m,
// TOML und Env (Env schlägt Datei), 0 = unbegrenzt, Unsinn und negative
// Werte sind klare Fehler.
// TestLoadLimits covers queue.max_text / queue.max_age: defaults 400 / 2m,
// TOML and env (env beats file), 0 = unlimited, garbage and negative values
// are clear errors.
func TestLoadLimits(t *testing.T) {
fehlendeDatei := filepath.Join(t.TempDir(), "gibt-es-nicht.toml")
writeTOML := func(t *testing.T, content string) string {
path := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return path
}
cfg, err := Load(Options{File: fehlendeDatei, Getenv: env(nil)})
cfg, err := Load(Options{File: missingFile(t), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Queue.MaxText != DefaultMaxText || cfg.Queue.MaxAge != DefaultMaxAge {
t.Errorf("Defaults = %d/%v, want %d/%v", cfg.Queue.MaxText, cfg.Queue.MaxAge, DefaultMaxText, DefaultMaxAge)
t.Errorf("defaults = %d/%v, want %d/%v", cfg.Queue.MaxText, cfg.Queue.MaxAge, DefaultMaxText, DefaultMaxAge)
}
cfg, err = Load(Options{File: writeTOML(t, "[queue]\nmax_text = 0\nmax_age = \"45s\"\n"), Getenv: env(nil)})
@@ -769,20 +583,49 @@ func TestLoadLimits(t *testing.T) {
t.Fatalf("Load: %v", err)
}
if cfg.Queue.MaxText != 250 || cfg.Queue.MaxAge != 0 {
t.Errorf("Env = %d/%v, want 250/0 (Env schlägt Datei)", cfg.Queue.MaxText, cfg.Queue.MaxAge)
t.Errorf("env = %d/%v, want 250/0 (env beats file)", cfg.Queue.MaxText, cfg.Queue.MaxAge)
}
for name, kv := range map[string]map[string]string{
"TALK_MAX_TEXT=abc": {"TALK_MAX_TEXT": "abc"},
"TALK_MAX_TEXT=-1": {"TALK_MAX_TEXT": "-1"},
"TALK_MAX_AGE=bald": {"TALK_MAX_AGE": "bald"},
"TALK_MAX_AGE=soon": {"TALK_MAX_AGE": "soon"},
"TALK_MAX_AGE=-1s": {"TALK_MAX_AGE": "-1s"},
} {
if _, err := Load(Options{File: fehlendeDatei, Getenv: env(kv)}); err == nil {
t.Errorf("%s: kein Fehler, want klarer Fehler", name)
if _, err := Load(Options{File: missingFile(t), Getenv: env(kv)}); err == nil {
t.Errorf("%s: no error, want a clear error", name)
}
}
if _, err := Load(Options{File: writeTOML(t, "[queue]\nmax_text = -5\n"), Getenv: env(nil)}); err == nil {
t.Error("max_text = -5 in TOML: kein Fehler, want klarer Fehler")
t.Error("max_text = -5 in TOML: no error, want a clear error")
}
}
// TestLoadLang covers the fallback language: default en, TOML, env
// TALK_LANG beats the file, anything but de/en is an error.
func TestLoadLang(t *testing.T) {
cfg, err := Load(Options{File: missingFile(t), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Lang != "en" {
t.Errorf("Lang = %q, want default en", cfg.Lang)
}
cfg, err = Load(Options{File: writeTOML(t, "lang = \"de\"\n"), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Lang != "de" {
t.Errorf("Lang = %q, want de from TOML", cfg.Lang)
}
cfg, err = Load(Options{File: writeTOML(t, "lang = \"de\"\n"), Getenv: env(map[string]string{"TALK_LANG": "en"})})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Lang != "en" {
t.Errorf("Lang = %q, want en from env", cfg.Lang)
}
if _, err := Load(Options{File: missingFile(t), Getenv: env(map[string]string{"TALK_LANG": "fr"})}); err == nil || !strings.Contains(err.Error(), "lang") {
t.Errorf("TALK_LANG=fr: error = %v, want a clear lang error", err)
}
}
+4 -6
View File
@@ -1,7 +1,5 @@
// 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. Pfade und Env sind injizierbar (Tests).
// Voice-Mapping voice.<provider>.<lang> und Provider-Endpunkte folgen mit
// Task 03.
// Package config resolves talk's configuration in layers: built-in defaults,
// then the optional config.toml in the platform-native config directory,
// then TALK_* environment variables (which win). It validates the result and
// reports problems as clear errors instead of silently falling back.
package config
+14 -15
View File
@@ -6,26 +6,25 @@ import (
"runtime"
)
// fileName ist der Name der Config-Datei im Config-Verzeichnis.
// fileName is the config file's name inside the config directory.
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)")
// ErrNoHome reports that the home directory cannot be determined (no HOME or
// APPDATA environment variable).
var ErrNoHome = errors.New("config directory unknown (HOME or APPDATA not set)")
// 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.
// Dir returns talk's platform-native config directory: ~/.config/talk on
// Linux (or $XDG_CONFIG_HOME/talk), ~/Library/Application Support/talk on
// macOS, %APPDATA%\talk on Windows.
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.
// dirFor is the testable platform logic behind Dir: goos is a parameter so
// all platforms can be table-tested from one host.
//
// XDG_CONFIG_HOME wird nur berücksichtigt, wenn es einen absoluten Pfad
// enthält (XDG-Spezifikation); relative Werte werden ignoriert.
// XDG_CONFIG_HOME is honoured only when it holds an absolute path (XDG
// spec); relative values are ignored.
func dirFor(goos string, getenv func(string) string) (string, error) {
if getenv == nil {
return "", ErrNoHome
@@ -43,7 +42,7 @@ func dirFor(goos string, getenv func(string) string) (string, error) {
return "", ErrNoHome
}
return filepath.Join(home, "Library", "Application Support", "talk"), nil
default: // linux und sonstige unixartige Systeme
default: // linux and other unix-likes
if xdg := getenv("XDG_CONFIG_HOME"); xdg != "" && filepath.IsAbs(xdg) {
return filepath.Join(xdg, "talk"), nil
}
@@ -55,8 +54,8 @@ func dirFor(goos string, getenv func(string) string) (string, error) {
}
}
// Path liefert den vollständigen Pfad der config.toml im platform-nativen
// Config-Verzeichnis (Diagnose, Setup-Wizard).
// Path returns the full path of config.toml in the config directory
// (diagnostics, setup).
func Path(getenv func(string) string) (string, error) {
dir, err := Dir(getenv)
if err != nil {