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
+
+
+
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
+
+
+