// 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 ( "errors" "fmt" "io/fs" "os" "path/filepath" "strings" "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) { 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 }