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
This commit is contained in:
2026-03-26 21:51:27 +01:00
parent d43914f869
commit b52e36b579
2 changed files with 295 additions and 10 deletions
+129 -10
View File
@@ -9,7 +9,9 @@ import (
"io/fs" "io/fs"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"time"
"github.com/BurntSushi/toml" "github.com/BurntSushi/toml"
@@ -40,11 +42,13 @@ type rawConfig struct {
// LoadResult is the return type from Load(). It carries the merged FreqConfig map, // 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), // 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 { type LoadResult struct {
FreqCfgs map[classify.TrafficClass]synth.FreqConfig FreqCfgs map[classify.TrafficClass]synth.FreqConfig
UserRules []classify.Rule UserRules []classify.Rule
ConfigPath string ConfigPath string
AutoClasses map[classify.TrafficClass]bool // classes with auto-assigned frequencies
} }
// validWaveforms maps TOML waveform strings to WaveformType constants. // validWaveforms maps TOML waveform strings to WaveformType constants.
@@ -69,7 +73,12 @@ func Load(configPath string) (LoadResult, error) {
} }
if path == "" { if path == "" {
// No config found during auto-discovery — use defaults silently (CFG-02) // 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) raw, err := parseFile(path)
@@ -86,14 +95,16 @@ func Load(configPath string) (LoadResult, error) {
userRules := convertRules(raw.Rules) userRules := convertRules(raw.Rules)
freqCfgs := copyDefaults() freqCfgs := copyDefaults()
autoClasses := map[classify.TrafficClass]bool{}
// Add auto-freq entries BEFORE merge so that [sounds.X] overrides for user classes apply. // 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) merge(freqCfgs, raw.Sounds)
return LoadResult{ return LoadResult{
FreqCfgs: freqCfgs, FreqCfgs: freqCfgs,
UserRules: userRules, UserRules: userRules,
ConfigPath: path, ConfigPath: path,
AutoClasses: autoClasses,
}, nil }, nil
} }
@@ -210,7 +221,8 @@ func autoAssignFreq(className string) float64 {
// addAutoFreqEntries adds a FreqConfig entry for each user-defined class that doesn't // 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. // 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. // 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 { for _, rule := range userRules {
if _, exists := cfgs[rule.Class]; !exists { if _, exists := cfgs[rule.Class]; !exists {
baseHz := autoAssignFreq(string(rule.Class)) 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), Harmonics: synth.WaveformPresetHarmonics(synth.WaveformSine, baseHz, synth.SampleRate),
Pan: 0.0, 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, ", ")) 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. // copyDefaults returns a shallow copy of synth.ClassFreqConfigs.
// Shallow copy is safe because merge assigns fresh Harmonics slices from // Shallow copy is safe because merge assigns fresh Harmonics slices from
// WaveformPresetHarmonics, never mutating the original default slice. // WaveformPresetHarmonics, never mutating the original default slice.
+166
View File
@@ -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) // TestAutoFreqSkipsBuiltins: TOML with [[rules]] (class="HTTPS", protocol="tcp", port=443)
// -> FreqCfgs["HTTPS"].BaseHz == 175.0 (the default), NOT an auto-assigned value. // -> FreqCfgs["HTTPS"].BaseHz == 175.0 (the default), NOT an auto-assigned value.
func TestAutoFreqSkipsBuiltins(t *testing.T) { func TestAutoFreqSkipsBuiltins(t *testing.T) {