From 19c3db163bcb93b886e20a245166c672485da20a Mon Sep 17 00:00:00 2001 From: helio Date: Sun, 1 Mar 2026 17:43:10 +0000 Subject: [PATCH 1/6] feat: Add Rust server support to docker compose This commit introduces support for running a Rust game server using Docker Compose. Changes include: - Adding a `rust-server` service to `compose.yaml`. - Creating a `common.cfg` for Rust server configuration. - Updating `Makefile` to include a `lift-rust-server` target. - Modifying `README.md` to include instructions for starting the Rust server and a section on protocol compliance. - Adjusting the Mordhau healthcheck port in `compose.yaml`. --- .serverdata/mh/Game.ini | 3 +-- .serverdata/mh/common.cfg | 6 ------ .serverdata/rust/common.cfg | 5 +++++ Makefile | 3 +++ README.md | 20 ++++++++++++++++++-- compose.yaml | 20 +++++++++++++++++++- 6 files changed, 46 insertions(+), 11 deletions(-) create mode 100644 .serverdata/rust/common.cfg diff --git a/.serverdata/mh/Game.ini b/.serverdata/mh/Game.ini index 3b7f6e7..14de959 100644 --- a/.serverdata/mh/Game.ini +++ b/.serverdata/mh/Game.ini @@ -23,7 +23,7 @@ MapRotation=FFA_Contraband [/Script/Mordhau.MordhauGameSession] MaxSlots=16 -ServerName=monkedoesitagain +ServerName=tcprcon-cli test server AdminPassword=localpassword RconPassword=localpassword ServerKey=Key @@ -54,4 +54,3 @@ ServerLagReportColor2= ChatFeedWebhookURL= ChatFeedColor= ServerPassword= - diff --git a/.serverdata/mh/common.cfg b/.serverdata/mh/common.cfg index 34238e3..7487b61 100644 --- a/.serverdata/mh/common.cfg +++ b/.serverdata/mh/common.cfg @@ -1,9 +1,3 @@ -################################## -######## Common Settings ######### -################################## -# PLACE GLOBAL SETTINGS HERE -## These settings will apply to all instances. - ip="0.0.0.0" port="7777" beaconport="15000" diff --git a/.serverdata/rust/common.cfg b/.serverdata/rust/common.cfg new file mode 100644 index 0000000..eab21eb --- /dev/null +++ b/.serverdata/rust/common.cfg @@ -0,0 +1,5 @@ +stats="on" +rconpassword="localpassword" +rconweb="0" +rconport="7778" +servername="tcprcon-cli test server" diff --git a/Makefile b/Makefile index 77cfad8..007cb9e 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,9 @@ RCON_PORT=7778 lift-mh-server: docker compose run mh-server +lift-rust-server: + docker compose run rust-server + build: go build -o .out/tcprcon diff --git a/README.md b/README.md index 70b5e8a..66158cb 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ - [Using Environment Variable for Password](#using-environment-variable-for-password) - [Configuration Profiles](#configuration-profiles) - [CLI Flags](#cli-flags) + - [Protocol Compliance](#protocol-compliance) - [Using as a Library](#using-as-a-library) - [Streaming Responses](#streaming-responses) - [License](#license) @@ -33,9 +34,18 @@ You can use the provided `Makefile` and `compose.yaml` to spin up a local develo ### Getting Started -1. **Start the Game Server**: +1. **Start a Game Server**: + + Beware that the first time you build and run the server it might take a while for its RCON port to be usable, not sure why, but Rust one for example took a few minutes before it was responding, idk. + + ```bash + make lift-mh-server + ``` + + or + ```bash - make lift-mh-server + make lift-rust-server ``` *Note: The server uses `network_mode: host` and may take a few minutes to fully initialize, make sure network_mode is supported by your docker engine* @@ -149,6 +159,12 @@ tcprcon-cli --profile="my_server" --port=27015 saves current connection parameters as a profile. Value is the profile name. ``` +## Protocol Compliance + +While `tcprcon-cli` follows the standard Source RCON Protocol, some game servers (like Rust) have non-standard implementations that might cause unexpected behavior, such as duplicated responses or incorrect packet IDs, the cli should still work, you might just have to deal with an overly chatty server. + +For a detailed breakdown of known server quirks and how they are handled, see the [Caveats section in the core library documentation](https://github.com/UltimateForm/tcprcon#caveats). + ## Using as a Library See https://github.com/UltimateForm/tcprcon diff --git a/compose.yaml b/compose.yaml index 1cfdc9a..f9bc0b7 100644 --- a/compose.yaml +++ b/compose.yaml @@ -12,7 +12,7 @@ services: network_mode: host restart: unless-stopped healthcheck: - test: ["CMD", "nc", "-z", "localhost", "12512"] + test: ["CMD", "nc", "-z", "localhost", "7778"] interval: 10s timeout: 5s retries: 10 @@ -22,5 +22,23 @@ services: - .serverdata/mh/common.cfg:/data/config-lgsm/mhserver/common.cfg - .serverdata/mh/Game.ini:/data/serverfiles/Mordhau/Saved/Config/LinuxServer/Game.ini + rust-server: + image: ghcr.io/gameservermanagers/gameserver:rust + container_name: rustserver + restart: unless-stopped + ports: + - "7778:7778" + healthcheck: + test: ["CMD", "nc", "-z", "localhost", "7778"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + volumes: + - linuxgsm-rust:/data + - .serverdata/rust/common.cfg:/data/config-lgsm/rustserver/common.cfg + network_mode: host + volumes: linuxgsm-mh: + linuxgsm-rust: From 37bcb692a5d0ec4eccc7e89d75e152018ab91406 Mon Sep 17 00:00:00 2001 From: helio Date: Sun, 1 Mar 2026 19:04:12 +0000 Subject: [PATCH 2/6] feat: Refactor config loading to use explicit base path --- cmd/main.go | 19 ++++++++++++++----- internal/config/config.go | 26 +++++++++++++++----------- internal/config/config_test.go | 7 +++++++ internal/config/errors.go | 5 +++++ 4 files changed, 41 insertions(+), 16 deletions(-) create mode 100644 internal/config/config_test.go create mode 100644 internal/config/errors.go diff --git a/cmd/main.go b/cmd/main.go index 444f44f..beb42c0 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -105,8 +105,17 @@ func Execute() { flag.Parse() logLevel := uint8(logLevelParam) logger.Setup(logLevel) - - resolvedAddress, resolvedPort, resolvedPassword, err := config.Resolve(profileParam, addressParam, portParam, passwordParam) + configBasePath, err := os.UserConfigDir() + if err != nil { + logger.Critical.Fatal(err) + } + resolvedAddress, resolvedPort, resolvedPassword, err := config.Resolve( + configBasePath, + profileParam, + addressParam, + portParam, + passwordParam, + ) if err != nil { logger.Critical.Fatal(err) } @@ -121,7 +130,7 @@ func Execute() { // TODO: consider moving to config lib if saveParam != "" { - cfg, loadErr := config.Load() + cfg, loadErr := config.Load(configBasePath) if loadErr != nil { logger.Critical.Fatal(errors.Join(errors.New("failed to load config for saving"), loadErr)) } @@ -142,10 +151,10 @@ func Execute() { } cfg.SetProfile(saveParam, newProfile) - if saveErr := cfg.Save(); saveErr != nil { + if saveErr := cfg.Save(configBasePath); saveErr != nil { logger.Critical.Fatal(errors.Join(errors.New("failed to save config file"), saveErr)) } - configFilePath, pathErr := config.GetConfigPath() + configFilePath, pathErr := config.BuildConfigPath(configBasePath) if pathErr != nil { logger.Critical.Fatal(errors.Join(errors.New("failed to get config file path for display"), pathErr)) } diff --git a/internal/config/config.go b/internal/config/config.go index f56015b..a30d816 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -24,18 +24,22 @@ const ( configFileName = "config.json" ) -func GetConfigPath() (string, error) { - configDir, err := os.UserConfigDir() - if err != nil { - return "", err +func BuildConfigPath(basePath string) (string, error) { + if basePath == "" { + return "", ErrUndefinedConfigBasePath } + // configDir, err := os.UserConfigDir() + // if err != nil { + // return "", err + // } - fullPath := filepath.Join(configDir, configDirName) + fullPath := filepath.Join(basePath, configDirName) return filepath.Join(fullPath, configFileName), nil } -func Load() (*Config, error) { - path, err := GetConfigPath() +func Load(baseConfigPath string) (*Config, error) { + + path, err := BuildConfigPath(baseConfigPath) if err != nil { return nil, err } @@ -62,8 +66,8 @@ func Load() (*Config, error) { return &cfg, nil } -func (source *Config) Save() error { - path, err := GetConfigPath() +func (source *Config) Save(configBasePath string) error { + path, err := BuildConfigPath(configBasePath) if err != nil { return err } @@ -97,8 +101,8 @@ func (source *Config) SetProfile(name string, p Profile) { source.Profiles[name] = p } -func Resolve(profileName string, addrFlag string, portFlag uint, pwFlag string) (string, uint, string, error) { - cfg, err := Load() +func Resolve(configBasePath string, profileName string, addrFlag string, portFlag uint, pwFlag string) (string, uint, string, error) { + cfg, err := Load(configBasePath) if err != nil { return "", 0, "", err } diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..9d8c61e --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,7 @@ +package config + +import "testing" + +func TestShouldLoad(t *testing.T) { + t.Error("Unimplememted") +} diff --git a/internal/config/errors.go b/internal/config/errors.go new file mode 100644 index 0000000..d91a72e --- /dev/null +++ b/internal/config/errors.go @@ -0,0 +1,5 @@ +package config + +import "errors" + +var ErrUndefinedConfigBasePath error = errors.New("undefined config base path") From 2cf3a3ea30829e8915d7204ec5e96252416cade4 Mon Sep 17 00:00:00 2001 From: helio Date: Sun, 1 Mar 2026 19:42:30 +0000 Subject: [PATCH 3/6] feat: Test config loading when file exists --- internal/config/config_test.go | 48 +++++++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 9d8c61e..4bd5737 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,7 +1,49 @@ package config -import "testing" +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" +) -func TestShouldLoad(t *testing.T) { - t.Error("Unimplememted") +func TestShouldLoadWhenFileExists(t *testing.T) { + sourceProfile := Profile{ + Address: "localhost", + Port: 7778, + Password: "localpassword", + } + sourceConfig := Config{ + Profiles: map[string]Profile{ + "docker": sourceProfile, + }, + } + baseConfigPath := t.TempDir() + configFolder := filepath.Join(baseConfigPath, configDirName) + _ = os.Mkdir(configFolder, 0700) + jsonStr, _ := json.MarshalIndent(sourceConfig, "", " ") + os.WriteFile( + filepath.Join(configFolder, configFileName), + []byte(jsonStr), + 0600, + ) + + cfg, err := Load(baseConfigPath) + if err != nil { + t.Error(errors.Join(errors.New("unexpected error loading config"), err)) + } + if cfg == nil { + t.Error("unexpected nil config") + } + if len(cfg.Profiles) == 0 { + t.Error("unexpected zero-length profiles") + } + profile, exists := cfg.GetProfile("docker") + if !exists { + t.Error("unexpected profile (docker) absence in loaded config") + } + if profile != sourceProfile { + t.Errorf("unexpected mismatching loaded data, expected: %+v; received: %+v", sourceProfile, profile) + } } From 5d617a53f269465635e463354c751261f203ffba Mon Sep 17 00:00:00 2001 From: helio Date: Sun, 1 Mar 2026 21:13:57 +0000 Subject: [PATCH 4/6] feat: Test config save and load functionaliaty --- internal/config/config_test.go | 48 +++++++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 4bd5737..2f6aa4d 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -31,19 +31,55 @@ func TestShouldLoadWhenFileExists(t *testing.T) { cfg, err := Load(baseConfigPath) if err != nil { - t.Error(errors.Join(errors.New("unexpected error loading config"), err)) + t.Fatal(errors.Join(errors.New("unexpected error loading config"), err)) } if cfg == nil { - t.Error("unexpected nil config") + t.Fatal("unexpected nil config") } - if len(cfg.Profiles) == 0 { - t.Error("unexpected zero-length profiles") + profile, exists := cfg.GetProfile("docker") + if !exists { + t.Fatal("unexpected profile (docker) absence in loaded config") + } + if profile != sourceProfile { + t.Fatalf("unexpected mismatching loaded data, expected: %+v; received: %+v", sourceProfile, profile) + } +} + +func TestShouldSaveToConfig(t *testing.T) { + baseConfigPath := t.TempDir() + sourceProfile := Profile{ + Address: "localhost", + Port: 7778, + Password: "localpassword", + } + sourceConfig := Config{ + Profiles: map[string]Profile{ + "docker": sourceProfile, + }, + } + sourceConfig.Save(baseConfigPath) + filePath := filepath.Join(baseConfigPath, configDirName, configFileName) + fileInfo, err := os.Stat(filePath) + if err != nil { + t.Fatal(errors.Join(errors.New("unexpect file stat error"), err)) + } + if fileMode := fileInfo.Mode(); fileMode.Perm() != 0600 { + t.Errorf("unxpected file mode: %v, expected 0600", fileMode) + } + fileBytes, err := os.ReadFile(filePath) + if err != nil { + t.Fatal(errors.Join(errors.New("unexpected file read error"), err)) + } + var cfg Config + err = json.Unmarshal(fileBytes, &cfg) + if err != nil { + t.Fatal(errors.Join(errors.New("unexpected json unmarshal error"), err)) } profile, exists := cfg.GetProfile("docker") if !exists { - t.Error("unexpected profile (docker) absence in loaded config") + t.Fatal("unexpected profile (docker) absence in loaded config") } if profile != sourceProfile { - t.Errorf("unexpected mismatching loaded data, expected: %+v; received: %+v", sourceProfile, profile) + t.Fatalf("unexpected mismatching loaded data, expected: %+v; received: %+v", sourceProfile, profile) } } From db0991c588565a48d7be4514a041e2b66526b283 Mon Sep 17 00:00:00 2001 From: helio Date: Sun, 1 Mar 2026 21:14:59 +0000 Subject: [PATCH 5/6] fix: Fix typo in config test error message --- internal/config/config_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 2f6aa4d..9d1e30a 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -61,7 +61,7 @@ func TestShouldSaveToConfig(t *testing.T) { filePath := filepath.Join(baseConfigPath, configDirName, configFileName) fileInfo, err := os.Stat(filePath) if err != nil { - t.Fatal(errors.Join(errors.New("unexpect file stat error"), err)) + t.Fatal(errors.Join(errors.New("unexpected file stat error"), err)) } if fileMode := fileInfo.Mode(); fileMode.Perm() != 0600 { t.Errorf("unxpected file mode: %v, expected 0600", fileMode) From 4e16cf390157b4fbf21ced8a5835d471dbedbc06 Mon Sep 17 00:00:00 2001 From: UltimateForm Date: Sat, 7 Mar 2026 02:05:02 +0000 Subject: [PATCH 6/6] more tests --- cmd/main.go | 4 +- internal/config/config.go | 10 +- internal/config/config_test.go | 206 +++++++++++++++++++++++++++++++++ 3 files changed, 212 insertions(+), 8 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index beb42c0..80b197e 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -28,8 +28,8 @@ var saveParam string var profileParam string func init() { - flag.StringVar(&addressParam, "address", "localhost", "RCON address, excluding port") - flag.UintVar(&portParam, "port", 7778, "RCON port") + flag.StringVar(&addressParam, "address", config.DefaultAddr, "RCON address, excluding port") + flag.UintVar(&portParam, "port", config.DefaultPort, "RCON port") flag.StringVar(&passwordParam, "pw", "", "RCON password, if not provided will attempt to load from env variables, if unavailable will prompt") flag.UintVar(&logLevelParam, "log", logger.LevelWarning, "sets log level (syslog severity tiers) for execution") flag.StringVar(&inputCmdParam, "cmd", "", "command to execute, if provided will not enter into interactive mode") diff --git a/internal/config/config.go b/internal/config/config.go index a30d816..6127bdf 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,16 +22,14 @@ type Config struct { const ( configDirName = "tcprcon" configFileName = "config.json" + DefaultAddr = "localhost" + DefaultPort = 7778 ) func BuildConfigPath(basePath string) (string, error) { if basePath == "" { return "", ErrUndefinedConfigBasePath } - // configDir, err := os.UserConfigDir() - // if err != nil { - // return "", err - // } fullPath := filepath.Join(basePath, configDirName) return filepath.Join(fullPath, configFileName), nil @@ -120,10 +118,10 @@ func Resolve(configBasePath string, profileName string, addrFlag string, portFla // only override if the flags are still at their default values // NOTE: this logic assumes defaults are "localhost", 7778, and "" // TODO: this "default" handling can introduce bugs, rethink this at some point - if finalAddr == "localhost" && p.Address != "" { + if finalAddr == DefaultAddr && p.Address != "" { finalAddr = p.Address } - if finalPort == 7778 && p.Port != 0 { + if finalPort == DefaultPort && p.Port != 0 { finalPort = p.Port } if finalPw == "" && p.Password != "" { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 9d1e30a..eae3ca1 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -83,3 +83,209 @@ func TestShouldSaveToConfig(t *testing.T) { t.Fatalf("unexpected mismatching loaded data, expected: %+v; received: %+v", sourceProfile, profile) } } + +func TestShouldReturnErrorWhenBasePathIsEmpty(t *testing.T) { + _, err := Load("") + if err == nil { + t.Fatal("expected error for empty base path, got nil") + } + if !errors.Is(err, ErrUndefinedConfigBasePath) { + t.Fatalf("expected ErrUndefinedConfigBasePath, got %v", err) + } +} + +func TestSaveCreatesConfigDirIfMissing(t *testing.T) { + baseConfigPath := t.TempDir() + sourceProfile := Profile{ + Address: "localhost", + Port: 7778, + Password: "localpassword", + } + sourceConfig := Config{ + Profiles: map[string]Profile{ + "docker": sourceProfile, + }, + } + + err := sourceConfig.Save(baseConfigPath) + if err != nil { + t.Fatal(errors.Join(errors.New("unexpected save error"), err)) + } + + configDirPath := filepath.Join(baseConfigPath, configDirName) + dirInfo, err := os.Stat(configDirPath) + if err != nil { + t.Fatal(errors.Join(errors.New("unexpected dir stat error"), err)) + } + if !dirInfo.IsDir() { + t.Fatal("expected config dir to be a directory") + } + if dirInfo.Mode().Perm() != 0700 { + t.Errorf("unexpected dir mode: %v, expected 0700", dirInfo.Mode()) + } + + filePath := filepath.Join(configDirPath, configFileName) + if _, err := os.Stat(filePath); os.IsNotExist(err) { + t.Fatal("expected config file to exist after Save") + } +} + +func TestSaveOverwritesExistingProfile(t *testing.T) { + baseConfigPath := t.TempDir() + firstProfile := Profile{ + Address: "localhost", + Port: 7778, + Password: "firstpassword", + } + cfg := Config{ + Profiles: map[string]Profile{ + "myserver": firstProfile, + }, + } + cfg.Save(baseConfigPath) + + updatedProfile := Profile{ + Address: "192.168.1.50", + Port: 9000, + Password: "updatedpassword", + } + cfg.SetProfile("myserver", updatedProfile) + err := cfg.Save(baseConfigPath) + if err != nil { + t.Fatal(errors.Join(errors.New("unexpected save error"), err)) + } + + loaded, err := Load(baseConfigPath) + if err != nil { + t.Fatal(errors.Join(errors.New("unexpected load error"), err)) + } + profile, exists := loaded.GetProfile("myserver") + if !exists { + t.Fatal("expected profile 'myserver' to exist after overwrite") + } + if profile != updatedProfile { + t.Fatalf("expected updated profile %+v, got %+v", updatedProfile, profile) + } +} + +func TestResolveShouldLoadFromConfig(t *testing.T) { + baseConfigPath := t.TempDir() + sourceProfile := Profile{ + Address: "169.230.184.1", + Port: 7482, + Password: "mycloset", + } + sourceConfig := Config{ + Profiles: map[string]Profile{ + "pleyades": sourceProfile, + }, + } + sourceConfig.Save(baseConfigPath) + resolvedAddress, resolvedPort, resolvedPassword, err := Resolve( + baseConfigPath, + "pleyades", + DefaultAddr, + DefaultPort, + "", + ) + + if err != nil { + t.Fatal(errors.Join(errors.New("unexpected load error"), err)) + } + + if resolvedAddress != sourceProfile.Address { + t.Errorf("expected address %s, but got %s", sourceProfile.Address, resolvedAddress) + } + if resolvedPort != sourceProfile.Port { + t.Errorf("expected port %d, but got %d", sourceProfile.Port, resolvedPort) + } + if resolvedPassword != sourceProfile.Password { + t.Errorf("expected password %s, but got %s", sourceProfile.Password, resolvedPassword) + } +} + +func TestResolveShouldNotOverrideExplicitFlags(t *testing.T) { + baseConfigPath := t.TempDir() + sourceProfile := Profile{ + Address: "169.230.184.1", + Port: 7482, + Password: "profilepassword", + } + sourceConfig := Config{ + Profiles: map[string]Profile{ + "pleyades": sourceProfile, + }, + } + sourceConfig.Save(baseConfigPath) + + explicitAddr := "10.0.0.1" + explicitPort := uint(9999) + explicitPw := "explicitpassword" + + resolvedAddress, resolvedPort, resolvedPassword, err := Resolve( + baseConfigPath, + "pleyades", + explicitAddr, + explicitPort, + explicitPw, + ) + if err != nil { + t.Fatal(errors.Join(errors.New("unexpected resolve error"), err)) + } + if resolvedAddress != explicitAddr { + t.Errorf("expected explicit address %s, but got %s", explicitAddr, resolvedAddress) + } + if resolvedPort != explicitPort { + t.Errorf("expected explicit port %d, but got %d", explicitPort, resolvedPort) + } + if resolvedPassword != explicitPw { + t.Errorf("expected explicit password %s, but got %s", explicitPw, resolvedPassword) + } +} + +func TestResolveShouldReturnErrorForMissingProfile(t *testing.T) { + baseConfigPath := t.TempDir() + emptyConfig := Config{ + Profiles: map[string]Profile{}, + } + emptyConfig.Save(baseConfigPath) + + _, _, _, err := Resolve(baseConfigPath, "nonexistent", DefaultAddr, DefaultPort, "") + if err == nil { + t.Fatal("expected error for missing profile, got nil") + } +} + +func TestResolveShouldReturnDefaultsWhenNoProfile(t *testing.T) { + baseConfigPath := t.TempDir() + + resolvedAddress, resolvedPort, resolvedPassword, err := Resolve( + baseConfigPath, + "", + DefaultAddr, + DefaultPort, + "", + ) + if err != nil { + t.Fatal(errors.Join(errors.New("unexpected resolve error"), err)) + } + if resolvedAddress != DefaultAddr { + t.Errorf("expected default address %s, but got %s", DefaultAddr, resolvedAddress) + } + if resolvedPort != DefaultPort { + t.Errorf("expected default port %d, but got %d", DefaultPort, resolvedPort) + } + if resolvedPassword != "" { + t.Errorf("expected empty password, but got %s", resolvedPassword) + } +} + +func TestBuildConfigPathReturnsErrorOnEmptyBase(t *testing.T) { + _, err := BuildConfigPath("") + if err == nil { + t.Fatal("expected error for empty base path, got nil") + } + if !errors.Is(err, ErrUndefinedConfigBasePath) { + t.Fatalf("expected ErrUndefinedConfigBasePath, got %v", err) + } +}