feat(wisp): add config storage layer for transient/local settings
Implement wisp-based config storage at .beads-wisp/config/<rig>.json for local-only settings that are never synced via git. API: - Get(key) - returns value or nil - Set(key, value) - stores value - Block(key) - marks key as blocked (NullValue equivalent) - Unset(key) - removes from values and blocked - IsBlocked(key) - checks if blocked (gt-3w685) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
318
internal/wisp/config.go
Normal file
318
internal/wisp/config.go
Normal file
@@ -0,0 +1,318 @@
|
||||
// Package wisp provides utilities for working with the .beads-wisp directory.
|
||||
// This file implements wisp-based config storage for transient/local settings.
|
||||
package wisp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// WispConfigDir is the directory for wisp config storage (never synced via git).
|
||||
const WispConfigDir = ".beads-wisp"
|
||||
|
||||
// ConfigSubdir is the subdirectory within WispConfigDir for config files.
|
||||
const ConfigSubdir = "config"
|
||||
|
||||
// ConfigFile represents the JSON structure for wisp config storage.
|
||||
// Storage location: .beads-wisp/config/<rig>.json
|
||||
type ConfigFile struct {
|
||||
Rig string `json:"rig"`
|
||||
Values map[string]interface{} `json:"values"`
|
||||
Blocked []string `json:"blocked"`
|
||||
}
|
||||
|
||||
// Config provides access to wisp-based config storage for a specific rig.
|
||||
// This storage is local-only and never synced via git.
|
||||
type Config struct {
|
||||
mu sync.RWMutex
|
||||
townRoot string
|
||||
rigName string
|
||||
filePath string
|
||||
}
|
||||
|
||||
// NewConfig creates a new Config for the given rig.
|
||||
// townRoot is the path to the town directory (e.g., ~/gt).
|
||||
// rigName is the rig identifier (e.g., "gastown").
|
||||
func NewConfig(townRoot, rigName string) *Config {
|
||||
filePath := filepath.Join(townRoot, WispConfigDir, ConfigSubdir, rigName+".json")
|
||||
return &Config{
|
||||
townRoot: townRoot,
|
||||
rigName: rigName,
|
||||
filePath: filePath,
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigPath returns the path to the config file.
|
||||
func (c *Config) ConfigPath() string {
|
||||
return c.filePath
|
||||
}
|
||||
|
||||
// load reads the config file from disk.
|
||||
// Returns a new empty ConfigFile if the file doesn't exist.
|
||||
func (c *Config) load() (*ConfigFile, error) {
|
||||
data, err := os.ReadFile(c.filePath)
|
||||
if os.IsNotExist(err) {
|
||||
return &ConfigFile{
|
||||
Rig: c.rigName,
|
||||
Values: make(map[string]interface{}),
|
||||
Blocked: []string{},
|
||||
}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config: %w", err)
|
||||
}
|
||||
|
||||
var cfg ConfigFile
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal config: %w", err)
|
||||
}
|
||||
|
||||
// Ensure maps are initialized
|
||||
if cfg.Values == nil {
|
||||
cfg.Values = make(map[string]interface{})
|
||||
}
|
||||
if cfg.Blocked == nil {
|
||||
cfg.Blocked = []string{}
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// save writes the config file to disk atomically.
|
||||
func (c *Config) save(cfg *ConfigFile) error {
|
||||
// Ensure directory exists
|
||||
dir := filepath.Dir(c.filePath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("create config dir: %w", err)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal config: %w", err)
|
||||
}
|
||||
|
||||
// Write atomically via temp file
|
||||
tmp := c.filePath + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0644); err != nil { //nolint:gosec // G306: wisp config is non-sensitive operational data
|
||||
return fmt.Errorf("write temp: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tmp, c.filePath); err != nil {
|
||||
_ = os.Remove(tmp) // cleanup on failure
|
||||
return fmt.Errorf("rename: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns the value for the given key, or nil if not set.
|
||||
// Returns nil for blocked keys.
|
||||
func (c *Config) Get(key string) interface{} {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
cfg, err := c.load()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Blocked keys always return nil
|
||||
if c.isBlockedInternal(cfg, key) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return cfg.Values[key]
|
||||
}
|
||||
|
||||
// GetString returns the value for the given key as a string.
|
||||
// Returns empty string if not set, not a string, or blocked.
|
||||
func (c *Config) GetString(key string) string {
|
||||
val := c.Get(key)
|
||||
if s, ok := val.(string); ok {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetBool returns the value for the given key as a bool.
|
||||
// Returns false if not set, not a bool, or blocked.
|
||||
func (c *Config) GetBool(key string) bool {
|
||||
val := c.Get(key)
|
||||
if b, ok := val.(bool); ok {
|
||||
return b
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Set stores a value for the given key.
|
||||
// Setting a blocked key has no effect (the block takes precedence).
|
||||
func (c *Config) Set(key string, value interface{}) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
cfg, err := c.load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Cannot set a blocked key
|
||||
if c.isBlockedInternal(cfg, key) {
|
||||
return nil // silently ignore
|
||||
}
|
||||
|
||||
cfg.Values[key] = value
|
||||
return c.save(cfg)
|
||||
}
|
||||
|
||||
// Block marks a key as blocked (equivalent to NullValue).
|
||||
// Blocked keys return nil on Get and cannot be Set.
|
||||
func (c *Config) Block(key string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
cfg, err := c.load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Don't add duplicate
|
||||
if c.isBlockedInternal(cfg, key) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove from values and add to blocked
|
||||
delete(cfg.Values, key)
|
||||
cfg.Blocked = append(cfg.Blocked, key)
|
||||
return c.save(cfg)
|
||||
}
|
||||
|
||||
// Unset removes a key from both values and blocked lists.
|
||||
func (c *Config) Unset(key string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
cfg, err := c.load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove from values
|
||||
delete(cfg.Values, key)
|
||||
|
||||
// Remove from blocked
|
||||
cfg.Blocked = removeFromSlice(cfg.Blocked, key)
|
||||
|
||||
return c.save(cfg)
|
||||
}
|
||||
|
||||
// IsBlocked returns true if the key is blocked.
|
||||
func (c *Config) IsBlocked(key string) bool {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
cfg, err := c.load()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return c.isBlockedInternal(cfg, key)
|
||||
}
|
||||
|
||||
// isBlockedInternal checks if a key is in the blocked list (no locking).
|
||||
func (c *Config) isBlockedInternal(cfg *ConfigFile, key string) bool {
|
||||
for _, k := range cfg.Blocked {
|
||||
if k == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Keys returns all keys (both set and blocked).
|
||||
func (c *Config) Keys() []string {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
cfg, err := c.load()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(cfg.Values)+len(cfg.Blocked))
|
||||
for k := range cfg.Values {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
for _, k := range cfg.Blocked {
|
||||
// Only add if not already in values (shouldn't happen but be safe)
|
||||
found := false
|
||||
for _, existing := range keys {
|
||||
if existing == k {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// All returns a copy of all values (excludes blocked keys).
|
||||
func (c *Config) All() map[string]interface{} {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
cfg, err := c.load()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make(map[string]interface{}, len(cfg.Values))
|
||||
for k, v := range cfg.Values {
|
||||
result[k] = v
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// BlockedKeys returns a copy of all blocked keys.
|
||||
func (c *Config) BlockedKeys() []string {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
cfg, err := c.load()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make([]string, len(cfg.Blocked))
|
||||
copy(result, cfg.Blocked)
|
||||
return result
|
||||
}
|
||||
|
||||
// Clear removes all values and blocked keys.
|
||||
func (c *Config) Clear() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
cfg := &ConfigFile{
|
||||
Rig: c.rigName,
|
||||
Values: make(map[string]interface{}),
|
||||
Blocked: []string{},
|
||||
}
|
||||
return c.save(cfg)
|
||||
}
|
||||
|
||||
// removeFromSlice removes all occurrences of a string from a slice.
|
||||
func removeFromSlice(slice []string, item string) []string {
|
||||
result := make([]string, 0, len(slice))
|
||||
for _, s := range slice {
|
||||
if s != item {
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
224
internal/wisp/config_test.go
Normal file
224
internal/wisp/config_test.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package wisp
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfig_BasicOperations(t *testing.T) {
|
||||
// Create temp directory for test
|
||||
tmpDir := t.TempDir()
|
||||
rigName := "testrig"
|
||||
|
||||
cfg := NewConfig(tmpDir, rigName)
|
||||
|
||||
// Test initial state - Get returns nil
|
||||
if got := cfg.Get("key1"); got != nil {
|
||||
t.Errorf("Get(key1) = %v, want nil", got)
|
||||
}
|
||||
|
||||
// Test Set and Get
|
||||
if err := cfg.Set("key1", "value1"); err != nil {
|
||||
t.Fatalf("Set(key1) error: %v", err)
|
||||
}
|
||||
|
||||
if got := cfg.Get("key1"); got != "value1" {
|
||||
t.Errorf("Get(key1) = %v, want value1", got)
|
||||
}
|
||||
|
||||
// Test GetString
|
||||
if got := cfg.GetString("key1"); got != "value1" {
|
||||
t.Errorf("GetString(key1) = %v, want value1", got)
|
||||
}
|
||||
|
||||
// Test file was created in correct location
|
||||
expectedPath := filepath.Join(tmpDir, WispConfigDir, ConfigSubdir, rigName+".json")
|
||||
if _, err := os.Stat(expectedPath); os.IsNotExist(err) {
|
||||
t.Errorf("Config file not created at expected path: %s", expectedPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_Block(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
cfg := NewConfig(tmpDir, "testrig")
|
||||
|
||||
// Set a value first
|
||||
if err := cfg.Set("key1", "value1"); err != nil {
|
||||
t.Fatalf("Set(key1) error: %v", err)
|
||||
}
|
||||
|
||||
// Verify it's not blocked
|
||||
if cfg.IsBlocked("key1") {
|
||||
t.Error("key1 should not be blocked initially")
|
||||
}
|
||||
|
||||
// Block the key
|
||||
if err := cfg.Block("key1"); err != nil {
|
||||
t.Fatalf("Block(key1) error: %v", err)
|
||||
}
|
||||
|
||||
// Verify it's blocked
|
||||
if !cfg.IsBlocked("key1") {
|
||||
t.Error("key1 should be blocked after Block()")
|
||||
}
|
||||
|
||||
// Get should return nil for blocked key
|
||||
if got := cfg.Get("key1"); got != nil {
|
||||
t.Errorf("Get(key1) = %v, want nil for blocked key", got)
|
||||
}
|
||||
|
||||
// Set should be ignored for blocked key
|
||||
if err := cfg.Set("key1", "newvalue"); err != nil {
|
||||
t.Fatalf("Set on blocked key error: %v", err)
|
||||
}
|
||||
if got := cfg.Get("key1"); got != nil {
|
||||
t.Errorf("Get(key1) after Set = %v, want nil (blocked key)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_Unset(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
cfg := NewConfig(tmpDir, "testrig")
|
||||
|
||||
// Set and then unset
|
||||
if err := cfg.Set("key1", "value1"); err != nil {
|
||||
t.Fatalf("Set(key1) error: %v", err)
|
||||
}
|
||||
if err := cfg.Unset("key1"); err != nil {
|
||||
t.Fatalf("Unset(key1) error: %v", err)
|
||||
}
|
||||
if got := cfg.Get("key1"); got != nil {
|
||||
t.Errorf("Get(key1) after Unset = %v, want nil", got)
|
||||
}
|
||||
|
||||
// Block and then unset
|
||||
if err := cfg.Block("key2"); err != nil {
|
||||
t.Fatalf("Block(key2) error: %v", err)
|
||||
}
|
||||
if !cfg.IsBlocked("key2") {
|
||||
t.Error("key2 should be blocked")
|
||||
}
|
||||
if err := cfg.Unset("key2"); err != nil {
|
||||
t.Fatalf("Unset(key2) error: %v", err)
|
||||
}
|
||||
if cfg.IsBlocked("key2") {
|
||||
t.Error("key2 should not be blocked after Unset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_TypedGetters(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
cfg := NewConfig(tmpDir, "testrig")
|
||||
|
||||
// Test GetBool
|
||||
if err := cfg.Set("enabled", true); err != nil {
|
||||
t.Fatalf("Set(enabled) error: %v", err)
|
||||
}
|
||||
if got := cfg.GetBool("enabled"); !got {
|
||||
t.Error("GetBool(enabled) = false, want true")
|
||||
}
|
||||
if got := cfg.GetBool("nonexistent"); got {
|
||||
t.Error("GetBool(nonexistent) = true, want false")
|
||||
}
|
||||
|
||||
// GetString on non-string returns empty
|
||||
if got := cfg.GetString("enabled"); got != "" {
|
||||
t.Errorf("GetString(enabled) = %q, want empty for bool value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_AllAndKeys(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
cfg := NewConfig(tmpDir, "testrig")
|
||||
|
||||
// Set some values
|
||||
_ = cfg.Set("key1", "value1")
|
||||
_ = cfg.Set("key2", 42)
|
||||
_ = cfg.Block("key3")
|
||||
|
||||
// Test All (should not include blocked)
|
||||
all := cfg.All()
|
||||
if len(all) != 2 {
|
||||
t.Errorf("All() returned %d items, want 2", len(all))
|
||||
}
|
||||
if all["key1"] != "value1" {
|
||||
t.Errorf("All()[key1] = %v, want value1", all["key1"])
|
||||
}
|
||||
|
||||
// Test BlockedKeys
|
||||
blocked := cfg.BlockedKeys()
|
||||
if len(blocked) != 1 || blocked[0] != "key3" {
|
||||
t.Errorf("BlockedKeys() = %v, want [key3]", blocked)
|
||||
}
|
||||
|
||||
// Test Keys (includes both)
|
||||
keys := cfg.Keys()
|
||||
if len(keys) != 3 {
|
||||
t.Errorf("Keys() returned %d items, want 3", len(keys))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_Clear(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
cfg := NewConfig(tmpDir, "testrig")
|
||||
|
||||
// Set some values and blocks
|
||||
_ = cfg.Set("key1", "value1")
|
||||
_ = cfg.Block("key2")
|
||||
|
||||
// Clear
|
||||
if err := cfg.Clear(); err != nil {
|
||||
t.Fatalf("Clear() error: %v", err)
|
||||
}
|
||||
|
||||
// Verify everything is gone
|
||||
if got := cfg.Get("key1"); got != nil {
|
||||
t.Errorf("Get(key1) after Clear = %v, want nil", got)
|
||||
}
|
||||
if cfg.IsBlocked("key2") {
|
||||
t.Error("key2 should not be blocked after Clear")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_Persistence(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
rigName := "testrig"
|
||||
|
||||
// Create first config instance and set value
|
||||
cfg1 := NewConfig(tmpDir, rigName)
|
||||
if err := cfg1.Set("persistent", "value"); err != nil {
|
||||
t.Fatalf("Set error: %v", err)
|
||||
}
|
||||
if err := cfg1.Block("blocked"); err != nil {
|
||||
t.Fatalf("Block error: %v", err)
|
||||
}
|
||||
|
||||
// Create second config instance and verify persistence
|
||||
cfg2 := NewConfig(tmpDir, rigName)
|
||||
if got := cfg2.Get("persistent"); got != "value" {
|
||||
t.Errorf("Persistence: Get(persistent) = %v, want value", got)
|
||||
}
|
||||
if !cfg2.IsBlocked("blocked") {
|
||||
t.Error("Persistence: blocked key should persist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_MultipleRigs(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
cfg1 := NewConfig(tmpDir, "rig1")
|
||||
cfg2 := NewConfig(tmpDir, "rig2")
|
||||
|
||||
// Set different values
|
||||
_ = cfg1.Set("key", "value1")
|
||||
_ = cfg2.Set("key", "value2")
|
||||
|
||||
// Verify isolation
|
||||
if got := cfg1.Get("key"); got != "value1" {
|
||||
t.Errorf("rig1 Get(key) = %v, want value1", got)
|
||||
}
|
||||
if got := cfg2.Get("key"); got != "value2" {
|
||||
t.Errorf("rig2 Get(key) = %v, want value2", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user