From ea0d7934613ba5c1c59bbd9fafa0678c5fb2d3fa Mon Sep 17 00:00:00 2001 From: MDietermann Date: Mon, 13 Apr 2026 19:56:53 +0200 Subject: [PATCH] feat(config): load embedded defaults and merge optional overlay --- go.mod | 1 + go.sum | 2 + internal/config/config.go | 82 ++++++++++++++++++++++++++++++++++ internal/config/config_test.go | 60 +++++++++++++++++++++++++ internal/config/defaults.yaml | 4 ++ 5 files changed, 149 insertions(+) create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/config/defaults.yaml diff --git a/go.mod b/go.mod index 7c7c3f6..c887a0d 100644 --- a/go.mod +++ b/go.mod @@ -7,4 +7,5 @@ require github.com/spf13/cobra v1.10.2 require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.9 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index a6ee3e0..ff4d6ec 100644 --- a/go.sum +++ b/go.sum @@ -8,3 +8,5 @@ github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..316f8a1 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,82 @@ +// Package config loads embedded defaults and optionally merges a user +// overlay from ~/.config/archduino/overlay.yaml. +package config + +import ( + _ "embed" + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +//go:embed defaults.yaml +var defaultsYAML []byte + +// Config is the fully-resolved runtime configuration. +type Config struct { + Version int `yaml:"version"` + ExtraPackages []string `yaml:"extra_packages"` + DisabledModules []string `yaml:"disabled_modules"` + ThemeOverrides map[string]string `yaml:"theme_overrides"` +} + +const currentVersion = 1 + +// Load returns defaults merged with the overlay at overlayPath (if present). +// If overlayPath is "", the default location (~/.config/archduino/overlay.yaml) +// is used. A missing overlay is not an error. +func Load(overlayPath string) (Config, error) { + var cfg Config + if err := yaml.Unmarshal(defaultsYAML, &cfg); err != nil { + return Config{}, fmt.Errorf("parse embedded defaults: %w", err) + } + + if overlayPath == "" { + home, err := os.UserHomeDir() + if err != nil { + return cfg, nil + } + overlayPath = filepath.Join(home, ".config", "archduino", "overlay.yaml") + } + + data, err := os.ReadFile(overlayPath) + if err != nil { + if os.IsNotExist(err) { + return cfg, nil + } + return Config{}, fmt.Errorf("read overlay %s: %w", overlayPath, err) + } + + var overlay Config + if err := yaml.Unmarshal(data, &overlay); err != nil { + return Config{}, fmt.Errorf("parse overlay %s: %w", overlayPath, err) + } + if overlay.Version != 0 && overlay.Version != currentVersion { + return Config{}, fmt.Errorf("overlay %s: unsupported version %d (want %d)", overlayPath, overlay.Version, currentVersion) + } + + return merge(cfg, overlay), nil +} + +func merge(base, over Config) Config { + out := base + if len(over.ExtraPackages) > 0 { + out.ExtraPackages = append(append([]string{}, base.ExtraPackages...), over.ExtraPackages...) + } + if len(over.DisabledModules) > 0 { + out.DisabledModules = append(append([]string{}, base.DisabledModules...), over.DisabledModules...) + } + if len(over.ThemeOverrides) > 0 { + merged := make(map[string]string, len(base.ThemeOverrides)+len(over.ThemeOverrides)) + for k, v := range base.ThemeOverrides { + merged[k] = v + } + for k, v := range over.ThemeOverrides { + merged[k] = v + } + out.ThemeOverrides = merged + } + return out +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..9bc6db8 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,60 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadDefaultsWhenNoOverlay(t *testing.T) { + dir := t.TempDir() + cfg, err := Load(filepath.Join(dir, "missing.yaml")) + if err != nil { + t.Fatal(err) + } + if cfg.Version != 1 { + t.Errorf("version = %d", cfg.Version) + } + if len(cfg.ExtraPackages) != 0 { + t.Errorf("extra_packages = %v", cfg.ExtraPackages) + } +} + +func TestLoadMergesOverlay(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "overlay.yaml") + content := []byte(` +version: 1 +extra_packages: [htop, ncdu] +disabled_modules: [renode] +theme_overrides: + starship: /tmp/mine.toml +`) + if err := os.WriteFile(path, content, 0o644); err != nil { + t.Fatal(err) + } + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if got := cfg.ExtraPackages; len(got) != 2 || got[0] != "htop" || got[1] != "ncdu" { + t.Errorf("extra_packages = %v", got) + } + if len(cfg.DisabledModules) != 1 || cfg.DisabledModules[0] != "renode" { + t.Errorf("disabled_modules = %v", cfg.DisabledModules) + } + if cfg.ThemeOverrides["starship"] != "/tmp/mine.toml" { + t.Errorf("theme_overrides = %v", cfg.ThemeOverrides) + } +} + +func TestLoadRejectsUnknownVersion(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "overlay.yaml") + if err := os.WriteFile(path, []byte("version: 99\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := Load(path); err == nil { + t.Fatal("expected version error") + } +} diff --git a/internal/config/defaults.yaml b/internal/config/defaults.yaml new file mode 100644 index 0000000..94293d7 --- /dev/null +++ b/internal/config/defaults.yaml @@ -0,0 +1,4 @@ +version: 1 +extra_packages: [] +disabled_modules: [] +theme_overrides: {}