diff --git a/config/config.go b/config/config.go index 8e2041e..44ac055 100644 --- a/config/config.go +++ b/config/config.go @@ -1,9 +1,183 @@ +// Package config loads, validates, and merges a TOML override file over the +// default synth.ClassFreqConfigs map. The single public entry point is Load. package config -import "github.com/netsynth/netsynth/classify" -import "github.com/netsynth/netsynth/synth" +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" -// Load is a stub — implementation pending GREEN phase. + "github.com/BurntSushi/toml" + + "github.com/netsynth/netsynth/classify" + "github.com/netsynth/netsynth/synth" +) + +// SoundOverride holds optional per-class sound parameters decoded from TOML. +// Pointer fields: nil = not set by user (keep default), non-nil = user override. +type SoundOverride struct { + Frequency *float64 `toml:"frequency"` + Waveform *string `toml:"waveform"` +} + +// rawConfig is the top-level TOML decode target. +type rawConfig struct { + Sounds map[string]SoundOverride `toml:"sounds"` +} + +// validWaveforms maps TOML waveform strings to WaveformType constants. +var validWaveforms = map[string]synth.WaveformType{ + "sine": synth.WaveformSine, + "square": synth.WaveformSquare, + "sawtooth": synth.WaveformSawtooth, + "triangle": synth.WaveformTriangle, +} + +// Load finds, parses, validates, and merges a TOML config file. +// +// configPath is the --config flag value; empty string triggers auto-discovery. +// Returns the merged FreqConfig map (defaults + overrides) ready for synth.NewBank. +// Returns an error on: explicit file not found, parse errors, unknown keys, +// type mismatches, or invalid waveform values. Returns no error (uses defaults) +// when no config is found during auto-discovery. func Load(configPath string) (map[classify.TrafficClass]synth.FreqConfig, error) { - return nil, nil + path, explicit, err := resolvePath(configPath) + if err != nil { + return nil, err + } + if path == "" { + // No config found during auto-discovery — use defaults silently (CFG-02) + return copyDefaults(), nil + } + + raw, err := parseFile(path) + if err != nil { + if explicit && errors.Is(err, fs.ErrNotExist) { + return nil, fmt.Errorf("config file not found: %s", configPath) + } + return nil, err + } + + if err := validate(raw); err != nil { + return nil, err + } + + return merge(copyDefaults(), raw.Sounds), nil +} + +// resolvePath resolves the config path from an explicit flag value or auto-discovery. +// Returns (path, explicit, error) where explicit=true means the user specified a path. +func resolvePath(configPath string) (string, bool, error) { + if configPath != "" { + return configPath, true, nil + } + return discoverPath(), false, nil +} + +// discoverPath probes the standard discovery locations in precedence order. +// Returns the first existing config path, or "" if none found. +// Discovery order: ./netsynth.toml > ~/.config/netsynth/config.toml +func discoverPath() string { + // 1. Working directory + if _, err := os.Stat("netsynth.toml"); err == nil { + return "netsynth.toml" + } + // 2. XDG config dir (~/.config/netsynth/config.toml or $XDG_CONFIG_HOME/netsynth/config.toml) + dir, err := os.UserConfigDir() + if err != nil { + return "" + } + p := filepath.Join(dir, "netsynth", "config.toml") + if _, err := os.Stat(p); err == nil { + return p + } + return "" +} + +// parseFile decodes the TOML file at path and checks for unknown keys via Undecoded(). +// Returns fs.ErrNotExist-wrapped error when the file does not exist. +func parseFile(path string) (rawConfig, error) { + var raw rawConfig + md, err := toml.DecodeFile(path, &raw) + if err != nil { + // Preserve the fs.ErrNotExist sentinel so Load can distinguish explicit vs discovered. + if errors.Is(err, fs.ErrNotExist) { + return raw, err + } + return raw, fmt.Errorf("config parse error: %w", err) + } + // Detect field-level typos within [sounds.] blocks (CFG-05). + // Note: class-name typos in [sounds.] are NOT caught here because all + // map keys are valid decode targets. Class validation happens in merge (D-09). + if undecoded := md.Undecoded(); len(undecoded) > 0 { + keyPath := strings.Join(undecoded[0], ".") + return raw, fmt.Errorf("config: unknown key %q — check spelling", keyPath) + } + return raw, nil +} + +// validate checks waveform strings before merge so we fail fast at startup (D-11). +func validate(raw rawConfig) error { + for _, override := range raw.Sounds { + if override.Waveform != nil { + if _, err := parseWaveform(*override.Waveform); err != nil { + return err + } + } + } + return nil +} + +// parseWaveform converts a TOML waveform string to a WaveformType. +func parseWaveform(s string) (synth.WaveformType, error) { + if wt, ok := validWaveforms[s]; ok { + return wt, nil + } + valid := []string{"sine", "square", "sawtooth", "triangle"} + return 0, fmt.Errorf("config: invalid waveform %q — valid values: %s", s, strings.Join(valid, ", ")) +} + +// copyDefaults returns a shallow copy of synth.ClassFreqConfigs. +// Shallow copy is safe because merge assigns fresh Harmonics slices from +// WaveformPresetHarmonics, never mutating the original default slice. +func copyDefaults() map[classify.TrafficClass]synth.FreqConfig { + result := make(map[classify.TrafficClass]synth.FreqConfig, len(synth.ClassFreqConfigs)) + for k, v := range synth.ClassFreqConfigs { + result[k] = v + } + return result +} + +// merge overlays per-class overrides onto the defaults map in-place. +// Only non-nil pointer fields in each SoundOverride are applied. +func merge( + defaults map[classify.TrafficClass]synth.FreqConfig, + overrides map[string]SoundOverride, +) map[classify.TrafficClass]synth.FreqConfig { + for className, override := range overrides { + class := classify.TrafficClass(className) + cfg, known := defaults[class] + if !known { + // D-09: unknown class name = warning (not error), in case Phase 7 defines it + fmt.Fprintf(os.Stderr, "Warning: config: unknown class %q (ignored)\n", className) + continue + } + if override.Frequency != nil { + cfg.BaseHz = *override.Frequency + // Regenerate harmonics when a waveform preset is active (Pitfall 3) + if cfg.WaveformType != synth.WaveformCustom { + cfg.Harmonics = synth.WaveformPresetHarmonics(cfg.WaveformType, cfg.BaseHz, synth.SampleRate) + } + } + if override.Waveform != nil { + wt, _ := parseWaveform(*override.Waveform) // already validated above + cfg.WaveformType = wt + cfg.Harmonics = synth.WaveformPresetHarmonics(wt, cfg.BaseHz, synth.SampleRate) + } + defaults[class] = cfg + } + return defaults } diff --git a/go.mod b/go.mod index fb3e5e4..14e0ca5 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( ) require ( + github.com/BurntSushi/toml v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/sjzar/go-lame v0.0.9 // indirect diff --git a/go.sum b/go.sum index 43e60b1..d246eea 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=