From e626b63bf2c6be460e63eb78b378039eef206922 Mon Sep 17 00:00:00 2001 From: balanza Date: Tue, 31 Mar 2026 17:42:11 +0200 Subject: [PATCH] refactor(config): read config into struct The current implementation uses dotenv to load the configuration into the environment, and then delegate the cli layer to handle the configuration coming from the DBX_ variables. With this new implementation, defaults, config files and environment variables are handled in a single point and treated as values. --- pkg/config/config.go | 115 +++++++++++++++++++++++++++++ pkg/config/config_internal_test.go | 101 +++++++++++++++++++++++++ 2 files changed, 216 insertions(+) create mode 100644 pkg/config/config_internal_test.go diff --git a/pkg/config/config.go b/pkg/config/config.go index 3d65ee51..2f9c00c3 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1,5 +1,13 @@ package config +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/ini.v1" +) + type Values struct { ContainerManagerType string SudoProgram string @@ -23,6 +31,28 @@ func DefaultValues() *Values { return toStruct(defaultsMap()) } +func LoadValues() (*Values, error) { + files, err := getConfigFilePaths() + if err != nil { + return nil, fmt.Errorf("failed to get config file paths: %w", err) + } + + configMaps := []map[string]string{defaultsMap()} + + for _, file := range files { + config, err := readConfigFile(file) + if err != nil { + return nil, fmt.Errorf("failed to read config file %q: %w", file, err) + } + configMaps = append(configMaps, config) + } + + configMaps = append(configMaps, readEnv()) + + merged := mergeConfigMaps(configMaps...) + return toStruct(merged), nil +} + func toStruct(configMap map[string]string) *Values { return &Values{ ContainerManagerType: configMap["container_manager"], @@ -36,3 +66,88 @@ func toStruct(configMap map[string]string) *Values { func toBool(value string) bool { return value == "true" } + +// getConfigFilePaths returns a list of configuration file paths in order of priority. +func getConfigFilePaths() ([]string, error) { + execPath, err := os.Executable() + if err != nil { + return nil, fmt.Errorf("failed to get executable path: %w", err) + } + + execPath, err = filepath.EvalSymlinks(execPath) + if err != nil { + return nil, fmt.Errorf("failed to evaluate symlinks for executable path: %w", err) + } + + selfDir := filepath.Dir(execPath) + + xdgConfigHome := os.Getenv("XDG_CONFIG_HOME") + if xdgConfigHome == "" { + xdgConfigHome = filepath.Join(os.Getenv("HOME"), ".config") + } + + home := os.Getenv("HOME") + + // Source configuration files, this is done in an hierarchy so local files have + // priority over system defaults + // leave priority to environment variables. + // + // On NixOS, for the distrobox derivation to pick up a static config file shipped + // by the package maintainer the path must be relative to the script itself. + return []string{ + filepath.Join(selfDir, "..", "share", "distrobox", "distrobox.conf"), // for NixOS + "/usr/share/distrobox/distrobox.conf", + "/usr/share/defaults/distrobox/distrobox.conf", + "/usr/etc/distrobox/distrobox.conf", + "/usr/local/share/distrobox/distrobox.conf", + "/etc/distrobox/distrobox.conf", + filepath.Join(xdgConfigHome, "distrobox", "distrobox.conf"), + filepath.Join(home, ".distroboxrc"), + }, nil +} + +func mergeConfigMaps(maps ...map[string]string) map[string]string { + merged := make(map[string]string) + for _, m := range maps { + for k, v := range m { + merged[k] = v + } + } + return merged +} + +func readConfigFile(filePath string) (map[string]string, error) { + cfg, err := ini.Load(filePath) + if err != nil { + if os.IsNotExist(err) { + return make(map[string]string), nil + } + return nil, fmt.Errorf("failed to load config file %q: %w", filePath, err) + } + + config := make(map[string]string) + for _, key := range cfg.Section("").Keys() { + config[key.Name()] = key.String() + } + + return config, nil +} + +func readEnv() map[string]string { + envConfig := make(map[string]string) + + if value, exists := os.LookupEnv("DBX_CONTAINER_MANAGER"); exists { + envConfig["container_manager"] = value + } + if value, exists := os.LookupEnv("DBX_SUDO_COMMAND"); exists { + envConfig["sudo_program"] = value + } + if value, exists := os.LookupEnv("DBX_SUDO_PROGRAM"); exists { + envConfig["sudo_program"] = value + } + if value, exists := os.LookupEnv("DBX_VERBOSE"); exists { + envConfig["verbose"] = value + } + + return envConfig +} diff --git a/pkg/config/config_internal_test.go b/pkg/config/config_internal_test.go new file mode 100644 index 00000000..5c1824bf --- /dev/null +++ b/pkg/config/config_internal_test.go @@ -0,0 +1,101 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestToStruct(t *testing.T) { + input := map[string]string{ + "container_manager": "docker", + "sudo_program": "doas", + "verbose": "true", + "container_image": "ubuntu:latest", + "container_name": "mybox", + } + + cfg := toStruct(input) + + assert.Equal(t, "docker", cfg.ContainerManagerType) + assert.Equal(t, "doas", cfg.SudoProgram) + assert.True(t, cfg.Verbose) + assert.Equal(t, "ubuntu:latest", cfg.DefaultContainerImage) + assert.Equal(t, "mybox", cfg.DefaultContainerName) +} + +func TestToStruct_MissingKeys(t *testing.T) { + cfg := toStruct(map[string]string{}) + + assert.Empty(t, cfg.ContainerManagerType) + assert.Empty(t, cfg.SudoProgram) + assert.False(t, cfg.Verbose) + assert.Empty(t, cfg.DefaultContainerImage) + assert.Empty(t, cfg.DefaultContainerName) +} + +func TestMergeConfigMaps(t *testing.T) { + base := map[string]string{ + "container_manager": "podman", + "verbose": "false", + } + override := map[string]string{ + "container_manager": "docker", + "container_name": "mybox", + } + + merged := mergeConfigMaps(base, override) + + assert.Equal(t, "docker", merged["container_manager"]) + assert.Equal(t, "false", merged["verbose"]) + assert.Equal(t, "mybox", merged["container_name"]) +} + +func TestMergeConfigMaps_Empty(t *testing.T) { + merged := mergeConfigMaps() + assert.Empty(t, merged) +} + +func TestReadConfigFile_KeyValues(t *testing.T) { + tmpFile := filepath.Join(t.TempDir(), "config.conf") + content := ` +container_manager=docker +container_name=mybox +verbose=true +` + require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0o644)) + + result, err := readConfigFile(tmpFile) + require.NoError(t, err) + + assert.Equal(t, "docker", result["container_manager"]) + assert.Equal(t, "mybox", result["container_name"]) + assert.Equal(t, "true", result["verbose"]) +} + +func TestReadConfigFile_WithComments(t *testing.T) { + tmpFile := filepath.Join(t.TempDir(), "config.conf") + content := ` +# This is a comment +container_manager=podman # Inline comment +# Another comment +verbose=false +` + require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0o644)) + + result, err := readConfigFile(tmpFile) + require.NoError(t, err) + + assert.Len(t, result, 2) + assert.Equal(t, "podman", result["container_manager"]) + assert.Equal(t, "false", result["verbose"]) +} + +func TestReadConfigFile_NotFound(t *testing.T) { + result, err := readConfigFile(filepath.Join(t.TempDir(), "nonexistent.conf")) + require.NoError(t, err) + assert.Empty(t, result) +}