diff --git a/cmd/netsynth/main.go b/cmd/netsynth/main.go index 341b439..d67c1af 100644 --- a/cmd/netsynth/main.go +++ b/cmd/netsynth/main.go @@ -18,17 +18,17 @@ import ( "github.com/netsynth/netsynth/classify" "github.com/netsynth/netsynth/config" "github.com/netsynth/netsynth/encode" - "github.com/netsynth/netsynth/synth" ) var ( - ifaceName string - listIfaces bool - verbose bool - outputPath string - bpfFilter string // NEW: --filter flag (CAPT-05) - readPath string // NEW: --read flag (CAPT-06) - configPath string // NEW: --config flag (CFG-03) + ifaceName string + listIfaces bool + verbose bool + outputPath string + bpfFilter string // NEW: --filter flag (CAPT-05) + readPath string // NEW: --read flag (CAPT-06) + configPath string // NEW: --config flag (CFG-03) + printConfig bool // NEW: --print-config flag (CFG-06) ) func main() { @@ -46,6 +46,7 @@ func main() { rootCmd.Flags().StringVar(&bpfFilter, "filter", "", "BPF filter expression (tcpdump syntax, e.g. \"port 53\")") rootCmd.Flags().StringVar(&readPath, "read", "", "Read packets from pcap file instead of live capture") rootCmd.Flags().StringVar(&configPath, "config", "", "Path to TOML config file (default: auto-discover)") + rootCmd.Flags().BoolVar(&printConfig, "print-config", false, "Print effective config as commented TOML and exit") if err := rootCmd.Execute(); err != nil { os.Exit(1) @@ -68,6 +69,11 @@ func run(cmd *cobra.Command, args []string) error { return runListInterfaces() } + // --print-config mode (CFG-06, D-09/D-10): must come before interface-required check + if printConfig { + return runPrintConfig() + } + // D-03: --read and -i are mutually exclusive if readPath != "" && ifaceName != "" { return fmt.Errorf("--read and -i are mutually exclusive; use one or the other") @@ -84,11 +90,10 @@ func run(cmd *cobra.Command, args []string) error { } // Load config (CFG-01 through CFG-05, D-11: fail fast before capture) - loadResult, err := config.Load(configPath) + result, err := config.Load(configPath) if err != nil { return err } - freqCfgs := loadResult.FreqCfgs // Resolve output path if outputPath == "" { @@ -100,13 +105,24 @@ func run(cmd *cobra.Command, args []string) error { } if readPath != "" { - return runPcapMode(cmd, freqCfgs) + return runPcapMode(cmd, result) } - return runLiveMode(cmd, freqCfgs) + return runLiveMode(cmd, result) +} + +// runPrintConfig loads the config and prints the effective configuration as commented TOML. +func runPrintConfig() error { + result, err := config.Load(configPath) + if err != nil { + return err + } + output := config.PrintConfig(result) + fmt.Print(output) + return nil } // runLiveMode runs the live packet capture pipeline. -func runLiveMode(cmd *cobra.Command, freqCfgs map[classify.TrafficClass]synth.FreqConfig) error { +func runLiveMode(cmd *cobra.Command, result config.LoadResult) error { // Set up signal handling (Ctrl+C) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() @@ -119,7 +135,9 @@ func runLiveMode(cmd *cobra.Command, freqCfgs map[classify.TrafficClass]synth.Fr } // Stage 2: Classify (CLAS-01) - classifier := classify.NewClassifier(classify.DefaultRules) + // D-04: user rules prepend before built-ins; first-match-wins (RULE-02) + allRules := append(result.UserRules, classify.DefaultRules...) + classifier := classify.NewClassifier(allRules) classified := make(chan classify.ClassifiedPacket, 1024) go func() { defer close(classified) @@ -155,7 +173,7 @@ func runLiveMode(cmd *cobra.Command, freqCfgs map[classify.TrafficClass]synth.Fr // D-07: Encoding status line fmt.Fprintf(os.Stderr, "Encoding %d windows to %s...\n", len(collectedSnapshots), outputPath) encodeStart := time.Now() - if err := encode.RunSynthesis(collectedSnapshots, outputPath, freqCfgs); err != nil { + if err := encode.RunSynthesis(collectedSnapshots, outputPath, result.FreqCfgs); err != nil { return fmt.Errorf("synthesis failed: %w", err) } encodeElapsed := time.Since(encodeStart) @@ -172,7 +190,7 @@ func runLiveMode(cmd *cobra.Command, freqCfgs map[classify.TrafficClass]synth.Fr } // runPcapMode runs the pcap file processing pipeline (CAPT-06). -func runPcapMode(cmd *cobra.Command, freqCfgs map[classify.TrafficClass]synth.FreqConfig) error { +func runPcapMode(cmd *cobra.Command, result config.LoadResult) error { // D-06: bookend start message fmt.Fprintf(os.Stderr, "Reading %s...\n", readPath) @@ -183,7 +201,9 @@ func runPcapMode(cmd *cobra.Command, freqCfgs map[classify.TrafficClass]synth.Fr } // Classify packets (reuse same classifier) - classifier := classify.NewClassifier(classify.DefaultRules) + // D-04: user rules prepend before built-ins; first-match-wins (RULE-02) + allRules := append(result.UserRules, classify.DefaultRules...) + classifier := classify.NewClassifier(allRules) classified := make(chan classify.ClassifiedPacket, 1024) go func() { defer close(classified) @@ -223,7 +243,7 @@ func runPcapMode(cmd *cobra.Command, freqCfgs map[classify.TrafficClass]synth.Fr // Encode fmt.Fprintf(os.Stderr, "Encoding %d windows to %s...\n", len(collectedSnapshots), outputPath) encodeStart := time.Now() - if err := encode.RunSynthesis(collectedSnapshots, outputPath, freqCfgs); err != nil { + if err := encode.RunSynthesis(collectedSnapshots, outputPath, result.FreqCfgs); err != nil { return fmt.Errorf("synthesis failed: %w", err) } encodeElapsed := time.Since(encodeStart) diff --git a/cmd/netsynth/main_test.go b/cmd/netsynth/main_test.go index 6fde72a..8012a77 100644 --- a/cmd/netsynth/main_test.go +++ b/cmd/netsynth/main_test.go @@ -2,6 +2,8 @@ package main import ( "bytes" + "os" + "path/filepath" "strings" "testing" @@ -16,6 +18,8 @@ func newTestCmd() *cobra.Command { var testFilter string var testRead string var testOutput string + var testPrintConfig bool + var testConfigPath string rootCmd := &cobra.Command{ Use: "netsynth", @@ -29,6 +33,8 @@ func newTestCmd() *cobra.Command { rootCmd.Flags().StringVarP(&testOutput, "output", "o", "", "Output MP3 file path") rootCmd.Flags().StringVar(&testFilter, "filter", "", "BPF filter expression") rootCmd.Flags().StringVar(&testRead, "read", "", "Read packets from pcap file instead of live capture") + rootCmd.Flags().BoolVar(&testPrintConfig, "print-config", false, "Print effective config") + rootCmd.Flags().StringVar(&testConfigPath, "config", "", "Path to TOML config file") // Wire test variables to package-level vars used by run() rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { @@ -38,6 +44,8 @@ func newTestCmd() *cobra.Command { outputPath = testOutput bpfFilter = testFilter readPath = testRead + printConfig = testPrintConfig + configPath = testConfigPath return nil } @@ -320,3 +328,72 @@ func TestHelpOutputNewFlags(t *testing.T) { t.Errorf("expected help/usage output to contain '--read', usage: %s", usageStr) } } + +// TestPrintConfigFlagRegistered verifies --print-config flag is registered. +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") + } +} + +// TestPrintConfigNoInterface verifies --print-config works without -i flag. +func TestPrintConfigNoInterface(t *testing.T) { + // Reset globals + ifaceName = "" + listIfaces = false + verbose = false + bpfFilter = "" + readPath = "" + outputPath = "" + printConfig = false + configPath = "" + + // Use a temp dir with no netsynth.toml so no config is auto-discovered + t.Chdir(t.TempDir()) + + 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) + } +} + +// TestPrintConfigWithConfigFile verifies --print-config loads and displays a user config. +func TestPrintConfigWithConfigFile(t *testing.T) { + // Create temp TOML with an override + dir := t.TempDir() + tomlPath := filepath.Join(dir, "test.toml") + if err := os.WriteFile(tomlPath, []byte("[sounds.ICMP]\nfrequency = 100.0\n"), 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + // Reset globals + ifaceName = "" + listIfaces = false + verbose = false + bpfFilter = "" + readPath = "" + outputPath = "" + printConfig = false + configPath = "" + + rootCmd := newTestCmd() + rootCmd.SetArgs([]string{"--print-config", "--config", tomlPath}) + + var outBuf, errBuf bytes.Buffer + rootCmd.SetOut(&outBuf) + rootCmd.SetErr(&errBuf) + + err := rootCmd.Execute() + if err != nil { + t.Fatalf("--print-config with --config should succeed, got: %v", err) + } +}