From 4da8caef24b79e2123c977ddbd15234a16d066e0 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Thu, 23 Oct 2025 17:29:31 -0700 Subject: [PATCH] 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 --- CONFIG.md | 84 ++++++++++- cmd/bd/main.go | 78 +++++++---- go.mod | 11 ++ go.sum | 23 +++ internal/config/config.go | 162 ++++++++++++++++++++++ internal/config/config_test.go | 246 +++++++++++++++++++++++++++++++++ 6 files changed, 574 insertions(+), 30 deletions(-) create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go diff --git a/CONFIG.md b/CONFIG.md index 5910beaa..f41dbbb2 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -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 diff --git a/cmd/bd/main.go b/cmd/bd/main.go index 1974c7d6..66a00568 100644 --- a/cmd/bd/main.go +++ b/cmd/bd/main.go @@ -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") diff --git a/go.mod b/go.mod index 7acfdfd8..4e5b4946 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index a4e25fd6..72c8f8aa 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 00000000..f3dbc0c4 --- /dev/null +++ b/internal/config/config.go @@ -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() +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 00000000..c367e6c1 --- /dev/null +++ b/internal/config/config_test.go @@ -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) + } +}