Add config.json support for database path configuration (bd-163)

- Create internal/configfile package for config.json handling
- bd init now creates .beads/config.json with database, version, and jsonl_export fields
- Database discovery checks config.json first, falls back to beads.db
- Update .gitignore to not ignore config.json (part of repo state)
- Update test to expect beads.db and config.json
- Backward compatible with existing beads.db-only setups
This commit is contained in:
Steve Yegge
2025-10-26 18:44:27 -07:00
parent 51abbb512e
commit 881e0940a7
5 changed files with 227 additions and 4 deletions

View File

@@ -13,6 +13,7 @@ import (
"os"
"path/filepath"
"github.com/steveyegge/beads/internal/configfile"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
@@ -163,7 +164,7 @@ type DatabaseInfo struct {
}
// findDatabaseInTree walks up the directory tree looking for .beads/*.db
// Prefers beads.db and returns an error (via stderr warning) if multiple .db files exist
// Prefers config.json, falls back to beads.db, and returns an error if multiple .db files exist
func findDatabaseInTree() string {
dir, err := os.Getwd()
if err != nil {
@@ -174,7 +175,15 @@ func findDatabaseInTree() string {
for {
beadsDir := filepath.Join(dir, ".beads")
if info, err := os.Stat(beadsDir); err == nil && info.IsDir() {
// Check for canonical beads.db first
// Check for config.json first (single source of truth)
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil {
dbPath := cfg.DatabasePath(beadsDir)
if _, err := os.Stat(dbPath); err == nil {
return dbPath
}
}
// Fall back to canonical beads.db for backward compatibility
canonicalDB := filepath.Join(beadsDir, "beads.db")
if _, err := os.Stat(canonicalDB); err == nil {
return canonicalDB

View File

@@ -9,6 +9,7 @@ import (
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/configfile"
"github.com/steveyegge/beads/internal/storage/sqlite"
)
@@ -104,8 +105,9 @@ bd.sock
db.sqlite
bd.db
# Keep JSONL exports (source of truth for git)
# Keep JSONL exports and config (source of truth for git)
!*.jsonl
!config.json
`
if err := os.WriteFile(gitignorePath, []byte(gitignoreContent), 0600); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to create .gitignore: %v\n", err)
@@ -139,6 +141,15 @@ bd.db
// Non-fatal - continue anyway
}
// Create config.json for explicit configuration
if useLocalBeads {
cfg := configfile.DefaultConfig(Version)
if err := cfg.Save(localBeadsDir); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to create config.json: %v\n", err)
// Non-fatal - continue anyway
}
}
// Check if git has existing issues to import (fresh clone scenario)
issueCount, jsonlPath := checkGitForIssues()
if issueCount > 0 {

View File

@@ -1,7 +1,8 @@
# Test bd init command
bd init --prefix test
stdout 'initialized successfully'
exists .beads/test.db
exists .beads/beads.db
exists .beads/config.json
exists .beads/.gitignore
grep '^\*\.db$' .beads/.gitignore
grep '^\*\.db-journal$' .beads/.gitignore
@@ -11,3 +12,4 @@ grep '^daemon\.log$' .beads/.gitignore
grep '^daemon\.pid$' .beads/.gitignore
grep '^bd\.sock$' .beads/.gitignore
grep '^!\*\.jsonl$' .beads/.gitignore
grep '^!config\.json$' .beads/.gitignore

View File

@@ -0,0 +1,73 @@
package configfile
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
const ConfigFileName = "config.json"
type Config struct {
Database string `json:"database"`
Version string `json:"version"`
JSONLExport string `json:"jsonl_export,omitempty"`
}
func DefaultConfig(version string) *Config {
return &Config{
Database: "beads.db",
Version: version,
JSONLExport: "beads.jsonl",
}
}
func ConfigPath(beadsDir string) string {
return filepath.Join(beadsDir, ConfigFileName)
}
func Load(beadsDir string) (*Config, error) {
configPath := ConfigPath(beadsDir)
data, err := os.ReadFile(configPath)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("reading config: %w", err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parsing config: %w", err)
}
return &cfg, nil
}
func (c *Config) Save(beadsDir string) error {
configPath := ConfigPath(beadsDir)
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return fmt.Errorf("marshaling config: %w", err)
}
if err := os.WriteFile(configPath, data, 0644); err != nil {
return fmt.Errorf("writing config: %w", err)
}
return nil
}
func (c *Config) DatabasePath(beadsDir string) string {
return filepath.Join(beadsDir, c.Database)
}
func (c *Config) JSONLPath(beadsDir string) string {
if c.JSONLExport == "" {
return filepath.Join(beadsDir, "beads.jsonl")
}
return filepath.Join(beadsDir, c.JSONLExport)
}

View File

@@ -0,0 +1,128 @@
package configfile
import (
"os"
"path/filepath"
"testing"
)
func TestDefaultConfig(t *testing.T) {
cfg := DefaultConfig("0.17.5")
if cfg.Database != "beads.db" {
t.Errorf("Database = %q, want beads.db", cfg.Database)
}
if cfg.Version != "0.17.5" {
t.Errorf("Version = %q, want 0.17.5", cfg.Version)
}
if cfg.JSONLExport != "beads.jsonl" {
t.Errorf("JSONLExport = %q, want beads.jsonl", cfg.JSONLExport)
}
}
func TestLoadSaveRoundtrip(t *testing.T) {
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0750); err != nil {
t.Fatalf("failed to create .beads directory: %v", err)
}
cfg := DefaultConfig("0.17.5")
if err := cfg.Save(beadsDir); err != nil {
t.Fatalf("Save() failed: %v", err)
}
loaded, err := Load(beadsDir)
if err != nil {
t.Fatalf("Load() failed: %v", err)
}
if loaded == nil {
t.Fatal("Load() returned nil config")
}
if loaded.Database != cfg.Database {
t.Errorf("Database = %q, want %q", loaded.Database, cfg.Database)
}
if loaded.Version != cfg.Version {
t.Errorf("Version = %q, want %q", loaded.Version, cfg.Version)
}
if loaded.JSONLExport != cfg.JSONLExport {
t.Errorf("JSONLExport = %q, want %q", loaded.JSONLExport, cfg.JSONLExport)
}
}
func TestLoadNonexistent(t *testing.T) {
tmpDir := t.TempDir()
cfg, err := Load(tmpDir)
if err != nil {
t.Fatalf("Load() returned error for nonexistent config: %v", err)
}
if cfg != nil {
t.Errorf("Load() = %v, want nil for nonexistent config", cfg)
}
}
func TestDatabasePath(t *testing.T) {
beadsDir := "/home/user/project/.beads"
cfg := &Config{Database: "beads.db"}
got := cfg.DatabasePath(beadsDir)
want := filepath.Join(beadsDir, "beads.db")
if got != want {
t.Errorf("DatabasePath() = %q, want %q", got, want)
}
}
func TestJSONLPath(t *testing.T) {
beadsDir := "/home/user/project/.beads"
tests := []struct {
name string
cfg *Config
want string
}{
{
name: "default",
cfg: &Config{JSONLExport: "beads.jsonl"},
want: filepath.Join(beadsDir, "beads.jsonl"),
},
{
name: "custom",
cfg: &Config{JSONLExport: "custom.jsonl"},
want: filepath.Join(beadsDir, "custom.jsonl"),
},
{
name: "empty falls back to default",
cfg: &Config{JSONLExport: ""},
want: filepath.Join(beadsDir, "beads.jsonl"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.cfg.JSONLPath(beadsDir)
if got != tt.want {
t.Errorf("JSONLPath() = %q, want %q", got, tt.want)
}
})
}
}
func TestConfigPath(t *testing.T) {
beadsDir := "/home/user/project/.beads"
got := ConfigPath(beadsDir)
want := filepath.Join(beadsDir, "config.json")
if got != want {
t.Errorf("ConfigPath() = %q, want %q", got, want)
}
}