From 87e366e36c94264073c12e6d5151ecd20b58b415 Mon Sep 17 00:00:00 2001 From: Konrad Neitzel Date: Wed, 7 Oct 2026 12:42:01 +0200 Subject: [PATCH] Keep a later config flag when extra words are present. A non-flag word no longer stops parsing, and a missing home directory refuses to create or rewrite a config instead of writing an empty log path. Co-authored-by: Cursor --- app.go | 14 +++ config_path.go | 13 ++- config_path_test.go | 215 +++++++++++++++++++++++++++++++++++++++++++ main.go | 3 +- station/config.go | 34 +++++-- station/home_test.go | 52 +++++++++++ 6 files changed, 317 insertions(+), 14 deletions(-) create mode 100644 station/home_test.go diff --git a/app.go b/app.go index 483ffbc..18a10aa 100644 --- a/app.go +++ b/app.go @@ -2,6 +2,8 @@ package main import ( "context" + "fmt" + "io" "gitea.neitzel.de/konrad/funknetz/audio" "gitea.neitzel.de/konrad/funknetz/station" @@ -51,3 +53,15 @@ func openStation(args []string) (*station.Station, error) { } return station.New(station.Options{ConfigPath: path}) } + +// openStationReported opens the station from args. A flag or config failure is +// one line on stderr and the returned error. The window is not opened. +func openStationReported(args []string, stderr io.Writer) (*station.Station, error) { + st, err := openStation(args) + if err != nil { + // The returned error is the failure. A write error does not replace it. + _, _ = fmt.Fprintln(stderr, err.Error()) + return nil, err + } + return st, nil +} diff --git a/config_path.go b/config_path.go index 424bdd3..6be1f2a 100644 --- a/config_path.go +++ b/config_path.go @@ -24,8 +24,17 @@ func resolveConfigPath(args []string) (string, error) { var config string fs.StringVar(&config, "config", "", "path to the station config file") fs.StringVar(&config, "c", "", "path to the station config file") - if err := fs.Parse(args); err != nil { - return "", err + rest := args + for { + if err := fs.Parse(rest); err != nil { + return "", err + } + rest = fs.Args() + if len(rest) == 0 { + break + } + // A non-flag word ends a parse. Skip it and keep later flags. + rest = rest[1:] } if config == "" { path, err := defaultConfigPath() diff --git a/config_path_test.go b/config_path_test.go index 8555dbf..1944142 100644 --- a/config_path_test.go +++ b/config_path_test.go @@ -3,11 +3,13 @@ package main import ( + "bytes" "os" "path/filepath" "strings" "testing" + "encoding/json/v2" ) func TestResolveConfigPathDefaultIsHome(t *testing.T) { @@ -122,3 +124,216 @@ func TestOpenStationUsesResolvedPath(t *testing.T) { t.Fatalf("callsign = %q, want DL0FN-1", st.State().Callsign) } } + +func TestConfigEqualsFlagSelectsTheFile(t *testing.T) { + abs := filepath.Join(t.TempDir(), "lab.json") + got, err := resolveConfigPath([]string{"--config=" + abs}) + if err != nil { + t.Fatal(err) + } + if got != abs { + t.Fatalf("--config= = %q, want %q", got, abs) + } +} + +func TestCreatedFileViaFlagMatchesHomeTemplate(t *testing.T) { + path := filepath.Join(t.TempDir(), "lab.json") + var stderr bytes.Buffer + st, err := openStationReported([]string{"-c", path}, &stderr) + if st != nil { + t.Fatal("station = non-nil, want nil before the window") + } + if err == nil { + t.Fatal("error = nil, want the file created") + } + if got, want := err.Error(), "created "+path; got != want { + t.Fatalf("error = %q, want %q", got, want) + } + if got, want := stderr.String(), err.Error()+"\n"; got != want { + t.Fatalf("stderr = %q, want %q", got, want) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if got, want := info.Mode().Perm(), os.FileMode(0o600); got != want { + t.Fatalf("mode = %o, want %o", got, want) + } + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var got map[string]any + if err := json.Unmarshal(body, &got); err != nil { + t.Fatalf("created file is not JSON: %v\n%s", err, body) + } + if got["host"] != "" || got["callsign"] != "" || got["authKey"] != "" { + t.Fatalf("string fields = %#v, want empty host, callsign, and authKey", got) + } + if got["port"] != float64(0) || got["talkGroup"] != float64(0) { + t.Fatalf("numeric fields = %#v, want port and talkGroup at zero", got) + } + if got["enableLogging"] != false { + t.Fatalf("enableLogging = %#v, want false", got["enableLogging"]) + } + home, err := os.UserHomeDir() + if err != nil { + t.Fatal(err) + } + if got["logFile"] != filepath.Join(home, "funknetz.log") { + t.Fatalf("logFile = %#v, want funknetz.log in the home directory", got["logFile"]) + } +} + +func TestLaterFlagSelectsTheFile(t *testing.T) { + dir := t.TempDir() + first := filepath.Join(dir, "first.json") + second := filepath.Join(dir, "second.json") + + got, err := resolveConfigPath([]string{"-c", first, "--config", second}) + if err != nil { + t.Fatal(err) + } + if got != second { + t.Fatalf("later --config = %q, want %q", got, second) + } + + got, err = resolveConfigPath([]string{"--config", first, "-c", second}) + if err != nil { + t.Fatal(err) + } + if got != second { + t.Fatalf("later -c = %q, want %q", got, second) + } +} + +func TestEmptyConfigFlagUsesHomeFile(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil { + t.Fatal(err) + } + want := filepath.Join(home, ".funknetz.json") + for _, args := range [][]string{ + {"-c", ""}, + {"--config", ""}, + {"--config="}, + } { + got, err := resolveConfigPath(args) + if err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("resolveConfigPath(%q) = %q, want %q", args, got, want) + } + } +} + +func TestExtraWordsDoNotChangeTheSelectedFile(t *testing.T) { + abs := filepath.Join(t.TempDir(), "lab.json") + got, err := resolveConfigPath([]string{"-c", abs, "leftover"}) + if err != nil { + t.Fatal(err) + } + if got != abs { + t.Fatalf("with leftover = %q, want %q", got, abs) + } + + home, err := os.UserHomeDir() + if err != nil { + t.Fatal(err) + } + got, err = resolveConfigPath([]string{"note"}) + if err != nil { + t.Fatal(err) + } + want := filepath.Join(home, ".funknetz.json") + if got != want { + t.Fatalf("bare word = %q, want %q", got, want) + } + + got, err = resolveConfigPath([]string{"note", "-c", abs}) + if err != nil { + t.Fatal(err) + } + if got != abs { + t.Fatalf("word before -c = %q, want %q", got, abs) + } + + second := filepath.Join(filepath.Dir(abs), "second.json") + got, err = resolveConfigPath([]string{"-c", abs, "leftover", "--config", second}) + if err != nil { + t.Fatal(err) + } + if got != second { + t.Fatalf("flag after a word = %q, want %q", got, second) + } +} + +func TestSelectedFileIsLeftByteForByte(t *testing.T) { + path := filepath.Join(t.TempDir(), "lab.json") + original := []byte("{\"host\":\"127.0.0.1\",\"port\":5308,\"callsign\":\"DL1ABC\",\"authKey\":\"a b\",\"talkGroup\":20,\"enableLogging\":false,\"logFile\":\"/tmp/funknetz.log\"}\n") + if err := os.WriteFile(path, original, 0o640); err != nil { + t.Fatal(err) + } + + st, err := openStation([]string{"--config", path}) + if err != nil { + t.Fatal(err) + } + if st.State().Callsign != "DL1ABC" { + t.Fatalf("callsign = %q, want DL1ABC", st.State().Callsign) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != string(original) { + t.Fatalf("file = %q, want %q", got, original) + } +} + +func TestEmptyHostOnSelectedFileNamesHost(t *testing.T) { + path := filepath.Join(t.TempDir(), "lab.json") + body := []byte("{\"host\":\"\",\"port\":5308,\"callsign\":\"DL1ABC\",\"authKey\":\"key\",\"talkGroup\":20,\"enableLogging\":false,\"logFile\":\"/tmp/funknetz.log\"}\n") + if err := os.WriteFile(path, body, 0o600); err != nil { + t.Fatal(err) + } + var stderr bytes.Buffer + st, err := openStationReported([]string{"-c", path}, &stderr) + if st != nil { + t.Fatal("station = non-nil, want nil") + } + if err == nil || !strings.Contains(err.Error(), "host") { + t.Fatalf("error = %v, want it to name host", err) + } + if stderr.String() != err.Error()+"\n" { + t.Fatalf("stderr = %q, want the error line", stderr.String()) + } +} + +func TestBadFlagPrintsParserError(t *testing.T) { + cases := []struct { + args []string + want string + }{ + {[]string{"-not-a-flag"}, "flag provided but not defined: -not-a-flag\n"}, + {[]string{"-c"}, "flag needs an argument: -c\n"}, + {[]string{"--config"}, "flag needs an argument: -config\n"}, + } + for _, tc := range cases { + var stderr bytes.Buffer + st, err := openStationReported(tc.args, &stderr) + if st != nil { + t.Fatal("station = non-nil, want nil") + } + if err == nil { + t.Fatal("error = nil, want a flag error") + } + if got := stderr.String(); got != tc.want { + t.Fatalf("stderr = %q, want %q", got, tc.want) + } + if strings.Contains(strings.ToLower(stderr.String()), "usage") { + t.Fatalf("stderr = %q, want no usage text", stderr.String()) + } + } +} diff --git a/main.go b/main.go index a122f98..e383678 100644 --- a/main.go +++ b/main.go @@ -17,9 +17,8 @@ import ( var assets embed.FS func main() { - st, err := openStation(os.Args[1:]) + st, err := openStationReported(os.Args[1:], os.Stderr) if err != nil { - fmt.Fprintln(os.Stderr, err.Error()) os.Exit(1) } app := &App{station: st} diff --git a/station/config.go b/station/config.go index d6c8bd3..059826a 100644 --- a/station/config.go +++ b/station/config.go @@ -62,7 +62,11 @@ func loadConfig(path string) (loaded, error) { } func writeTemplate(path string) error { - body, err := jsonv2.Marshal(templateFile{LogFile: defaultLogPath()}) + logFile, err := defaultLogPath() + if err != nil { + return err + } + body, err := jsonv2.Marshal(templateFile{LogFile: logFile}) if err != nil { return err } @@ -99,7 +103,11 @@ func parseConfig(path string, body []byte) (loaded, error) { if !operationalKindsOK(shape) { return parseOperational(shape) } - shape = withLogDefaults(shape) + var err error + shape, err = withLogDefaults(shape) + if err != nil { + return loaded{}, err + } if err := rewriteConfig(path, shape); err != nil { return loaded{}, fmt.Errorf("the file could not be read: %w", err) } @@ -189,12 +197,14 @@ func intField(name string, v jsontext.Value, min, max int) (int, error) { return n, nil } -func defaultLogPath() string { - home, err := os.UserHomeDir() +var userHomeDir = os.UserHomeDir + +func defaultLogPath() (string, error) { + home, err := userHomeDir() if err != nil { - return "" + return "", fmt.Errorf("config path: %w", err) } - return filepath.Join(home, "funknetz.log") + return filepath.Join(home, "funknetz.log"), nil } func present(v jsontext.Value) bool { return len(v) > 0 } @@ -243,7 +253,7 @@ func blockedLog(shape fileShape) (block bool, notice string) { return true, "" } -func withLogDefaults(shape fileShape) fileShape { +func withLogDefaults(shape fileShape) (fileShape, error) { shape.Host = rawOr(shape.Host, `""`) shape.Port = rawOr(shape.Port, "0") shape.Callsign = rawOr(shape.Callsign, `""`) @@ -251,13 +261,17 @@ func withLogDefaults(shape fileShape) fileShape { shape.TalkGroup = rawOr(shape.TalkGroup, "0") shape.EnableLogging = rawOr(shape.EnableLogging, "false") if !present(shape.LogFile) { - raw, err := jsonv2.Marshal(defaultLogPath()) + logFile, err := defaultLogPath() if err != nil { - raw = []byte(`""`) + return fileShape{}, err + } + raw, err := jsonv2.Marshal(logFile) + if err != nil { + return fileShape{}, err } shape.LogFile = jsontext.Value(raw) } - return shape + return shape, nil } func rawOr(v jsontext.Value, fallback string) jsontext.Value { diff --git a/station/home_test.go b/station/home_test.go new file mode 100644 index 0000000..b6102ed --- /dev/null +++ b/station/home_test.go @@ -0,0 +1,52 @@ +package station + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestMissingHomeDoesNotCreateConfig(t *testing.T) { + orig := userHomeDir + t.Cleanup(func() { userHomeDir = orig }) + userHomeDir = func() (string, error) { + return "", errors.New("no home") + } + + path := filepath.Join(t.TempDir(), "lab.json") + _, err := New(Options{ConfigPath: path}) + if err == nil || !strings.HasPrefix(err.Error(), "config path:") { + t.Fatalf("New() error = %v, want a config path failure", err) + } + if _, statErr := os.Stat(path); !errors.Is(statErr, fs.ErrNotExist) { + t.Fatalf("stat = %v, want the file absent", statErr) + } +} + +func TestMissingHomeDoesNotRewriteConfig(t *testing.T) { + orig := userHomeDir + t.Cleanup(func() { userHomeDir = orig }) + userHomeDir = func() (string, error) { + return "", errors.New("no home") + } + + path := filepath.Join(t.TempDir(), "lab.json") + original := []byte("{\"host\":\"127.0.0.1\",\"port\":5308,\"callsign\":\"DL1ABC\",\"authKey\":\"key\",\"talkGroup\":20}\n") + if err := os.WriteFile(path, original, 0o640); err != nil { + t.Fatal(err) + } + _, err := New(Options{ConfigPath: path}) + if err == nil || !strings.HasPrefix(err.Error(), "config path:") { + t.Fatalf("New() error = %v, want a config path failure", err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != string(original) { + t.Fatalf("file = %q, want %q", got, original) + } +}