feat(06-01): implement config package with TOML load, merge, validate
- Load() discovers, parses, validates, and merges TOML config over defaults - SoundOverride struct with *float64/*string pointer fields for partial-merge (CFG-04) - parseFile uses BurntSushi/toml DecodeFile + Undecoded() for unknown-key errors (CFG-05) - discoverPath probes ./netsynth.toml then ~/.config/netsynth/config.toml (CFG-02) - merge applies non-nil overrides per class; warns on unknown class names (D-09) - parseWaveform maps sine/square/sawtooth/triangle strings to WaveformType - Harmonics regenerated via WaveformPresetHarmonics when waveform/frequency changes - All 9 tests pass; go vet clean
This commit is contained in:
+178
-4
@@ -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
|
package config
|
||||||
|
|
||||||
import "github.com/netsynth/netsynth/classify"
|
import (
|
||||||
import "github.com/netsynth/netsynth/synth"
|
"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) {
|
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.<class>] blocks (CFG-05).
|
||||||
|
// Note: class-name typos in [sounds.<name>] 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||||
github.com/sjzar/go-lame v0.0.9 // indirect
|
github.com/sjzar/go-lame v0.0.9 // indirect
|
||||||
|
|||||||
@@ -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/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.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
|||||||
Reference in New Issue
Block a user