2026-04-13 13:33:48 +00:00
|
|
|
package config
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
2026-04-13 15:28:47 +00:00
|
|
|
"strings"
|
2026-04-13 13:33:48 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
CurrentVersion = 1
|
|
|
|
|
DefaultFile = "config.json"
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-13 15:28:47 +00:00
|
|
|
var (
|
|
|
|
|
ErrFutureVersion = errors.New("future config version")
|
|
|
|
|
ErrUnsupportedVersion = errors.New("unsupported config version")
|
|
|
|
|
ErrInvalidConfig = errors.New("invalid config")
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-13 13:33:48 +00:00
|
|
|
type FileConfig[T any] struct {
|
|
|
|
|
Version int `json:"version"`
|
|
|
|
|
CurrentProfile string `json:"current_profile"`
|
|
|
|
|
Profiles map[string]T `json:"profiles"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 15:28:47 +00:00
|
|
|
type ValidationIssue struct {
|
|
|
|
|
Path string
|
|
|
|
|
Message string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type ValidationError struct {
|
|
|
|
|
Issues []ValidationIssue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (e *ValidationError) Error() string {
|
|
|
|
|
parts := make([]string, 0, len(e.Issues))
|
|
|
|
|
for _, issue := range e.Issues {
|
|
|
|
|
if issue.Path == "" {
|
|
|
|
|
parts = append(parts, issue.Message)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
parts = append(parts, fmt.Sprintf("%s: %s", issue.Path, issue.Message))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return fmt.Sprintf("invalid config: %s", strings.Join(parts, "; "))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (e *ValidationError) Unwrap() error {
|
|
|
|
|
return ErrInvalidConfig
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type Migration func(doc map[string]json.RawMessage) error
|
|
|
|
|
|
|
|
|
|
type Validator[T any] func(FileConfig[T]) []ValidationIssue
|
|
|
|
|
|
|
|
|
|
type Options[T any] struct {
|
|
|
|
|
FileName string
|
|
|
|
|
Version int
|
|
|
|
|
Migrations map[int]Migration
|
|
|
|
|
Validator Validator[T]
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 13:33:48 +00:00
|
|
|
type Store[T any] struct {
|
2026-04-13 15:28:47 +00:00
|
|
|
dirName string
|
|
|
|
|
fileName string
|
|
|
|
|
version int
|
|
|
|
|
migrations map[int]Migration
|
|
|
|
|
validator Validator[T]
|
2026-04-13 13:33:48 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func NewStore[T any](dirName string) Store[T] {
|
2026-04-13 15:28:47 +00:00
|
|
|
return NewStoreWithOptions[T](dirName, Options[T]{})
|
2026-04-13 13:33:48 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func NewStoreWithFile[T any](dirName, fileName string) Store[T] {
|
2026-04-13 15:28:47 +00:00
|
|
|
return NewStoreWithOptions[T](dirName, Options[T]{FileName: fileName})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func NewStoreWithOptions[T any](dirName string, options Options[T]) Store[T] {
|
|
|
|
|
fileName := options.FileName
|
|
|
|
|
if fileName == "" {
|
|
|
|
|
fileName = DefaultFile
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
version := options.Version
|
|
|
|
|
if version <= 0 {
|
|
|
|
|
version = CurrentVersion
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 13:33:48 +00:00
|
|
|
return Store[T]{
|
2026-04-13 15:28:47 +00:00
|
|
|
dirName: dirName,
|
|
|
|
|
fileName: fileName,
|
|
|
|
|
version: version,
|
|
|
|
|
migrations: buildMigrations(version, options.Migrations),
|
|
|
|
|
validator: options.Validator,
|
2026-04-13 13:33:48 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s Store[T]) Default() FileConfig[T] {
|
|
|
|
|
return FileConfig[T]{
|
2026-04-13 15:28:47 +00:00
|
|
|
Version: s.version,
|
2026-04-13 13:33:48 +00:00
|
|
|
Profiles: map[string]T{},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s Store[T]) ConfigPath() (string, error) {
|
|
|
|
|
userConfigDir, err := os.UserConfigDir()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", fmt.Errorf("resolve user config dir: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return filepath.Join(userConfigDir, s.dirName, s.fileName), nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s Store[T]) Load(path string) (FileConfig[T], error) {
|
|
|
|
|
data, err := os.ReadFile(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
|
|
|
return s.Default(), nil
|
|
|
|
|
}
|
|
|
|
|
return FileConfig[T]{}, fmt.Errorf("read config %s: %w", path, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(data) == 0 {
|
|
|
|
|
return s.Default(), nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 15:28:47 +00:00
|
|
|
doc, err := parseDocument(path, data)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return FileConfig[T]{}, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := s.migrateDocument(path, doc); err != nil {
|
|
|
|
|
return FileConfig[T]{}, err
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 13:33:48 +00:00
|
|
|
cfg := s.Default()
|
2026-04-13 15:28:47 +00:00
|
|
|
data, err = json.Marshal(doc)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return FileConfig[T]{}, fmt.Errorf("encode config %s: %w", path, err)
|
|
|
|
|
}
|
2026-04-13 13:33:48 +00:00
|
|
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
|
|
|
return FileConfig[T]{}, fmt.Errorf("parse config %s: %w", path, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
s.normalize(&cfg)
|
2026-04-13 15:28:47 +00:00
|
|
|
if err := s.validate(cfg); err != nil {
|
|
|
|
|
return FileConfig[T]{}, fmt.Errorf("validate config %s: %w", path, err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 13:33:48 +00:00
|
|
|
return cfg, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s Store[T]) LoadDefault() (FileConfig[T], string, error) {
|
|
|
|
|
path, err := s.ConfigPath()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return FileConfig[T]{}, "", err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cfg, err := s.Load(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return FileConfig[T]{}, "", err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return cfg, path, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s Store[T]) Save(path string, cfg FileConfig[T]) error {
|
|
|
|
|
s.normalize(&cfg)
|
2026-04-13 15:28:47 +00:00
|
|
|
if err := s.validate(cfg); err != nil {
|
|
|
|
|
return fmt.Errorf("validate config %s: %w", path, err)
|
|
|
|
|
}
|
2026-04-13 13:33:48 +00:00
|
|
|
|
|
|
|
|
dir := filepath.Dir(path)
|
|
|
|
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
|
|
|
return fmt.Errorf("create config dir %s: %w", dir, err)
|
|
|
|
|
}
|
|
|
|
|
if err := os.Chmod(dir, 0o700); err != nil {
|
|
|
|
|
return fmt.Errorf("set config dir permissions %s: %w", dir, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
data, err := json.MarshalIndent(cfg, "", " ")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("encode config: %w", err)
|
|
|
|
|
}
|
|
|
|
|
data = append(data, '\n')
|
|
|
|
|
|
|
|
|
|
tmpFile, err := os.CreateTemp(dir, "config-*.json")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("create temp config in %s: %w", dir, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tmpPath := tmpFile.Name()
|
|
|
|
|
cleanup := true
|
|
|
|
|
defer func() {
|
|
|
|
|
_ = tmpFile.Close()
|
|
|
|
|
if cleanup {
|
|
|
|
|
_ = os.Remove(tmpPath)
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
if err := tmpFile.Chmod(0o600); err != nil {
|
|
|
|
|
return fmt.Errorf("set temp config permissions %s: %w", tmpPath, err)
|
|
|
|
|
}
|
|
|
|
|
if _, err := tmpFile.Write(data); err != nil {
|
|
|
|
|
return fmt.Errorf("write temp config %s: %w", tmpPath, err)
|
|
|
|
|
}
|
|
|
|
|
if err := tmpFile.Close(); err != nil {
|
|
|
|
|
return fmt.Errorf("close temp config %s: %w", tmpPath, err)
|
|
|
|
|
}
|
|
|
|
|
if err := os.Rename(tmpPath, path); err != nil {
|
|
|
|
|
return fmt.Errorf("replace config %s: %w", path, err)
|
|
|
|
|
}
|
|
|
|
|
if err := os.Chmod(path, 0o600); err != nil {
|
|
|
|
|
return fmt.Errorf("set config permissions %s: %w", path, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cleanup = false
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s Store[T]) SaveDefault(cfg FileConfig[T]) (string, error) {
|
|
|
|
|
path, err := s.ConfigPath()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := s.Save(path, cfg); err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return path, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s Store[T]) normalize(cfg *FileConfig[T]) {
|
|
|
|
|
if cfg.Version == 0 {
|
2026-04-13 15:28:47 +00:00
|
|
|
cfg.Version = s.version
|
2026-04-13 13:33:48 +00:00
|
|
|
}
|
|
|
|
|
if cfg.Profiles == nil {
|
|
|
|
|
cfg.Profiles = map[string]T{}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-13 15:28:47 +00:00
|
|
|
|
|
|
|
|
func (s Store[T]) validate(cfg FileConfig[T]) error {
|
|
|
|
|
issues := make([]ValidationIssue, 0, 2)
|
|
|
|
|
if cfg.Version != s.version {
|
|
|
|
|
issues = append(issues, ValidationIssue{
|
|
|
|
|
Path: "version",
|
|
|
|
|
Message: fmt.Sprintf("must be %d", s.version),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
if cfg.CurrentProfile != "" {
|
|
|
|
|
if _, ok := cfg.Profiles[cfg.CurrentProfile]; !ok {
|
|
|
|
|
issues = append(issues, ValidationIssue{
|
|
|
|
|
Path: "current_profile",
|
|
|
|
|
Message: fmt.Sprintf("references unknown profile %q", cfg.CurrentProfile),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if s.validator != nil {
|
|
|
|
|
issues = append(issues, s.validator(cfg)...)
|
|
|
|
|
}
|
|
|
|
|
if len(issues) == 0 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return &ValidationError{Issues: issues}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s Store[T]) migrateDocument(path string, doc map[string]json.RawMessage) error {
|
|
|
|
|
version, err := documentVersion(doc)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("parse config %s: %w", path, err)
|
|
|
|
|
}
|
|
|
|
|
if version > s.version {
|
|
|
|
|
return fmt.Errorf("%w: config version %d is newer than supported version %d", ErrFutureVersion, version, s.version)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for version < s.version {
|
|
|
|
|
migration, ok := s.migrations[version]
|
|
|
|
|
if !ok {
|
|
|
|
|
return fmt.Errorf("%w: no migration registered from version %d to %d", ErrUnsupportedVersion, version, version+1)
|
|
|
|
|
}
|
|
|
|
|
if err := migration(doc); err != nil {
|
|
|
|
|
return fmt.Errorf("migrate config %s from version %d to %d: %w", path, version, version+1, err)
|
|
|
|
|
}
|
|
|
|
|
version++
|
|
|
|
|
if err := setDocumentVersion(doc, version); err != nil {
|
|
|
|
|
return fmt.Errorf("set config version %s to %d: %w", path, version, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func buildMigrations(version int, custom map[int]Migration) map[int]Migration {
|
|
|
|
|
migrations := map[int]Migration{}
|
|
|
|
|
if version >= 1 {
|
|
|
|
|
migrations[0] = func(doc map[string]json.RawMessage) error {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for fromVersion, migration := range custom {
|
|
|
|
|
migrations[fromVersion] = migration
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return migrations
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func parseDocument(path string, data []byte) (map[string]json.RawMessage, error) {
|
|
|
|
|
var doc map[string]json.RawMessage
|
|
|
|
|
if err := json.Unmarshal(data, &doc); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("parse config %s: %w", path, err)
|
|
|
|
|
}
|
|
|
|
|
return doc, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func documentVersion(doc map[string]json.RawMessage) (int, error) {
|
|
|
|
|
rawVersion, ok := doc["version"]
|
|
|
|
|
if !ok || len(rawVersion) == 0 {
|
|
|
|
|
return 0, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var version int
|
|
|
|
|
if err := json.Unmarshal(rawVersion, &version); err != nil {
|
|
|
|
|
return 0, fmt.Errorf("decode version: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if version < 0 {
|
|
|
|
|
return 0, fmt.Errorf("%w: version must be non-negative", ErrUnsupportedVersion)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return version, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func setDocumentVersion(doc map[string]json.RawMessage, version int) error {
|
|
|
|
|
rawVersion, err := json.Marshal(version)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
doc["version"] = rawVersion
|
|
|
|
|
return nil
|
|
|
|
|
}
|