package config import ( "fmt" "path/filepath" "reflect" "strings" "sync" "github.com/go-playground/validator/v10" "github.com/spf13/viper" ) // Schema marks configuration struct types. Use a struct type for T in [NewManager]. type Schema any // ManagerOpts defines options for the configuration manager. type ManagerOpts[T Schema] struct { // PreLoad is called before reading the configuration file. The schema pointer // may be newly allocated and empty. PreLoad func(*T) error // PostLoad is called after the file is unmarshalled and validated. Use it to // populate derived fields; validation runs again after PostLoad returns. PostLoad func(*T) error // EnvPrefix enables environment variable overrides with this prefix. EnvPrefix string // Defaults is a map of default values (dot-notation keys). Defaults map[string]any } // Manager loads and validates a typed configuration schema via viper. // Prefer [Manager.Load] during startup and [Manager.C] for quick access after load. type Manager[T Schema] struct { preLoad func(*T) error postLoad func(*T) error config *T loaded bool mu sync.RWMutex viper *viper.Viper } // NewManager creates a configuration manager for schema T. T must be a struct type. func NewManager[T Schema](configName, configType, configPath string) *Manager[T] { var zero T if reflect.TypeOf(zero).Kind() != reflect.Struct { panic(fmt.Sprintf("config: schema type %T must be a struct", zero)) } m := &Manager[T]{ viper: viper.New(), } m.viper.SetConfigName(configName) m.viper.SetConfigType(configType) m.viper.AddConfigPath(configPath) return m } // WithOpts sets additional options for the Manager. func (m *Manager[T]) WithOpts(opts *ManagerOpts[T]) *Manager[T] { if opts == nil { return m } if opts.PreLoad != nil { m.preLoad = opts.PreLoad } if opts.PostLoad != nil { m.postLoad = opts.PostLoad } if opts.Defaults != nil { for key, value := range opts.Defaults { m.viper.SetDefault(key, value) } } if opts.EnvPrefix != "" { m.WithEnvOverride(opts.EnvPrefix) } return m } // WithEnvOverride sets the environment variable prefix and enables AutomaticEnv. func (m *Manager[T]) WithEnvOverride(prefix string) *Manager[T] { m.viper.SetEnvPrefix(prefix) m.viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) m.viper.AutomaticEnv() return m } // C returns the loaded configuration, loading the file first if needed. // Panics if loading or validation fails. func (m *Manager[T]) C() *T { m.mu.RLock() if m.loaded { cfg := m.config m.mu.RUnlock() return cfg } m.mu.RUnlock() m.mu.Lock() defer m.mu.Unlock() if m.loaded { return m.config } if err := m.loadUnsafe(); err != nil { panic(fmt.Errorf("failed to load configuration: %w", err)) } return m.config } // Config is an alias for [Manager.C]. func (m *Manager[T]) Config() *T { return m.C() } // ConfigPath returns the absolute path to the configuration file that was used. func (m *Manager[T]) ConfigPath() string { if m.viper.ConfigFileUsed() == "" { return "" } absPath, err := filepath.Abs(m.viper.ConfigFileUsed()) if err != nil { return m.viper.ConfigFileUsed() } return absPath } // Viper returns the underlying viper instance. Prefer [Manager.Load] and [Manager.C] // so validation and loaded state stay consistent. func (m *Manager[T]) Viper() *viper.Viper { return m.viper } // Load reads and validates the configuration file. func (m *Manager[T]) Load() error { m.mu.Lock() defer m.mu.Unlock() if m.loaded { return nil } return m.loadUnsafe() } func (m *Manager[T]) loadUnsafe() error { m.config = new(T) if m.preLoad != nil { if err := m.preLoad(m.config); err != nil { return fmt.Errorf("pre-load function failed: %w", err) } } if err := m.viper.ReadInConfig(); err != nil { return fmt.Errorf("viper failed to read config file: %w", err) } if err := m.viper.Unmarshal(m.config); err != nil { return fmt.Errorf("viper failed to unmarshal config file: %w", err) } validate := validator.New(validator.WithRequiredStructEnabled()) if err := validate.Struct(m.config); err != nil { return fmt.Errorf("validator failed to validate config file: %w", err) } if m.postLoad != nil { if err := m.postLoad(m.config); err != nil { return fmt.Errorf("post-load function failed: %w", err) } if err := validate.Struct(m.config); err != nil { return fmt.Errorf("validator failed after post-load: %w", err) } } m.loaded = true return nil }