ScuttleBot

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

Keyboard Shortcuts

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