ScuttleBot

scuttlebot / internal / api / config_store.go
Source Blame History 111 lines
17e2c1d… lmata 1 package api
17e2c1d… lmata 2
17e2c1d… lmata 3 import (
17e2c1d… lmata 4 "fmt"
17e2c1d… lmata 5 "os"
17e2c1d… lmata 6 "path/filepath"
17e2c1d… lmata 7 "sync"
17e2c1d… lmata 8
17e2c1d… lmata 9 "github.com/conflicthq/scuttlebot/internal/config"
17e2c1d… lmata 10 )
17e2c1d… lmata 11
17e2c1d… lmata 12 // ConfigStore holds the running config and knows how to write it back to disk
17e2c1d… lmata 13 // with history snapshots. It is the single write path for all config mutations.
17e2c1d… lmata 14 type ConfigStore struct {
17e2c1d… lmata 15 mu sync.RWMutex
17e2c1d… lmata 16 cfg config.Config
17e2c1d… lmata 17 path string // absolute path to scuttlebot.yaml
17e2c1d… lmata 18 historyDir string // where snapshots land
17e2c1d… lmata 19 onChange []func(config.Config)
17e2c1d… lmata 20 }
17e2c1d… lmata 21
17e2c1d… lmata 22 // NewConfigStore creates a ConfigStore for the given config file path.
17e2c1d… lmata 23 // The initial config value is copied in.
17e2c1d… lmata 24 func NewConfigStore(path string, cfg config.Config) *ConfigStore {
17e2c1d… lmata 25 histDir := cfg.History.Dir
17e2c1d… lmata 26 if histDir == "" {
17e2c1d… lmata 27 histDir = filepath.Join(cfg.Ergo.DataDir, "config-history")
17e2c1d… lmata 28 }
17e2c1d… lmata 29 return &ConfigStore{
17e2c1d… lmata 30 cfg: cfg,
17e2c1d… lmata 31 path: path,
17e2c1d… lmata 32 historyDir: histDir,
17e2c1d… lmata 33 }
17e2c1d… lmata 34 }
17e2c1d… lmata 35
17e2c1d… lmata 36 // Get returns a copy of the current config.
17e2c1d… lmata 37 func (s *ConfigStore) Get() config.Config {
17e2c1d… lmata 38 s.mu.RLock()
17e2c1d… lmata 39 defer s.mu.RUnlock()
17e2c1d… lmata 40 return s.cfg
17e2c1d… lmata 41 }
17e2c1d… lmata 42
17e2c1d… lmata 43 // OnChange registers a callback invoked (in a new goroutine) after every
17e2c1d… lmata 44 // successful Save. Multiple callbacks are called concurrently.
17e2c1d… lmata 45 func (s *ConfigStore) OnChange(fn func(config.Config)) {
17e2c1d… lmata 46 s.mu.Lock()
17e2c1d… lmata 47 defer s.mu.Unlock()
17e2c1d… lmata 48 s.onChange = append(s.onChange, fn)
17e2c1d… lmata 49 }
17e2c1d… lmata 50
17e2c1d… lmata 51 // Save snapshots the current file, writes next to disk, updates the in-memory
17e2c1d… lmata 52 // copy, and fires all OnChange callbacks.
17e2c1d… lmata 53 func (s *ConfigStore) Save(next config.Config) error {
17e2c1d… lmata 54 s.mu.Lock()
17e2c1d… lmata 55 defer s.mu.Unlock()
17e2c1d… lmata 56
17e2c1d… lmata 57 keep := next.History.Keep
17e2c1d… lmata 58 if keep == 0 {
17e2c1d… lmata 59 keep = 20 // safety fallback; keep=0 in config means "use default"
17e2c1d… lmata 60 }
17e2c1d… lmata 61
17e2c1d… lmata 62 // Snapshot before overwrite (no-op if file doesn't exist yet).
17e2c1d… lmata 63 if err := config.SnapshotConfig(s.historyDir, s.path); err != nil {
17e2c1d… lmata 64 return fmt.Errorf("config store: snapshot: %w", err)
17e2c1d… lmata 65 }
17e2c1d… lmata 66
17e2c1d… lmata 67 // Write the new config to disk.
17e2c1d… lmata 68 if err := next.Save(s.path); err != nil {
17e2c1d… lmata 69 return fmt.Errorf("config store: save: %w", err)
17e2c1d… lmata 70 }
17e2c1d… lmata 71
17e2c1d… lmata 72 // Prune history to keep entries.
17e2c1d… lmata 73 base := filepath.Base(s.path)
17e2c1d… lmata 74 if err := config.PruneHistory(s.historyDir, base, keep); err != nil {
17e2c1d… lmata 75 // Non-fatal: log would be nice but we don't have a logger here.
17e2c1d… lmata 76 // Callers can surface this separately.
17e2c1d… lmata 77 _ = err
17e2c1d… lmata 78 }
17e2c1d… lmata 79
17e2c1d… lmata 80 s.cfg = next
17e2c1d… lmata 81
17e2c1d… lmata 82 // Fire callbacks outside the lock in fresh goroutines.
17e2c1d… lmata 83 cbs := make([]func(config.Config), len(s.onChange))
17e2c1d… lmata 84 copy(cbs, s.onChange)
17e2c1d… lmata 85 for _, fn := range cbs {
17e2c1d… lmata 86 go fn(next)
17e2c1d… lmata 87 }
17e2c1d… lmata 88 return nil
17e2c1d… lmata 89 }
17e2c1d… lmata 90
17e2c1d… lmata 91 // ListHistory returns the snapshots for the managed config file.
17e2c1d… lmata 92 func (s *ConfigStore) ListHistory() ([]config.HistoryEntry, error) {
17e2c1d… lmata 93 s.mu.RLock()
17e2c1d… lmata 94 histDir := s.historyDir
17e2c1d… lmata 95 path := s.path
17e2c1d… lmata 96 s.mu.RUnlock()
17e2c1d… lmata 97 base := filepath.Base(path)
17e2c1d… lmata 98 return config.ListHistory(histDir, base)
17e2c1d… lmata 99 }
17e2c1d… lmata 100
17e2c1d… lmata 101 // ReadHistoryFile returns the raw bytes of a snapshot by filename.
17e2c1d… lmata 102 func (s *ConfigStore) ReadHistoryFile(filename string) ([]byte, error) {
17e2c1d… lmata 103 s.mu.RLock()
17e2c1d… lmata 104 histDir := s.historyDir
17e2c1d… lmata 105 s.mu.RUnlock()
17e2c1d… lmata 106 // Sanitize: only allow simple filenames (no path separators).
17e2c1d… lmata 107 if filepath.Base(filename) != filename {
17e2c1d… lmata 108 return nil, fmt.Errorf("config store: invalid history filename")
17e2c1d… lmata 109 }
17e2c1d… lmata 110 return os.ReadFile(filepath.Join(histDir, filename))
17e2c1d… lmata 111 }

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button