Files
gastown/internal/wisp/config.go
nux ae88c12e07 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>
2026-01-06 19:01:34 -08:00

319 lines
7.0 KiB
Go

// 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
}