--- phase: 07-custom-rules-and-print-config plan: 01 type: tdd wave: 1 depends_on: [] files_modified: - config/config.go - config/config_test.go autonomous: true requirements: - RULE-01 - RULE-02 - RULE-03 must_haves: truths: - "TOML [[rules]] blocks parse into classify.Rule slices" - "Missing protocol or class in a rule produces a clear error at startup" - "User rules are returned separately from FreqCfgs for caller to prepend" - "New class names without explicit sound config get auto-assigned frequencies in 1200-2400 Hz range" - "Built-in class names in user rules do not get overwritten by auto-freq" artifacts: - path: "config/config.go" provides: "RawRule, LoadResult, validateRules, convertRules, autoAssignFreq, addAutoFreqEntries" contains: "type LoadResult struct" - path: "config/config_test.go" provides: "Tests for rule parsing, validation, auto-freq, LoadResult" contains: "TestLoadCustomRules" key_links: - from: "config/config.go" to: "classify/rules.go" via: "convertRules produces []classify.Rule" pattern: "classify\\.Rule" - from: "config/config.go" to: "synth/config.go" via: "autoAssignFreq creates FreqConfig entries with WaveformPresetHarmonics" pattern: "synth\\.WaveformPresetHarmonics" --- Extend the config package to parse `[[rules]]` TOML blocks into classification rules, validate them, auto-assign frequencies for new class names, and return a `LoadResult` struct from `Load()`. Purpose: This is the data layer for user-defined classification rules (RULE-01, RULE-02, RULE-03). The LoadResult struct becomes the contract consumed by Plan 02 for CLI wiring and print-config. Output: Updated `config/config.go` with RawRule, LoadResult, validation, auto-freq; comprehensive tests in `config/config_test.go`. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/07-custom-rules-and-print-config/07-CONTEXT.md @.planning/phases/07-custom-rules-and-print-config/07-RESEARCH.md @config/config.go @config/config_test.go @classify/rules.go @classify/types.go @synth/config.go From classify/rules.go: ```go type Rule struct { Protocol string DstPort uint16 Class TrafficClass } var DefaultRules = []Rule{ ... } // 12 rules, first-match-wins ``` From classify/types.go: ```go type TrafficClass string func AllClasses() []TrafficClass // returns 14 built-in classes ``` From synth/config.go: ```go type WaveformType int const WaveformSine WaveformType = 1 type FreqConfig struct { BaseHz float64 Harmonics []HarmonicDef Pan float64 WaveformType WaveformType } var ClassFreqConfigs = map[classify.TrafficClass]FreqConfig{ ... } // 14 entries, max 1047 Hz func WaveformPresetHarmonics(wt WaveformType, baseHz float64, sampleRate int) []HarmonicDef const SampleRate = 44100 ``` From config/config.go (current): ```go type SoundOverride struct { Frequency *float64 `toml:"frequency"` Waveform *string `toml:"waveform"` } type rawConfig struct { Sounds map[string]SoundOverride `toml:"sounds"` } func Load(configPath string) (map[classify.TrafficClass]synth.FreqConfig, error) ``` From config/config_test.go (patterns): ```go func writeTOML(t *testing.T, content string) string // creates temp TOML file // Tests call config.Load(path) and check returned map ``` Task 1: RawRule, LoadResult, validation, conversion, and auto-freq with TDD config/config.go, config/config_test.go config/config.go, config/config_test.go, classify/rules.go, classify/types.go, synth/config.go - TestLoadCustomRules: TOML with `[[rules]]` block (port=8080, protocol="tcp", class="MyApp") + `[sounds.MyApp]` (frequency=300.0) parses successfully; LoadResult.UserRules has len 1 with Protocol="tcp", DstPort=8080, Class="MyApp"; LoadResult.FreqCfgs["MyApp"].BaseHz == 300.0 - TestLoadCustomRuleNoPort: TOML with `[[rules]]` (protocol="udp", class="AllUDP", no port field) parses; UserRules[0].DstPort == 0 - TestLoadCustomRuleMissingProtocol: TOML `[[rules]]` with class="X" but no protocol field -> error containing "protocol is required" - TestLoadCustomRuleMissingClass: TOML `[[rules]]` with protocol="tcp" but no class field -> error containing "class is required" - TestLoadCustomRuleInvalidProtocol: TOML `[[rules]]` with protocol="ftp" -> error containing "invalid protocol" - TestLoadCustomRuleUnknownField: TOML `[[rules]]` with typo_field="bad" -> error containing "typo_field" (from Undecoded()) - TestUserRulesPrepend: Load returns UserRules separately from FreqCfgs; caller can do `append(result.UserRules, classify.DefaultRules...)` to get user rules first - TestAutoFreqAssignment: TOML with `[[rules]]` (class="GameServer", protocol="tcp") and NO `[sounds.GameServer]` -> FreqCfgs contains "GameServer" entry with BaseHz in range [1200, 2350] and WaveformType == WaveformSine - TestAutoFreqDeterministic: Two Load() calls with same class name produce same BaseHz - TestAutoFreqSkipsBuiltins: TOML with `[[rules]]` (class="HTTPS", protocol="tcp", port=443) -> FreqCfgs["HTTPS"].BaseHz == 175.0 (the default), NOT an auto-assigned value - TestLoadResultConfigPath: Load(explicit_path) -> LoadResult.ConfigPath == explicit_path; Load("") with no file -> LoadResult.ConfigPath == "" - TestLoadNoConfigReturnsLoadResult: Load("") in empty dir returns LoadResult with len(FreqCfgs)==14, len(UserRules)==0, ConfigPath=="" - TestExistingTestsStillPass: All 8 existing tests in config_test.go continue to pass after Load() signature change (they need updating to use LoadResult) RED phase -- Write all test functions listed in behavior above in config/config_test.go. Tests call config.Load() and assert on LoadResult fields. Update the 8 existing tests to use the new LoadResult return type (e.g., `result, err := config.Load(path); cfgs := result.FreqCfgs`). Run tests -- they must all fail (Load still returns bare map). GREEN phase -- Modify config/config.go: 1. Add RawRule struct (per D-01): ```go type RawRule struct { Port *uint16 `toml:"port"` Protocol string `toml:"protocol"` Class string `toml:"class"` } ``` 2. Add Rules field to rawConfig: ```go type rawConfig struct { Sounds map[string]SoundOverride `toml:"sounds"` Rules []RawRule `toml:"rules"` } ``` 3. Add LoadResult struct (per D-13, Claude's Discretion: struct over tuple): ```go type LoadResult struct { FreqCfgs map[classify.TrafficClass]synth.FreqConfig UserRules []classify.Rule ConfigPath string } ``` 4. Add validateRules function called from validate(): ```go 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 } ``` 5. Add convertRules function: ```go 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 } ``` 6. Add autoAssignFreq function (per D-07/D-08, using FNV-32a): ```go import "hash/fnv" 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 } ``` 7. Add addAutoFreqEntries function (called AFTER merge, per Pitfall 4): ```go 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, } } } } ``` 8. Change Load() signature to return LoadResult: ```go func Load(configPath string) (LoadResult, error) { ``` - When no config found: return `LoadResult{FreqCfgs: copyDefaults(), ConfigPath: ""}`, nil - After parseFile + validate + merge: call convertRules, call addAutoFreqEntries, return LoadResult with FreqCfgs, UserRules, and the resolved config path - Update validate() to also call validateRules(raw.Rules) - The `merge()` function for unknown class names should NO LONGER print a warning for classes that appear in raw.Rules -- those are legitimate custom classes. Keep the warning only for [sounds.X] where X is neither a built-in class nor a class defined in [[rules]]. 9. Update the merge() function: Change the unknown-class warning logic. Instead of always warning on unknown class names in sounds, accept the `userRules []RawRule` as a parameter (or check after conversion). Simplest: after converting rules, pass the set of user-defined class names to merge so it can skip the warning for those. Alternatively, run merge first (with warnings), then let addAutoFreqEntries handle user-defined classes. The warning is acceptable for now -- it only fires for [sounds.X] where X has no matching [[rules]] entry AND is not a built-in class. Keep existing warning behavior, it is harmless. REFACTOR phase -- Clean up if needed. Ensure all tests pass. Run `go test ./config/...` -- all tests must pass. Run `go test ./...` -- full suite must pass (the Load() call site in main.go will break; that is expected and fixed in Plan 02). go test ./config/... -v -count=1 - config/config.go contains `type RawRule struct` with `Port *uint16`, `Protocol string`, `Class string` fields - config/config.go contains `type LoadResult struct` with `FreqCfgs`, `UserRules`, `ConfigPath` fields - config/config.go contains `func Load(configPath string) (LoadResult, error)` - config/config.go contains `func validateRules(rules []RawRule) error` - config/config.go contains `func convertRules(raw []RawRule) []classify.Rule` - config/config.go contains `func autoAssignFreq(className string) float64` with `fnv.New32a()` - config/config.go contains `func addAutoFreqEntries(` - config/config.go imports `"hash/fnv"` - config/config_test.go contains `TestLoadCustomRules` - config/config_test.go contains `TestAutoFreqAssignment` - config/config_test.go contains `TestAutoFreqSkipsBuiltins` - config/config_test.go contains `TestLoadCustomRuleMissingProtocol` - `go test ./config/... -count=1` exits 0 Load() returns LoadResult with FreqCfgs + UserRules + ConfigPath. TOML [[rules]] blocks parse, validate (protocol required, class required, valid protocols only), and convert to classify.Rule slices. Auto-frequency assignment creates FreqConfig entries for new class names in 1200-2350 Hz range using FNV-32a. Built-in class names from user rules are NOT overwritten. All existing config tests updated and passing. Full config test suite green. - `go test ./config/... -v -count=1` passes all tests including new rule-related tests - `go vet ./config/...` reports no issues - LoadResult struct is exported and usable from cmd/netsynth package - config.Load() returns LoadResult struct (not bare map) - [[rules]] TOML blocks parse into UserRules field - Validation catches missing protocol, missing class, invalid protocol - Auto-freq assigns 1200-2350 Hz for new class names, skips built-ins - All 8 existing config tests updated and passing - All new tests passing After completion, create `.planning/phases/07-custom-rules-and-print-config/07-01-SUMMARY.md`