From b52e36b57936b5c0c23fa782c5ddf4059e89481c Mon Sep 17 00:00:00 2001 From: Markus Graf Date: Thu, 26 Mar 2026 21:51:27 +0100 Subject: [PATCH] feat(07-02): implement PrintConfig function with comment annotations - Add AutoClasses map[TrafficClass]bool to LoadResult for tracking auto-assigned classes - PrintConfig() returns commented TOML with header (source, date), [[rules]] section, [sounds.*] section - waveformString() helper converts WaveformType back to string - classAnnotation() returns default/override/auto-assigned per class - Built-in classes emitted in AllClasses() order; user-defined classes sorted alphabetically - Rules section emits [[rules]] blocks; port omitted when DstPort==0 - Tests: TestPrintConfigContainsAllClasses, TestPrintConfigSourcePath, TestPrintConfigNoSourcePath, TestPrintConfigContainsRules, TestPrintConfigRuleNoPort, TestPrintConfigDefaultAnnotation, TestPrintConfigOverrideAnnotation, TestPrintConfigAutoAssignedAnnotation --- config/config.go | 139 ++++++++++++++++++++++++++++++++--- config/config_test.go | 166 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 295 insertions(+), 10 deletions(-) diff --git a/config/config.go b/config/config.go index 5f6b800..a9734ae 100644 --- a/config/config.go +++ b/config/config.go @@ -9,7 +9,9 @@ import ( "io/fs" "os" "path/filepath" + "sort" "strings" + "time" "github.com/BurntSushi/toml" @@ -40,11 +42,13 @@ type rawConfig struct { // 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). +// the resolved config file path (empty string if no config was found), +// and the set of classes that were auto-assigned frequencies. type LoadResult struct { - FreqCfgs map[classify.TrafficClass]synth.FreqConfig - UserRules []classify.Rule - ConfigPath string + FreqCfgs map[classify.TrafficClass]synth.FreqConfig + UserRules []classify.Rule + ConfigPath string + AutoClasses map[classify.TrafficClass]bool // classes with auto-assigned frequencies } // validWaveforms maps TOML waveform strings to WaveformType constants. @@ -69,7 +73,12 @@ func Load(configPath string) (LoadResult, error) { } if path == "" { // No config found during auto-discovery — use defaults silently (CFG-02) - return LoadResult{FreqCfgs: copyDefaults(), UserRules: []classify.Rule{}, ConfigPath: ""}, nil + return LoadResult{ + FreqCfgs: copyDefaults(), + UserRules: []classify.Rule{}, + ConfigPath: "", + AutoClasses: map[classify.TrafficClass]bool{}, + }, nil } raw, err := parseFile(path) @@ -86,14 +95,16 @@ func Load(configPath string) (LoadResult, error) { userRules := convertRules(raw.Rules) freqCfgs := copyDefaults() + autoClasses := map[classify.TrafficClass]bool{} // Add auto-freq entries BEFORE merge so that [sounds.X] overrides for user classes apply. - addAutoFreqEntries(freqCfgs, userRules) + addAutoFreqEntries(freqCfgs, userRules, autoClasses) merge(freqCfgs, raw.Sounds) return LoadResult{ - FreqCfgs: freqCfgs, - UserRules: userRules, - ConfigPath: path, + FreqCfgs: freqCfgs, + UserRules: userRules, + ConfigPath: path, + AutoClasses: autoClasses, }, nil } @@ -210,7 +221,8 @@ func autoAssignFreq(className string) float64 { // 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) { +// autoClasses is populated with the class names that were auto-assigned. +func addAutoFreqEntries(cfgs map[classify.TrafficClass]synth.FreqConfig, userRules []classify.Rule, autoClasses map[classify.TrafficClass]bool) { for _, rule := range userRules { if _, exists := cfgs[rule.Class]; !exists { baseHz := autoAssignFreq(string(rule.Class)) @@ -220,6 +232,7 @@ func addAutoFreqEntries(cfgs map[classify.TrafficClass]synth.FreqConfig, userRul Harmonics: synth.WaveformPresetHarmonics(synth.WaveformSine, baseHz, synth.SampleRate), Pan: 0.0, } + autoClasses[rule.Class] = true } } } @@ -233,6 +246,112 @@ func parseWaveform(s string) (synth.WaveformType, error) { return 0, fmt.Errorf("config: invalid waveform %q — valid values: %s", s, strings.Join(valid, ", ")) } +// waveformString converts a WaveformType back to its TOML string representation. +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" + } +} + +// PrintConfig returns the effective configuration as commented TOML output. +// The output includes a header with source path and generation date, an optional +// [[rules]] section for user-defined rules, and a [sounds.*] section for all +// traffic classes in deterministic order (built-ins first, then user-defined sorted). +// Each sound entry is annotated as (default), (override), or (auto-assigned). +func PrintConfig(result LoadResult) string { + var sb strings.Builder + + // Header + fmt.Fprintf(&sb, "# NetSynth effective configuration\n") + if result.ConfigPath != "" { + fmt.Fprintf(&sb, "# Config source: %s\n", result.ConfigPath) + } else { + fmt.Fprintf(&sb, "# Config source: none (using defaults)\n") + } + fmt.Fprintf(&sb, "# Generated: %s\n", time.Now().UTC().Format("2006-01-02T15:04:05Z")) + fmt.Fprintf(&sb, "\n") + + // [[rules]] section (if any user rules exist) + if len(result.UserRules) > 0 { + fmt.Fprintf(&sb, "# Classification rules (user-defined, prepended before built-in rules)\n") + for _, rule := range result.UserRules { + fmt.Fprintf(&sb, "[[rules]]\n") + if rule.DstPort != 0 { + fmt.Fprintf(&sb, "port = %d\n", rule.DstPort) + } + fmt.Fprintf(&sb, "protocol = %q\n", rule.Protocol) + fmt.Fprintf(&sb, "class = %q\n", string(rule.Class)) + fmt.Fprintf(&sb, "\n") + } + } + + // [sounds.*] section — built-in classes first, then user-defined sorted alphabetically + builtinSet := map[classify.TrafficClass]bool{} + for _, cls := range classify.AllClasses() { + builtinSet[cls] = true + } + + // Collect user-defined classes (in FreqCfgs but not in AllClasses) + var userClasses []string + for cls := range result.FreqCfgs { + if !builtinSet[cls] { + userClasses = append(userClasses, string(cls)) + } + } + sort.Strings(userClasses) + + // Emit built-in classes first + for _, cls := range classify.AllClasses() { + cfg := result.FreqCfgs[cls] + annotation := classAnnotation(cls, cfg, result.AutoClasses) + fmt.Fprintf(&sb, "# %s -- %.1f Hz (%s)\n", string(cls), cfg.BaseHz, annotation) + fmt.Fprintf(&sb, "[sounds.%s]\n", string(cls)) + fmt.Fprintf(&sb, "frequency = %.1f\n", cfg.BaseHz) + fmt.Fprintf(&sb, "waveform = %q\n", waveformString(cfg.WaveformType)) + fmt.Fprintf(&sb, "\n") + } + + // Emit user-defined classes sorted alphabetically + for _, clsStr := range userClasses { + cls := classify.TrafficClass(clsStr) + cfg := result.FreqCfgs[cls] + annotation := classAnnotation(cls, cfg, result.AutoClasses) + fmt.Fprintf(&sb, "# %s -- %.1f Hz (%s)\n", clsStr, cfg.BaseHz, annotation) + fmt.Fprintf(&sb, "[sounds.%s]\n", clsStr) + fmt.Fprintf(&sb, "frequency = %.1f\n", cfg.BaseHz) + fmt.Fprintf(&sb, "waveform = %q\n", waveformString(cfg.WaveformType)) + fmt.Fprintf(&sb, "\n") + } + + return sb.String() +} + +// classAnnotation returns the annotation string for a traffic class entry. +// Returns "default", "override", or "auto-assigned". +func classAnnotation(cls classify.TrafficClass, cfg synth.FreqConfig, autoClasses map[classify.TrafficClass]bool) string { + if autoClasses[cls] { + return "auto-assigned" + } + defaultCfg, isBuiltin := synth.ClassFreqConfigs[cls] + if !isBuiltin { + // User-defined class that was manually specified in [sounds.*] (not auto-assigned) + return "override" + } + if cfg.BaseHz == defaultCfg.BaseHz && cfg.WaveformType == defaultCfg.WaveformType { + return "default" + } + return "override" +} + // 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. diff --git a/config/config_test.go b/config/config_test.go index a2a4330..24e7131 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -392,6 +392,172 @@ class = "MyDeterministicClass" } } +// --- PrintConfig tests --- + +// TestPrintConfigContainsAllClasses: defaults LoadResult produces output with all 14 class names. +func TestPrintConfigContainsAllClasses(t *testing.T) { + t.Chdir(t.TempDir()) + result, err := config.Load("") + if err != nil { + t.Fatalf("Load: %v", err) + } + output := config.PrintConfig(result) + classNames := []string{ + "ICMP", "DNS", "HTTPS", "HTTP", "SSH", "SMTP", + "NTP", "DHCP", "other-TCP", "other-UDP", + "unknown-1", "unknown-2", "unknown-3", "unknown-4", + } + for _, name := range classNames { + if !strings.Contains(output, name) { + t.Errorf("PrintConfig output missing class %q", name) + } + } +} + +// TestPrintConfigSourcePath: LoadResult with ConfigPath set shows the path in header. +func TestPrintConfigSourcePath(t *testing.T) { + path := writeTOML(t, "[sounds.ICMP]\nfrequency = 100.0\n") + result, err := config.Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + output := config.PrintConfig(result) + if !strings.Contains(output, "# Config source: "+path) { + t.Errorf("expected output to contain '# Config source: %s', got:\n%s", path, output) + } +} + +// TestPrintConfigNoSourcePath: LoadResult with no ConfigPath shows "none" in header. +func TestPrintConfigNoSourcePath(t *testing.T) { + t.Chdir(t.TempDir()) + result, err := config.Load("") + if err != nil { + t.Fatalf("Load: %v", err) + } + output := config.PrintConfig(result) + if !strings.Contains(output, "# Config source: none") { + t.Errorf("expected output to contain '# Config source: none', got:\n%s", output) + } +} + +// TestPrintConfigContainsRules: LoadResult with user rules emits [[rules]] section. +func TestPrintConfigContainsRules(t *testing.T) { + tomlContent := ` +[[rules]] +port = 8080 +protocol = "tcp" +class = "MyApp" +` + path := writeTOML(t, tomlContent) + result, err := config.Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + output := config.PrintConfig(result) + if !strings.Contains(output, "[[rules]]") { + t.Errorf("expected output to contain '[[rules]]', got:\n%s", output) + } + if !strings.Contains(output, "port = 8080") { + t.Errorf("expected output to contain 'port = 8080', got:\n%s", output) + } + if !strings.Contains(output, `protocol = "tcp"`) { + t.Errorf("expected output to contain 'protocol = \"tcp\"', got:\n%s", output) + } + if !strings.Contains(output, `class = "MyApp"`) { + t.Errorf("expected output to contain 'class = \"MyApp\"', got:\n%s", output) + } +} + +// TestPrintConfigRuleNoPort: rule with DstPort==0 omits port line in output. +func TestPrintConfigRuleNoPort(t *testing.T) { + tomlContent := ` +[[rules]] +protocol = "udp" +class = "AllUDP" +` + path := writeTOML(t, tomlContent) + result, err := config.Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + output := config.PrintConfig(result) + // Find the [[rules]] block and check no port line follows before the next blank line + rulesIdx := strings.Index(output, "[[rules]]") + if rulesIdx < 0 { + t.Fatal("expected [[rules]] in output") + } + rulesSection := output[rulesIdx:] + // Extract until the next blank line after [[rules]] + lines := strings.Split(rulesSection, "\n") + for _, line := range lines { + if strings.HasPrefix(strings.TrimSpace(line), "port =") { + t.Errorf("expected no 'port =' line for rule with DstPort=0, got line: %q", line) + } + if line == "" { + break // end of this rule block + } + } +} + +// TestPrintConfigDefaultAnnotation: default ICMP entry annotated as (default). +func TestPrintConfigDefaultAnnotation(t *testing.T) { + t.Chdir(t.TempDir()) + result, err := config.Load("") + if err != nil { + t.Fatalf("Load: %v", err) + } + output := config.PrintConfig(result) + if !strings.Contains(output, "(default)") { + t.Errorf("expected '(default)' annotation in output, got:\n%s", output) + } + // Specifically check ICMP line + if !strings.Contains(output, "ICMP") { + t.Errorf("expected ICMP in output") + } +} + +// TestPrintConfigOverrideAnnotation: ICMP with changed BaseHz annotated as (override). +func TestPrintConfigOverrideAnnotation(t *testing.T) { + path := writeTOML(t, "[sounds.ICMP]\nfrequency = 100.0\n") + result, err := config.Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + output := config.PrintConfig(result) + // Find the ICMP comment line and check annotation + lines := strings.Split(output, "\n") + for _, line := range lines { + if strings.Contains(line, "# ICMP") { + if !strings.Contains(line, "(override)") { + t.Errorf("expected ICMP comment to contain '(override)', got: %q", line) + } + return + } + } + t.Error("did not find ICMP comment line in output") +} + +// TestPrintConfigAutoAssignedAnnotation: user-defined class gets (auto-assigned) annotation. +func TestPrintConfigAutoAssignedAnnotation(t *testing.T) { + tomlContent := ` +[[rules]] +protocol = "tcp" +class = "GameServer" +` + path := writeTOML(t, tomlContent) + result, err := config.Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + output := config.PrintConfig(result) + if !strings.Contains(output, "(auto-assigned)") { + t.Errorf("expected '(auto-assigned)' annotation in output, got:\n%s", output) + } + if !strings.Contains(output, "GameServer") { + t.Errorf("expected GameServer in output") + } +} + // TestAutoFreqSkipsBuiltins: TOML with [[rules]] (class="HTTPS", protocol="tcp", port=443) // -> FreqCfgs["HTTPS"].BaseHz == 175.0 (the default), NOT an auto-assigned value. func TestAutoFreqSkipsBuiltins(t *testing.T) {