Migrate to Viper for unified configuration management (bd-78)
- Add Viper dependency and create internal/config package - Initialize Viper singleton with config file search paths - Bind all global flags to Viper with proper precedence (flags > env > config > defaults) - Replace manual os.Getenv() calls with config.GetString/GetBool/GetDuration - Update CONFIG.md with comprehensive Viper documentation - Add comprehensive tests for config precedence and env binding - Walk up parent directories to discover .beads/config.yaml from subdirectories - Add env key replacer for hyphenated keys (BD_NO_DAEMON -> no-daemon) - Remove deprecated prefer-global-daemon setting - Move Viper config apply before early-return to support version/init/help commands Hybrid architecture maintains separation: - Viper: User-specific tool preferences (--json, --no-daemon, etc.) - bd config: Team-shared project data (Jira URLs, Linear tokens, etc.) All tests passing. Closes bd-78, bd-79, bd-80, bd-81, bd-82, bd-83. Amp-Thread-ID: https://ampcode.com/threads/T-0d0f8c1d-b877-4fa9-8477-b6fea63fb664 Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
162
internal/config/config.go
Normal file
162
internal/config/config.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
var v *viper.Viper
|
||||
|
||||
// Initialize sets up the viper configuration singleton
|
||||
// Should be called once at application startup
|
||||
func Initialize() error {
|
||||
v = viper.New()
|
||||
|
||||
// Set config file name and type
|
||||
v.SetConfigName("config")
|
||||
v.SetConfigType("yaml")
|
||||
|
||||
// Add config search paths (in order of precedence)
|
||||
// 1. Walk up from CWD to find project .beads/ directory
|
||||
// This allows commands to work from subdirectories
|
||||
cwd, err := os.Getwd()
|
||||
if err == nil {
|
||||
// Walk up parent directories to find .beads/config.yaml
|
||||
for dir := cwd; dir != filepath.Dir(dir); dir = filepath.Dir(dir) {
|
||||
beadsDir := filepath.Join(dir, ".beads")
|
||||
configPath := filepath.Join(beadsDir, "config.yaml")
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
// Found .beads/config.yaml - add this path
|
||||
v.AddConfigPath(beadsDir)
|
||||
break
|
||||
}
|
||||
// Also check if .beads directory exists (even without config.yaml)
|
||||
if info, err := os.Stat(beadsDir); err == nil && info.IsDir() {
|
||||
v.AddConfigPath(beadsDir)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Also add CWD/.beads for backward compatibility
|
||||
v.AddConfigPath(filepath.Join(cwd, ".beads"))
|
||||
}
|
||||
|
||||
// 2. User config directory (~/.config/bd/)
|
||||
if configDir, err := os.UserConfigDir(); err == nil {
|
||||
v.AddConfigPath(filepath.Join(configDir, "bd"))
|
||||
}
|
||||
|
||||
// 3. Home directory (~/.beads/)
|
||||
if homeDir, err := os.UserHomeDir(); err == nil {
|
||||
v.AddConfigPath(filepath.Join(homeDir, ".beads"))
|
||||
}
|
||||
|
||||
// Automatic environment variable binding
|
||||
// Environment variables take precedence over config file
|
||||
// E.g., BD_JSON, BD_NO_DAEMON, BD_ACTOR, BD_DB
|
||||
v.SetEnvPrefix("BD")
|
||||
|
||||
// Replace hyphens and dots with underscores for env var mapping
|
||||
// This allows BD_NO_DAEMON to map to "no-daemon" config key
|
||||
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_"))
|
||||
v.AutomaticEnv()
|
||||
|
||||
// Set defaults for all flags
|
||||
v.SetDefault("json", false)
|
||||
v.SetDefault("no-daemon", false)
|
||||
v.SetDefault("no-auto-flush", false)
|
||||
v.SetDefault("no-auto-import", false)
|
||||
v.SetDefault("db", "")
|
||||
v.SetDefault("actor", "")
|
||||
|
||||
// Additional environment variables (not prefixed with BD_)
|
||||
// These are bound explicitly for backward compatibility
|
||||
v.BindEnv("flush-debounce", "BEADS_FLUSH_DEBOUNCE")
|
||||
v.BindEnv("auto-start-daemon", "BEADS_AUTO_START_DAEMON")
|
||||
|
||||
// Set defaults for additional settings
|
||||
v.SetDefault("flush-debounce", "5s")
|
||||
v.SetDefault("auto-start-daemon", true)
|
||||
|
||||
// Read config file if it exists (don't error if not found)
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
|
||||
// Config file found but another error occurred
|
||||
return fmt.Errorf("error reading config file: %w", err)
|
||||
}
|
||||
// Config file not found - this is ok, we'll use defaults
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetString retrieves a string configuration value
|
||||
func GetString(key string) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return v.GetString(key)
|
||||
}
|
||||
|
||||
// GetBool retrieves a boolean configuration value
|
||||
func GetBool(key string) bool {
|
||||
if v == nil {
|
||||
return false
|
||||
}
|
||||
return v.GetBool(key)
|
||||
}
|
||||
|
||||
// GetInt retrieves an integer configuration value
|
||||
func GetInt(key string) int {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
return v.GetInt(key)
|
||||
}
|
||||
|
||||
// GetDuration retrieves a duration configuration value
|
||||
func GetDuration(key string) time.Duration {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
return v.GetDuration(key)
|
||||
}
|
||||
|
||||
// Set sets a configuration value
|
||||
func Set(key string, value interface{}) {
|
||||
if v != nil {
|
||||
v.Set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
// BindPFlag is reserved for future use if we want to bind Cobra flags directly to Viper
|
||||
// For now, we handle flag precedence manually in PersistentPreRun
|
||||
// Uncomment and implement if needed:
|
||||
//
|
||||
// func BindPFlag(key string, flag *pflag.Flag) error {
|
||||
// if v == nil {
|
||||
// return fmt.Errorf("viper not initialized")
|
||||
// }
|
||||
// return v.BindPFlag(key, flag)
|
||||
// }
|
||||
|
||||
// ConfigFileUsed returns the path to the config file being used
|
||||
func ConfigFileUsed() string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return v.ConfigFileUsed()
|
||||
}
|
||||
|
||||
// AllSettings returns all configuration settings as a map
|
||||
func AllSettings() map[string]interface{} {
|
||||
if v == nil {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
return v.AllSettings()
|
||||
}
|
||||
246
internal/config/config_test.go
Normal file
246
internal/config/config_test.go
Normal file
@@ -0,0 +1,246 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestInitialize(t *testing.T) {
|
||||
// Test that initialization doesn't error
|
||||
err := Initialize()
|
||||
if err != nil {
|
||||
t.Fatalf("Initialize() returned error: %v", err)
|
||||
}
|
||||
|
||||
if v == nil {
|
||||
t.Fatal("viper instance is nil after Initialize()")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaults(t *testing.T) {
|
||||
// Reset viper for test isolation
|
||||
err := Initialize()
|
||||
if err != nil {
|
||||
t.Fatalf("Initialize() returned error: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
key string
|
||||
expected interface{}
|
||||
getter func(string) interface{}
|
||||
}{
|
||||
{"json", false, func(k string) interface{} { return GetBool(k) }},
|
||||
{"no-daemon", false, func(k string) interface{} { return GetBool(k) }},
|
||||
{"no-auto-flush", false, func(k string) interface{} { return GetBool(k) }},
|
||||
{"no-auto-import", false, func(k string) interface{} { return GetBool(k) }},
|
||||
{"db", "", func(k string) interface{} { return GetString(k) }},
|
||||
{"actor", "", func(k string) interface{} { return GetString(k) }},
|
||||
{"flush-debounce", 5 * time.Second, func(k string) interface{} { return GetDuration(k) }},
|
||||
{"auto-start-daemon", true, func(k string) interface{} { return GetBool(k) }},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.key, func(t *testing.T) {
|
||||
got := tt.getter(tt.key)
|
||||
if got != tt.expected {
|
||||
t.Errorf("GetXXX(%q) = %v, want %v", tt.key, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvironmentBinding(t *testing.T) {
|
||||
// Test environment variable binding
|
||||
tests := []struct {
|
||||
envVar string
|
||||
key string
|
||||
value string
|
||||
expected interface{}
|
||||
getter func(string) interface{}
|
||||
}{
|
||||
{"BD_JSON", "json", "true", true, func(k string) interface{} { return GetBool(k) }},
|
||||
{"BD_NO_DAEMON", "no-daemon", "true", true, func(k string) interface{} { return GetBool(k) }},
|
||||
{"BD_ACTOR", "actor", "testuser", "testuser", func(k string) interface{} { return GetString(k) }},
|
||||
{"BD_DB", "db", "/tmp/test.db", "/tmp/test.db", func(k string) interface{} { return GetString(k) }},
|
||||
{"BEADS_FLUSH_DEBOUNCE", "flush-debounce", "10s", 10 * time.Second, func(k string) interface{} { return GetDuration(k) }},
|
||||
{"BEADS_AUTO_START_DAEMON", "auto-start-daemon", "false", false, func(k string) interface{} { return GetBool(k) }},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.envVar, func(t *testing.T) {
|
||||
// Set environment variable
|
||||
oldValue := os.Getenv(tt.envVar)
|
||||
os.Setenv(tt.envVar, tt.value)
|
||||
defer os.Setenv(tt.envVar, oldValue)
|
||||
|
||||
// Re-initialize viper to pick up env var
|
||||
err := Initialize()
|
||||
if err != nil {
|
||||
t.Fatalf("Initialize() returned error: %v", err)
|
||||
}
|
||||
|
||||
got := tt.getter(tt.key)
|
||||
if got != tt.expected {
|
||||
t.Errorf("GetXXX(%q) with %s=%s = %v, want %v", tt.key, tt.envVar, tt.value, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigFile(t *testing.T) {
|
||||
// Create a temporary directory for config file
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create a config file
|
||||
configContent := `
|
||||
json: true
|
||||
no-daemon: true
|
||||
actor: configuser
|
||||
flush-debounce: 15s
|
||||
`
|
||||
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
|
||||
t.Fatalf("failed to write config file: %v", err)
|
||||
}
|
||||
|
||||
// Change to tmp directory so config file is discovered
|
||||
origDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get working directory: %v", err)
|
||||
}
|
||||
defer os.Chdir(origDir)
|
||||
|
||||
// Create .beads directory
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||
t.Fatalf("failed to create .beads directory: %v", err)
|
||||
}
|
||||
|
||||
// Move config to .beads directory
|
||||
beadsConfigPath := filepath.Join(beadsDir, "config.yaml")
|
||||
if err := os.Rename(configPath, beadsConfigPath); err != nil {
|
||||
t.Fatalf("failed to move config file: %v", err)
|
||||
}
|
||||
|
||||
if err := os.Chdir(tmpDir); err != nil {
|
||||
t.Fatalf("failed to change directory: %v", err)
|
||||
}
|
||||
|
||||
// Initialize viper
|
||||
err = Initialize()
|
||||
if err != nil {
|
||||
t.Fatalf("Initialize() returned error: %v", err)
|
||||
}
|
||||
|
||||
// Test that config file values are loaded
|
||||
if got := GetBool("json"); got != true {
|
||||
t.Errorf("GetBool(json) = %v, want true", got)
|
||||
}
|
||||
|
||||
if got := GetBool("no-daemon"); got != true {
|
||||
t.Errorf("GetBool(no-daemon) = %v, want true", got)
|
||||
}
|
||||
|
||||
if got := GetString("actor"); got != "configuser" {
|
||||
t.Errorf("GetString(actor) = %q, want \"configuser\"", got)
|
||||
}
|
||||
|
||||
if got := GetDuration("flush-debounce"); got != 15*time.Second {
|
||||
t.Errorf("GetDuration(flush-debounce) = %v, want 15s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigPrecedence(t *testing.T) {
|
||||
// Create a temporary directory for config file
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create a config file with json: false
|
||||
configContent := `json: false`
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||
t.Fatalf("failed to create .beads directory: %v", err)
|
||||
}
|
||||
|
||||
configPath := filepath.Join(beadsDir, "config.yaml")
|
||||
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
|
||||
t.Fatalf("failed to write config file: %v", err)
|
||||
}
|
||||
|
||||
// Change to tmp directory
|
||||
origDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get working directory: %v", err)
|
||||
}
|
||||
defer os.Chdir(origDir)
|
||||
|
||||
if err := os.Chdir(tmpDir); err != nil {
|
||||
t.Fatalf("failed to change directory: %v", err)
|
||||
}
|
||||
|
||||
// Test 1: Config file value (json: false)
|
||||
err = Initialize()
|
||||
if err != nil {
|
||||
t.Fatalf("Initialize() returned error: %v", err)
|
||||
}
|
||||
|
||||
if got := GetBool("json"); got != false {
|
||||
t.Errorf("GetBool(json) from config file = %v, want false", got)
|
||||
}
|
||||
|
||||
// Test 2: Environment variable overrides config file
|
||||
os.Setenv("BD_JSON", "true")
|
||||
defer os.Unsetenv("BD_JSON")
|
||||
|
||||
err = Initialize()
|
||||
if err != nil {
|
||||
t.Fatalf("Initialize() returned error: %v", err)
|
||||
}
|
||||
|
||||
if got := GetBool("json"); got != true {
|
||||
t.Errorf("GetBool(json) with env var = %v, want true (env should override config)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAndGet(t *testing.T) {
|
||||
err := Initialize()
|
||||
if err != nil {
|
||||
t.Fatalf("Initialize() returned error: %v", err)
|
||||
}
|
||||
|
||||
// Test Set and Get
|
||||
Set("test-key", "test-value")
|
||||
if got := GetString("test-key"); got != "test-value" {
|
||||
t.Errorf("GetString(test-key) = %q, want \"test-value\"", got)
|
||||
}
|
||||
|
||||
Set("test-bool", true)
|
||||
if got := GetBool("test-bool"); got != true {
|
||||
t.Errorf("GetBool(test-bool) = %v, want true", got)
|
||||
}
|
||||
|
||||
Set("test-int", 42)
|
||||
if got := GetInt("test-int"); got != 42 {
|
||||
t.Errorf("GetInt(test-int) = %d, want 42", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllSettings(t *testing.T) {
|
||||
err := Initialize()
|
||||
if err != nil {
|
||||
t.Fatalf("Initialize() returned error: %v", err)
|
||||
}
|
||||
|
||||
Set("custom-key", "custom-value")
|
||||
|
||||
settings := AllSettings()
|
||||
if settings == nil {
|
||||
t.Fatal("AllSettings() returned nil")
|
||||
}
|
||||
|
||||
// Check that our custom key is in the settings
|
||||
if val, ok := settings["custom-key"]; !ok || val != "custom-value" {
|
||||
t.Errorf("AllSettings() missing or incorrect custom-key: got %v", val)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user