feat(07-01): add RawRule, LoadResult, validation, auto-freq to config package

- Add RawRule struct with Port *uint16, Protocol, Class fields
- Add LoadResult struct with FreqCfgs, UserRules, ConfigPath fields
- Change Load() signature to return LoadResult instead of bare map
- Add validateRules: checks protocol required, class required, valid protocols
- Add convertRules: converts RawRule slices to classify.Rule slices
- Add autoAssignFreq: FNV-32a deterministic Hz in [1200-2350] range
- Add addAutoFreqEntries: creates FreqConfig for new class names, skips built-ins
- Reorder ops: addAutoFreqEntries before merge so sounds overrides apply to user classes
- Update main.go call site to use LoadResult.FreqCfgs
- Update all 8 existing tests to use LoadResult return type
- Add 13 new tests covering rule parsing, validation, auto-freq, and LoadResult
This commit is contained in:
2026-03-26 21:44:53 +01:00
parent 006e465b1e
commit 4b365cd5f4
3 changed files with 389 additions and 18 deletions
+104 -11
View File
@@ -5,6 +5,7 @@ package config
import (
"errors"
"fmt"
"hash/fnv"
"io/fs"
"os"
"path/filepath"
@@ -23,9 +24,27 @@ type SoundOverride struct {
Waveform *string `toml:"waveform"`
}
// RawRule holds a user-defined classification rule as decoded from TOML.
// Port is a pointer so we can distinguish "not set" (nil, matches any port) from port=0.
type RawRule struct {
Port *uint16 `toml:"port"`
Protocol string `toml:"protocol"`
Class string `toml:"class"`
}
// rawConfig is the top-level TOML decode target.
type rawConfig struct {
Sounds map[string]SoundOverride `toml:"sounds"`
Rules []RawRule `toml:"rules"`
}
// LoadResult is the return type from Load(). It carries the merged FreqConfig map,
// the user-defined classification rules (to be prepended before DefaultRules by the caller),
// and the resolved config file path (empty string if no config was found).
type LoadResult struct {
FreqCfgs map[classify.TrafficClass]synth.FreqConfig
UserRules []classify.Rule
ConfigPath string
}
// validWaveforms maps TOML waveform strings to WaveformType constants.
@@ -39,33 +58,43 @@ var validWaveforms = map[string]synth.WaveformType{
// 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 a LoadResult with the merged FreqConfig map, user-defined rules, and resolved path.
// 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) {
// type mismatches, invalid waveform values, or invalid rule definitions.
// Returns no error (uses defaults) when no config is found during auto-discovery.
func Load(configPath string) (LoadResult, error) {
path, explicit, err := resolvePath(configPath)
if err != nil {
return nil, err
return LoadResult{}, err
}
if path == "" {
// No config found during auto-discovery — use defaults silently (CFG-02)
return copyDefaults(), nil
return LoadResult{FreqCfgs: copyDefaults(), UserRules: []classify.Rule{}, ConfigPath: ""}, 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 LoadResult{}, fmt.Errorf("config file not found: %s", configPath)
}
return nil, err
return LoadResult{}, err
}
if err := validate(raw); err != nil {
return nil, err
return LoadResult{}, err
}
return merge(copyDefaults(), raw.Sounds), nil
userRules := convertRules(raw.Rules)
freqCfgs := copyDefaults()
// Add auto-freq entries BEFORE merge so that [sounds.X] overrides for user classes apply.
addAutoFreqEntries(freqCfgs, userRules)
merge(freqCfgs, raw.Sounds)
return LoadResult{
FreqCfgs: freqCfgs,
UserRules: userRules,
ConfigPath: path,
}, nil
}
// resolvePath resolves the config path from an explicit flag value or auto-discovery.
@@ -119,7 +148,7 @@ func parseFile(path string) (rawConfig, error) {
return raw, nil
}
// validate checks waveform strings before merge so we fail fast at startup (D-11).
// validate checks waveform strings and rules before merge so we fail fast at startup (D-11).
func validate(raw rawConfig) error {
for _, override := range raw.Sounds {
if override.Waveform != nil {
@@ -128,9 +157,73 @@ func validate(raw rawConfig) error {
}
}
}
return validateRules(raw.Rules)
}
// validateRules checks that each rule has a valid protocol and a non-empty class.
func validateRules(rules []RawRule) error {
validProtocols := map[string]bool{"tcp": true, "udp": true, "icmp": true}
for i, r := range rules {
if r.Protocol == "" {
return fmt.Errorf("config: rules[%d]: protocol is required", i)
}
if !validProtocols[r.Protocol] {
return fmt.Errorf("config: rules[%d]: invalid protocol %q -- valid: tcp, udp, icmp", i, r.Protocol)
}
if r.Class == "" {
return fmt.Errorf("config: rules[%d]: class is required", i)
}
}
return nil
}
// convertRules converts a slice of RawRule (from TOML) into classify.Rule slice.
func convertRules(raw []RawRule) []classify.Rule {
result := make([]classify.Rule, len(raw))
for i, r := range raw {
var port uint16
if r.Port != nil {
port = *r.Port
}
result[i] = classify.Rule{
Protocol: r.Protocol,
DstPort: port,
Class: classify.TrafficClass(r.Class),
}
}
return result
}
// autoAssignFreq computes a deterministic frequency in [1200, 2350] Hz for a class name
// using FNV-32a hashing. Same input always produces the same output.
func autoAssignFreq(className string) float64 {
h := fnv.New32a()
h.Write([]byte(className))
const (
baseHz = 1200.0
stepHz = 50.0
numSteps = uint32(24)
)
return baseHz + float64(h.Sum32()%numSteps)*stepHz
}
// addAutoFreqEntries adds a FreqConfig entry for each user-defined class that doesn't
// already have one in the map. Built-in classes that appear in user rules are skipped.
// Must be called AFTER merge() so that [sounds.X] overrides are already applied.
func addAutoFreqEntries(cfgs map[classify.TrafficClass]synth.FreqConfig, userRules []classify.Rule) {
for _, rule := range userRules {
if _, exists := cfgs[rule.Class]; !exists {
baseHz := autoAssignFreq(string(rule.Class))
cfgs[rule.Class] = synth.FreqConfig{
BaseHz: baseHz,
WaveformType: synth.WaveformSine,
Harmonics: synth.WaveformPresetHarmonics(synth.WaveformSine, baseHz, synth.SampleRate),
Pan: 0.0,
}
}
}
}
// parseWaveform converts a TOML waveform string to a WaveformType.
func parseWaveform(s string) (synth.WaveformType, error) {
if wt, ok := validWaveforms[s]; ok {