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.
This commit is contained in:
balanza
2026-06-14 13:04:28 +02:00
committed by Alessio Biancalana
parent 4b22fa130d
commit e626b63bf2
2 changed files with 216 additions and 0 deletions
+115
View File
@@ -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
}
+101
View File
@@ -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)
}