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