Files
2026-07-13 13:39:47 +08:00

346 lines
7.7 KiB
Go

// Package conf provides a typed, file-backed configuration store inspired by
// sindresorhus/conf. It persists configuration as a JSON file with atomic
// writes and supports dot-notation access, default values, and change listeners.
package conf
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
)
// changeListener holds a callback for a specific key.
type changeListener struct {
id uint64
key string
fn func(newVal, oldVal any)
}
// Conf is a typed, file-backed configuration store.
type Conf[T any] struct {
mu sync.RWMutex
path string
defaults T
data map[string]any
listeners []changeListener
nextID uint64
}
// New creates a new Conf instance. It reads the config file if it exists,
// or initializes from defaults.
func New[T any](opts Options[T]) (*Conf[T], error) {
opts.applyDefaults()
cwd := opts.CWD
if cwd == "" {
var err error
cwd, err = os.Getwd()
if err != nil {
return nil, fmt.Errorf("conf: get working directory: %w", err)
}
}
filePath := filepath.Join(cwd, opts.ConfigName+"."+opts.FileExtension)
c := &Conf[T]{
path: filePath,
defaults: opts.Defaults,
}
// Convert defaults to map[string]any
defaultsMap, err := structToMap(opts.Defaults)
if err != nil {
return nil, fmt.Errorf("conf: marshal defaults: %w", err)
}
// Try to read existing file
if fileData, readErr := os.ReadFile(filePath); readErr == nil {
fileMap := make(map[string]any)
if jsonErr := json.Unmarshal(fileData, &fileMap); jsonErr == nil {
// Merge defaults under file data (file wins)
c.data = mergeMaps(defaultsMap, fileMap)
return c, nil
}
}
// No existing file or invalid JSON — use defaults
c.data = defaultsMap
// Write initial config
if err := c.write(); err != nil {
return nil, fmt.Errorf("conf: write initial config: %w", err)
}
return c, nil
}
// Get retrieves a value using dot-notation (e.g. "foo.bar").
func (c *Conf[T]) Get(key string) any {
c.mu.RLock()
defer c.mu.RUnlock()
return dotGet(c.data, key)
}
// Set sets a value using dot-notation and persists to disk.
// Change listeners are notified if the value changed.
func (c *Conf[T]) Set(key string, value any) error {
c.mu.Lock()
oldVal := dotGet(c.data, key)
dotSet(c.data, key, value)
if err := c.write(); err != nil {
c.mu.Unlock()
return err
}
// Collect matching listeners while holding the lock
listeners := c.matchListeners(key)
c.mu.Unlock()
// Fire listeners outside the lock
for _, l := range listeners {
l.fn(value, oldVal)
}
return nil
}
// Delete removes a key using dot-notation and persists to disk.
func (c *Conf[T]) Delete(key string) error {
c.mu.Lock()
defer c.mu.Unlock()
dotDelete(c.data, key)
return c.write()
}
// Store returns the entire configuration as the typed struct T.
func (c *Conf[T]) Store() T {
c.mu.RLock()
defer c.mu.RUnlock()
var result T
data, _ := json.Marshal(c.data)
json.Unmarshal(data, &result)
return result
}
// SetStore replaces the entire configuration and persists to disk.
func (c *Conf[T]) SetStore(store T) error {
newMap, err := structToMap(store)
if err != nil {
return fmt.Errorf("conf: marshal store: %w", err)
}
c.mu.Lock()
oldData := c.data
c.data = newMap
if err := c.write(); err != nil {
c.mu.Unlock()
return err
}
// Collect all listeners and their old/new values
type lc struct {
fn func(newVal, oldVal any)
newVal any
oldVal any
}
var calls []lc
for _, l := range c.listeners {
oldV := dotGet(oldData, l.key)
newV := dotGet(newMap, l.key)
calls = append(calls, lc{fn: l.fn, newVal: newV, oldVal: oldV})
}
c.mu.Unlock()
for _, call := range calls {
call.fn(call.newVal, call.oldVal)
}
return nil
}
// Update merges partial updates into the configuration.
// Only the provided keys are updated; others remain unchanged.
func (c *Conf[T]) Update(partial map[string]any) error {
c.mu.Lock()
// Track old values for listeners
type lc struct {
fn func(newVal, oldVal any)
newVal any
oldVal any
}
var calls []lc
for key, value := range partial {
oldVal := dotGet(c.data, key)
dotSet(c.data, key, value)
for _, l := range c.listeners {
if l.key == key {
calls = append(calls, lc{fn: l.fn, newVal: value, oldVal: oldVal})
}
}
}
if err := c.write(); err != nil {
c.mu.Unlock()
return err
}
c.mu.Unlock()
for _, call := range calls {
call.fn(call.newVal, call.oldVal)
}
return nil
}
// OnDidChange registers a listener that fires when the given key changes.
// Returns an unsubscribe function.
func (c *Conf[T]) OnDidChange(key string, fn func(newVal, oldVal any)) func() {
c.mu.Lock()
defer c.mu.Unlock()
id := c.nextID
c.nextID++
c.listeners = append(c.listeners, changeListener{id: id, key: key, fn: fn})
return func() {
c.mu.Lock()
defer c.mu.Unlock()
for i, existing := range c.listeners {
if existing.id == id {
c.listeners = append(c.listeners[:i], c.listeners[i+1:]...)
return
}
}
}
}
// Path returns the path to the config file.
func (c *Conf[T]) Path() string {
return c.path
}
// Reload re-reads the config file from disk and fires OnDidChange listeners
// for any keys whose values differ from the in-memory state.
func (c *Conf[T]) Reload() error {
fileData, err := os.ReadFile(c.path)
if err != nil {
return fmt.Errorf("conf: read file: %w", err)
}
fileMap := make(map[string]any)
if err := json.Unmarshal(fileData, &fileMap); err != nil {
return fmt.Errorf("conf: unmarshal: %w", err)
}
c.mu.Lock()
oldData := c.data
c.data = fileMap
// Collect listeners with changed values
type lc struct {
fn func(newVal, oldVal any)
newVal any
oldVal any
}
var calls []lc
for _, l := range c.listeners {
oldV := dotGet(oldData, l.key)
newV := dotGet(fileMap, l.key)
if !jsonEqual(oldV, newV) {
calls = append(calls, lc{fn: l.fn, newVal: newV, oldVal: oldV})
}
}
c.mu.Unlock()
for _, call := range calls {
call.fn(call.newVal, call.oldVal)
}
return nil
}
// write persists the current data to disk atomically.
// Must be called with c.mu held.
func (c *Conf[T]) write() error {
data, err := json.MarshalIndent(c.data, "", " ")
if err != nil {
return fmt.Errorf("conf: marshal: %w", err)
}
dir := filepath.Dir(c.path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("conf: create directory: %w", err)
}
// Atomic write: write to temp file, then rename
tmp := c.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return fmt.Errorf("conf: write temp file: %w", err)
}
if err := os.Rename(tmp, c.path); err != nil {
os.Remove(tmp)
return fmt.Errorf("conf: rename: %w", err)
}
return nil
}
// matchListeners returns listeners whose key matches. Must be called with c.mu held.
func (c *Conf[T]) matchListeners(key string) []changeListener {
var matched []changeListener
for _, l := range c.listeners {
if l.key == key {
matched = append(matched, l)
}
}
return matched
}
// jsonEqual compares two values by their JSON representation.
func jsonEqual(a, b any) bool {
aj, _ := json.Marshal(a)
bj, _ := json.Marshal(b)
return string(aj) == string(bj)
}
// structToMap converts a struct to map[string]any via JSON round-trip.
func structToMap(v any) (map[string]any, error) {
data, err := json.Marshal(v)
if err != nil {
return nil, err
}
var m map[string]any
if err := json.Unmarshal(data, &m); err != nil {
return nil, err
}
return m, nil
}
// mergeMaps merges src into dst. Values in src override those in dst.
func mergeMaps(dst, src map[string]any) map[string]any {
result := make(map[string]any)
for k, v := range dst {
result[k] = v
}
for k, v := range src {
if srcMap, ok := v.(map[string]any); ok {
if dstMap, ok := result[k].(map[string]any); ok {
result[k] = mergeMaps(dstMap, srcMap)
continue
}
}
result[k] = v
}
return result
}