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 <cursoragent@cursor.com>
This commit is contained in:
@@ -2,6 +2,8 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
"gitea.neitzel.de/konrad/funknetz/audio"
|
"gitea.neitzel.de/konrad/funknetz/audio"
|
||||||
"gitea.neitzel.de/konrad/funknetz/station"
|
"gitea.neitzel.de/konrad/funknetz/station"
|
||||||
@@ -51,3 +53,15 @@ func openStation(args []string) (*station.Station, error) {
|
|||||||
}
|
}
|
||||||
return station.New(station.Options{ConfigPath: path})
|
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
|
||||||
|
}
|
||||||
|
|||||||
+11
-2
@@ -24,8 +24,17 @@ func resolveConfigPath(args []string) (string, error) {
|
|||||||
var config string
|
var config string
|
||||||
fs.StringVar(&config, "config", "", "path to the station config file")
|
fs.StringVar(&config, "config", "", "path to the station config file")
|
||||||
fs.StringVar(&config, "c", "", "path to the station config file")
|
fs.StringVar(&config, "c", "", "path to the station config file")
|
||||||
if err := fs.Parse(args); err != nil {
|
rest := args
|
||||||
return "", err
|
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 == "" {
|
if config == "" {
|
||||||
path, err := defaultConfigPath()
|
path, err := defaultConfigPath()
|
||||||
|
|||||||
@@ -3,11 +3,13 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"encoding/json/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestResolveConfigPathDefaultIsHome(t *testing.T) {
|
func TestResolveConfigPathDefaultIsHome(t *testing.T) {
|
||||||
@@ -122,3 +124,216 @@ func TestOpenStationUsesResolvedPath(t *testing.T) {
|
|||||||
t.Fatalf("callsign = %q, want DL0FN-1", st.State().Callsign)
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,9 +17,8 @@ import (
|
|||||||
var assets embed.FS
|
var assets embed.FS
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
st, err := openStation(os.Args[1:])
|
st, err := openStationReported(os.Args[1:], os.Stderr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, err.Error())
|
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
app := &App{station: st}
|
app := &App{station: st}
|
||||||
|
|||||||
+24
-10
@@ -62,7 +62,11 @@ func loadConfig(path string) (loaded, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeTemplate(path string) 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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -99,7 +103,11 @@ func parseConfig(path string, body []byte) (loaded, error) {
|
|||||||
if !operationalKindsOK(shape) {
|
if !operationalKindsOK(shape) {
|
||||||
return parseOperational(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 {
|
if err := rewriteConfig(path, shape); err != nil {
|
||||||
return loaded{}, fmt.Errorf("the file could not be read: %w", err)
|
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
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultLogPath() string {
|
var userHomeDir = os.UserHomeDir
|
||||||
home, err := os.UserHomeDir()
|
|
||||||
|
func defaultLogPath() (string, error) {
|
||||||
|
home, err := userHomeDir()
|
||||||
if err != nil {
|
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 }
|
func present(v jsontext.Value) bool { return len(v) > 0 }
|
||||||
@@ -243,7 +253,7 @@ func blockedLog(shape fileShape) (block bool, notice string) {
|
|||||||
return true, ""
|
return true, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func withLogDefaults(shape fileShape) fileShape {
|
func withLogDefaults(shape fileShape) (fileShape, error) {
|
||||||
shape.Host = rawOr(shape.Host, `""`)
|
shape.Host = rawOr(shape.Host, `""`)
|
||||||
shape.Port = rawOr(shape.Port, "0")
|
shape.Port = rawOr(shape.Port, "0")
|
||||||
shape.Callsign = rawOr(shape.Callsign, `""`)
|
shape.Callsign = rawOr(shape.Callsign, `""`)
|
||||||
@@ -251,13 +261,17 @@ func withLogDefaults(shape fileShape) fileShape {
|
|||||||
shape.TalkGroup = rawOr(shape.TalkGroup, "0")
|
shape.TalkGroup = rawOr(shape.TalkGroup, "0")
|
||||||
shape.EnableLogging = rawOr(shape.EnableLogging, "false")
|
shape.EnableLogging = rawOr(shape.EnableLogging, "false")
|
||||||
if !present(shape.LogFile) {
|
if !present(shape.LogFile) {
|
||||||
raw, err := jsonv2.Marshal(defaultLogPath())
|
logFile, err := defaultLogPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
raw = []byte(`""`)
|
return fileShape{}, err
|
||||||
|
}
|
||||||
|
raw, err := jsonv2.Marshal(logFile)
|
||||||
|
if err != nil {
|
||||||
|
return fileShape{}, err
|
||||||
}
|
}
|
||||||
shape.LogFile = jsontext.Value(raw)
|
shape.LogFile = jsontext.Value(raw)
|
||||||
}
|
}
|
||||||
return shape
|
return shape, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func rawOr(v jsontext.Value, fallback string) jsontext.Value {
|
func rawOr(v jsontext.Value, fallback string) jsontext.Value {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user