2026-03-14 10:21:18 +00:00
|
|
|
// Package config provides layered configuration management for the Core framework.
|
|
|
|
|
//
|
2026-03-31 18:31:04 +00:00
|
|
|
// Configuration values are resolved in priority order: defaults -> file -> env -> Set().
|
2026-03-14 10:21:18 +00:00
|
|
|
// Values are stored in a YAML file at ~/.core/config.yaml by default.
|
|
|
|
|
//
|
|
|
|
|
// Keys use dot notation for nested access:
|
|
|
|
|
//
|
|
|
|
|
// cfg.Set("dev.editor", "vim")
|
|
|
|
|
// var editor string
|
|
|
|
|
// cfg.Get("dev.editor", &editor)
|
|
|
|
|
package config
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"fmt"
|
|
|
|
|
"iter"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
2026-03-31 18:09:32 +00:00
|
|
|
"sort"
|
2026-03-14 10:21:18 +00:00
|
|
|
"strings"
|
|
|
|
|
"sync"
|
|
|
|
|
|
|
|
|
|
coreio "forge.lthn.ai/core/go-io"
|
|
|
|
|
coreerr "forge.lthn.ai/core/go-log"
|
|
|
|
|
core "forge.lthn.ai/core/go/pkg/core"
|
|
|
|
|
"github.com/spf13/viper"
|
|
|
|
|
"gopkg.in/yaml.v3"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Config implements the core.Config interface with layered resolution.
|
|
|
|
|
// It uses viper as the underlying configuration engine.
|
|
|
|
|
type Config struct {
|
|
|
|
|
mu sync.RWMutex
|
2026-03-31 18:18:01 +00:00
|
|
|
full *viper.Viper // Full configuration (file + env + defaults)
|
|
|
|
|
file *viper.Viper // File-backed configuration only (for persistence)
|
2026-03-14 10:21:18 +00:00
|
|
|
medium coreio.Medium
|
|
|
|
|
path string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Option is a functional option for configuring a Config instance.
|
|
|
|
|
type Option func(*Config)
|
|
|
|
|
|
|
|
|
|
// WithMedium sets the storage medium for configuration file operations.
|
|
|
|
|
func WithMedium(m coreio.Medium) Option {
|
|
|
|
|
return func(c *Config) {
|
|
|
|
|
c.medium = m
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// WithPath sets the path to the configuration file.
|
|
|
|
|
func WithPath(path string) Option {
|
|
|
|
|
return func(c *Config) {
|
|
|
|
|
c.path = path
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// WithEnvPrefix sets the prefix for environment variables.
|
|
|
|
|
func WithEnvPrefix(prefix string) Option {
|
|
|
|
|
return func(c *Config) {
|
2026-03-31 18:25:05 +00:00
|
|
|
c.full.SetEnvPrefix(strings.TrimSuffix(prefix, "_"))
|
2026-03-14 10:21:18 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// New creates a new Config instance with the given options.
|
|
|
|
|
// If no medium is provided, it defaults to io.Local.
|
|
|
|
|
// If no path is provided, it defaults to ~/.core/config.yaml.
|
|
|
|
|
func New(opts ...Option) (*Config, error) {
|
|
|
|
|
c := &Config{
|
2026-03-31 18:18:01 +00:00
|
|
|
full: viper.New(),
|
|
|
|
|
file: viper.New(),
|
2026-03-14 10:21:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Configure viper defaults
|
2026-03-31 18:18:01 +00:00
|
|
|
c.full.SetEnvPrefix("CORE_CONFIG")
|
|
|
|
|
c.full.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
2026-03-14 10:21:18 +00:00
|
|
|
|
|
|
|
|
for _, opt := range opts {
|
|
|
|
|
opt(c)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if c.medium == nil {
|
|
|
|
|
c.medium = coreio.Local
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if c.path == "" {
|
|
|
|
|
home, err := os.UserHomeDir()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, coreerr.E("config.New", "failed to determine home directory", err)
|
|
|
|
|
}
|
|
|
|
|
c.path = filepath.Join(home, ".core", "config.yaml")
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 18:18:01 +00:00
|
|
|
c.full.AutomaticEnv()
|
2026-03-14 10:21:18 +00:00
|
|
|
|
|
|
|
|
// Load existing config file if it exists
|
|
|
|
|
if c.medium.Exists(c.path) {
|
|
|
|
|
if err := c.LoadFile(c.medium, c.path); err != nil {
|
|
|
|
|
return nil, coreerr.E("config.New", "failed to load config file", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return c, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 18:06:44 +00:00
|
|
|
func configTypeForPath(path string) (string, error) {
|
|
|
|
|
ext := strings.ToLower(filepath.Ext(path))
|
|
|
|
|
if ext == "" && filepath.Base(path) == ".env" {
|
|
|
|
|
return "env", nil
|
|
|
|
|
}
|
2026-03-31 18:22:59 +00:00
|
|
|
if ext == "" {
|
|
|
|
|
return "yaml", nil
|
|
|
|
|
}
|
2026-03-31 18:06:44 +00:00
|
|
|
|
|
|
|
|
switch ext {
|
|
|
|
|
case ".yaml", ".yml":
|
|
|
|
|
return "yaml", nil
|
|
|
|
|
case ".json":
|
|
|
|
|
return "json", nil
|
|
|
|
|
case ".toml":
|
|
|
|
|
return "toml", nil
|
|
|
|
|
case ".env":
|
|
|
|
|
return "env", nil
|
|
|
|
|
default:
|
|
|
|
|
return "", coreerr.E("config.configTypeForPath", "unsupported config file type: "+path, nil)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-14 10:21:18 +00:00
|
|
|
// LoadFile reads a configuration file from the given medium and path and merges it into the current config.
|
2026-03-31 18:06:44 +00:00
|
|
|
// It supports YAML, JSON, TOML, and dotenv files (.env).
|
2026-03-14 10:21:18 +00:00
|
|
|
func (c *Config) LoadFile(m coreio.Medium, path string) error {
|
|
|
|
|
c.mu.Lock()
|
|
|
|
|
defer c.mu.Unlock()
|
|
|
|
|
|
2026-03-31 18:18:01 +00:00
|
|
|
configType, err := configTypeForPath(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return coreerr.E("config.LoadFile", "failed to determine config file type: "+path, err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-14 10:21:18 +00:00
|
|
|
content, err := m.Read(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return coreerr.E("config.LoadFile", fmt.Sprintf("failed to read config file: %s", path), err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 18:18:01 +00:00
|
|
|
parsed := viper.New()
|
|
|
|
|
parsed.SetConfigType(configType)
|
|
|
|
|
if err := parsed.MergeConfig(strings.NewReader(content)); err != nil {
|
|
|
|
|
return coreerr.E("config.LoadFile", fmt.Sprintf("failed to parse config file: %s", path), err)
|
2026-03-14 10:21:18 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-31 18:18:01 +00:00
|
|
|
settings := parsed.AllSettings()
|
|
|
|
|
|
|
|
|
|
// Keep the persisted and runtime views aligned with the same parsed data.
|
|
|
|
|
if err := c.file.MergeConfigMap(settings); err != nil {
|
|
|
|
|
return coreerr.E("config.LoadFile", "failed to merge config into file settings", err)
|
2026-03-14 10:21:18 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-31 18:18:01 +00:00
|
|
|
if err := c.full.MergeConfigMap(settings); err != nil {
|
|
|
|
|
return coreerr.E("config.LoadFile", "failed to merge config into full settings", err)
|
2026-03-14 10:21:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get retrieves a configuration value by dot-notation key and stores it in out.
|
|
|
|
|
// If key is empty, it unmarshals the entire configuration into out.
|
|
|
|
|
// The out parameter must be a pointer to the target type.
|
|
|
|
|
func (c *Config) Get(key string, out any) error {
|
|
|
|
|
c.mu.RLock()
|
|
|
|
|
defer c.mu.RUnlock()
|
|
|
|
|
|
|
|
|
|
if key == "" {
|
2026-03-31 18:18:01 +00:00
|
|
|
if err := c.full.Unmarshal(out); err != nil {
|
2026-03-14 10:21:18 +00:00
|
|
|
return coreerr.E("config.Get", "failed to unmarshal full config", err)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 18:18:01 +00:00
|
|
|
if !c.full.IsSet(key) {
|
2026-03-14 10:21:18 +00:00
|
|
|
return coreerr.E("config.Get", fmt.Sprintf("key not found: %s", key), nil)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 18:18:01 +00:00
|
|
|
if err := c.full.UnmarshalKey(key, out); err != nil {
|
2026-03-14 10:21:18 +00:00
|
|
|
return coreerr.E("config.Get", fmt.Sprintf("failed to unmarshal key: %s", key), err)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Set stores a configuration value in memory.
|
|
|
|
|
// Call Commit() to persist changes to disk.
|
|
|
|
|
func (c *Config) Set(key string, v any) error {
|
|
|
|
|
c.mu.Lock()
|
|
|
|
|
defer c.mu.Unlock()
|
|
|
|
|
|
2026-03-31 18:18:01 +00:00
|
|
|
c.file.Set(key, v)
|
|
|
|
|
c.full.Set(key, v)
|
2026-03-14 10:21:18 +00:00
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Commit persists any changes made via Set() to the configuration file on disk.
|
|
|
|
|
// This will only save the configuration that was loaded from the file or explicitly Set(),
|
|
|
|
|
// preventing environment variable leakage.
|
|
|
|
|
func (c *Config) Commit() error {
|
|
|
|
|
c.mu.Lock()
|
|
|
|
|
defer c.mu.Unlock()
|
|
|
|
|
|
2026-03-31 18:18:01 +00:00
|
|
|
if err := Save(c.medium, c.path, c.file.AllSettings()); err != nil {
|
2026-03-14 10:21:18 +00:00
|
|
|
return coreerr.E("config.Commit", "failed to save config", err)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 18:09:32 +00:00
|
|
|
// All returns an iterator over all configuration values in lexical key order
|
|
|
|
|
// (including environment variables).
|
2026-03-14 10:21:18 +00:00
|
|
|
func (c *Config) All() iter.Seq2[string, any] {
|
|
|
|
|
c.mu.RLock()
|
|
|
|
|
defer c.mu.RUnlock()
|
2026-03-31 18:09:32 +00:00
|
|
|
|
2026-03-31 18:18:01 +00:00
|
|
|
settings := c.full.AllSettings()
|
2026-03-31 18:09:32 +00:00
|
|
|
keys := make([]string, 0, len(settings))
|
|
|
|
|
for key := range settings {
|
|
|
|
|
keys = append(keys, key)
|
|
|
|
|
}
|
|
|
|
|
sort.Strings(keys)
|
|
|
|
|
|
|
|
|
|
return func(yield func(string, any) bool) {
|
|
|
|
|
for _, key := range keys {
|
|
|
|
|
if !yield(key, settings[key]) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-14 10:21:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Path returns the path to the configuration file.
|
|
|
|
|
func (c *Config) Path() string {
|
|
|
|
|
return c.path
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Load reads a YAML configuration file from the given medium and path.
|
|
|
|
|
// Returns the parsed data as a map, or an error if the file cannot be read or parsed.
|
|
|
|
|
// Deprecated: Use Config.LoadFile instead.
|
|
|
|
|
func Load(m coreio.Medium, path string) (map[string]any, error) {
|
2026-03-31 18:22:59 +00:00
|
|
|
switch ext := strings.ToLower(filepath.Ext(path)); ext {
|
|
|
|
|
case "", ".yaml", ".yml":
|
|
|
|
|
// These paths are safe to treat as YAML sources.
|
|
|
|
|
default:
|
|
|
|
|
return nil, coreerr.E("config.Load", "unsupported config file type: "+path, nil)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-14 10:21:18 +00:00
|
|
|
content, err := m.Read(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, coreerr.E("config.Load", "failed to read config file: "+path, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
v := viper.New()
|
|
|
|
|
v.SetConfigType("yaml")
|
|
|
|
|
if err := v.ReadConfig(strings.NewReader(content)); err != nil {
|
|
|
|
|
return nil, coreerr.E("config.Load", "failed to parse config file: "+path, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return v.AllSettings(), nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Save writes configuration data to a YAML file at the given path.
|
|
|
|
|
// It ensures the parent directory exists before writing.
|
|
|
|
|
func Save(m coreio.Medium, path string, data map[string]any) error {
|
2026-03-31 18:20:26 +00:00
|
|
|
switch ext := strings.ToLower(filepath.Ext(path)); ext {
|
|
|
|
|
case "", ".yaml", ".yml":
|
|
|
|
|
// These paths are safe to treat as YAML destinations.
|
|
|
|
|
default:
|
|
|
|
|
return coreerr.E("config.Save", "unsupported config file type: "+path, nil)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-14 10:21:18 +00:00
|
|
|
out, err := yaml.Marshal(data)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return coreerr.E("config.Save", "failed to marshal config", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
dir := filepath.Dir(path)
|
|
|
|
|
if err := m.EnsureDir(dir); err != nil {
|
|
|
|
|
return coreerr.E("config.Save", "failed to create config directory: "+dir, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := m.Write(path, string(out)); err != nil {
|
|
|
|
|
return coreerr.E("config.Save", "failed to write config file: "+path, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Ensure Config implements core.Config at compile time.
|
|
|
|
|
var _ core.Config = (*Config)(nil)
|