package config import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "strings" ) const ( CurrentVersion = 1 DefaultFile = "config.json" ) var ( ErrFutureVersion = errors.New("future config version") ErrUnsupportedVersion = errors.New("unsupported config version") ErrInvalidConfig = errors.New("invalid config") ) type FileConfig[T any] struct { Version int `json:"version"` CurrentProfile string `json:"current_profile"` Profiles map[string]T `json:"profiles"` } 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] } type Store[T any] struct { dirName string fileName string version int migrations map[int]Migration validator Validator[T] } func NewStore[T any](dirName string) Store[T] { return NewStoreWithOptions[T](dirName, Options[T]{}) } func NewStoreWithFile[T any](dirName, fileName string) Store[T] { 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 } return Store[T]{ dirName: dirName, fileName: fileName, version: version, migrations: buildMigrations(version, options.Migrations), validator: options.Validator, } } func (s Store[T]) Default() FileConfig[T] { return FileConfig[T]{ Version: s.version, 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 } doc, err := parseDocument(path, data) if err != nil { return FileConfig[T]{}, err } if err := s.migrateDocument(path, doc); err != nil { return FileConfig[T]{}, err } cfg := s.Default() data, err = json.Marshal(doc) if err != nil { return FileConfig[T]{}, fmt.Errorf("encode config %s: %w", path, err) } if err := json.Unmarshal(data, &cfg); err != nil { return FileConfig[T]{}, fmt.Errorf("parse config %s: %w", path, err) } s.normalize(&cfg) if err := s.validate(cfg); err != nil { return FileConfig[T]{}, fmt.Errorf("validate config %s: %w", path, err) } 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) if err := s.validate(cfg); err != nil { return fmt.Errorf("validate config %s: %w", path, err) } 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 { cfg.Version = s.version } if cfg.Profiles == nil { cfg.Profiles = map[string]T{} } } 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 }