feat: Merge template config into existing user config during setup install (#189)

* docs: Add config merging design spec for #114

Defines the merge-during-setup-install approach for keeping user
configs up to date with new template keys while preserving all
existing values, comments, and formatting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: Add implementation plan for config merging

TDD-based plan with 6 tasks: dependency setup, failing tests,
core merge implementation, integration test, WriteTemplateConfig
integration, and full verification.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: Merge template config into existing user config during setup install

Instead of skipping when a config file exists, WriteTemplateConfig() now
merges missing keys from the embedded template into the user's config
using YAML AST manipulation. Preserves all user values, comments, and
formatting. Only adds keys present in the template but absent in the
user's config.

Closes #114

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: Graceful error handling for config loading and setup commands

Replace panics in loadViperConfig with error returns so the app falls
back to defaults instead of crashing on malformed config files. Add
SilenceUsage to setup install/remove commands so runtime errors don't
dump the full usage text.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* add test cmd in readme

* update copy text

* refactor: Address review feedback on config merging

- Rename existing/template to dest/source for generic util naming
- Remove unnecessary code comments (Rule N references, obvious comments)
- Add AGENTS.md with dev guide and code style rules, symlink CLAUDE.md
- Add BenchmarkMergeYAML (~46μs/op on M4 Pro)
- Remove stale design spec

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* update `MergeYAML` to use from dry/utils

* update AGENTS.md

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sahil Bansal
2026-03-31 19:21:01 +05:30
committed by GitHub
co-authored by Claude Opus 4.6
parent be4b751091
commit d112ded3da
9 changed files with 213 additions and 77 deletions
+23 -9
View File
@@ -11,6 +11,7 @@ import (
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
"github.com/safedep/dry/log"
"github.com/safedep/dry/utils"
)
const (
@@ -272,10 +273,12 @@ func initConfig() {
// loadConfig loads the configuration from the config file.
// This is where we determine the source of config and use the appropriate loader.
// Right now we only support loading from a config file using Viper. All loader
// functions should be safe with reasonable defaults and panic only in case of system errors.
// Right now we only support loading from a config file using Viper. If loading
// fails, the default configuration is used and a warning is logged.
func loadConfig() {
loadViperConfig()
if err := loadViperConfig(); err != nil {
log.Warnf("Failed to load config, using defaults: %v", err)
}
}
// configDir computes the path to the config directory.
@@ -346,7 +349,10 @@ func ConfigureSandbox(isInstallationCommand bool) {
}
}
// WriteTemplateConfig writes the template configuration file to disk if it doesn't already exist.
// WriteTemplateConfig writes the template configuration file to disk.
// If the config file does not exist, the full template is written.
// If it already exists, missing keys from the template are merged
// into the existing config while preserving all user values and comments.
func WriteTemplateConfig() error {
configDir, err := configDir()
if err != nil {
@@ -362,13 +368,21 @@ func WriteTemplateConfig() error {
return fmt.Errorf("failed to get config file path: %w", err)
}
// Do not overwrite the config file if it already exists
if _, err := os.Stat(configFilePath); err == nil {
return nil
existingConfig, err := os.ReadFile(configFilePath)
if os.IsNotExist(err) {
return os.WriteFile(configFilePath, []byte(templateConfig), 0o644)
}
if err != nil {
return fmt.Errorf("failed to read existing config: %w", err)
}
if err := os.WriteFile(configFilePath, []byte(templateConfig), 0o644); err != nil {
return fmt.Errorf("failed to write template config: %w", err)
merged, err := utils.MergeYAML(existingConfig, []byte(templateConfig))
if err != nil {
return fmt.Errorf("failed to merge config: %w", err)
}
if err := os.WriteFile(configFilePath, merged, 0o644); err != nil {
return fmt.Errorf("failed to write merged config: %w", err)
}
return nil
+52
View File
@@ -6,6 +6,7 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestConfigIsNeverNil(t *testing.T) {
@@ -43,3 +44,54 @@ func TestConfigHasDefaultValues(t *testing.T) {
assert.Equal(t, filepath.Join(userConfigDir, "safedep/pmg/config.yml"), config.configFilePath)
})
}
func TestWriteTemplateConfigMergesExistingConfig(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("PMG_CONFIG_DIR", tmpDir)
configPath := filepath.Join(tmpDir, "config.yml")
// Write a partial user config
userConfig := []byte("transitive: false\ntransitive_depth: 10\n")
err := os.WriteFile(configPath, userConfig, 0o644)
require.NoError(t, err)
// Re-init so paths point to tmpDir
initConfig()
// Run WriteTemplateConfig — should merge, not skip
err = WriteTemplateConfig()
require.NoError(t, err)
// Read back
result, err := os.ReadFile(configPath)
require.NoError(t, err)
raw := string(result)
// User values preserved
assert.Contains(t, raw, "transitive: false")
assert.Contains(t, raw, "transitive_depth: 10")
// New keys from template added
assert.Contains(t, raw, "proxy_mode:")
assert.Contains(t, raw, "sandbox:")
assert.Contains(t, raw, "verbosity:")
}
func TestWriteTemplateConfigCreatesNewFile(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("PMG_CONFIG_DIR", tmpDir)
initConfig()
err := WriteTemplateConfig()
require.NoError(t, err)
configPath := filepath.Join(tmpDir, "config.yml")
result, err := os.ReadFile(configPath)
require.NoError(t, err)
// Should be the full template
assert.Equal(t, templateConfig, string(result))
}
+8 -6
View File
@@ -9,17 +9,18 @@ import (
)
// loadViperConfig loads the configuration using Viper if available.
// This function will panic for system errors since it is part of the init path.
func loadViperConfig() {
// It returns an error if the config file exists but cannot be read or parsed,
// allowing the caller to fall back to default configuration.
func loadViperConfig() error {
configPath, err := configFilePath()
if err != nil {
panic(fmt.Errorf("failed to get config file path: %w", err))
return fmt.Errorf("failed to get config file path: %w", err)
}
// Check if config file exists before attempting to load
// If it doesn't exist, we use the default configuration (see config.go)
if _, err := os.Stat(configPath); os.IsNotExist(err) {
return
return nil
}
v := viper.New()
@@ -31,13 +32,14 @@ func loadViperConfig() {
v.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
if err := v.ReadInConfig(); err != nil {
panic(fmt.Errorf("failed to read config file %s: %w", configPath, err))
return fmt.Errorf("failed to read config file %s: %w", configPath, err)
}
var loadedConfig Config
if err := v.Unmarshal(&loadedConfig); err != nil {
panic(fmt.Errorf("failed to unmarshal config: %w", err))
return fmt.Errorf("failed to unmarshal config: %w", err)
}
globalConfig.Config = loadedConfig
return nil
}