mcp-framework/config/config.go

346 lines
8 KiB
Go
Raw Normal View History

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
}