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:
Steve Yegge
2025-10-23 17:29:31 -07:00
parent c4d4a852fa
commit 4da8caef24
6 changed files with 574 additions and 30 deletions

View File

@@ -1,10 +1,88 @@
# Configuration System
bd supports per-project configuration stored in `.beads/*.db` for external integrations and user preferences.
bd has two complementary configuration systems:
## Overview
1. **Tool-level configuration** (Viper): User preferences for tool behavior (flags, output format)
2. **Project-level configuration** (`bd config`): Integration data and project-specific settings
Configuration is:
## Tool-Level Configuration (Viper)
### Overview
Tool preferences control how `bd` behaves globally or per-user. These are stored in config files or environment variables and managed by [Viper](https://github.com/spf13/viper).
**Configuration precedence** (highest to lowest):
1. Command-line flags (`--json`, `--no-daemon`, etc.)
2. Environment variables (`BD_JSON`, `BD_NO_DAEMON`, etc.)
3. Config file (`~/.config/bd/config.yaml` or `.beads/config.yaml`)
4. Defaults
### Config File Locations
Viper searches for `config.yaml` in these locations (in order):
1. `.beads/config.yaml` - Project-specific tool settings (version-controlled)
2. `~/.config/bd/config.yaml` - User-specific tool settings
3. `~/.beads/config.yaml` - Legacy user settings
### Supported Settings
Tool-level settings you can configure:
| Setting | Flag | Environment Variable | Default | Description |
|---------|------|---------------------|---------|-------------|
| `json` | `--json` | `BD_JSON` | `false` | Output in JSON format |
| `no-daemon` | `--no-daemon` | `BD_NO_DAEMON` | `false` | Force direct mode, bypass daemon |
| `no-auto-flush` | `--no-auto-flush` | `BD_NO_AUTO_FLUSH` | `false` | Disable auto JSONL export |
| `no-auto-import` | `--no-auto-import` | `BD_NO_AUTO_IMPORT` | `false` | Disable auto JSONL import |
| `db` | `--db` | `BD_DB` | (auto-discover) | Database path |
| `actor` | `--actor` | `BD_ACTOR` | `$USER` | Actor name for audit trail |
| `flush-debounce` | - | `BEADS_FLUSH_DEBOUNCE` | `5s` | Debounce time for auto-flush |
| `auto-start-daemon` | - | `BEADS_AUTO_START_DAEMON` | `true` | Auto-start daemon if not running |
### Example Config File
`~/.config/bd/config.yaml`:
```yaml
# Default to JSON output for scripting
json: true
# Disable daemon for single-user workflows
no-daemon: true
# Custom debounce for auto-flush (default 5s)
flush-debounce: 10s
# Auto-start daemon (default true)
auto-start-daemon: true
```
`.beads/config.yaml` (project-specific):
```yaml
# Project team prefers longer flush delay
flush-debounce: 15s
```
### Why Two Systems?
**Tool settings (Viper)** are user preferences:
- How should I see output? (`--json`)
- Should I use the daemon? (`--no-daemon`)
- How should the CLI behave?
**Project config (`bd config`)** is project data:
- What's our Jira URL?
- What are our Linear tokens?
- How do we map statuses?
This separation is correct: **tool settings are user-specific, project config is team-shared**.
Agents benefit from `bd config`'s structured CLI interface over manual YAML editing.
## Project-Level Configuration (`bd config`)
### Overview
Project configuration is:
- **Per-project**: Isolated to each `.beads/*.db` database
- **Version-control-friendly**: Stored in SQLite, queryable and scriptable
- **Machine-readable**: JSON output for automation

View File

@@ -20,6 +20,7 @@ import (
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/steveyegge/beads"
"github.com/steveyegge/beads/internal/config"
"github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/storage/sqlite"
@@ -83,6 +84,30 @@ var rootCmd = &cobra.Command{
Short: "bd - Dependency-aware issue tracker",
Long: `Issues chained together like beads. A lightweight issue tracker with first-class dependency support.`,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
// Apply viper configuration if flags weren't explicitly set
// Priority: flags > viper (config file + env vars) > defaults
// Do this BEFORE early-return so init/version/help respect config
// If flag wasn't explicitly set, use viper value
if !cmd.Flags().Changed("json") {
jsonOutput = config.GetBool("json")
}
if !cmd.Flags().Changed("no-daemon") {
noDaemon = config.GetBool("no-daemon")
}
if !cmd.Flags().Changed("no-auto-flush") {
noAutoFlush = config.GetBool("no-auto-flush")
}
if !cmd.Flags().Changed("no-auto-import") {
noAutoImport = config.GetBool("no-auto-import")
}
if !cmd.Flags().Changed("db") && dbPath == "" {
dbPath = config.GetString("db")
}
if !cmd.Flags().Changed("actor") && actor == "" {
actor = config.GetString("actor")
}
// Skip database initialization for commands that don't need a database
if cmd.Name() == "init" || cmd.Name() == "daemon" || cmd.Name() == "help" || cmd.Name() == "version" || cmd.Name() == "quickstart" {
return
@@ -144,12 +169,13 @@ var rootCmd = &cobra.Command{
}
}
// Set actor from flag, env, or default
// Priority: --actor flag > BD_ACTOR env > USER env > "unknown"
// Set actor from flag, viper (env), or default
// Priority: --actor flag > viper (config + BD_ACTOR env) > USER env > "unknown"
// Note: Viper handles BD_ACTOR automatically via AutomaticEnv()
if actor == "" {
if bdActor := os.Getenv("BD_ACTOR"); bdActor != "" {
actor = bdActor
} else if user := os.Getenv("USER"); user != "" {
// Viper already populated from config file or BD_ACTOR env
// Fall back to USER env if still empty
if user := os.Getenv("USER"); user != "" {
actor = user
} else {
actor = "unknown"
@@ -374,20 +400,14 @@ var rootCmd = &cobra.Command{
}
// getDebounceDuration returns the auto-flush debounce duration
// Configurable via BEADS_FLUSH_DEBOUNCE (e.g., "500ms", "10s")
// Configurable via config file or BEADS_FLUSH_DEBOUNCE env var (e.g., "500ms", "10s")
// Defaults to 5 seconds if not set or invalid
func getDebounceDuration() time.Duration {
envVal := os.Getenv("BEADS_FLUSH_DEBOUNCE")
if envVal == "" {
duration := config.GetDuration("flush-debounce")
if duration == 0 {
// If parsing failed, use default
return 5 * time.Second
}
duration, err := time.ParseDuration(envVal)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: invalid BEADS_FLUSH_DEBOUNCE value '%s', using default 5s\n", envVal)
return 5 * time.Second
}
return duration
}
@@ -418,22 +438,21 @@ func shouldAutoStartDaemon() bool {
return false // Explicit opt-out
}
// Check legacy BEADS_AUTO_START_DAEMON for backward compatibility
autoStart := strings.ToLower(strings.TrimSpace(os.Getenv("BEADS_AUTO_START_DAEMON")))
if autoStart != "" {
// Accept common falsy values
return autoStart != "false" && autoStart != "0" && autoStart != "no" && autoStart != "off"
}
return true // Default to enabled (always-daemon mode)
// Use viper to read from config file or BEADS_AUTO_START_DAEMON env var
// Viper handles BEADS_AUTO_START_DAEMON automatically via BindEnv
return config.GetBool("auto-start-daemon") // Defaults to true
}
// shouldUseGlobalDaemon determines if global daemon should be preferred
// based on environment variables, config, or heuristics (multi-repo detection)
// based on heuristics (multi-repo detection)
// Note: Global daemon is deprecated; this always returns false for now
func shouldUseGlobalDaemon() bool {
// Check explicit environment variable first
if pref := os.Getenv("BEADS_PREFER_GLOBAL_DAEMON"); pref != "" {
return pref == "1" || strings.ToLower(pref) == "true"
}
// Global daemon support is deprecated
// Always use local daemon (per-project .beads/ socket)
return false
// Previously supported BEADS_PREFER_GLOBAL_DAEMON env var, but global
// daemon has issues with multi-workspace git workflows
// Heuristic: detect multiple beads repositories
home, err := os.UserHomeDir()
@@ -1352,6 +1371,11 @@ var (
)
func init() {
// Initialize viper configuration
if err := config.Initialize(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to initialize config: %v\n", err)
}
rootCmd.PersistentFlags().StringVar(&dbPath, "db", "", "Database path (default: auto-discover .beads/*.db or ~/.beads/default.db)")
rootCmd.PersistentFlags().StringVar(&actor, "actor", "", "Actor name for audit trail (default: $BD_ACTOR or $USER)")
rootCmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, "Output in JSON format")

11
go.mod
View File

@@ -18,17 +18,28 @@ require (
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/viper v1.21.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/text v0.28.0 // indirect
golang.org/x/tools v0.37.0 // indirect
modernc.org/libc v1.66.3 // indirect
modernc.org/mathutil v1.7.1 // indirect

23
go.sum
View File

@@ -7,6 +7,10 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -20,18 +24,33 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
@@ -42,6 +61,8 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
@@ -52,6 +73,8 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

162
internal/config/config.go Normal file
View 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()
}

View 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)
}
}