diff --git a/internal/wisp/config.go b/internal/wisp/config.go new file mode 100644 index 00000000..a81d089d --- /dev/null +++ b/internal/wisp/config.go @@ -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/.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 +} diff --git a/internal/wisp/config_test.go b/internal/wisp/config_test.go new file mode 100644 index 00000000..bdfc6ee5 --- /dev/null +++ b/internal/wisp/config_test.go @@ -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) + } +}