diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 104ac55..356abe1 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -68,7 +68,11 @@ Plans: 2. User-defined rules fire before built-in protocol rules — a custom rule for port 443 overrides the default HTTPS classification for packets on that port 3. A user-defined class name gets its own synthesis layer automatically — no silence or missing audio for traffic matched by a custom rule 4. User runs `netsynth --print-config` and sees the full effective config (defaults merged with their overrides) as commented TOML, without starting a capture -**Plans**: TBD +**Plans:** 2 plans + +Plans: +- [ ] 07-01-PLAN.md — Config extension: RawRule, LoadResult, rule validation, auto-freq assignment (TDD) +- [ ] 07-02-PLAN.md — CLI wiring: --print-config flag, user rule prepend, PrintConfig output ## Progress @@ -80,4 +84,4 @@ Plans: | 4. Power User Features | v1.0 | 2/2 | Complete | 2026-03-26 | | 5. Waveform Types and Bank Decoupling | v1.1 | 2/2 | Complete | 2026-03-26 | | 6. Config Package and Sound Overrides | v1.1 | 2/2 | Complete | 2026-03-26 | -| 7. Custom Rules and Print-Config | v1.1 | 0/? | Not started | - | +| 7. Custom Rules and Print-Config | v1.1 | 0/2 | Not started | - | diff --git a/.planning/phases/07-custom-rules-and-print-config/07-01-PLAN.md b/.planning/phases/07-custom-rules-and-print-config/07-01-PLAN.md new file mode 100644 index 0000000..6178999 --- /dev/null +++ b/.planning/phases/07-custom-rules-and-print-config/07-01-PLAN.md @@ -0,0 +1,302 @@ +--- +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` + diff --git a/.planning/phases/07-custom-rules-and-print-config/07-02-PLAN.md b/.planning/phases/07-custom-rules-and-print-config/07-02-PLAN.md new file mode 100644 index 0000000..5cc4457 --- /dev/null +++ b/.planning/phases/07-custom-rules-and-print-config/07-02-PLAN.md @@ -0,0 +1,444 @@ +--- +phase: 07-custom-rules-and-print-config +plan: 02 +type: execute +wave: 2 +depends_on: + - "07-01" +files_modified: + - cmd/netsynth/main.go + - cmd/netsynth/main_test.go + - config/config.go + - config/config_test.go +autonomous: true +requirements: + - RULE-02 + - CFG-06 + +must_haves: + truths: + - "User runs netsynth --print-config and sees full effective config as commented TOML on stdout without capture starting" + - "User rules prepend before built-in rules so first-match-wins gives user priority" + - "Print-config output shows source path when config file loaded" + - "Print-config output annotates defaults vs overrides vs auto-assigned" + - "Print-config works without -i flag" + - "Print-config includes [[rules]] section when user rules are present" + artifacts: + - path: "cmd/netsynth/main.go" + provides: "--print-config flag, runPrintConfig(), user rule prepend" + contains: "print-config" + - path: "config/config.go" + provides: "PrintConfig function" + contains: "func PrintConfig(" + - path: "cmd/netsynth/main_test.go" + provides: "Tests for --print-config flag" + contains: "TestPrintConfigFlagRegistered" + - path: "config/config_test.go" + provides: "Tests for PrintConfig output" + contains: "TestPrintConfigContainsAllClasses" + key_links: + - from: "cmd/netsynth/main.go" + to: "config/config.go" + via: "runPrintConfig calls config.Load then config.PrintConfig" + pattern: "config\\.PrintConfig" + - from: "cmd/netsynth/main.go" + to: "classify/rules.go" + via: "append(result.UserRules, classify.DefaultRules...)" + pattern: "append.*UserRules.*DefaultRules" +--- + + +Wire the LoadResult into main.go (user rules prepend, --print-config flag), implement the PrintConfig output function, and add comprehensive tests for both. + +Purpose: Completes RULE-02 (user rules fire before built-ins at the CLI level) and CFG-06 (--print-config UX). This is the final plan for Phase 7 and the v1.1 milestone. + +Output: Updated main.go with --print-config and user rule prepending; PrintConfig function in config package; tests in both test files. + + + +@$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 +@.planning/phases/07-custom-rules-and-print-config/07-01-SUMMARY.md + +@cmd/netsynth/main.go +@cmd/netsynth/main_test.go +@config/config.go +@config/config_test.go +@classify/rules.go +@classify/types.go +@synth/config.go + + + + +From config/config.go (after Plan 01): +```go +type LoadResult struct { + FreqCfgs map[classify.TrafficClass]synth.FreqConfig + UserRules []classify.Rule + ConfigPath string +} + +func Load(configPath string) (LoadResult, error) +``` + +From classify/rules.go: +```go +var DefaultRules = []Rule{ ... } // 12 rules +func NewClassifier(rules []Rule) *Classifier +``` + +From synth/config.go: +```go +var ClassFreqConfigs = map[classify.TrafficClass]FreqConfig{ ... } // 14 entries +type FreqConfig struct { + BaseHz float64 + Harmonics []HarmonicDef + Pan float64 + WaveformType WaveformType +} +``` + +From classify/types.go: +```go +func AllClasses() []TrafficClass // 14 built-in classes in display order +``` + +From cmd/netsynth/main.go (current run() flow): +```go +// listIfaces check is first early-exit +// then mutual exclusion check for --read and -i +// then interface-required check +// then BPF filter validation +// then config.Load(configPath) +// then output path resolution +// then runLiveMode or runPcapMode +``` + +From cmd/netsynth/main_test.go (patterns): +```go +func newTestCmd() *cobra.Command // creates fresh command with all flags +// Tests use rootCmd.SetArgs, rootCmd.Execute(), check err +// PersistentPreRunE wires test vars to package-level vars +``` + + + + + + + Task 1: Wire LoadResult into main.go and add --print-config flag + cmd/netsynth/main.go, cmd/netsynth/main_test.go + cmd/netsynth/main.go, cmd/netsynth/main_test.go, config/config.go, classify/rules.go + +**main.go changes:** + +1. Add package-level var for print-config flag: +```go +var printConfig bool // add alongside existing configPath var +``` + +2. Register --print-config flag in main() alongside existing flags: +```go +rootCmd.Flags().BoolVar(&printConfig, "print-config", false, "Print effective config as commented TOML and exit") +``` + +3. In run(), add --print-config check as the SECOND early-exit (after listIfaces, BEFORE the mutual exclusion check). Per Pitfall 2 from research, this must come before the interface-required validation so `netsynth --print-config` works without `-i`: +```go +// --print-config mode (CFG-06, D-09/D-10) +if printConfig { + return runPrintConfig() +} +``` + +4. Add runPrintConfig function: +```go +func runPrintConfig() error { + result, err := config.Load(configPath) + if err != nil { + return err + } + output := config.PrintConfig(result) + fmt.Print(output) + return nil +} +``` + +5. Update ALL Load() call sites to use LoadResult (there is one in run()): +```go +// Load config (CFG-01 through CFG-05, D-11: fail fast before capture) +result, err := config.Load(configPath) +if err != nil { + return err +} +``` + +6. After config load, prepend user rules before creating classifier (per D-04, RULE-02). Update BOTH runLiveMode and runPcapMode. Change their signatures to accept LoadResult instead of bare map: +```go +func runLiveMode(cmd *cobra.Command, result config.LoadResult) error { + // ... + // D-04: user rules prepend before built-ins; first-match-wins + allRules := append(result.UserRules, classify.DefaultRules...) + classifier := classify.NewClassifier(allRules) + // ...use result.FreqCfgs where freqCfgs was used before... +} +``` +Do the same for runPcapMode. Update the call sites in run(): +```go +if readPath != "" { + return runPcapMode(cmd, result) +} +return runLiveMode(cmd, result) +``` + +7. In both runLiveMode and runPcapMode, replace `freqCfgs` parameter usage with `result.FreqCfgs` in the encode.RunSynthesis call: +```go +if err := encode.RunSynthesis(collectedSnapshots, outputPath, result.FreqCfgs); err != nil { +``` + +**main_test.go changes:** + +8. Update newTestCmd() to include --print-config flag and --config flag: +```go +var testPrintConfig bool +var testConfigPath string +// ...in flag registration: +rootCmd.Flags().BoolVar(&testPrintConfig, "print-config", false, "Print effective config") +rootCmd.Flags().StringVar(&testConfigPath, "config", "", "Path to TOML config file") +// ...in PersistentPreRunE: +printConfig = testPrintConfig +configPath = testConfigPath +``` + +9. Add TestPrintConfigFlagRegistered: +```go +func TestPrintConfigFlagRegistered(t *testing.T) { + rootCmd := newTestCmd() + f := rootCmd.Flags().Lookup("print-config") + if f == nil { + t.Fatal("expected --print-config flag to be registered") + } +} +``` + +10. Add TestPrintConfigNoInterface -- verifies --print-config works without -i: +```go +func TestPrintConfigNoInterface(t *testing.T) { + // Reset globals + ifaceName = "" + listIfaces = false + printConfig = false + configPath = "" + // ... reset all globals + t.Chdir(t.TempDir()) // no netsynth.toml in temp dir + + rootCmd := newTestCmd() + rootCmd.SetArgs([]string{"--print-config"}) + + var outBuf, errBuf bytes.Buffer + rootCmd.SetOut(&outBuf) + rootCmd.SetErr(&errBuf) + + err := rootCmd.Execute() + if err != nil { + t.Fatalf("--print-config should not require -i, got error: %v", err) + } +} +``` + +11. Add TestPrintConfigWithConfigFile -- verifies print-config loads and displays a user config: +```go +func TestPrintConfigWithConfigFile(t *testing.T) { + // Create temp TOML with an override + dir := t.TempDir() + tomlPath := filepath.Join(dir, "test.toml") + os.WriteFile(tomlPath, []byte("[sounds.ICMP]\nfrequency = 100.0\n"), 0644) + + // Reset globals + // ... + rootCmd := newTestCmd() + rootCmd.SetArgs([]string{"--print-config", "--config", tomlPath}) + + var outBuf, errBuf bytes.Buffer + rootCmd.SetOut(&outBuf) + rootCmd.SetErr(&errBuf) + + // Capture stdout by redirecting os.Stdout temporarily, OR + // check that no error occurred (PrintConfig writes to os.Stdout via fmt.Print) + err := rootCmd.Execute() + if err != nil { + t.Fatalf("--print-config with --config should succeed, got: %v", err) + } +} +``` + + + go test ./cmd/netsynth/... -v -count=1 && go test ./config/... -count=1 + + + - cmd/netsynth/main.go contains `var printConfig bool` + - cmd/netsynth/main.go contains `"print-config"` flag registration + - cmd/netsynth/main.go contains `if printConfig {` BEFORE the interface-required check + - cmd/netsynth/main.go contains `func runPrintConfig() error` + - cmd/netsynth/main.go contains `append(result.UserRules, classify.DefaultRules...)` + - cmd/netsynth/main.go contains `func runLiveMode(cmd *cobra.Command, result config.LoadResult)` + - cmd/netsynth/main.go contains `func runPcapMode(cmd *cobra.Command, result config.LoadResult)` + - cmd/netsynth/main_test.go contains `TestPrintConfigFlagRegistered` + - cmd/netsynth/main_test.go contains `TestPrintConfigNoInterface` + - cmd/netsynth/main_test.go contains `"print-config"` in newTestCmd() + - cmd/netsynth/main_test.go contains `"config"` flag in newTestCmd() + - `go test ./cmd/netsynth/... -count=1` exits 0 + - `go test ./config/... -count=1` exits 0 + + + --print-config flag registered, checked before interface validation (no -i required). runPrintConfig calls config.Load then config.PrintConfig, prints to stdout, exits. User rules prepended in both runLiveMode and runPcapMode via append(result.UserRules, classify.DefaultRules...). All existing and new tests pass. + + + + + Task 2: Implement PrintConfig output function with comment annotations + config/config.go, config/config_test.go + config/config.go, config/config_test.go, synth/config.go, classify/types.go, classify/rules.go + +Add the PrintConfig function to config/config.go and tests to config/config_test.go. + +**config/config.go additions:** + +1. Add an `AutoClasses` field to LoadResult to track which classes were auto-assigned (per Open Question 1 from research): +```go +type LoadResult struct { + FreqCfgs map[classify.TrafficClass]synth.FreqConfig + UserRules []classify.Rule + ConfigPath string + AutoClasses map[classify.TrafficClass]bool // classes with auto-assigned frequencies +} +``` +Update addAutoFreqEntries to populate this map. Also update Load() to initialize the map. + +2. Add the PrintConfig function. Use manual string building with fmt.Fprintf to a strings.Builder (per D-09, D-10, D-11). The function signature: +```go +func PrintConfig(result LoadResult) string +``` + +3. Output format (per Pattern 5 from research): +``` +# NetSynth effective configuration +# Config source: +# Generated: + +``` + +Then, if result.UserRules is non-empty, emit the [[rules]] section: +``` +# Classification rules (user-defined, prepended before built-in rules) +[[rules]] +port = 8080 +protocol = "tcp" +class = "MyApp" + +``` +For each user rule, emit a `[[rules]]` block. If DstPort == 0, omit the `port` line (per D-02 semantics). + +Then emit the [sounds] section. Iterate in a deterministic order: first AllClasses() (14 built-in classes in display order), then any user-defined classes sorted alphabetically. For each class: +``` + # -- Hz () + [sounds.] + frequency = + waveform = "" + +``` +Where annotation is: +- `default` -- class is in synth.ClassFreqConfigs AND FreqCfgs entry matches the default BaseHz and WaveformType +- `override` -- class is in synth.ClassFreqConfigs BUT FreqCfgs entry differs from default (user changed it) +- `auto-assigned` -- class is in result.AutoClasses + +4. Add waveformString helper to convert WaveformType back to string: +```go +func waveformString(wt synth.WaveformType) string { + switch wt { + case synth.WaveformSine: + return "sine" + case synth.WaveformSquare: + return "square" + case synth.WaveformSawtooth: + return "sawtooth" + case synth.WaveformTriangle: + return "triangle" + default: + return "custom" + } +} +``` + +5. For deterministic ordering of user-defined classes (not in AllClasses()), collect them, sort by string value, and append after built-in classes. + +**config/config_test.go additions:** + +6. TestPrintConfigContainsAllClasses: Create a LoadResult with defaults (no overrides, no user rules). Call PrintConfig. Assert output contains all 14 class names from classify.AllClasses(): "ICMP", "DNS", "HTTPS", "HTTP", "SSH", "SMTP", "NTP", "DHCP", "other-TCP", "other-UDP", "unknown-1", "unknown-2", "unknown-3", "unknown-4". + +7. TestPrintConfigSourcePath: Create LoadResult with ConfigPath="/home/user/netsynth.toml". Assert output contains `# Config source: /home/user/netsynth.toml`. + +8. TestPrintConfigNoSourcePath: Create LoadResult with ConfigPath="". Assert output contains `# Config source: none`. + +9. TestPrintConfigContainsRules: Create LoadResult with UserRules containing one rule (Protocol="tcp", DstPort=8080, Class="MyApp"). Assert output contains `[[rules]]`, `port = 8080`, `protocol = "tcp"`, `class = "MyApp"`. + +10. TestPrintConfigRuleNoPort: Create LoadResult with UserRules containing rule with DstPort=0. Assert output does NOT contain `port =` for that rule. + +11. TestPrintConfigDefaultAnnotation: Create LoadResult with defaults. Assert output contains `(default)` annotation for ICMP entry. + +12. TestPrintConfigOverrideAnnotation: Create LoadResult where ICMP has BaseHz=100.0 (differs from default 65.0). Assert output contains `(override)` for ICMP. + +13. TestPrintConfigAutoAssignedAnnotation: Create LoadResult with AutoClasses map containing "GameServer"=true. Assert output contains `(auto-assigned)` for GameServer entry. + + + go test ./config/... -v -count=1 -run "PrintConfig" && go test ./... -count=1 + + + - config/config.go contains `func PrintConfig(result LoadResult) string` + - config/config.go contains `func waveformString(wt synth.WaveformType) string` + - config/config.go LoadResult struct contains `AutoClasses map[classify.TrafficClass]bool` + - config/config.go PrintConfig output contains `# NetSynth effective configuration` + - config/config.go PrintConfig output contains `# Config source:` + - config/config_test.go contains `TestPrintConfigContainsAllClasses` + - config/config_test.go contains `TestPrintConfigSourcePath` + - config/config_test.go contains `TestPrintConfigContainsRules` + - config/config_test.go contains `TestPrintConfigDefaultAnnotation` + - config/config_test.go contains `TestPrintConfigOverrideAnnotation` + - config/config_test.go contains `TestPrintConfigAutoAssignedAnnotation` + - `go test ./... -count=1` exits 0 (full suite green) + + + PrintConfig produces commented TOML output with: header (source path, date), [[rules]] section for user rules (port omitted when 0), [sounds.*] section for all classes in deterministic order. Each sound entry annotated as (default), (override), or (auto-assigned). Full test suite green including all existing tests. + + + + + + +- `go test ./... -count=1` -- full suite green +- `go vet ./...` -- no issues +- `netsynth --print-config` outputs commented TOML to stdout (manual check) +- `netsynth --print-config --config ` shows overrides annotated as such + + + +- --print-config flag works without -i, outputs to stdout, exits without capture +- User rules prepended before DefaultRules in both live and pcap modes +- PrintConfig output contains all 14 built-in classes plus any user-defined classes +- Comment annotations correctly distinguish default / override / auto-assigned +- Source path shown in header when config loaded +- [[rules]] section present in output when user rules exist +- Full go test suite passes + + + +After completion, create `.planning/phases/07-custom-rules-and-print-config/07-02-SUMMARY.md` +