// Package config loads, validates, and merges a TOML override file over the // default synth.ClassFreqConfigs map. The single public entry point is Load. package config import ( "errors" "fmt" "hash/fnv" "io/fs" "os" "path/filepath" "sort" "strings" "time" "github.com/BurntSushi/toml" "github.com/netsynth/netsynth/classify" "github.com/netsynth/netsynth/synth" ) // SoundOverride holds optional per-class sound parameters decoded from TOML. // Pointer fields: nil = not set by user (keep default), non-nil = user override. type SoundOverride struct { Frequency *float64 `toml:"frequency"` Waveform *string `toml:"waveform"` } // RawRule holds a user-defined classification rule as decoded from TOML. // Port is a pointer so we can distinguish "not set" (nil, matches any port) from port=0. type RawRule struct { Port *uint16 `toml:"port"` Protocol string `toml:"protocol"` Class string `toml:"class"` } // rawConfig is the top-level TOML decode target. type rawConfig struct { Sounds map[string]SoundOverride `toml:"sounds"` Rules []RawRule `toml:"rules"` Groups map[string]string `toml:"groups"` } // 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 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 AutoClasses map[classify.TrafficClass]bool // classes with auto-assigned frequencies } // validWaveforms maps TOML waveform strings to WaveformType constants. var validWaveforms = map[string]synth.WaveformType{ "sine": synth.WaveformSine, "square": synth.WaveformSquare, "sawtooth": synth.WaveformSawtooth, "triangle": synth.WaveformTriangle, } // Load finds, parses, validates, and merges a TOML config file. // // configPath is the --config flag value; empty string triggers auto-discovery. // Returns a LoadResult with the merged FreqConfig map, user-defined rules, and resolved path. // Returns an error on: explicit file not found, parse errors, unknown keys, // type mismatches, invalid waveform values, or invalid rule definitions. // Returns no error (uses defaults) when no config is found during auto-discovery. func Load(configPath string) (LoadResult, error) { path, explicit, err := resolvePath(configPath) if err != nil { return LoadResult{}, err } if path == "" { // No config found during auto-discovery — use defaults silently (CFG-02) return LoadResult{ FreqCfgs: copyDefaults(), UserRules: []classify.Rule{}, ConfigPath: "", AutoClasses: map[classify.TrafficClass]bool{}, }, nil } raw, err := parseFile(path) if err != nil { if explicit && errors.Is(err, fs.ErrNotExist) { return LoadResult{}, fmt.Errorf("config file not found: %s", configPath) } return LoadResult{}, err } if err := validate(raw); err != nil { return LoadResult{}, err } 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, autoClasses) merge(freqCfgs, raw.Sounds) applyGroupOverrides(freqCfgs, raw.Groups) return LoadResult{ FreqCfgs: freqCfgs, UserRules: userRules, ConfigPath: path, AutoClasses: autoClasses, }, nil } // resolvePath resolves the config path from an explicit flag value or auto-discovery. // Returns (path, explicit, error) where explicit=true means the user specified a path. func resolvePath(configPath string) (string, bool, error) { if configPath != "" { return configPath, true, nil } return discoverPath(), false, nil } // discoverPath probes the standard discovery locations in precedence order. // Returns the first existing config path, or "" if none found. // Discovery order: ./netsynth.toml > ~/.config/netsynth/config.toml func discoverPath() string { // 1. Working directory if _, err := os.Stat("netsynth.toml"); err == nil { return "netsynth.toml" } // 2. XDG config dir (~/.config/netsynth/config.toml or $XDG_CONFIG_HOME/netsynth/config.toml) dir, err := os.UserConfigDir() if err != nil { return "" } p := filepath.Join(dir, "netsynth", "config.toml") if _, err := os.Stat(p); err == nil { return p } return "" } // parseFile decodes the TOML file at path and checks for unknown keys via Undecoded(). // Returns fs.ErrNotExist-wrapped error when the file does not exist. func parseFile(path string) (rawConfig, error) { var raw rawConfig md, err := toml.DecodeFile(path, &raw) if err != nil { // Preserve the fs.ErrNotExist sentinel so Load can distinguish explicit vs discovered. if errors.Is(err, fs.ErrNotExist) { return raw, err } return raw, fmt.Errorf("config parse error: %w", err) } // Detect field-level typos within [sounds.] blocks (CFG-05). // Note: class-name typos in [sounds.] are NOT caught here because all // map keys are valid decode targets. Class validation happens in merge (D-09). if undecoded := md.Undecoded(); len(undecoded) > 0 { keyPath := strings.Join(undecoded[0], ".") return raw, fmt.Errorf("config: unknown key %q — check spelling", keyPath) } return raw, nil } // validate checks waveform strings and rules before merge so we fail fast at startup (D-11). func validate(raw rawConfig) error { for _, override := range raw.Sounds { if override.Waveform != nil { if _, err := parseWaveform(*override.Waveform); err != nil { return err } } } return validateRules(raw.Rules) } // validateRules checks that each rule has a valid protocol and a non-empty class. 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 } // convertRules converts a slice of RawRule (from TOML) into classify.Rule slice. 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 } // autoAssignFreq computes a deterministic frequency in [2500, 4000] Hz for a class name // using FNV-32a hashing. Same input always produces the same output. func autoAssignFreq(className string) float64 { h := fnv.New32a() h.Write([]byte(className)) const ( baseHz = 2500.0 stepHz = 50.0 numSteps = uint32(31) // [2500, 4000] Hz in 50 Hz steps ) return baseHz + float64(h.Sum32()%numSteps)*stepHz } // 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. // 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)) cfgs[rule.Class] = synth.FreqConfig{ BaseHz: baseHz, WaveformType: synth.WaveformSine, Harmonics: synth.WaveformPresetHarmonics(synth.WaveformSine, baseHz, synth.SampleRate), Pan: 0.0, } autoClasses[rule.Class] = true } } } // parseWaveform converts a TOML waveform string to a WaveformType. func parseWaveform(s string) (synth.WaveformType, error) { if wt, ok := validWaveforms[s]; ok { return wt, nil } valid := []string{"sine", "square", "sawtooth", "triangle"} 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" } } // applyGroupOverrides overlays [groups] reassignments onto freqCfgs.Group in-place. // Unknown class names produce a warning to stderr (D-09). // Unknown group names are silently accepted -- users can invent custom groups (D-09). func applyGroupOverrides(cfgs map[classify.TrafficClass]synth.FreqConfig, groups map[string]string) { for className, groupName := range groups { cls := classify.TrafficClass(className) cfg, known := cfgs[cls] if !known { fmt.Fprintf(os.Stderr, "Warning: config: [groups]: unknown class %q (ignored)\n", className) continue } cfg.Group = groupName cfgs[cls] = cfg } } // groupOrder defines the canonical display order for --print-config section headers (D-04). var groupOrder = []string{ "Infrastructure", "Web", "Mail", "Remote Access", "File Transfer", "Database", "Discovery", "VoIP", "Unknown", } // 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 grouped by family, then user-defined // Build group -> []TrafficClass index from AllClasses() builtinByGroup := map[string][]classify.TrafficClass{} builtinSet := map[classify.TrafficClass]bool{} for _, cls := range classify.AllClasses() { builtinSet[cls] = true cfg := result.FreqCfgs[cls] grp := cfg.Group builtinByGroup[grp] = append(builtinByGroup[grp], cls) } // Sort each group by ascending BaseHz (D-05) using result.FreqCfgs (effective Hz, not defaults) for grp := range builtinByGroup { classes := builtinByGroup[grp] sort.Slice(classes, func(i, j int) bool { return result.FreqCfgs[classes[i]].BaseHz < result.FreqCfgs[classes[j]].BaseHz }) } // Emit built-in classes grouped with headers (D-04) for _, grp := range groupOrder { classes, ok := builtinByGroup[grp] if !ok || len(classes) == 0 { continue } fmt.Fprintf(&sb, "# %s\n\n", grp) for _, cls := range classes { 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") } } // Check for custom groups (from [groups] reassignment) that are not in groupOrder. // For non-canonical group names (user-invented), collect and emit separately. customGroups := map[string][]classify.TrafficClass{} for _, cls := range classify.AllClasses() { cfg := result.FreqCfgs[cls] grp := cfg.Group isCanonical := false for _, cg := range groupOrder { if grp == cg { isCanonical = true break } } if !isCanonical { customGroups[grp] = append(customGroups[grp], cls) } } // Sort and emit custom group sections var customGroupNames []string for grp := range customGroups { customGroupNames = append(customGroupNames, grp) } sort.Strings(customGroupNames) for _, grp := range customGroupNames { classes := customGroups[grp] sort.Slice(classes, func(i, j int) bool { return result.FreqCfgs[classes[i]].BaseHz < result.FreqCfgs[classes[j]].BaseHz }) fmt.Fprintf(&sb, "# %s\n\n", grp) for _, cls := range classes { 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 (in FreqCfgs but not in AllClasses) under "# User-defined" (D-06) var userClasses []string for cls := range result.FreqCfgs { if !builtinSet[cls] { userClasses = append(userClasses, string(cls)) } } sort.Strings(userClasses) if len(userClasses) > 0 { fmt.Fprintf(&sb, "# User-defined\n\n") 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. func copyDefaults() map[classify.TrafficClass]synth.FreqConfig { result := make(map[classify.TrafficClass]synth.FreqConfig, len(synth.ClassFreqConfigs)) for k, v := range synth.ClassFreqConfigs { result[k] = v } return result } // merge overlays per-class overrides onto the defaults map in-place. // Only non-nil pointer fields in each SoundOverride are applied. func merge( defaults map[classify.TrafficClass]synth.FreqConfig, overrides map[string]SoundOverride, ) map[classify.TrafficClass]synth.FreqConfig { for className, override := range overrides { class := classify.TrafficClass(className) cfg, known := defaults[class] if !known { // D-09: unknown class name = warning (not error), in case Phase 7 defines it fmt.Fprintf(os.Stderr, "Warning: config: unknown class %q (ignored)\n", className) continue } if override.Frequency != nil { cfg.BaseHz = *override.Frequency // Regenerate harmonics when a waveform preset is active (Pitfall 3) if cfg.WaveformType != synth.WaveformCustom { cfg.Harmonics = synth.WaveformPresetHarmonics(cfg.WaveformType, cfg.BaseHz, synth.SampleRate) } } if override.Waveform != nil { wt, _ := parseWaveform(*override.Waveform) // already validated above cfg.WaveformType = wt cfg.Harmonics = synth.WaveformPresetHarmonics(wt, cfg.BaseHz, synth.SampleRate) } defaults[class] = cfg } return defaults }